From 5cf8261e62748968eec95097450ca5edcea23f8e Mon Sep 17 00:00:00 2001 From: Emil Date: Fri, 11 Sep 2026 20:12:46 +0500 Subject: [PATCH 01/19] feat(selection): SelectionArea-parity chrome, registry fixes, and autoscroll Bring read-only selection up to SelectableRegion quality: content-gated gestures, directed handles with soft-wrap affinity, live toolbar anchors, deferred registry remove/heal, dual-pass highlight paint, and host-union edge autoscroll while dragging. --- AGENTS.md | 4 + CHANGELOG.md | 59 + README.md | 5 +- docs/architecture.md | 7 +- docs/development.md | 2 +- docs/rendering.md | 24 +- docs/selection.md | 155 +- lib/flutter_md.dart | 1 + lib/src/render/block_painter.dart | 90 +- lib/src/render/blocks/alert.dart | 9 + lib/src/render/blocks/code.dart | 5 + lib/src/render/blocks/heading.dart | 9 +- lib/src/render/blocks/list.dart | 19 +- lib/src/render/blocks/paragraph.dart | 11 +- lib/src/render/blocks/quote.dart | 10 +- lib/src/render/blocks/table.dart | 5 + lib/src/render/markdown_painter.dart | 94 +- lib/src/render/markdown_render_object.dart | 126 +- lib/src/render/span_builder.dart | 14 + lib/src/selection.dart | 609 ++++++-- lib/src/selection_autoscroll.dart | 379 +++++ lib/src/selection_scope.dart | 1268 ++++++++++++++-- lib/src/theme.dart | 2 +- test/selection/selection_autoscroll_test.dart | 699 +++++++++ .../selection_handle_endpoints_test.dart | 210 +++ test/selection/selection_handles_test.dart | 238 ++- test/selection/selection_test.dart | 91 ++ test/selection/selection_widget_test.dart | 1337 ++++++++++++++++- test/theme/theme_test.dart | 4 +- test/unit_test.dart | 6 +- 30 files changed, 5120 insertions(+), 372 deletions(-) create mode 100644 lib/src/selection_autoscroll.dart create mode 100644 test/selection/selection_autoscroll_test.dart create mode 100644 test/selection/selection_handle_endpoints_test.dart diff --git a/AGENTS.md b/AGENTS.md index 8644471..312ec8c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 0402849..3f9dcb7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,62 @@ +## Unreleased + +### Registry / multi-body selection +- **FIXED**: `removeDocument` defers while a surface for that id is still 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. + +### 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. + +### 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**: Programmatic `controller.selectAll()` / assigning a non-collapsed + `controller.selection` starts the toolbar lifecycle without a gesture. +- **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 diff --git a/README.md b/README.md index 1ede646..809ac35 100644 --- a/README.md +++ b/README.md @@ -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. @@ -510,7 +513,7 @@ class _MyWidgetState extends State { ## 📊 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. diff --git a/docs/architecture.md b/docs/architecture.md index acff16e..f473bcc 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -42,9 +42,10 @@ defaults), lays them out top-to-bottom recording each block's top-`y` in For selection, the same widget opts in when given a `documentId` **and** a resolvable `MarkdownSelectionController` (explicit or via an ambient `MarkdownSelectionScope`). The render object then registers itself with the -controller as a `MarkdownSelectionSurface` and paints the selection highlight -(looked up from the controller) on top of the glyphs, so it stays visible over -opaque backgrounds. See [selection](selection.md). +controller as a `MarkdownSelectionSurface`, heals the document into the registry +on attach, and paints the selection highlight (looked up from the controller) on +top of the glyphs, so it stays visible over opaque backgrounds. Registry remove +is deferred while a surface is still mounted. See [selection](selection.md). ## Why the model is immutable and selection anchors to it diff --git a/docs/development.md b/docs/development.md index b14316a..ec1b5c9 100644 --- a/docs/development.md +++ b/docs/development.md @@ -106,7 +106,7 @@ Base `package:flutter_lints/flutter.yaml`, plus `strict-casts`, `strict-raw-type ## Dependencies -- `dependencies`: `flutter` (sdk), `meta: ^1.16.0`. +- `dependencies`: `flutter` (sdk), `meta: any`. - `dev_dependencies`: `flutter_test` (sdk), `flutter_lints: >=5.0.0 <7.0.0`, `markdown: ^7.3.0` (benchmark comparison only), `benchmark_harness: ^2.3.1`. diff --git a/docs/rendering.md b/docs/rendering.md index 9a3869b..35b856e 100644 --- a/docs/rendering.md +++ b/docs/rendering.md @@ -79,6 +79,7 @@ use `size.width` for full-width backgrounds/rules and to bail when too narrow). String get renderedText; // MUST equal markdownBlockRenderedText(block) int offsetForLocalPosition(Offset local); // block-local → rendered-text index List boxesForRange(int start, int end);// block-local highlight rects for [start, end) +bool get selectionHighlightAboveCachedContent; // true ⇒ also paint highlight above Picture ``` Two mixins implement it for you: @@ -211,12 +212,17 @@ $Spacer`). Internal (`@meta.internal`, only via `src/render.dart`): - Glyphs cached in a `ui.Picture` keyed by size; reused on repaint; nulled only on `update`/`invalidateLayout`. -- **Selection highlight is painted outside that cache, on top of the glyphs** - (`MarkdownRenderObject.paint` calls `_painter.paint(...)` then - `_painter.paintHighlight(...)`), so drags/streaming never rebuild the glyph - cache, and a translucent highlight stays visible over opaque block/inline - backgrounds (code fences, `inline code`, `==mark==`). Color = - `controller.selectionColor ?? _kSelectionColor` (`0x552196F3`). -- `isRepaintBoundary => controller != null`; `alwaysNeedsCompositing => false`. -- Handle `LeaderLayer`s are pushed in `paint` (only when a scope supplied - start/end `LayerLink` + local offset) so native handles follow scrolling content. +- **Selection highlight is painted outside the glyph [Picture] cache**, in two + passes matching `SelectableRegion` / `SelectionArea`: first **under** the + content picture (sharp text — no washed-out overlay), then **above** only for + blocks that paint opaque chrome (`selectionHighlightAboveCachedContent`, e.g. + code fences, table zebra rows, and blocks whose spans use monospace/highlight + backgrounds) so the tint stays visible there. Drags/streaming never rebuild + the glyph cache. Color = `controller.selectionColor ?? _kSelectionColor` + (`0x552196F3`). +- `isRepaintBoundary => controller != null`; `alwaysNeedsCompositing` when the + surface owns a start and/or end handle `LeaderLayer`. +- Handle `LeaderLayer`s are pushed in `paint` via `LayerHandle` (only when a + scope supplied start/end `LayerLink` + local offset) so native handles follow + scrolling content. Links clear on detach so remount / virtualization cannot + leave ghost leaders. diff --git a/docs/selection.md b/docs/selection.md index fb9e821..76c8464 100644 --- a/docs/selection.md +++ b/docs/selection.md @@ -38,14 +38,20 @@ mount-independent: - `putDocument(id, model, {order})` — insert-or-update; **the streaming entry point**. No-op early return when neither model nor order changed (streaming can call once per token; the model check is `identical`). A model change triggers - reconciliation (§). + reconciliation (§). Also **cancels** a deferred remove for the same `id` + (remount / heal after a dispose that raced ahead of detach). - `removeDocument(id)` — removes and drops the selection if either endpoint was in - that doc. + that doc. **Deferred while a surface for `id` is still mounted:** the registry + entry stays until `detachSurface`, so hit-testing / `rangeFor` cannot see a + mounted body with no document (`di=-1`). Eager remove while the render object + is still attached is unsafe — parent `State.dispose` can run before child + detach. - `documentCount` (prefer over `documents.length`, which allocates), `hasDocuments`, `documents`. - **`MarkdownDocumentRef`** `{Object id, Markdown model, int? order}` — `order` null ⇒ registration order; supply e.g. the message index so **unmounted** docs - still order correctly. + still order correctly. Use **unique** `order` values that match visual reading + order (`List.sort` is not stable on ties). Ordering is O(1): `_sort()` sorts `_docs` by `order` then `_reindex()` rebuilds a `Map _indexById`, so `_orderIndex(id)` is a map lookup. This matters @@ -68,8 +74,17 @@ onto a live render object, **implemented by `MarkdownRenderObject`**: The controller keeps a `Map` keyed by `documentId`; render objects `attachSurface`/`detachSurface` on attach/detach. +On attach (and when the controller / model is wired while already attached), +`MarkdownRenderObject` also `putDocument`s its current model so a mounted surface +is never missing from the registry (heals races where geometry attaches before +an app-level registration). `detachSurface` flushes any deferred +`removeDocument` for that id. + `rangeFor(documentId, blockIndex)` returns the selected `TextRange` within a block (null if outside the selection) — the per-block highlight lookup during paint. +If either selection endpoint's document is **unregistered**, `rangeFor` returns +null for every block (and extraction yields empty): unregistered ids must not +sort as `-1` and paint every registered body between `0` and `endDoc`. ## Reconciliation (streaming) @@ -129,19 +144,43 @@ but still exposes the controller), `selectionColor`, `contextMenuBuilder` (null no toolbar), `magnifierConfiguration`, `selectionControls`, `onSelectionChanged`. Statics: `MarkdownSelectionScope.of/maybeOf` (→ controller), `stateOf` (→ state). -- **Gestures:** a `TapAndPanGestureRecognizer` restricted to mouse/stylus/trackpad - with `DragStartBehavior.down` handles both taps and drags from one recognizer so - consecutive-tap counting stays intact — **single** click collapses/clears, - **double** selects the word, **triple** selects the block, `Shift`-click extends, - and a drag after a double/triple click keeps word/block granularity. On **touch** - a `LongPressGestureRecognizer` grabs the whole word then extends by word, and a - `DoubleTapGestureRecognizer` selects the word + pops the toolbar (both tap/press - based, so a plain swipe still scrolls an enclosing `ListView`). A secondary-only - `TapGestureRecognizer` shows the toolbar on right-click; with no primary - callbacks it never competes with the tap-and-pan recognizer. Word boundaries - come from the mounted painter's `TextPainter.getWordBoundary` (via - `MarkdownSelectionSurface.wordBoundaryForGlobal`), with the text-based +- **Gestures:** mouse uses `TapAndPanGestureRecognizer`; touch/stylus/trackpad + use a content-gated `TapAndHorizontalDragGestureRecognizer` (SelectableRegion + split) so consecutive-tap counting stays on one recognizer with **no** + `DoubleTapGestureRecognizer` arena delay — **single** tap dismisses/clears on + touch (caret on mouse), **double** selects the word on tap-down and shows + chrome on tap-up, **triple** selects the block, `Shift`-click extends, and a + drag after a double/triple keeps word/block granularity. The touch recognizer + and long-press only join the arena on a selectable hit (or while a non-collapsed + selection / toolbar is up). With an active selection, `eagerVictoryOnDrag` is + on so horizontal swipes stay with selection (block dismissible); without a + selection it stays off so edge swipes on chrome reach ancestors. Horizontal + motion still yields vertical scrolling to ancestor scrollables. A + content-gated long-press grabs the whole word then extends by word. A + secondary-only recognizer shows the toolbar on right-click. Gesture **starts** + (long-press, multi-tap, mouse caret) require a strict hit inside a mounted + selectable surface — the host may wrap chrome + gaps, but presses on buttons + / empty space do not clamp onto the nearest markdown. Nearest-neighbor clamp + remains for **extend** while an active drag crosses gaps between surfaces. + Word boundaries come from the mounted painter's `TextPainter.getWordBoundary` + (via `MarkdownSelectionSurface.wordBoundaryForGlobal`), with the text-based `wordRangeIn` heuristic as an unmounted-document fallback. +- **Soft-wrap affinity:** hit-testing captures `TextAffinity` from + `TextPainter.getPositionForOffset` and stores it ephemerally on the controller + (not on model `MarkdownPosition`). Handle endpoints use + `getOffsetForCaret` with that affinity so an end handle at a wrap stays on the + visual line under the pointer instead of snapping to the next line's start. + Word/block granular drag commits reading-start/end affinities (downstream / + upstream) so reverse expansion does not leave stale wrap affinities. If + directed carets still nearly coincide while the selection paints a span, + `selectionHandleEndpoints` falls back to visible rect extremes. +- **Edge autoscroll:** while dragging a non-collapsed selection (mouse pan, long- + press move, or handle drag) near the **visible clip** of the host union + (mounted selectable bodies ∩ padded viewport), + `applyMarkdownSelectionAutoscroll` jumps the scrollable. A `Ticker` keeps + scrolling while the pointer stays in-band and the host-union edge is not yet + flush; it pauses on hard-stop / leave-band / arming-gate. Configure via + `MarkdownSelectionScope.autoscroll` (default `edgeZone: 48`). - **Cursor:** the `MarkdownWidget` render object is a `MouseTrackerAnnotation`; it shows the click (hand) cursor over an actionable link (a span with a tap recognizer, detected on hover via `handleEvent` → `markNeedsPaint` so @@ -152,13 +191,58 @@ Statics: `MarkdownSelectionScope.of/maybeOf` (→ controller), `stateOf` (→ st select-all, Shift+arrows extend (char/word/line/doc), Esc clear. Extension intents no-op when `collapseSelection` is true (unshifted arrows don't move it). - **Native handles + magnifier:** touch platforms only (android/iOS/fuchsia). A - Flutter `SelectionOverlay` is driven from `controller.selectionHandleEndpoints()`; - handle drags call `controller.moveSelectionEdgeToGlobal(...)`. Overlay creation - is skipped when there's no `Overlay` host. + Flutter `SelectionOverlay` is driven from `controller.selectionHandleEndpoints()` + on the directed base/extent edges (types flip when reversed); handle (and + long-press) drags call `controller.moveSelectionEdgeToGlobal(...)` and update + the magnifier. Android long-press defers showing handles until press-end. + Overlay creation is skipped when there's no `Overlay` host. In-flight drag + updates stay pointer-synchronous (not deferred to a later frame). - **Toolbar:** `ContextMenuController` + `AdaptiveTextSelectionToolbar`; default items are Copy (non-collapsed selection) and Select-all (`hasDocuments`). - Public ops: `copySelection`, `selectAll`, `clearSelection`, `showToolbar`, - `hideToolbar`. + Expanding (body or handle drag) hides the menu; it may re-show on drag end. + Programmatic `controller.selectAll()` / assigning a non-collapsed + `controller.selection` sets `toolbarWanted` and starts the toolbar lifecycle + without a gesture (same restore path as scroll remount). Public ops: + `copySelection`, `selectAll`, `clearSelection`, `showToolbar`, `hideToolbar`. + + **Anchors (`contextMenuAnchors`):** absolute global points derived from the + intersection of painted selection rects with the scope bounds and any + enclosing `RenderAbstractViewport` (same idea as + `TextSelectionToolbarAnchors.fromSelection`). Default placement: + + | Visible geometry | Anchors | + | -------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | + | Both directed endpoints in the clip | `visible.topCenter` / `visible.bottomCenter` (Material/Cupertino prefer above when it fits) | + | Exactly one endpoint in the clip | Bottom-only → prefer **below** that caret when there is room; top-only → stock above that caret | + | Neither mounted endpoint in the clip (mid-viewport of a large range) | Pin near the **top** of the visible region (secondary only a short offset below) so the toolbar’s below-fallback cannot sink to the host bottom | + | Intersection empty (scrolled / virtualized away) | Overlay removed; `MarkdownSelectionController.toolbarWanted` keeps show intent until intentional `hideToolbar` — scrolling or remounting restores the menu when paint re-enters view | + + Endpoint-in-clip checks use **mounted, non-proxied** carets + (`selectionEndpointGlobalRect`) — handle proxies onto visible paint must not + look like a real edge. These rules are package defaults, not a public config. + Customize placement or hide/show policy via `contextMenuBuilder` (read + `contextMenuAnchors` or ignore them). Prefer a future single geometry policy + object over ad-hoc knobs if a second host needs different mid-selection chrome. + + While the menu is up, scroll / size-changed rebuilds the overlay entry so + anchors stay live (unlike handles, which follow surface `LeaderLayer`s + without a rebuild). Two scroll paths: a descendant `NotificationListener` + (when the scope **wraps** the scrollable) and an ancestor + `ScrollNotificationObserver` (e.g. `Scaffold`, when the scope is a scrollable + **child**). Prefer wrapping the scrollable with the host so both toolbar and + gesture hit-testing stay aligned. Framework `SelectableRegion` often avoids + rebuilds by wrapping the menu in a `CompositedTransformFollower` tied to a + target that scrolls with the content; a host that wraps a whole scrollable + cannot rely on a scope-level `LayerLink` alone. Rebuilds are deferred off the + build/layout phase (same rule as `SelectionOverlay.markNeedsBuild`) so scroll + notifications during a list tile rebuild cannot dirty `_OverlayEntryWidget` + mid-build. `SizeChangedLayoutNotification` only schedules geometry / overlay + work for after the frame — reading `localToGlobal` / `RenderBox.size` + synchronously from that callback (including the drag-time handle sync path) + runs during `performLayout` and asserts `sizeAccessAllowed`. Secondary-tap + freeze is cleared when the selection moves. Focus loss while the app is + resumed clears the selection. + - **`MarkdownSelectionGroup`:** pass the same group to several controllers and at most one has an active selection — a new non-collapsed selection clears the others. `clearExternal()` clears all (call when a non-Markdown `SelectableText`/ @@ -168,10 +252,12 @@ Statics: `MarkdownSelectionScope.of/maybeOf` (→ controller), `stateOf` (→ st `MarkdownWidget` is selectable only when given a `documentId` **and** a resolvable controller (explicit `controller:` or ambient `MarkdownSelectionScope.maybeOf`); -otherwise it's inert. **The app must register the document's model** with the -controller (`setDocuments`/`putDocument`) — the widget does not self-register. -Typical pattern: keep models in a list, feed them to the controller, and give each -`MarkdownWidget` its matching `documentId`. +otherwise it's inert. Prefer an **app-owned** registry (`setDocuments` / +`putDocument` with stable ids and unique reading-order `order` values) so +Select-all / extraction stay correct for unmounted bodies. The render object +also heals with `putDocument` on attach so a mounted selectable body is never +absent from the registry; that heal alone is not a substitute for explicit +registration when documents outlive their widgets (virtualized lists). ## Gotchas / known limitations @@ -183,11 +269,17 @@ Typical pattern: keep models in a list, feed them to the controller, and give ea to block start-end within the linearized text, not visual lines). Only `extendSelectionToAdjacentLine` uses real on-screen geometry (and no-ops when the endpoint is unmounted). -- Handle anchors can lag after a **reflow with no selection change** (streaming to - another block, font/scale change) until the next selection change — the overlay - sync is driven by selection-change notifications only. -- `moveSelectionEdgeToGlobal` refuses a drag that would collapse the selection (to - avoid disposing the overlay mid-gesture). +- Handle anchors refresh on selection change, scroll, and mounted-surface layout + change (`SizeChangedLayoutNotification`). Streaming reflow with no selection + change still relies on those notifications (or the next selection update). +- Toolbar mid-selection geometry (neither mounted endpoint in clip → top-pin; + bottom-only endpoint → prefer below that caret; both in clip → stock + above/below anchors; empty intersection → hide + `toolbarWanted` restore) is + intentional package default, not a public knob. Override via + `contextMenuBuilder` if a host needs different chrome. +- `moveSelectionEdgeToGlobal` keeps a one-character minimum while dragging + (nudges instead of collapsing) and allows directed edges to cross; handle + overlays follow base/extent and flip types when reversed. - `MarkdownSelectedBlock.sourceRange` is documented as best-effort but is currently always null. - `MarkdownThemeData.blockFilter` drops whole blocks at render time, but selection @@ -201,3 +293,8 @@ Typical pattern: keep models in a list, feed them to the controller, and give ea - Documents are ordered by their `order` key via `List.sort`, which is **not stable**, so documents sharing an identical `order` have unspecified relative order. Supply unique `order` values (e.g. the message index). +- Do not assume `removeDocument` drops the registry entry immediately: if a surface + is still mounted, removal waits for `detachSurface`. A later `putDocument` for the + same id cancels the pending remove (dispose → remount race). +- A selection whose base or extent points at an unregistered document paints nothing + via `rangeFor` (safe empty) — do not rely on `-1` ordering. diff --git a/lib/flutter_md.dart b/lib/flutter_md.dart index a301310..ab6ecf7 100644 --- a/lib/flutter_md.dart +++ b/lib/flutter_md.dart @@ -25,6 +25,7 @@ export 'src/render.dart' BlockPainter$Spacer; export 'src/highlight/engine.dart' show CodeHighlightTheme, SyntaxHighlighter; export 'src/selection.dart'; +export 'src/selection_autoscroll.dart'; export 'src/selection_scope.dart'; export 'src/theme.dart'; export 'src/widget.dart'; diff --git a/lib/src/render/block_painter.dart b/lib/src/render/block_painter.dart index c261e09..e0e5d79 100644 --- a/lib/src/render/block_painter.dart +++ b/lib/src/render/block_painter.dart @@ -59,6 +59,15 @@ abstract interface class SelectableBlockPainter implements BlockPainter { /// Maps a block-local [local] offset to a rendered-text index. int offsetForLocalPosition(Offset local); + /// Maps a block-local [local] point to a rendered-text index and the + /// [TextAffinity] from [TextPainter.getPositionForOffset]. Affinity matters + /// at soft-wrap boundaries (same offset, end of one visual line vs start of + /// the next) so handles stay on the line under the pointer. + (int, TextAffinity) positionAndAffinityForLocal(Offset local); + + /// Block-local caret rect for [offset] with [affinity] (soft-wrap aware). + Rect caretRectFor(int offset, TextAffinity affinity); + /// Highlight rectangles (block-local) for the rendered range `[start, end)`. List boxesForRange(int start, int end); @@ -71,6 +80,13 @@ abstract interface class SelectableBlockPainter implements BlockPainter { /// Whether an actionable link (a span carrying a tap recognizer) sits under /// the block-local [local] point — used to show the click (hand) cursor. bool isLinkAtLocal(Offset local); + + /// When true, the selection highlight is also painted *above* the cached + /// content picture so an opaque block background (e.g. a code fence) does + /// not hide it. Default text blocks leave this false so glyphs stay on top + /// of the highlight — matching [SelectableRegion] / [SelectionArea] paint + /// order (sharp text, no washed-out overlay). + bool get selectionHighlightAboveCachedContent; } /// Provides [SelectableBlockPainter] for a block backed by a single @@ -83,12 +99,37 @@ mixin SelectableTextBlock implements SelectableBlockPainter { /// Block-local origin where [selectionPainter] is painted. Offset get selectionOrigin => Offset.zero; + @override + bool get selectionHighlightAboveCachedContent => false; + @override String get renderedText => selectionPainter.plainText; @override int offsetForLocalPosition(Offset local) => - selectionPainter.getPositionForOffset(local - selectionOrigin).offset; + positionAndAffinityForLocal(local).$1; + + @override + (int, TextAffinity) positionAndAffinityForLocal(Offset local) { + final position = + selectionPainter.getPositionForOffset(local - selectionOrigin); + return (position.offset, position.affinity); + } + + @override + Rect caretRectFor(int offset, TextAffinity affinity) { + final len = selectionPainter.plainText.length; + final o = offset.clamp(0, len); + final position = TextPosition(offset: o, affinity: affinity); + final caret = selectionPainter.getOffsetForCaret(position, Rect.zero); + final height = selectionPainter.getFullHeightForCaret(position, Rect.zero); + return Rect.fromLTWH( + selectionOrigin.dx + caret.dx, + selectionOrigin.dy + caret.dy, + 0, + height, + ); + } @override List boxesForRange(int start, int end) => selectionPainter @@ -146,12 +187,49 @@ mixin MultiPainterSelectable implements SelectableBlockPainter { List get fragments; @override - int offsetForLocalPosition(Offset local) { + bool get selectionHighlightAboveCachedContent => false; + + @override + int offsetForLocalPosition(Offset local) => + positionAndAffinityForLocal(local).$1; + + @override + (int, TextAffinity) positionAndAffinityForLocal(Offset local) { final fragment = _nearestFragment(local); - if (fragment == null) return 0; - final inner = - fragment.painter.getPositionForOffset(local - fragment.origin).offset; - return fragment.textStart + inner.clamp(0, fragment.length); + if (fragment == null) { + return (0, TextAffinity.downstream); + } + final dx = + (local.dx - fragment.origin.dx).clamp(0.0, fragment.painter.width); + final dy = + (local.dy - fragment.origin.dy).clamp(0.0, fragment.painter.height); + final position = fragment.painter.getPositionForOffset(Offset(dx, dy)); + final offset = + fragment.textStart + position.offset.clamp(0, fragment.length); + return (offset, position.affinity); + } + + @override + Rect caretRectFor(int offset, TextAffinity affinity) { + final fragment = _fragmentForOffset(offset); + if (fragment == null) return Rect.zero; + final local = (offset - fragment.textStart).clamp(0, fragment.length); + final position = TextPosition(offset: local, affinity: affinity); + final caret = fragment.painter.getOffsetForCaret(position, Rect.zero); + final height = fragment.painter.getFullHeightForCaret(position, Rect.zero); + return Rect.fromLTWH( + fragment.origin.dx + caret.dx, + fragment.origin.dy + caret.dy, + 0, + height, + ); + } + + SelectableFragment? _fragmentForOffset(int offset) { + for (final fragment in fragments) { + if (offset <= fragment.textEnd) return fragment; + } + return fragments.isEmpty ? null : fragments.last; } @override diff --git a/lib/src/render/blocks/alert.dart b/lib/src/render/blocks/alert.dart index e543755..df2155f 100644 --- a/lib/src/render/blocks/alert.dart +++ b/lib/src/render/blocks/alert.dart @@ -29,6 +29,8 @@ class BlockPainter$Alert required List spans, required this.theme, }) : _accent = theme.alertColorFor(alert), + _selectionHighlightAboveCachedContent = + markdownSpansPaintOpaqueBackground(spans), titlePainter = TextPainter( text: TextSpan( text: alert.title, @@ -48,6 +50,13 @@ class BlockPainter$Alert textScaler: theme.textScaler, ); + /// Alert chrome and optional inline monospace/highlight fills sit in the + /// content [Picture]. + @override + bool get selectionHighlightAboveCachedContent => + _selectionHighlightAboveCachedContent; + final bool _selectionHighlightAboveCachedContent; + /// The kind of the alert being painted. final MD$AlertType alert; diff --git a/lib/src/render/blocks/code.dart b/lib/src/render/blocks/code.dart index d44733c..6920395 100644 --- a/lib/src/render/blocks/code.dart +++ b/lib/src/render/blocks/code.dart @@ -17,6 +17,11 @@ class BlockPainter$Code with SelectableTextBlock implements BlockPainter { @override Offset get selectionOrigin => const Offset(padding, padding); + /// Opaque fence chrome sits in the content [Picture]; highlight must paint + /// above that picture or it disappears under the fill. + @override + bool get selectionHighlightAboveCachedContent => true; + /// Creates a code-block painter for [text] in [language], styled by [theme]. BlockPainter$Code({ required String text, diff --git a/lib/src/render/blocks/heading.dart b/lib/src/render/blocks/heading.dart index 8315d10..463f508 100644 --- a/lib/src/render/blocks/heading.dart +++ b/lib/src/render/blocks/heading.dart @@ -24,7 +24,9 @@ class BlockPainter$Heading required int level, required List spans, required this.theme, - }) : painter = TextPainter( + }) : _selectionHighlightAboveCachedContent = + markdownSpansPaintOpaqueBackground(spans), + painter = TextPainter( text: paragraphFromMarkdownSpans( spans: spans, theme: theme, @@ -35,6 +37,11 @@ class BlockPainter$Heading textScaler: theme.textScaler, ); + @override + bool get selectionHighlightAboveCachedContent => + _selectionHighlightAboveCachedContent; + final bool _selectionHighlightAboveCachedContent; + /// The theme used to style the heading. final MarkdownThemeData theme; diff --git a/lib/src/render/blocks/list.dart b/lib/src/render/blocks/list.dart index fe538db..d66663a 100644 --- a/lib/src/render/blocks/list.dart +++ b/lib/src/render/blocks/list.dart @@ -46,7 +46,24 @@ class BlockPainter$List required List items, required this.theme, }) : _items = items, - _painters = <_ListItemMetrics>[]; + _painters = <_ListItemMetrics>[], + _selectionHighlightAboveCachedContent = + _listItemsPaintOpaqueBackground(items); + + /// Inline monospace/highlight fills in item text live in the content + /// [Picture]. + @override + bool get selectionHighlightAboveCachedContent => + _selectionHighlightAboveCachedContent; + final bool _selectionHighlightAboveCachedContent; + + static bool _listItemsPaintOpaqueBackground(List items) { + for (final item in items) { + if (markdownSpansPaintOpaqueBackground(item.spans)) return true; + if (_listItemsPaintOpaqueBackground(item.children)) return true; + } + return false; + } /// The theme used to style the list. final MarkdownThemeData theme; diff --git a/lib/src/render/blocks/paragraph.dart b/lib/src/render/blocks/paragraph.dart index aac47e0..2707ba7 100644 --- a/lib/src/render/blocks/paragraph.dart +++ b/lib/src/render/blocks/paragraph.dart @@ -23,7 +23,9 @@ class BlockPainter$Paragraph BlockPainter$Paragraph({ required List spans, required this.theme, - }) : painter = TextPainter( + }) : _selectionHighlightAboveCachedContent = + markdownSpansPaintOpaqueBackground(spans), + painter = TextPainter( text: paragraphFromMarkdownSpans( spans: spans, theme: theme, @@ -33,6 +35,13 @@ class BlockPainter$Paragraph textScaler: theme.textScaler, ); + /// Inline monospace/highlight fills live in the content [Picture]; paint the + /// selection tint above that picture when present. + @override + bool get selectionHighlightAboveCachedContent => + _selectionHighlightAboveCachedContent; + final bool _selectionHighlightAboveCachedContent; + /// The theme used to style the paragraph. final MarkdownThemeData theme; diff --git a/lib/src/render/blocks/quote.dart b/lib/src/render/blocks/quote.dart index 5a8fb3c..07623a3 100644 --- a/lib/src/render/blocks/quote.dart +++ b/lib/src/render/blocks/quote.dart @@ -28,7 +28,9 @@ class BlockPainter$Quote required List spans, required this.indent, required this.theme, - }) : painter = TextPainter( + }) : _selectionHighlightAboveCachedContent = + markdownSpansPaintOpaqueBackground(spans), + painter = TextPainter( text: paragraphFromMarkdownSpans( spans: spans, theme: theme, @@ -45,6 +47,12 @@ class BlockPainter$Quote ..strokeWidth = 4.0 ..style = PaintingStyle.fill; + /// Inline monospace/highlight fills live in the content [Picture]. + @override + bool get selectionHighlightAboveCachedContent => + _selectionHighlightAboveCachedContent; + final bool _selectionHighlightAboveCachedContent; + /// The theme used to style the quote. final MarkdownThemeData theme; diff --git a/lib/src/render/blocks/table.dart b/lib/src/render/blocks/table.dart index 8e8b55c..bc386fd 100644 --- a/lib/src/render/blocks/table.dart +++ b/lib/src/render/blocks/table.dart @@ -39,6 +39,11 @@ class BlockPainter$Table ..color = theme.surfaceColor ?? const Color.fromARGB(255, 235, 235, 235); + /// Zebra row fills (and cell inline chrome) sit in the content [Picture]; + /// highlight must paint above that picture or it disappears under the fill. + @override + bool get selectionHighlightAboveCachedContent => true; + /// Padding for table cells. static const double padding = 8.0; diff --git a/lib/src/render/markdown_painter.dart b/lib/src/render/markdown_painter.dart index 266aa15..4fcc3a7 100644 --- a/lib/src/render/markdown_painter.dart +++ b/lib/src/render/markdown_painter.dart @@ -45,6 +45,9 @@ class MarkdownPainter { /// Current markdown entity to render. Markdown _markdown; + /// The markdown model currently painted by this painter. + Markdown get markdown => _markdown; + /// Current theme for the markdown widget. MarkdownThemeData _theme; @@ -154,14 +157,35 @@ class MarkdownPainter { /// Maps a content-local [local] offset to `(sourceBlockIndex, offset)`, or /// null if the hit block does not support selection. (int, int)? positionForLocal(Offset local) { + final hit = positionAndAffinityForLocal(local); + return hit == null ? null : (hit.$1, hit.$2); + } + + /// Like [positionForLocal] but also returns the soft-wrap [TextAffinity]. + (int, int, TextAffinity)? positionAndAffinityForLocal(Offset local) { if (_blockPainters.isEmpty) return null; final idx = _blockIndexForDy(local.dy); final painter = _blockPainters[idx]; if (painter is! SelectableBlockPainter) return null; final blockLocal = Offset(local.dx, local.dy - _blockOffsets[idx]); final len = painter.renderedText.length; - final offset = painter.offsetForLocalPosition(blockLocal).clamp(0, len); - return (_sourceIndices[idx], offset); + final (offset, affinity) = painter.positionAndAffinityForLocal(blockLocal); + return (_sourceIndices[idx], offset.clamp(0, len), affinity); + } + + /// Block-local caret rect for a source block [sourceIndex] at [offset] with + /// [affinity], shifted into content-local coordinates. Null when the block + /// is not painted or not selectable. + Rect? caretRectFor(int sourceIndex, int offset, TextAffinity affinity) { + for (var i = 0; i < _blockPainters.length; i++) { + if (_sourceIndices[i] != sourceIndex) continue; + final painter = _blockPainters[i]; + if (painter is! SelectableBlockPainter) return null; + return painter + .caretRectFor(offset, affinity) + .shift(Offset(0, _blockOffsets[i])); + } + return null; } /// Maps a content-local [local] point to the word range at it, as @@ -196,14 +220,23 @@ class MarkdownPainter { /// Paints the selection highlight of every selectable block, using [rangeOf] /// to look up the selected rendered range for a source block index. + /// + /// When [aboveCachedContentOnly] is true, only blocks that report + /// [SelectableBlockPainter.selectionHighlightAboveCachedContent] are painted + /// (opaque chrome that would hide an under-content highlight). void paintHighlight( Canvas canvas, TextRange? Function(int sourceIndex) rangeOf, - Paint paint, - ) { + Paint paint, { + bool aboveCachedContentOnly = false, + }) { for (var i = 0; i < _blockPainters.length; i++) { final painter = _blockPainters[i]; if (painter is! SelectableBlockPainter) continue; + if (aboveCachedContentOnly != + painter.selectionHighlightAboveCachedContent) { + continue; + } final range = rangeOf(_sourceIndices[i]); if (range == null || range.start >= range.end) continue; final top = _blockOffsets[i]; @@ -289,6 +322,59 @@ class MarkdownPainter { return _size = Size(width, height); } + /// Vertical positions where each visual text line ends, top-down. + /// + /// Positions are absolute in the laid-out content. Runs sharing a bottom + /// edge — cells of a table row, a list bullet and its first line — count as + /// one line. Blocks without text (spacer, divider) contribute no line of + /// their own; their height still shows in the positions after them. + /// + /// Only valid after [layout]. + List textLineBottoms() { + if (_isEmpty) return const []; + final blocks = _blockPainters; + if (_blockOffsets.length != blocks.length) return const []; + final bottoms = []; + for (var i = 0; i < blocks.length; i++) { + final top = _blockOffsets[i]; + for (final (painter, offset) in _textFragments(blocks[i])) { + var bottom = top + offset.dy; + for (final line in painter.computeLineMetrics()) { + bottom += line.height; + bottoms.add(bottom); + } + } + } + bottoms.sort(); + final lines = []; + for (final bottom in bottoms) { + // Runs of one line round to slightly different bottoms; a cut may only + // land where every run on that line has ended. + if (lines.isNotEmpty && bottom - lines.last < 1) { + lines[lines.length - 1] = bottom; + continue; + } + lines.add(bottom); + } + return lines; + } + + /// Selectable text runs of [block], as `(painter, block-local origin)`. + static Iterable<(TextPainter, Offset)> _textFragments(BlockPainter block) { + if (block is SelectableTextBlock) { + return <(TextPainter, Offset)>[ + ( + block.selectionPainter, + block.selectionOrigin, + ) + ]; + } + if (block is MultiPainterSelectable) { + return block.fragments.map((f) => (f.painter, f.origin)); + } + return const <(TextPainter, Offset)>[]; + } + /// Routes a tap-down / tap-up to the block under the pointer, re-basing the /// event's position into that block's local space (only taps are handled; the /// block painters use it to fire link recognizers). diff --git a/lib/src/render/markdown_render_object.dart b/lib/src/render/markdown_render_object.dart index 7f4cad2..a1b6b19 100644 --- a/lib/src/render/markdown_render_object.dart +++ b/lib/src/render/markdown_render_object.dart @@ -15,7 +15,7 @@ import '../selection.dart'; import '../theme.dart'; import 'markdown_painter.dart'; -/// Default color used to paint the selection highlight over the glyphs. +/// Default color used to paint the selection highlight under the glyphs. const Color _kSelectionColor = Color(0x552196F3); @meta.internal @@ -42,10 +42,20 @@ class MarkdownRenderObject extends RenderBox // Selection-handle leader layers pushed during paint so native handles // (drawn by the scope's SelectionOverlay) follow the content as it scrolls. + // [LayerHandle] keeps a single LeaderLayer per link across paints (matches + // RenderEditable) so stale leaders cannot linger and double-up chrome. LayerLink? _startHandleLink; Offset? _startHandleLocal; LayerLink? _endHandleLink; Offset? _endHandleLocal; + final LayerHandle _startHandleLayer = LayerHandle(); + final LayerHandle _endHandleLayer = LayerHandle(); + + /// Whether this surface currently owns a start and/or end handle leader. + @visibleForTesting + bool get debugHasSelectionHandleLeaders => + (_startHandleLink != null && _startHandleLocal != null) || + (_endHandleLink != null && _endHandleLocal != null); void _onSelectionChange() { if (!_disposed) markNeedsPaint(); @@ -55,9 +65,16 @@ class MarkdownRenderObject extends RenderBox void _attachController() { final controller = _controller; - if (controller == null) return; + final id = _documentId; + if (controller == null || id == null) return; controller.addListener(_onSelectionChange); - if (attached) controller.attachSurface(this); + if (attached) { + // Mounted surfaces must exist in the registry so [rangeFor] / ordering + // can resolve them. AppMarkdown also putDocuments; this heals races where + // the RO attaches before (or without) an app-level registration. + controller.putDocument(id, _painter.markdown); + controller.attachSurface(this); + } } void _detachController() { @@ -65,6 +82,7 @@ class MarkdownRenderObject extends RenderBox if (controller == null) return; controller.removeListener(_onSelectionChange); controller.detachSurface(this); + _clearSelectionHandleLayers(); } /// Wires (or rewires) this render object to a selection [controller] under @@ -95,18 +113,35 @@ class MarkdownRenderObject extends RenderBox @override MarkdownPosition? positionForGlobal(Offset globalPosition) { + final hit = hitForGlobal(globalPosition); + return hit?.$1; + } + + @override + (MarkdownPosition, TextAffinity)? hitForGlobal(Offset globalPosition) { final id = _documentId; if (id == null) return null; final local = globalToLocal(globalPosition); - final hit = _painter.positionForLocal(local); + final hit = _painter.positionAndAffinityForLocal(local); if (hit == null) return null; - return MarkdownPosition( - documentId: id, - blockIndex: hit.$1, - offset: hit.$2, + return ( + MarkdownPosition( + documentId: id, + blockIndex: hit.$1, + offset: hit.$2, + ), + hit.$3, ); } + @override + Rect? caretRectFor(MarkdownPosition position, TextAffinity affinity) { + final id = _documentId; + if (id == null || position.documentId != id) return null; + return _painter.caretRectFor( + position.blockIndex, position.offset, affinity); + } + @override (int, int, int)? wordBoundaryForGlobal(Offset globalPosition) { if (_documentId == null) return null; @@ -150,7 +185,18 @@ class MarkdownRenderObject extends RenderBox _endHandleLocal = endLocal; changed = true; } - if (changed && !_disposed && attached) markNeedsPaint(); + if (!changed || _disposed || !attached) return; + markNeedsCompositingBitsUpdate(); + markNeedsPaint(); + } + + void _clearSelectionHandleLayers() { + _startHandleLink = null; + _startHandleLocal = null; + _endHandleLink = null; + _endHandleLocal = null; + _startHandleLayer.layer = null; + _endHandleLayer.layer = null; } @override @@ -191,7 +237,7 @@ class MarkdownRenderObject extends RenderBox bool get isRepaintBoundary => _controller != null; @override - bool get alwaysNeedsCompositing => false; + bool get alwaysNeedsCompositing => debugHasSelectionHandleLeaders; @override bool get sizedByParent => false; @@ -273,7 +319,12 @@ class MarkdownRenderObject extends RenderBox void attach(PipelineOwner owner) { super.attach(owner); PaintingBinding.instance.systemFonts.addListener(_handleSystemFontsChange); - _controller?.attachSurface(this); + final controller = _controller; + final id = _documentId; + if (controller != null && id != null) { + controller.putDocument(id, _painter.markdown); + controller.attachSurface(this); + } } /// Updates the render object with a new values. @@ -290,6 +341,11 @@ class MarkdownRenderObject extends RenderBox // Mark the render object as needing layout. markNeedsLayout(); } + final controller = _controller; + final id = _documentId; + if (controller != null && id != null) { + controller.putDocument(id, markdown); + } } @override @@ -298,6 +354,9 @@ class MarkdownRenderObject extends RenderBox PaintingBinding.instance.systemFonts .removeListener(_handleSystemFontsChange); _controller?.detachSurface(this); + // Drop leaders before leaving the tree so a detached surface cannot keep + // LayerLinks alive and produce ghost / double handles after remount churn. + _clearSelectionHandleLayers(); super.detach(); } @@ -306,6 +365,7 @@ class MarkdownRenderObject extends RenderBox void dispose() { _disposed = true; _controller?.removeListener(_onSelectionChange); + _clearSelectionHandleLayers(); super.dispose(); _painter.dispose(); } @@ -321,21 +381,29 @@ class MarkdownRenderObject extends RenderBox ..translate(offset.dx, offset.dy); //..clipRect(Rect.fromLTWH(0, 0, size.width, size.height)); - _painter.paint(canvas, size); - - // Paint the selection highlight OUTSIDE the cached content Picture, but ON - // TOP of the glyphs, so a translucent highlight stays visible even over - // opaque block/inline backgrounds (code fences, `inline code`, ==mark==). - // Still outside the Picture, so drag/streaming repaints never rebuild the - // glyph cache (the S7 invariant holds). + // Selection highlight stays outside the cached content Picture (S7: drag / + // streaming never rebuilds the glyph cache). Paint order matches + // SelectableRegion: highlight under glyphs so text stays sharp, then a + // second pass above opaque block chrome (code fences, table zebra rows, + // inline monospace/highlight) so the tint stays visible there. final controller = _controller; final id = _documentId; + TextRange? rangeOf(int source) => controller != null && id != null + ? controller.rangeFor(id, source) + : null; if (controller != null && id != null) { _highlightPaint.color = controller.selectionColor ?? _kSelectionColor; + _painter.paintHighlight(canvas, rangeOf, _highlightPaint); + } + + _painter.paint(canvas, size); + + if (controller != null && id != null) { _painter.paintHighlight( canvas, - (source) => controller.rangeFor(id, source), + rangeOf, _highlightPaint, + aboveCachedContentOnly: true, ); } @@ -346,20 +414,24 @@ class MarkdownRenderObject extends RenderBox final startLink = _startHandleLink; final startLocal = _startHandleLocal; if (startLink != null && startLocal != null) { - context.pushLayer( - LeaderLayer(link: startLink, offset: offset + startLocal), - _paintNothing, - Offset.zero, + _startHandleLayer.layer = LeaderLayer( + link: startLink, + offset: offset + startLocal, ); + context.pushLayer(_startHandleLayer.layer!, _paintNothing, Offset.zero); + } else { + _startHandleLayer.layer = null; } final endLink = _endHandleLink; final endLocal = _endHandleLocal; if (endLink != null && endLocal != null) { - context.pushLayer( - LeaderLayer(link: endLink, offset: offset + endLocal), - _paintNothing, - Offset.zero, + _endHandleLayer.layer = LeaderLayer( + link: endLink, + offset: offset + endLocal, ); + context.pushLayer(_endHandleLayer.layer!, _paintNothing, Offset.zero); + } else { + _endHandleLayer.layer = null; } } } diff --git a/lib/src/render/span_builder.dart b/lib/src/render/span_builder.dart index 1adcc56..13c5a15 100644 --- a/lib/src/render/span_builder.dart +++ b/lib/src/render/span_builder.dart @@ -23,6 +23,20 @@ TapGestureRecognizer? _buildTapRecognizer( return null; } +/// Whether any of [spans] paints an opaque / translucent fill behind glyphs +/// (`monospace` / `highlight` backgrounds from [MarkdownThemeData]). Used to +/// decide whether the selection tint must paint *above* the cached content +/// picture so those fills do not hide it. +bool markdownSpansPaintOpaqueBackground(Iterable spans) { + for (final span in spans) { + if (span.style.contains(MD$Style.monospace) || + span.style.contains(MD$Style.highlight)) { + return true; + } + } + return false; +} + /// Helper function to create a [TextSpan] from markdown spans. /// This function filters the spans based on the theme's span filter, /// and applies the appropriate text style to each span. diff --git a/lib/src/selection.dart b/lib/src/selection.dart index 8422f8a..a5bc32c 100644 --- a/lib/src/selection.dart +++ b/lib/src/selection.dart @@ -1,4 +1,4 @@ -import 'dart:ui' show Color, Offset, Rect, TextRange; +import 'dart:ui' show Color, Offset, Rect, TextAffinity, TextRange; import 'package:flutter/foundation.dart'; import 'package:flutter/rendering.dart' show LayerLink; @@ -475,6 +475,13 @@ abstract interface class MarkdownSelectionSurface { /// Maps a global point to a logical position, or null if outside any text. MarkdownPosition? positionForGlobal(Offset globalPosition); + /// Like [positionForGlobal] but also returns soft-wrap [TextAffinity]. + (MarkdownPosition, TextAffinity)? hitForGlobal(Offset globalPosition); + + /// Content-local caret rect for [position] with [affinity], or null when this + /// surface does not own that position / cannot place a caret. + Rect? caretRectFor(MarkdownPosition position, TextAffinity affinity); + /// The word range at a global point, as `(blockIndex, start, end)` in the /// block's rendered-text space, using the platform word segmentation. Null /// when the point misses selectable text. @@ -506,6 +513,11 @@ abstract interface class MarkdownSelectionSurface { void repaintSelection(); } +/// Minimum global distance between directed handle carets. Closer than this +/// with a real selection span is treated as coincident (peanut) and expanded +/// onto visible selection extremes. +const double _kMinHandleCaretSeparation = 2.0; + /// The mounted geometry at the two ends of a selection, for placing handles. @immutable class MarkdownHandleEndpoints { @@ -520,7 +532,7 @@ class MarkdownHandleEndpoints { required this.endGlobal, }); - /// The surface owning the reading-order start edge. + /// The surface owning the directed start edge ([MarkdownSelection.base]). final MarkdownSelectionSurface startSurface; /// The local caret rect at the start edge (within [startSurface]). @@ -529,7 +541,7 @@ class MarkdownHandleEndpoints { /// The global caret rect at the start edge. final Rect startGlobal; - /// The surface owning the reading-order end edge. + /// The surface owning the directed end edge ([MarkdownSelection.extent]). final MarkdownSelectionSurface endSurface; /// The local caret rect at the end edge (within [endSurface]). @@ -611,14 +623,100 @@ class MarkdownSelectionController extends ChangeNotifier { final Map _surfaces = {}; + /// Ids whose [removeDocument] was deferred because a surface is still + /// mounted. Flushed in [detachSurface]; cancelled by a later [putDocument]. + /// + /// Parent [State.dispose] can run while the child's render object is still + /// attached, so an eager remove leaves a hittable surface with `di=-1`. + final Set _pendingRemoveIds = {}; + MarkdownSelection? _selection; + /// Soft-wrap affinity for [MarkdownSelection.base] / `.extent`. Chrome-only — + /// not part of model identity or reconciliation. + TextAffinity _baseAffinity = TextAffinity.downstream; + TextAffinity _extentAffinity = TextAffinity.downstream; + + bool _toolbarWanted = false; + + /// Whether the selection toolbar should stay available across scroll and + /// scope remounts. Set by [MarkdownSelectionScopeState.showToolbar], by a + /// non-collapsed public [selection] / [selectAll], and cleared by an + /// intentional hide or when the selection collapses. Survives list + /// virtualization so a remounted scope can restore the menu. Gesture + /// updates use [_commitSelection] and do not set this — the scope shows + /// the menu on drag end. + bool get toolbarWanted => _toolbarWanted; + set toolbarWanted(bool value) { + if (_toolbarWanted == value) return; + _toolbarWanted = value; + } + + /// Global caret rect for the mounted [base]/extent endpoint, or null when + /// that edge's surface is unmounted. Does not apply handle proxies. + Rect? selectionEndpointGlobalRect({required bool base}) { + final sel = _selection; + if (sel == null || sel.isCollapsed) return null; + final pos = base ? sel.base : sel.extent; + final affinity = base ? _baseAffinity : _extentAffinity; + final surface = _surfaces[pos.documentId]; + if (surface == null) return null; + final local = surface.caretRectFor(pos, affinity); + if (local == null) return null; + return local.shift(surface.globalBounds.topLeft); + } + + /// Whether the mounted (non-proxied) caret for [base] / extent intersects + /// [globalRect]. Unmounted endpoints return false — they are not "in view" + /// even if handle chrome would proxy them onto visible paint. + bool selectionEndpointOverlaps(Rect globalRect, {required bool base}) { + final global = selectionEndpointGlobalRect(base: base); + if (global == null) return false; + return globalRect.inflate(0.5).overlaps(global); + } + + /// True when either directed selection endpoint is mounted and overlaps + /// [globalRect] (see [selectionEndpointOverlaps]). + bool anySelectionEndpointOverlaps(Rect globalRect) => + selectionEndpointOverlaps(globalRect, base: true) || + selectionEndpointOverlaps(globalRect, base: false); + /// The current selection, or null when nothing is selected. + /// + /// Assigning a non-collapsed range marks [toolbarWanted] so a mounted + /// [MarkdownSelectionScope] starts (or restores) the toolbar without a + /// gesture. Gesture-driven updates go through [_commitSelection] instead. MarkdownSelection? get selection => _selection; set selection(MarkdownSelection? value) { if (value == _selection) return; _selection = value; - if (value != null && !value.isCollapsed) _group?._claim(this); + if (value == null || value.isCollapsed) { + toolbarWanted = false; + _baseAffinity = TextAffinity.downstream; + _extentAffinity = TextAffinity.downstream; + } else { + toolbarWanted = true; + _group?._claim(this); + } + notifyListeners(); + } + + void _commitSelection( + MarkdownSelection value, { + TextAffinity? baseAffinity, + TextAffinity? extentAffinity, + }) { + if (baseAffinity != null) _baseAffinity = baseAffinity; + if (extentAffinity != null) _extentAffinity = extentAffinity; + if (value.isCollapsed) toolbarWanted = false; + if (value == _selection) { + // Affinity-only update (same anchors, different soft-wrap line) still + // needs to refresh handle geometry. + notifyListeners(); + return; + } + _selection = value; + if (!value.isCollapsed) _group?._claim(this); notifyListeners(); } @@ -646,6 +744,7 @@ class MarkdownSelectionController extends ChangeNotifier { /// Replaces the whole registry (initial/bulk load), preserving a still-valid /// selection by clamping it into the new documents. void setDocuments(Iterable docs) { + _pendingRemoveIds.clear(); _docs ..clear() ..addAll(<_DocEntry>[ @@ -660,9 +759,12 @@ class MarkdownSelectionController extends ChangeNotifier { /// Inserts or updates one document. On a model change the selection is /// reconciled via [reconciliation] (this is the streaming entry point). void putDocument(Object id, Markdown model, {int? order}) { + // A remount / heal cancels a dispose that raced ahead of detach. + _pendingRemoveIds.remove(id); final idx = _orderIndex(id); if (idx < 0) { - _docs.add(_DocEntry(id, model, order ?? _docs.length)); + final assigned = order ?? _docs.length; + _docs.add(_DocEntry(id, model, assigned)); _sort(); notifyListeners(); return; @@ -683,7 +785,19 @@ class MarkdownSelectionController extends ChangeNotifier { } /// Removes a document. If the selection touched it, the selection is dropped. + /// + /// When a surface for [id] is still mounted, the remove is deferred until + /// [detachSurface] so hit-testing cannot outlive the registry entry. void removeDocument(Object id) { + if (_surfaces.containsKey(id)) { + _pendingRemoveIds.add(id); + return; + } + _forceRemoveDocument(id); + } + + void _forceRemoveDocument(Object id) { + _pendingRemoveIds.remove(id); _docs.removeWhere((e) => e.id == id); _reindex(); final sel = _selection; @@ -745,12 +859,18 @@ class MarkdownSelectionController extends ChangeNotifier { /// attach. void attachSurface(MarkdownSelectionSurface surface) { _surfaces[surface.documentId] = surface; + // Do not read globalBounds here — attach runs before layout; localToGlobal + // throws "not in the same render tree" / NEEDS-LAYOUT. } /// Unregisters a [surface]. Called on RenderObject detach/dispose. void detachSurface(MarkdownSelectionSurface surface) { - if (identical(_surfaces[surface.documentId], surface)) { - _surfaces.remove(surface.documentId); + final id = surface.documentId; + if (identical(_surfaces[id], surface)) { + _surfaces.remove(id); + if (_pendingRemoveIds.contains(id)) { + _forceRemoveDocument(id); + } } } @@ -762,16 +882,50 @@ class MarkdownSelectionController extends ChangeNotifier { /// When the point is inside a surface it is used directly; otherwise the /// vertically-nearest surface is chosen and the point clamped into it, so a /// drag through the gaps/edges between widgets still extends the selection. - MarkdownPosition? positionForGlobal(Offset globalPosition) { - final resolved = _resolveSurfaceAt(globalPosition); - return resolved?.$1.positionForGlobal(resolved.$2); + MarkdownPosition? positionForGlobal( + Offset globalPosition, { + bool requireContainment = false, + }) { + final hit = hitForGlobal( + globalPosition, + requireContainment: requireContainment, + ); + return hit?.$1; + } + + /// Like [positionForGlobal] but also returns soft-wrap [TextAffinity]. + (MarkdownPosition, TextAffinity)? hitForGlobal( + Offset globalPosition, { + bool requireContainment = false, + }) { + final resolved = _resolveSurfaceAt( + globalPosition, + clampToNearest: !requireContainment, + ); + if (resolved == null) return null; + return resolved.$1.hitForGlobal(resolved.$2); + } + + /// Whether [globalPosition] lies inside a mounted selectable surface's + /// bounds (no nearest-neighbor clamp). Used to gate gesture *starts*. + bool hitsSelectableContent(Offset globalPosition) { + for (final surface in _surfaces.values) { + final bounds = surface.globalBounds; + if (!bounds.isEmpty && bounds.contains(globalPosition)) return true; + } + return false; } - /// Resolves the surface a global point belongs to (directly when inside, - /// otherwise the vertically-nearest one) together with the point clamped into - /// that surface. Shared by [positionForGlobal] and [wordSelectionAt] so a - /// gesture through the gaps between widgets still resolves consistently. - (MarkdownSelectionSurface, Offset)? _resolveSurfaceAt(Offset globalPosition) { + /// Resolves the surface a global point belongs to. + /// + /// When [clampToNearest] is true (default), a miss picks the vertically + /// nearest surface and clamps the point into it so a drag through gaps + /// between widgets still extends. When false, a miss returns null so + /// gesture starts do not arm on chrome / empty space. + (MarkdownSelectionSurface, Offset)? _resolveSurfaceAt( + Offset globalPosition, { + bool clampToNearest = true, + }) { MarkdownSelectionSurface? nearest; var bestDistance = double.infinity; for (final surface in _surfaces.values) { @@ -780,6 +934,7 @@ class MarkdownSelectionController extends ChangeNotifier { // width/height) has nothing to select and would invert the clamp below. if (bounds.isEmpty) continue; if (bounds.contains(globalPosition)) return (surface, globalPosition); + if (!clampToNearest) continue; final dy = globalPosition.dy < bounds.top ? bounds.top - globalPosition.dy : (globalPosition.dy > bounds.bottom @@ -790,7 +945,7 @@ class MarkdownSelectionController extends ChangeNotifier { nearest = surface; } } - if (nearest == null) return null; + if (!clampToNearest || nearest == null) return null; final bounds = nearest.globalBounds; // Clamp INTO the surface, keeping the upper bound >= the lower bound so a // very small surface never inverts the limits (num.clamp throws then). @@ -811,43 +966,79 @@ class MarkdownSelectionController extends ChangeNotifier { void clear() => selection = null; /// Collapses the selection at [position]. - void collapseAt(MarkdownPosition position) => - selection = MarkdownSelection.collapsed(position); + void collapseAt( + MarkdownPosition position, { + TextAffinity affinity = TextAffinity.downstream, + }) => + _commitSelection( + MarkdownSelection.collapsed(position), + baseAffinity: affinity, + extentAffinity: affinity, + ); /// Extends the moving end of the selection to [position] (anchoring [base] /// first if there is no selection yet). - void extendTo(MarkdownPosition position) { + void extendTo( + MarkdownPosition position, { + TextAffinity affinity = TextAffinity.downstream, + }) { final sel = _selection; - selection = sel == null - ? MarkdownSelection.collapsed(position) - : MarkdownSelection(base: sel.base, extent: position); + if (sel == null) { + collapseAt(position, affinity: affinity); + return; + } + _commitSelection( + MarkdownSelection(base: sel.base, extent: position), + extentAffinity: affinity, + ); } /// Begins a selection at a global point (e.g. a drag start). - void startAtGlobal(Offset globalPosition) { - final p = positionForGlobal(globalPosition); - if (p != null) collapseAt(p); + /// + /// When [requireContainment] is true (default), a miss outside every + /// mounted surface is ignored — gesture starts must not clamp onto the + /// nearest markdown from chrome / empty space. + void startAtGlobal( + Offset globalPosition, { + bool requireContainment = true, + }) { + final hit = hitForGlobal( + globalPosition, + requireContainment: requireContainment, + ); + if (hit != null) collapseAt(hit.$1, affinity: hit.$2); } /// Extends the selection to a global point (e.g. a drag update). void extendToGlobal(Offset globalPosition) { - final p = positionForGlobal(globalPosition); - if (p != null) extendTo(p); + final hit = hitForGlobal(globalPosition); + if (hit != null) extendTo(hit.$1, affinity: hit.$2); } /// Selects everything across every registered document. + /// + /// Marks [toolbarWanted] so a mounted scope shows the context menu without + /// waiting for a gesture (same chrome lifecycle as assigning [selection]). void selectAll() { if (_docs.isEmpty) return; final first = _docs.first, last = _docs.last; if (first.model.blocks.isEmpty || last.model.blocks.isEmpty) return; final lastBlock = last.model.blocks.length - 1; - selection = MarkdownSelection( - base: MarkdownPosition(documentId: first.id, blockIndex: 0, offset: 0), - extent: MarkdownPosition( - documentId: last.id, - blockIndex: lastBlock, - offset: markdownBlockRenderedText(last.model.blocks[lastBlock]).length, + // Before [_commitSelection]'s notify so a listening scope restores chrome + // on the same turn (not only after a later gesture). + toolbarWanted = true; + _commitSelection( + MarkdownSelection( + base: MarkdownPosition(documentId: first.id, blockIndex: 0, offset: 0), + extent: MarkdownPosition( + documentId: last.id, + blockIndex: lastBlock, + offset: + markdownBlockRenderedText(last.model.blocks[lastBlock]).length, + ), ), + baseAffinity: TextAffinity.downstream, + extentAffinity: TextAffinity.upstream, ); } @@ -908,8 +1099,18 @@ class MarkdownSelectionController extends ChangeNotifier { /// (`TextPainter.getWordBoundary`, keeping intra-word punctuation like /// apostrophes); falls back to the text-based [wordRangeIn] heuristic when /// the surface can't answer (e.g. an unmounted document during a drag). - MarkdownSelection? wordSelectionAt(Offset globalPosition) { - final resolved = _resolveSurfaceAt(globalPosition); + /// + /// When [requireContainment] is true, returns null unless the point lies + /// inside a surface (no nearest-neighbor clamp). Gesture *starts* should + /// pass true; in-drag *extend* keeps the default false. + MarkdownSelection? wordSelectionAt( + Offset globalPosition, { + bool requireContainment = false, + }) { + final resolved = _resolveSurfaceAt( + globalPosition, + clampToNearest: !requireContainment, + ); if (resolved == null) return null; final (surface, point) = resolved; final id = surface.documentId; @@ -936,8 +1137,16 @@ class MarkdownSelectionController extends ChangeNotifier { /// The ordered selection of the whole block at a global point, without /// applying it. - MarkdownSelection? blockSelectionAt(Offset globalPosition) { - final p = positionForGlobal(globalPosition); + /// + /// See [wordSelectionAt] for [requireContainment]. + MarkdownSelection? blockSelectionAt( + Offset globalPosition, { + bool requireContainment = false, + }) { + final p = positionForGlobal( + globalPosition, + requireContainment: requireContainment, + ); if (p == null) return null; final di = _orderIndex(p.documentId); if (di < 0) return null; @@ -950,17 +1159,47 @@ class MarkdownSelectionController extends ChangeNotifier { /// Selects the word at a global point (double-click / long-press). Returns /// the ordered anchor selection it applied, or null if the point misses. - MarkdownSelection? selectWordAtGlobal(Offset globalPosition) { - final sel = wordSelectionAt(globalPosition); - if (sel != null) selection = sel; + /// + /// Defaults to [requireContainment] so chrome / empty-space presses do not + /// clamp onto the nearest markdown surface. + MarkdownSelection? selectWordAtGlobal( + Offset globalPosition, { + bool requireContainment = true, + }) { + final sel = wordSelectionAt( + globalPosition, + requireContainment: requireContainment, + ); + if (sel != null) { + // Soft-wrap: keep the trailing handle on the end of the visual run. + _commitSelection( + sel, + baseAffinity: TextAffinity.downstream, + extentAffinity: TextAffinity.upstream, + ); + } return sel; } /// Selects the whole block (paragraph/line) at a global point (triple-click). /// Returns the ordered anchor selection it applied, or null if it misses. - MarkdownSelection? selectBlockAtGlobal(Offset globalPosition) { - final sel = blockSelectionAt(globalPosition); - if (sel != null) selection = sel; + /// + /// Defaults to [requireContainment] — see [selectWordAtGlobal]. + MarkdownSelection? selectBlockAtGlobal( + Offset globalPosition, { + bool requireContainment = true, + }) { + final sel = blockSelectionAt( + globalPosition, + requireContainment: requireContainment, + ); + if (sel != null) { + _commitSelection( + sel, + baseAffinity: TextAffinity.downstream, + extentAffinity: TextAffinity.upstream, + ); + } return sel; } @@ -976,14 +1215,33 @@ class MarkdownSelectionController extends ChangeNotifier { ? wordSelectionAt(globalPosition) : blockSelectionAt(globalPosition); if (target == null) return; + // Soft-wrap caret placement: reading-start edges use downstream, reading- + // end edges use upstream. Directed base/extent may be reversed when the + // drag expands past the original anchor. + const startAff = TextAffinity.downstream; + const endAff = TextAffinity.upstream; // anchor and target are each ordered (base <= extent). Keep the fixed // anchor edge as the base and put the moving edge in extent. if (_compare(target.base, anchor.base) < 0) { - selection = MarkdownSelection(base: anchor.extent, extent: target.base); + // Expanding toward the start: directed base stays at the old reading + // end; directed extent moves to the new reading start (reversed). + _commitSelection( + MarkdownSelection(base: anchor.extent, extent: target.base), + baseAffinity: endAff, + extentAffinity: startAff, + ); } else if (_compare(target.extent, anchor.extent) > 0) { - selection = MarkdownSelection(base: anchor.base, extent: target.extent); + _commitSelection( + MarkdownSelection(base: anchor.base, extent: target.extent), + baseAffinity: startAff, + extentAffinity: endAff, + ); } else { - selection = MarkdownSelection(base: anchor.base, extent: anchor.extent); + _commitSelection( + MarkdownSelection(base: anchor.base, extent: anchor.extent), + baseAffinity: startAff, + extentAffinity: endAff, + ); } } @@ -1037,27 +1295,80 @@ class MarkdownSelectionController extends ChangeNotifier { } } - /// Moves one edge of the selection to a global point, keeping the other edge - /// fixed. Used by the draggable selection handles: [isStart] moves the - /// reading-order start edge, otherwise the end edge. + /// Whether [selection] is reversed: [MarkdownSelection.base] sits after + /// [MarkdownSelection.extent] in reading order. Handle overlays follow the + /// directed edges and flip types when this is true. + bool get isSelectionReversed { + final sel = _selection; + if (sel == null || sel.isCollapsed) return false; + return _compare(sel.base, sel.extent) > 0; + } + + /// Moves one directed edge of the selection to a global point, keeping the + /// other edge fixed. [isStart] moves [MarkdownSelection.base] (the start + /// handle); otherwise moves [MarkdownSelection.extent] (the end handle). + /// + /// Edges may cross (reversed selection). Landing exactly on the fixed edge + /// nudges one character away so the selection never collapses mid-drag. void moveSelectionEdgeToGlobal( Offset globalPosition, { required bool isStart, }) { final sel = _selection; if (sel == null) return; - final moved = positionForGlobal(globalPosition); - if (moved == null) return; - final (a, b) = _ordered(sel); + final hit = hitForGlobal(globalPosition); + if (hit == null) return; + final (rawMoved, affinity) = hit; + final fixed = isStart ? sel.extent : sel.base; + final previous = isStart ? sel.base : sel.extent; + final moved = _avoidCollapse(rawMoved, fixed: fixed, previous: previous); + if (moved == fixed) return; final next = isStart - ? MarkdownSelection(base: b, extent: moved) - : MarkdownSelection(base: a, extent: moved); - // Don't let a handle drag collapse the selection out from under itself - // (that would dispose the overlay mid-gesture); keep one caret gap. - if (next.isCollapsed) return; - selection = next; + ? MarkdownSelection(base: moved, extent: fixed) + : MarkdownSelection(base: fixed, extent: moved); + if (isStart) { + _commitSelection( + next, + baseAffinity: affinity, + extentAffinity: _affinityForDirectedEdge(sel, fixed), + ); + } else { + _commitSelection( + next, + baseAffinity: _affinityForDirectedEdge(sel, fixed), + extentAffinity: affinity, + ); + } + } + + /// If [moved] coincides with [fixed], bounce one character back toward + /// [previous] (the side the moving edge approached from). + MarkdownPosition _avoidCollapse( + MarkdownPosition moved, { + required MarkdownPosition fixed, + required MarkdownPosition previous, + }) { + if (moved != fixed) return moved; + final cmp = _compare(previous, fixed); + if (cmp > 0) { + return _stepCharacter(fixed, forward: true); + } + if (cmp < 0) { + return _stepCharacter(fixed, forward: false); + } + final forward = _stepCharacter(fixed, forward: true); + if (forward != fixed) return forward; + return _stepCharacter(fixed, forward: false); } + TextAffinity _affinityForDirectedEdge( + MarkdownSelection sel, + MarkdownPosition edge, + ) => + identical(edge, sel.base) || edge == sel.base + ? _baseAffinity + : _extentAffinity; + // --- geometry (mounted surfaces) ----------------------------------------- /// Global rects covering the current selection across every mounted surface, @@ -1079,45 +1390,152 @@ class MarkdownSelectionController extends ChangeNotifier { return out; } - /// Resolves the mounted surfaces and local/global caret rects at the two ends - /// of the current selection, for placing selection handles. Null when the - /// selection is collapsed or neither end is mounted. + /// Resolves the mounted surfaces and local/global caret rects at the two + /// directed edges of the current selection ([MarkdownSelection.base] / + /// [MarkdownSelection.extent]), for placing selection handles. + /// + /// When an edge's owning surface is unmounted (list virtualization) but + /// other surfaces still paint the selection, that handle is proxied onto + /// the nearest mounted rect so chrome stays visible. Null only when the + /// selection is collapsed or no mounted geometry can place either handle. + /// + /// When directed carets nearly coincide (soft-wrap affinity / scroll churn) + /// but the selection still paints a real span, both edges fall back to the + /// visible rect extremes so Material left+right handles do not stack into a + /// "peanut" at one caret. MarkdownHandleEndpoints? selectionHandleEndpoints() { final sel = _selection; if (sel == null || sel.isCollapsed) return null; - final (a, b) = _ordered(sel); - final startDoc = _orderIndex(a.documentId); - final endDoc = _orderIndex(b.documentId); - if (startDoc < 0 || endDoc < 0) return null; - MarkdownSelectionSurface? startSurface, endSurface; - Rect? startLocal, startGlobal, endLocal, endGlobal; - for (var d = startDoc; d <= endDoc; d++) { - final surface = _surfaces[_docs[d].id]; - if (surface == null) continue; - final local = surface.localSelectionRects(); - if (local.isEmpty) continue; - final global = surface.globalSelectionRects(); - if (global.length != local.length) continue; - if (startSurface == null) { - startSurface = surface; - startLocal = local.first; - startGlobal = global.first; + + MarkdownSelectionSurface? startSurface = _surfaces[sel.base.documentId]; + MarkdownSelectionSurface? endSurface = _surfaces[sel.extent.documentId]; + Rect? startLocal = startSurface?.caretRectFor(sel.base, _baseAffinity); + Rect? endLocal = endSurface?.caretRectFor(sel.extent, _extentAffinity); + + if (startSurface == null || startLocal == null) { + final proxy = _proxyHandleOnVisibleSelection(forStartEdge: true); + if (proxy != null) { + startSurface = proxy.$1; + startLocal = proxy.$2; + } + } + if (endSurface == null || endLocal == null) { + final proxy = _proxyHandleOnVisibleSelection(forStartEdge: false); + if (proxy != null) { + endSurface = proxy.$1; + endLocal = proxy.$2; } - endSurface = surface; - endLocal = local.last; - endGlobal = global.last; } - if (startSurface == null || endSurface == null) return null; + + if (startSurface == null || + startLocal == null || + endSurface == null || + endLocal == null) { + return null; + } + + final startOrigin = startSurface.globalBounds.topLeft; + final endOrigin = endSurface.globalBounds.topLeft; + final startGlobal = startLocal.shift(startOrigin); + final endGlobal = endLocal.shift(endOrigin); + final startPt = Offset(startGlobal.left, startGlobal.bottom); + final endPt = Offset(endGlobal.right, endGlobal.bottom); + + if ((startPt - endPt).distance < _kMinHandleCaretSeparation) { + final separated = _separateCoincidentHandleCarets(); + if (separated != null) return separated; + } + return MarkdownHandleEndpoints( startSurface: startSurface, - startLocal: startLocal!, - startGlobal: startGlobal!, + startLocal: startLocal, + startGlobal: startGlobal, endSurface: endSurface, - endLocal: endLocal!, - endGlobal: endGlobal!, + endLocal: endLocal, + endGlobal: endGlobal, ); } + /// Directed start/end from visible selection extremes when carets collapse. + MarkdownHandleEndpoints? _separateCoincidentHandleCarets() { + final readingStart = _proxyHandleOnVisibleSelection(forStartEdge: true); + final readingEnd = _proxyHandleOnVisibleSelection(forStartEdge: false); + if (readingStart == null || readingEnd == null) return null; + + final startGlobal = + readingStart.$2.shift(readingStart.$1.globalBounds.topLeft); + final endGlobal = readingEnd.$2.shift(readingEnd.$1.globalBounds.topLeft); + final startPt = Offset(startGlobal.left, startGlobal.bottom); + final endPt = Offset(endGlobal.right, endGlobal.bottom); + if ((startPt - endPt).distance < _kMinHandleCaretSeparation) { + return null; + } + + // Map reading-order extremes onto directed base/extent handles. + final reversed = isSelectionReversed; + final start = reversed ? readingEnd : readingStart; + final end = reversed ? readingStart : readingEnd; + return MarkdownHandleEndpoints( + startSurface: start.$1, + startLocal: start.$2, + startGlobal: start.$2.shift(start.$1.globalBounds.topLeft), + endSurface: end.$1, + endLocal: end.$2, + endGlobal: end.$2.shift(end.$1.globalBounds.topLeft), + ); + } + + /// Places a handle on the nearest mounted surface that still paints the + /// selection when the true edge surface is unmounted. + (MarkdownSelectionSurface, Rect)? _proxyHandleOnVisibleSelection({ + required bool forStartEdge, + }) { + final sel = _selection; + if (sel == null || sel.isCollapsed) return null; + final (a, b) = _ordered(sel); + final startDoc = _orderIndex(a.documentId); + final endDoc = _orderIndex(b.documentId); + if (startDoc < 0 || endDoc < 0) return null; + + if (forStartEdge) { + for (var d = startDoc; d <= endDoc; d++) { + final surface = _surfaces[_docs[d].id]; + if (surface == null) continue; + final locals = surface.localSelectionRects(); + if (locals.isEmpty) continue; + var best = locals.first; + for (final r in locals.skip(1)) { + if (r.top < best.top || (r.top == best.top && r.left < best.left)) { + best = r; + } + } + return ( + surface, + Rect.fromLTWH(best.left, best.top, 0, best.height), + ); + } + } else { + for (var d = endDoc; d >= startDoc; d--) { + final surface = _surfaces[_docs[d].id]; + if (surface == null) continue; + final locals = surface.localSelectionRects(); + if (locals.isEmpty) continue; + var best = locals.first; + for (final r in locals) { + if (r.bottom > best.bottom || + (r.bottom == best.bottom && r.right > best.right)) { + best = r; + } + } + return ( + surface, + Rect.fromLTWH(best.right, best.top, 0, best.height), + ); + } + } + return null; + } + /// The selected range within [documentId]'s block [blockIndex], or null when /// that block is not part of the current selection. Used by surfaces to paint /// their highlight. @@ -1129,6 +1547,9 @@ class MarkdownSelectionController extends ChangeNotifier { if (di < 0) return null; final startDoc = _orderIndex(a.documentId); final endDoc = _orderIndex(b.documentId); + // An unregistered endpoint used to sort to -1, so `di < startDoc` never + // excluded registered docs and every body between 0 and endDoc painted. + if (startDoc < 0 || endDoc < 0) return null; if (di < startDoc || di > endDoc) return null; final model = _docs[di].model; // di is already resolved above if (blockIndex < 0 || blockIndex >= model.blocks.length) return null; @@ -1202,9 +1623,21 @@ class MarkdownSelectionController extends ChangeNotifier { int _compare(MarkdownPosition a, MarkdownPosition b) { final ai = _orderIndex(a.documentId), bi = _orderIndex(b.documentId); - if (ai != bi) return ai.compareTo(bi); - if (a.blockIndex != b.blockIndex) + // Unregistered ids must not sort as -1 ahead of every registered doc — + // that inverted cross-document spans and made [rangeFor] paint the world. + if (ai < 0 && bi < 0) { + final byId = a.documentId.toString().compareTo(b.documentId.toString()); + if (byId != 0) return byId; + } else if (ai < 0) { + return 1; + } else if (bi < 0) { + return -1; + } else if (ai != bi) { + return ai.compareTo(bi); + } + if (a.blockIndex != b.blockIndex) { return a.blockIndex.compareTo(b.blockIndex); + } return a.offset.compareTo(b.offset); } diff --git a/lib/src/selection_autoscroll.dart b/lib/src/selection_autoscroll.dart new file mode 100644 index 0000000..715641f --- /dev/null +++ b/lib/src/selection_autoscroll.dart @@ -0,0 +1,379 @@ +// ignore_for_file: lines_longer_than_80_chars + +import 'dart:math' as math; + +import 'package:flutter/scheduler.dart'; +import 'package:flutter/widgets.dart'; + +/// Edge-zone autoscroll while dragging a markdown selection. +/// +/// Activation bands sit on the **padded scrollable viewport** (with optional +/// overshoot past the pad). The host union is the **hard stop / gate**: +/// +/// - Toward start only while host content still extends above the pad top +/// (`hostTop < paddedTop`), or the whole host sits above the viewport. +/// - Toward end only while host content still extends below the pad bottom +/// (`hostBottom > paddedBottom`), or the whole host sits below the viewport. +/// +/// A mid-viewport host that fits inside the pad therefore never drives the +/// far scrollable chrome. Inside the pad ± [edgeZone], velocity ramps with +/// depth; past that outer band, scroll continues at [maxVelocity] while the +/// host union still allows that direction (finger held past the edge), and +/// idles / hard-stops once the union is flush. +/// +/// After a hard stop, [MarkdownAutoscrollSession] disarms that direction until +/// the pointer leaves and re-enters the band (arming gate). +final class MarkdownSelectionAutoscrollConfig { + /// Creates an autoscroll config. + const MarkdownSelectionAutoscrollConfig({ + this.enabled = true, + this.edgeZone = 48, + this.maxVelocity = 600, + this.topPad = 0, + this.bottomPad = 0, + this.useMediaQueryPadding = true, + }) : assert(edgeZone > 0), + assert(maxVelocity >= 0), + assert(topPad >= 0), + assert(bottomPad >= 0); + + /// Autoscroll disabled — selection never drives the ancestor scrollable. + static const disabled = MarkdownSelectionAutoscrollConfig(enabled: false); + + /// Whether edge-zone scrolling is active. + final bool enabled; + + /// Height of each edge band, in logical pixels. + final double edgeZone; + + /// Scroll speed (px/s) at the outer edge of the band (`d == edgeZone`). + final double maxVelocity; + + /// Extra inset before the top band starts (added on top of media-query pad). + final double topPad; + + /// Extra inset before the bottom band starts. + final double bottomPad; + + /// When true, [MediaQuery.padding] / [MediaQuery.viewPadding] are added to + /// [topPad]/[bottomPad] so the edge zone starts before handles disappear + /// under app chrome. + final bool useMediaQueryPadding; +} + +/// Per-drag arming state for the host-union autoscroll gate. +/// +/// After a hard stop in one direction, that direction stays disarmed until the +/// pointer leaves the activation band (re-armed on leave so the next enter can +/// scroll again). +final class MarkdownAutoscrollSession { + bool _disarmedTowardStart = false; + bool _disarmedTowardEnd = false; + + /// Whether scrolling toward the start (min extent) is disarmed. + bool get isDisarmedTowardStart => _disarmedTowardStart; + + /// Whether scrolling toward the end (max extent) is disarmed. + bool get isDisarmedTowardEnd => _disarmedTowardEnd; + + /// Whether [towardStart] (min) or the opposite (max) direction is disarmed. + bool isDisarmed({required bool towardStart}) => + towardStart ? _disarmedTowardStart : _disarmedTowardEnd; + + /// Disarms scrolling toward start (min) or end (max). + void disarm({required bool towardStart}) { + if (towardStart) { + _disarmedTowardStart = true; + } else { + _disarmedTowardEnd = true; + } + } + + /// Clears both direction gates (call on drag start / end). + void reset() { + _disarmedTowardStart = false; + _disarmedTowardEnd = false; + } +} + +/// Outcome of one autoscroll step. +enum MarkdownAutoscrollResult { + /// Not in an edge band (or disabled / no scrollable). + idle, + + /// Scrolled (or held in-band while scrolling). Caller should keep ticking + /// while the drag remains active. + scrolled, + + /// Pointer is in an edge band but we must not scroll (host-union edge already + /// flush, scrollable cannot move, or arming gate disarmed). Caller should + /// **stop the ticker** until the next pointer update re-enters a band. + suppressed, +} + +/// Signed scroll velocity (px/s) for a pointer [localY] inside a **clip** of +/// height [clipHeight] whose top is at [clipTopLocalY] in the same local space. +/// +/// Positive means toward later text / max scroll for a standard downward axis; +/// callers flip the sign for reverse axes. +@visibleForTesting +double markdownAutoscrollVelocity({ + required double localY, + required double clipTopLocalY, + required double clipHeight, + required MarkdownSelectionAutoscrollConfig config, +}) { + if (!config.enabled || clipHeight <= 0) return 0; + final edge = config.edgeZone; + final topThreshold = clipTopLocalY + edge; + final bottomThreshold = clipTopLocalY + clipHeight - edge; + // Allow up to [edge] past the clip so pad insets / finger jitter just + // outside the visible host union still drive scroll — but not the far + // scrollable chrome when the host sits mid-viewport. + final outerTop = clipTopLocalY - edge; + final outerBottom = clipTopLocalY + clipHeight + edge; + if (localY < topThreshold && localY >= outerTop) { + final depth = (topThreshold - localY).clamp(0.0, edge); + final t = depth / edge; + return -config.maxVelocity * t * t; + } + if (localY > bottomThreshold && localY <= outerBottom) { + final depth = (localY - bottomThreshold).clamp(0.0, edge); + final t = depth / edge; + return config.maxVelocity * t * t; + } + return 0; +} + +/// Convenience overload: velocity against a full viewport with pad insets +/// (unit tests that don't need an explicit host-union clip). +@visibleForTesting +double markdownAutoscrollVelocityInViewport({ + required double localY, + required double viewportHeight, + required MarkdownSelectionAutoscrollConfig config, + double resolvedTopPad = 0, + double resolvedBottomPad = 0, +}) { + final clipTop = resolvedTopPad; + final clipHeight = viewportHeight - resolvedTopPad - resolvedBottomPad; + return markdownAutoscrollVelocity( + localY: localY, + clipTopLocalY: clipTop, + clipHeight: clipHeight, + config: config, + ); +} + +/// Resolves effective top/bottom pads: explicit config + optional MediaQuery. +@visibleForTesting +(double top, double bottom) resolveMarkdownAutoscrollPads({ + required MarkdownSelectionAutoscrollConfig config, + required BuildContext? context, +}) { + var top = config.topPad; + var bottom = config.bottomPad; + if (context case final context? + when context.mounted && config.useMediaQueryPadding) { + final data = [ + MediaQuery.maybePaddingOf(context), + MediaQuery.maybeViewPaddingOf(context) + ]; + if (data case [final padding?, final viewPadding?]) { + top += math.max(padding.top, viewPadding.top); + bottom += math.max(padding.bottom, viewPadding.bottom); + } + } + return (top, bottom); +} + +/// Applies one autoscroll step to the nearest ancestor [Scrollable] of +/// [context]. +/// +/// [hostUnionTopGlobalY] / [hostUnionBottomGlobalY] are the global Y bounds of +/// the union of mounted selectable bodies under the selection host. Bands use +/// the padded viewport; the host union only gates / hard-stops each direction. +MarkdownAutoscrollResult applyMarkdownSelectionAutoscroll({ + required Offset globalPosition, + required BuildContext? context, + required MarkdownSelectionAutoscrollConfig config, + required Duration? lastTimestamp, + required void Function(Duration?) storeTimestamp, + required double hostUnionTopGlobalY, + required double hostUnionBottomGlobalY, + MarkdownAutoscrollSession? session, +}) { + void clear() => storeTimestamp(null); + + if (!config.enabled || context == null || !context.mounted) { + clear(); + return MarkdownAutoscrollResult.idle; + } + final scrollable = _scrollableFor(context); + if (scrollable == null) { + clear(); + return MarkdownAutoscrollResult.idle; + } + final position = scrollable.position; + if (!position.hasPixels || !position.hasContentDimensions) { + clear(); + return MarkdownAutoscrollResult.idle; + } + + final box = scrollable.context.findRenderObject() as RenderBox?; + if (box == null || !box.hasSize) { + clear(); + return MarkdownAutoscrollResult.idle; + } + + final (topPad, bottomPad) = resolveMarkdownAutoscrollPads( + config: config, + context: scrollable.context, + ); + final hostPads = resolveMarkdownAutoscrollPads( + config: config, + context: context, + ); + var resolvedTop = math.max(topPad, hostPads.$1); + var resolvedBottom = math.max(bottomPad, hostPads.$2); + + // Chrome that sits *outside* the scrollable (AppBar above / bottom nav below) + // still eats finger space in screen coords. Fold that gap into the pads so + // the edge band starts before handles vanish under it. + final screenHeight = MediaQuery.maybeSizeOf(context)?.height; + if (config.useMediaQueryPadding && screenHeight != null) { + final origin = box.localToGlobal(Offset.zero); + final above = origin.dy; + final below = screenHeight - (origin.dy + box.size.height); + if (below > 0) { + resolvedBottom = math.max(resolvedBottom, below.clamp(0.0, 120.0)); + } + if (above > 0 && resolvedTop < 8) { + resolvedTop = math.max(resolvedTop, 8); + } + } + + final viewportOriginY = box.localToGlobal(Offset.zero).dy; + final viewportHeight = box.size.height; + final localY = globalPosition.dy - viewportOriginY; + + final paddedTop = viewportOriginY + resolvedTop; + final paddedBottom = viewportOriginY + viewportHeight - resolvedBottom; + final clipHeight = paddedBottom - paddedTop; + if (clipHeight <= 0) { + session?.reset(); + clear(); + return MarkdownAutoscrollResult.idle; + } + + // Host still has content to reveal past each pad edge (or sits entirely + // off-screen past that edge — drag must be able to pull it back). + final canTowardStart = hostUnionTopGlobalY < paddedTop - 0.5 || + hostUnionBottomGlobalY <= paddedTop + 0.5; + final canTowardEnd = hostUnionBottomGlobalY > paddedBottom + 0.5 || + hostUnionTopGlobalY >= paddedBottom - 0.5; + + final clipTopLocal = resolvedTop; + var velocity = markdownAutoscrollVelocity( + localY: localY, + clipTopLocalY: clipTopLocal, + clipHeight: clipHeight, + config: config, + ); + + if (velocity == 0) { + // Past the quadratic outer band: keep scrolling at max while the host + // union still has content that way (finger held past the viewport edge). + // Mid-viewport hosts that do not extend past the pad stay gated out. + final outerTop = clipTopLocal - config.edgeZone; + final outerBottom = clipTopLocal + clipHeight + config.edgeZone; + if (localY < outerTop && canTowardStart) { + velocity = -config.maxVelocity; + } else if (localY > outerBottom && canTowardEnd) { + velocity = config.maxVelocity; + } else { + // Left the band (or past-viewport with nothing left to reveal) → re-arm. + session?.reset(); + clear(); + return MarkdownAutoscrollResult.idle; + } + } + + final towardStart = velocity < 0; + // Direction gate: band hit but host has nothing left to reveal that way. + if ((towardStart && !canTowardStart) || (!towardStart && !canTowardEnd)) { + session?.disarm(towardStart: towardStart); + clear(); + return MarkdownAutoscrollResult.suppressed; + } + + if (session != null && session.isDisarmed(towardStart: towardStart)) { + clear(); + return MarkdownAutoscrollResult.suppressed; + } + + // Reverse axes: positive velocity should still mean "toward maxScrollExtent". + velocity = switch (scrollable.axisDirection) { + AxisDirection.down || AxisDirection.right => velocity, + AxisDirection.up || AxisDirection.left => -velocity, + }; + + final previous = lastTimestamp; + final now = _autoscrollNow(previous); + storeTimestamp(now); + final dtSeconds = + previous == null ? 1 / 60 : (now - previous).inMicroseconds / 1e6; + final dt = dtSeconds.clamp(0.0, 0.05); + final delta = velocity * dt; + if (delta.abs() < 0.5) { + return MarkdownAutoscrollResult.scrolled; + } + + final before = position.pixels; + final next = (before + delta).clamp( + position.minScrollExtent, + position.maxScrollExtent, + ); + if ((next - before).abs() < 0.5) { + session?.disarm(towardStart: towardStart); + clear(); + return MarkdownAutoscrollResult.suppressed; + } + + position.jumpTo(next); + return MarkdownAutoscrollResult.scrolled; +} + +Duration _autoscrollNow(Duration? last) { + final binding = SchedulerBinding.instance; + return switch (binding.schedulerPhase) { + SchedulerPhase.idle => + (last ?? Duration.zero) + const Duration(milliseconds: 16), + _ => binding.currentFrameTimeStamp, + }; +} + +/// Like [Scrollable.maybeOf], but also accepts a context whose widget *is* the +/// [Scrollable] (maybeOf only walks ancestors). +ScrollableState? _scrollableFor(BuildContext context) => switch (context) { + StatefulElement(state: final ScrollableState state) => state, + _ => Scrollable.maybeOf(context), + }; + +/// Convenience: depth fraction used by tests / diagnostics. +@visibleForTesting +double markdownAutoscrollDepthFraction({ + required double localY, + required double clipTopLocalY, + required double clipHeight, + required MarkdownSelectionAutoscrollConfig config, +}) { + final v = markdownAutoscrollVelocity( + localY: localY, + clipTopLocalY: clipTopLocalY, + clipHeight: clipHeight, + config: config, + ); + if (v == 0 || config.maxVelocity == 0) return 0; + return math.sqrt(v.abs() / config.maxVelocity); +} diff --git a/lib/src/selection_scope.dart b/lib/src/selection_scope.dart index 5ab2a5d..5bcc02a 100644 --- a/lib/src/selection_scope.dart +++ b/lib/src/selection_scope.dart @@ -1,13 +1,18 @@ +import 'dart:math' as math; + import 'package:flutter/cupertino.dart' show cupertinoTextSelectionHandleControls, cupertinoDesktopTextSelectionHandleControls; +import 'package:flutter/foundation.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; import 'package:flutter/scheduler.dart'; import 'package:flutter/services.dart'; import 'selection.dart'; +import 'selection_autoscroll.dart'; /// Signature for building the selection context menu (toolbar), mirroring /// `SelectableRegion.contextMenuBuilder`. @@ -67,6 +72,7 @@ class MarkdownSelectionScope extends StatefulWidget { this.magnifierConfiguration, this.selectionControls, this.onSelectionChanged, + this.autoscroll = const MarkdownSelectionAutoscrollConfig(), super.key, }); @@ -101,6 +107,10 @@ class MarkdownSelectionScope extends StatefulWidget { /// Called whenever the selection changes. final ValueChanged? onSelectionChanged; + /// Edge-zone autoscroll while dragging a non-collapsed selection near an + /// ancestor scrollable's edge. + final MarkdownSelectionAutoscrollConfig autoscroll; + /// The default [contextMenuBuilder]: an [AdaptiveTextSelectionToolbar] built /// from the scope's [MarkdownSelectionScopeState.contextMenuButtonItems]. static Widget defaultContextMenuBuilder( @@ -131,7 +141,8 @@ class MarkdownSelectionScope extends StatefulWidget { /// State for [MarkdownSelectionScope]. Public so a custom [contextMenuBuilder] /// can drive it (copy/select-all/clear + toolbar geometry), mirroring /// `SelectableRegionState`. -class MarkdownSelectionScopeState extends State { +class MarkdownSelectionScopeState extends State + with TickerProviderStateMixin { final ContextMenuController _contextMenuController = ContextMenuController(); final LayerLink _startHandleLink = LayerLink(); final LayerLink _endHandleLink = LayerLink(); @@ -139,7 +150,9 @@ class MarkdownSelectionScopeState extends State { SelectionOverlay? _selectionOverlay; FocusNode? _internalFocusNode; Offset? _lastSecondaryTapDown; - Offset? _lastDoubleTapDown; + + /// Prior secondary-tap anchor used for macOS same-spot toolbar toggle. + Offset? _previousSecondaryTapAnchor; MarkdownSelection? _lastSelection; // Multi-tap / granular selection state. [_granularity] is 1 = character, @@ -148,6 +161,34 @@ class MarkdownSelectionScopeState extends State { int _granularity = 1; MarkdownSelection? _granularAnchor; + /// Last pointer position while a selection drag is active (for edge + /// autoscroll frame ticks). + Offset? _dragGlobal; + bool _dragMovesStartEdge = false; + bool _dragIsHandle = false; + Duration? _lastAutoscrollTimestamp; + Ticker? _autoscrollTicker; + final MarkdownAutoscrollSession _autoscrollSession = + MarkdownAutoscrollSession(); + + /// When true, skip creating/showing handles (Android long-press: show on end). + bool _deferHandleShow = false; + + /// Coalesces post-frame toolbar rebuilds. + bool _toolbarBuildScheduled = false; + + /// Coalesces post-frame geometry restores (scroll can notify before layout). + bool _toolbarGeometryRefreshScheduled = false; + + /// Coalesces post-frame handle-overlay syncs deferred off layout/build. + bool _overlaySyncScheduled = false; + + /// Ancestor observer (e.g. [Scaffold]) so the toolbar follows parent scrolls + /// when this scope is a scrollable *child*, not an ancestor. + ScrollNotificationObserverState? _scrollNotificationObserver; + + PointerDeviceKind? _lastPointerDeviceKind; + FocusNode get _focusNode => widget.focusNode ?? (_internalFocusNode ??= FocusNode(debugLabel: 'MarkdownSelectionScope')); @@ -160,17 +201,32 @@ class MarkdownSelectionScopeState extends State { super.initState(); _lastSelection = widget.controller.selection; widget.controller.addListener(_onControllerChanged); + _focusNode.addListener(_handleFocusChanged); + // Scope may remount after list virtualization while [toolbarWanted] is + // still set on the controller — restore chrome once layout exists. + SchedulerBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + _refreshToolbarForVisibleGeometry(); + }); } @override void didChangeDependencies() { super.didChangeDependencies(); _applySelectionColor(); + _scrollNotificationObserver?.removeListener(_onAncestorScrollNotification); + _scrollNotificationObserver = ScrollNotificationObserver.maybeOf(context); + _scrollNotificationObserver?.addListener(_onAncestorScrollNotification); } @override void didUpdateWidget(covariant MarkdownSelectionScope oldWidget) { super.didUpdateWidget(oldWidget); + if (!identical(oldWidget.focusNode, widget.focusNode)) { + (oldWidget.focusNode ?? _internalFocusNode) + ?.removeListener(_handleFocusChanged); + _focusNode.addListener(_handleFocusChanged); + } if (!identical(oldWidget.controller, widget.controller)) { _clearHandles(); // drop leaders/overlay bound to the old controller oldWidget.controller.removeListener(_onControllerChanged); @@ -186,6 +242,10 @@ class MarkdownSelectionScopeState extends State { @override void dispose() { + _stopAutoscroll(); + _scrollNotificationObserver?.removeListener(_onAncestorScrollNotification); + _scrollNotificationObserver = null; + _focusNode.removeListener(_handleFocusChanged); widget.controller.removeListener(_onControllerChanged); _contextMenuController.remove(); _selectionOverlay?.dispose(); @@ -194,6 +254,20 @@ class MarkdownSelectionScopeState extends State { super.dispose(); } + void _handleFocusChanged() { + if (_focusNode.hasFocus) return; + // Keep the range while a pointer drag is active — list rebuilds during + // autoscroll can steal focus without an intentional dismiss. + if (_dragGlobal != null) return; + // Retain selection when the app is inactive/backgrounded (e.g. desktop + // window switch); clear only when focus is lost while resumed (or before + // the binding has reported a lifecycle — typical in widget tests). + final lifecycle = SchedulerBinding.instance.lifecycleState; + if (lifecycle case null || AppLifecycleState.resumed) { + clearSelection(); + } + } + void _applySelectionColor() { controller.selectionColor = widget.selectionColor ?? DefaultSelectionStyle.of(context).selectionColor; @@ -204,8 +278,28 @@ class MarkdownSelectionScopeState extends State { if (sel != _lastSelection) { _lastSelection = sel; widget.onSelectionChanged?.call(sel); + // Selection moved — drop a frozen right-click anchor so the toolbar + // tracks the live selection bounds instead. + if (_contextMenuController.isShown) { + _lastSecondaryTapDown = null; + } } - if (sel == null || sel.isCollapsed) hideToolbar(); + + if (sel case final current? when !current.isCollapsed) { + if (_dragGlobal == null) { + // Outside a drag: restore when [toolbarWanted] (scroll remount / + // programmatic selectAll / selection=), else start chrome for keyboard + // / other _commitSelection ranges. + if (controller.toolbarWanted) { + _refreshToolbarForVisibleGeometry(); + } else { + showToolbar(); + } + } + } else { + hideToolbar(); + } + // Mid-drag: keep the menu hidden until drag-end [showToolbar]. _syncOverlay(); } @@ -238,20 +332,36 @@ class MarkdownSelectionScopeState extends State { widget.magnifierConfiguration ?? TextMagnifier.adaptiveMagnifierConfiguration; - /// Syncs the handle overlay, deferring to a post-frame callback when called - /// during a build/layout/paint phase (e.g. a streaming `setState`). + /// Syncs the handle overlay. During an in-flight drag, updates run + /// pointer-synchronously when geometry walks are safe so highlight/handles + /// track the finger; always defers during build/layout/paint (including + /// drag) because [selectionHandleEndpoints] uses [localToGlobal]. void _syncOverlay() { final phase = SchedulerBinding.instance.schedulerPhase; - if (phase == SchedulerPhase.persistentCallbacks || - phase == SchedulerPhase.midFrameMicrotasks) { + final inUnsafePhase = phase == SchedulerPhase.persistentCallbacks || + phase == SchedulerPhase.midFrameMicrotasks; + + if (_dragGlobal != null && !inUnsafePhase) { + _updateHandlesAndOverlay(); + return; + } + + if (inUnsafePhase) { + if (_overlaySyncScheduled) return; + _overlaySyncScheduled = true; SchedulerBinding.instance.addPostFrameCallback((_) { - if (mounted) _updateHandlesAndOverlay(); + _overlaySyncScheduled = false; + if (!mounted) return; + _updateHandlesAndOverlay(); }); - } else { - _updateHandlesAndOverlay(); + return; } + + _updateHandlesAndOverlay(); } + void _toggleToolbar() => toolbarIsVisible ? hideToolbar() : showToolbar(); + void _updateHandlesAndOverlay() { if (!_handlesEnabled) { _clearHandles(); @@ -263,49 +373,68 @@ class MarkdownSelectionScopeState extends State { return; } _applyHandles(endpoints); - final box = context.findRenderObject() as RenderBox?; - if (box == null || !box.hasSize) return; - final startPoint = TextSelectionPoint( - box.globalToLocal( - Offset(endpoints.startGlobal.left, endpoints.startGlobal.bottom)), - TextDirection.ltr, - ); - final endPoint = TextSelectionPoint( - box.globalToLocal( - Offset(endpoints.endGlobal.right, endpoints.endGlobal.bottom)), - TextDirection.ltr, - ); - final overlay = _selectionOverlay; - if (overlay == null) { - if (Overlay.maybeOf(context) == null) return; // no host for handles - _selectionOverlay = SelectionOverlay( - context: context, - startHandleType: TextSelectionHandleType.left, - lineHeightAtStart: endpoints.startLocal.height, - onStartHandleDragStart: (d) => _onHandleDragStart(d, isStart: true), - onStartHandleDragUpdate: (d) => _onHandleDragUpdate(d, isStart: true), - onStartHandleDragEnd: (_) => _onHandleDragEnd(), - endHandleType: TextSelectionHandleType.right, - lineHeightAtEnd: endpoints.endLocal.height, - onEndHandleDragStart: (d) => _onHandleDragStart(d, isStart: false), - onEndHandleDragUpdate: (d) => _onHandleDragUpdate(d, isStart: false), - onEndHandleDragEnd: (_) => _onHandleDragEnd(), - selectionEndpoints: [startPoint, endPoint], - selectionControls: _effectiveControls, - selectionDelegate: null, - clipboardStatus: null, - startHandleLayerLink: _startHandleLink, - endHandleLayerLink: _endHandleLink, - toolbarLayerLink: _toolbarLink, - magnifierConfiguration: _effectiveMagnifier, - )..showHandles(); - } else { - overlay - ..startHandleType = TextSelectionHandleType.left - ..lineHeightAtStart = endpoints.startLocal.height - ..endHandleType = TextSelectionHandleType.right - ..lineHeightAtEnd = endpoints.endLocal.height - ..selectionEndpoints = [startPoint, endPoint]; + if (context.findRenderObject() case final RenderBox box when box.hasSize) { + final startPoint = TextSelectionPoint( + box.globalToLocal( + Offset(endpoints.startGlobal.left, endpoints.startGlobal.bottom)), + TextDirection.ltr, + ); + final endPoint = TextSelectionPoint( + box.globalToLocal( + Offset(endpoints.endGlobal.right, endpoints.endGlobal.bottom)), + TextDirection.ltr, + ); + final reversed = controller.isSelectionReversed; + final startType = reversed + ? TextSelectionHandleType.right + : TextSelectionHandleType.left; + final endType = reversed + ? TextSelectionHandleType.left + : TextSelectionHandleType.right; + switch (_selectionOverlay) { + case null: + if (Overlay.maybeOf(context) == null) { + return; // no host for handles + } + final overlay = _selectionOverlay = SelectionOverlay( + context: context, + startHandleType: startType, + lineHeightAtStart: endpoints.startLocal.height, + onStartHandleDragStart: (d) => _onHandleDragStart(d, isStart: true), + onStartHandleDragUpdate: (d) => + _onHandleDragUpdate(d, isStart: true), + onStartHandleDragEnd: (_) => _onHandleDragEnd(), + endHandleType: endType, + lineHeightAtEnd: endpoints.endLocal.height, + onEndHandleDragStart: (d) => _onHandleDragStart(d, isStart: false), + onEndHandleDragUpdate: (d) => + _onHandleDragUpdate(d, isStart: false), + onEndHandleDragEnd: (_) => _onHandleDragEnd(), + selectionEndpoints: [startPoint, endPoint], + selectionControls: _effectiveControls, + selectionDelegate: null, + clipboardStatus: null, + startHandleLayerLink: _startHandleLink, + endHandleLayerLink: _endHandleLink, + toolbarLayerLink: _toolbarLink, + magnifierConfiguration: _effectiveMagnifier, + ); + // Android long-press: keep overlay for magnifier, show handles on + // end. + if (!_deferHandleShow) { + overlay.showHandles(); + } + case final overlay: + overlay + ..startHandleType = startType + ..lineHeightAtStart = endpoints.startLocal.height + ..endHandleType = endType + ..lineHeightAtEnd = endpoints.endLocal.height + ..selectionEndpoints = [startPoint, endPoint]; + if (!_deferHandleShow) { + overlay.showHandles(); + } + } } } @@ -314,10 +443,20 @@ class MarkdownSelectionScopeState extends State { void _applyHandles(MarkdownHandleEndpoints? e) { final startSurface = e?.startSurface; final endSurface = e?.endSurface; - final startLocal = - e == null ? null : Offset(e.startLocal.left, e.startLocal.bottom); - final endLocal = - e == null ? null : Offset(e.endLocal.right, e.endLocal.bottom); + final startLocal = switch (e) { + null => null, + final endpoints => Offset( + endpoints.startLocal.left, + endpoints.startLocal.bottom, + ), + }; + final endLocal = switch (e) { + null => null, + final endpoints => Offset( + endpoints.endLocal.right, + endpoints.endLocal.bottom, + ), + }; for (final surface in controller.mountedSurfaces) { final isStart = identical(surface, startSurface); final isEnd = identical(surface, endSurface); @@ -338,35 +477,62 @@ class MarkdownSelectionScopeState extends State { } void _onHandleDragStart(DragStartDetails d, {required bool isStart}) { + _dragIsHandle = true; + _dragMovesStartEdge = isStart; + _dragGlobal = d.globalPosition; + _autoscrollSession.reset(); + hideToolbar(); _selectionOverlay ?.showMagnifier(_magnifierInfo(d.globalPosition, isStart: isStart)); + _driveAutoscroll(); } void _onHandleDragUpdate(DragUpdateDetails d, {required bool isStart}) { + _dragIsHandle = true; + _dragMovesStartEdge = isStart; + _dragGlobal = d.globalPosition; final e = controller.selectionHandleEndpoints(); - final lineHeight = - e == null ? 0.0 : (isStart ? e.startLocal.height : e.endLocal.height); + final lineHeight = switch (e) { + null => 0.0, + final endpoints => + isStart ? endpoints.startLocal.height : endpoints.endLocal.height, + }; controller.moveSelectionEdgeToGlobal( d.globalPosition - Offset(0, lineHeight / 2), isStart: isStart, ); _selectionOverlay ?.updateMagnifier(_magnifierInfo(d.globalPosition, isStart: isStart)); + _driveAutoscroll(); } void _onHandleDragEnd() { + _stopAutoscroll(); _selectionOverlay?.hideMagnifier(); - showToolbar(); + if (controller.selection case final sel? when !sel.isCollapsed) { + showToolbar(); + } + } + + void _onBodyDragEnd() { + _stopAutoscroll(); + if (controller.selection case final sel? when !sel.isCollapsed) { + showToolbar(); + } } MagnifierInfo _magnifierInfo(Offset gesture, {required bool isStart}) { final e = controller.selectionHandleEndpoints(); - final caret = e == null - ? Rect.fromCenter(center: gesture, width: 0, height: 24) - : (isStart ? e.startGlobal : e.endGlobal); - final bounds = e == null - ? caret - : (isStart ? e.startSurface.globalBounds : e.endSurface.globalBounds); + final caret = switch (e) { + null => Rect.fromCenter(center: gesture, width: 0, height: 24), + final endpoints => isStart ? endpoints.startGlobal : endpoints.endGlobal, + }; + final bounds = switch (e) { + null => caret, + final endpoints => isStart + ? endpoints.startSurface.globalBounds + : endpoints.endSurface.globalBounds, + }; return MagnifierInfo( globalGesturePosition: gesture, caretRect: caret, @@ -375,6 +541,180 @@ class MarkdownSelectionScopeState extends State { ); } + // --- edge autoscroll ----------------------------------------------------- + + void _ensureAutoscrollTicker() { + if (_autoscrollTicker != null) return; + _autoscrollTicker = createTicker((_) { + if (!mounted || _dragGlobal == null) { + _pauseAutoscrollTicker(); + return; + } + _tickAutoscroll(); + }) + ..start(); + } + + /// Disposes the frame ticker but keeps the drag pointer so a later update + /// can restart scrolling after leave-band / re-enter. + void _pauseAutoscrollTicker() { + _autoscrollTicker?.dispose(); + _autoscrollTicker = null; + _lastAutoscrollTimestamp = null; + } + + void _stopAutoscroll() { + _pauseAutoscrollTicker(); + _autoscrollSession.reset(); + _dragGlobal = null; + _dragIsHandle = false; + } + + /// Global Y span of every mounted selectable body (host union). + (double, double)? _hostUnionGlobalY() { + final surfaces = controller.mountedSurfaces; + if (surfaces.isEmpty) return null; + var top = double.infinity; + var bottom = double.negativeInfinity; + for (final surface in surfaces) { + final bounds = surface.globalBounds; + if (bounds.top < top) top = bounds.top; + if (bounds.bottom > bottom) bottom = bounds.bottom; + } + if (!top.isFinite || !bottom.isFinite || bottom < top) return null; + return (top, bottom); + } + + void _tickAutoscroll() { + final global = _dragGlobal; + final sel = controller.selection; + if (global case final position?) { + if (sel case final current? when !current.isCollapsed) { + if (!widget.autoscroll.enabled) { + _pauseAutoscrollTicker(); + return; + } + + final union = _hostUnionGlobalY(); + if (union == null) { + _pauseAutoscrollTicker(); + return; + } + + // Prefer a BuildContext under a mounted surface so Scrollable.maybeOf + // walks up to the enclosing ListView (scope context is an ancestor of + // the scrollable, not a descendant). + MarkdownSelectionSurface? content; + for (final surface in controller.mountedSurfaces) { + if (surface.globalBounds.contains(position)) { + content = surface; + break; + } + } + content ??= switch (controller.mountedSurfaces) { + final surfaces when surfaces.isEmpty => null, + final surfaces => surfaces.first, + }; + final scrollContext = switch (content) { + final surface? => _contextForSurface(surface), + null => null, + } ?? + _scrollableContextNear(position) ?? + context; + + final result = applyMarkdownSelectionAutoscroll( + globalPosition: position, + context: scrollContext, + config: widget.autoscroll, + lastTimestamp: _lastAutoscrollTimestamp, + storeTimestamp: (t) => _lastAutoscrollTimestamp = t, + hostUnionTopGlobalY: union.$1, + hostUnionBottomGlobalY: union.$2, + session: _autoscrollSession, + ); + + if (result != MarkdownAutoscrollResult.scrolled) { + // Leave-band / hard-stop / gate: stop the forever ticker. The next + // pointer update restarts only if apply returns scrolled again — + // avoids START→PAUSE thrash on every out-of-band move. + _pauseAutoscrollTicker(); + return; + } + + // Keep the frame ticker alive while the finger stays in-band. + _ensureAutoscrollTicker(); + + // After the scroll jump, re-hit-test so selection grows with content. + if (_dragIsHandle) { + final e = controller.selectionHandleEndpoints(); + final lineHeight = switch (e) { + null => 0.0, + final endpoints => _dragMovesStartEdge + ? endpoints.startLocal.height + : endpoints.endLocal.height, + }; + controller.moveSelectionEdgeToGlobal( + position - Offset(0, lineHeight / 2), + isStart: _dragMovesStartEdge, + ); + } else { + _extendSelectionAt(position); + } + return; + } + } + _pauseAutoscrollTicker(); + } + + /// Pointer-driven autoscroll step: only starts the ticker when scrolling. + void _driveAutoscroll() { + _tickAutoscroll(); + } + + /// Element whose [RenderObject] is [surface], if mounted under this scope. + BuildContext? _contextForSurface(MarkdownSelectionSurface surface) { + Element? found; + void visit(Element element) { + if (found case final _?) return; + if (identical(element.renderObject, surface)) { + found = element; + return; + } + element.visitChildren(visit); + } + + context.visitChildElements(visit); + return found; + } + + /// Nearest descendant [Scrollable] under [global] (the scrollable element + /// itself is fine — [applyMarkdownSelectionAutoscroll] resolves it). + BuildContext? _scrollableContextNear(Offset global) { + BuildContext? best; + var bestDistance = double.infinity; + void visit(Element element) { + if (element.widget case Scrollable()) { + if (element.renderObject case final RenderBox box when box.hasSize) { + final origin = box.localToGlobal(Offset.zero); + final bounds = origin & box.size; + final dy = switch (global.dy) { + final y when y < bounds.top => bounds.top - y, + final y when y > bounds.bottom => y - bounds.bottom, + _ => 0.0, + }; + if (dy < bestDistance) { + bestDistance = dy; + best = element; + } + } + } + element.visitChildren(visit); + } + + context.visitChildElements(visit); + return best; + } + // --- public selection ops ------------------------------------------------ /// Copies the current selection to the clipboard and hides the toolbar. @@ -404,8 +744,7 @@ class MarkdownSelectionScopeState extends State { /// something is selected) and Select-all (when there is any content). List get contextMenuButtonItems { final items = []; - final sel = controller.selection; - if (sel != null && !sel.isCollapsed) { + if (controller.selection case final sel? when !sel.isCollapsed) { items.add(ContextMenuButtonItem( type: ContextMenuButtonType.copy, onPressed: copySelection, @@ -423,64 +762,292 @@ class MarkdownSelectionScopeState extends State { return items; } - /// Where to anchor the toolbar: the last right-click point, else the top / - /// bottom center of the selection's bounding box. + /// Where to anchor the toolbar: the last right-click point, else the + /// selection clipped to this scope's visible bounds. + /// + /// Mirrors [TextSelectionToolbarAnchors.fromSelection]: absolute selection + /// rects that have scrolled outside the host must not pin the menu to the + /// host's top/bottom edges (that sinks the toolbar to the screen bottom). + /// + /// When the visible selection fills most of the clip (mid-viewport view of a + /// very large range, both edges off-screen), anchors stay near the **top** of + /// that visible region so the menu's below-fallback cannot land on the + /// host bottom. When only the bottom endpoint is in view, prefer **below** + /// that caret; otherwise use stock top/bottom selection anchors. TextSelectionToolbarAnchors get contextMenuAnchors { - final secondary = _lastSecondaryTapDown; - if (secondary != null) { + if (_lastSecondaryTapDown case final secondary?) { return TextSelectionToolbarAnchors(primaryAnchor: secondary); } - final rects = controller.globalSelectionRects(); - if (rects.isEmpty) { - final box = context.findRenderObject() as RenderBox?; - final bounds = box != null && box.hasSize - ? box.localToGlobal(Offset.zero) & box.size - : Rect.zero; + if (_toolbarClipBounds() case final clip?) { + if (_visibleSelectionBoundsIn(clip) case final visible?) { + return _anchorsForVisibleSelection(visible, clip); + } + } + // No painted selection in view — neutral anchor; scroll/size handlers hide + // the menu when geometry leaves the host. + final host = _hostGlobalBounds(); + return TextSelectionToolbarAnchors( + primaryAnchor: host.isEmpty ? Offset.zero : host.center, + ); + } + + /// Resolves toolbar anchors for the painted selection inside [clip]. + /// + /// - Neither endpoint in clip → top-pin (tall mid-viewport range). + /// - Bottom endpoint only → prefer below that caret. + /// - Top endpoint only, or both in clip → stock above/below anchors + /// (Material/Cupertino prefer above when it fits). + /// + /// Uses mounted, non-proxied carets — handle proxies must not disable + /// top-pin or steal edge-stick. + TextSelectionToolbarAnchors _anchorsForVisibleSelection( + Rect visible, + Rect clip, + ) { + final baseRect = controller.selectionEndpointGlobalRect(base: true); + final extentRect = controller.selectionEndpointGlobalRect(base: false); + final hit = clip.inflate(0.5); + final baseIn = switch (baseRect) { + final rect? => hit.overlaps(rect), + null => false, + }; + final extentIn = switch (extentRect) { + final rect? => hit.overlaps(rect), + null => false, + }; + + return switch ((baseIn, extentIn, baseRect, extentRect)) { + (false, false, _, _) => _topPinnedAnchors(visible), + (false, true, _, final edge?) => _anchorsPreferringBelow( + above: edge.topCenter, + below: edge.bottomCenter, + ), + (true, false, final edge?, _) => _stockSelectionAnchors(edge), + _ => _stockSelectionAnchors(visible), + }; + } + + /// Stock [TextSelectionToolbarAnchors.fromSelection]-style top/bottom pair. + static TextSelectionToolbarAnchors _stockSelectionAnchors(Rect rect) => + TextSelectionToolbarAnchors( + primaryAnchor: rect.topCenter, + secondaryAnchor: rect.bottomCenter, + ); + + /// Mid-viewport tall selection: keep both anchors near the visible top so a + /// below-fallback cannot sink to the host bottom. + static TextSelectionToolbarAnchors _topPinnedAnchors(Rect visible) { + final primary = visible.topCenter; + return TextSelectionToolbarAnchors( + primaryAnchor: primary, + secondaryAnchor: Offset( + primary.dx, + math.min(primary.dy + _kTallSelectionSecondaryOffset, visible.bottom), + ), + ); + } + + /// Prefers placing the menu **below** [below] when there is room. + /// + /// Material/Cupertino map `primaryAnchor` → `anchorAbove` and prefer above + /// whenever it fits. Touch platforms force `fitsAbove` false by parking + /// primary near the screen top; desktop toolbars use only `primaryAnchor`, + /// so primary is the below point there. + TextSelectionToolbarAnchors _anchorsPreferringBelow({ + required Offset above, + required Offset below, + }) { + final mqSize = MediaQuery.maybeSizeOf(context); + final mqPadding = MediaQuery.maybePaddingOf(context); + final size = mqSize ?? Size.zero; + final padding = mqPadding ?? EdgeInsets.zero; + const toolbarHeight = 48.0; + const aboveGap = 8.0; + const belowGap = TextSelectionToolbar.kToolbarContentDistanceBelow; + + final spaceBelow = size.height - padding.bottom - below.dy - belowGap; + final spaceAbove = above.dy - padding.top - aboveGap; + if (spaceBelow < toolbarHeight && spaceBelow < spaceAbove) { return TextSelectionToolbarAnchors( - primaryAnchor: bounds.topCenter, - secondaryAnchor: bounds.bottomCenter, + primaryAnchor: above, + secondaryAnchor: below, ); } - var bounds = rects.first; - for (final rect in rects.skip(1)) { - bounds = bounds.expandToInclude(rect); + + final isTouch = switch (defaultTargetPlatform) { + TargetPlatform.android || + TargetPlatform.iOS || + TargetPlatform.fuchsia => + true, + _ => false, + }; + if (!isTouch) { + return TextSelectionToolbarAnchors( + primaryAnchor: below, + secondaryAnchor: below, + ); } + return TextSelectionToolbarAnchors( - primaryAnchor: bounds.topCenter, - secondaryAnchor: bounds.bottomCenter, + primaryAnchor: Offset(below.dx, padding.top + aboveGap), + secondaryAnchor: below, ); } + /// How far below the visible top to place the secondary anchor when + /// top-pinning, so a below-fallback stays near the top instead of the + /// clip bottom. + static const double _kTallSelectionSecondaryOffset = 48; + + /// Global bounds of this scope's render box, or [Rect.zero] if unavailable. + Rect _hostGlobalBounds() { + return switch (context.findRenderObject()) { + final RenderBox box when box.hasSize => + box.localToGlobal(Offset.zero) & box.size, + _ => Rect.zero, + }; + } + + /// Host bounds intersected with any enclosing [RenderAbstractViewport]. + /// + /// When the scope **wraps** the scrollable, the host itself is the clip. + /// When the scope is a **child** of a list, the host may scroll off-screen + /// while still reporting a non-empty size — the viewport intersection goes + /// empty so the toolbar can dismiss. + Rect? _toolbarClipBounds() { + final host = _hostGlobalBounds(); + if (host.isEmpty) return null; + var clip = host; + if (context.findRenderObject() case final box?) { + if (RenderAbstractViewport.maybeOf(box) case final RenderBox viewportBox + when viewportBox.hasSize) { + final viewportRect = + viewportBox.localToGlobal(Offset.zero) & viewportBox.size; + clip = clip.intersect(viewportRect); + } + } + if (clip.isEmpty) return null; + return clip; + } + + /// Union of painted selection rects intersected with the toolbar clip. + /// + /// Null when the selection is collapsed, unmounted, or entirely outside the + /// visible host / enclosing viewport (scrolled away / virtualized). + Rect? _visibleSelectionBounds() => switch (_toolbarClipBounds()) { + final clip? => _visibleSelectionBoundsIn(clip), + null => null, + }; + + Rect? _visibleSelectionBoundsIn(Rect clip) { + final rects = controller.globalSelectionRects(); + if (rects.isEmpty) return null; + final bounds = rects.reduce((a, b) => a.expandToInclude(b)); + final visible = bounds.intersect(clip); + if (visible.isEmpty) return null; + return visible; + } + /// Whether the toolbar is currently visible. bool get toolbarIsVisible => _contextMenuController.isShown; /// Shows the context toolbar. [location] anchors it at a point (e.g. the /// right-click position); otherwise it anchors to the selection. + /// + /// Remembers that the menu should stay available across scroll and scope + /// remounts ([MarkdownSelectionController.toolbarWanted]): if the selection + /// is currently off-screen the overlay is deferred until geometry returns. void showToolbar([Offset? location]) { - final builder = widget.contextMenuBuilder; - if (builder == null) return; - if (Overlay.maybeOf(context, rootOverlay: true) == null) return; - _lastSecondaryTapDown = location; - _contextMenuController.remove(); - _contextMenuController.show( - context: context, - contextMenuBuilder: (context) => builder(context, this), - ); + if (widget.contextMenuBuilder case final builder?) { + if (Overlay.maybeOf(context, rootOverlay: true) == null) return; + controller.toolbarWanted = true; + _lastSecondaryTapDown = location; + if (location == null) { + if (_visibleSelectionBounds() case null) { + _contextMenuController.remove(); + return; + } + } + _contextMenuController.remove(); + _contextMenuController.show( + context: context, + contextMenuBuilder: (context) => builder(context, this), + ); + } } - /// Hides the context toolbar. + /// Hides the context toolbar and clears the scroll-restore intent. void hideToolbar() { + controller.toolbarWanted = false; _lastSecondaryTapDown = null; _contextMenuController.remove(); } // --- gestures ------------------------------------------------------------ + /// SelectableRegion-style consecutive-tap capping. + int _effectiveConsecutiveTapCount(int rawCount) { + var maxConsecutiveTap = 3; + switch (defaultTargetPlatform) { + case TargetPlatform.android: + case TargetPlatform.fuchsia: + if (_lastPointerDeviceKind case final kind? + when kind != PointerDeviceKind.mouse) { + maxConsecutiveTap = 2; + } + return _cycleTapCount(rawCount, maxConsecutiveTap); + case TargetPlatform.linux: + return _cycleTapCount(rawCount, maxConsecutiveTap); + case TargetPlatform.iOS: + case TargetPlatform.macOS: + case TargetPlatform.windows: + return math.min(rawCount, maxConsecutiveTap); + } + } + + static int _cycleTapCount(int rawCount, int maxConsecutiveTap) => + switch (rawCount) { + final n when n <= maxConsecutiveTap => n, + final n when n % maxConsecutiveTap == 0 => maxConsecutiveTap, + final n => n % maxConsecutiveTap, + }; + + bool _positionIsOnActiveSelection(Offset global) { + if (controller.selection case final sel? when !sel.isCollapsed) { + for (final rect in controller.globalSelectionRects()) { + if (rect.contains(global)) return true; + } + } + return false; + } + + /// Forces handles + toolbar to refresh even when the range is unchanged + /// (double-tap re-select must not be a no-op). + void _refreshSelectionChrome({Offset? toolbarAt}) { + _syncOverlay(); + _updateHandlesAndOverlay(); + if (controller.selection case final sel? when !sel.isCollapsed) { + showToolbar(toolbarAt); + } + } + /// Anchors a fresh selection at [global] with the given consecutive /// [tapCount]: 1 collapses a caret (character granularity), 2 selects the /// word, 3+ selects the whole block. Stores the granular anchor for a drag. - void _beginSelection(Offset global, int tapCount) { - _granularity = tapCount <= 1 ? 1 : (tapCount == 2 ? 2 : 3); + /// + /// Returns false when [global] misses every mounted selectable surface + /// (strict containment — no nearest-neighbor clamp), so callers skip + /// haptic / drag arming / chrome. + bool _beginSelection(Offset global, int tapCount) { + if (!controller.hitsSelectableContent(global)) { + return false; + } + final effective = _effectiveConsecutiveTapCount(tapCount); + _granularity = switch (effective) { + <= 1 => 1, + 2 => 2, + _ => 3, + }; switch (_granularity) { case 2: _granularAnchor = controller.selectWordAtGlobal(global); @@ -490,68 +1057,327 @@ class MarkdownSelectionScopeState extends State { controller.startAtGlobal(global); _granularAnchor = null; } + return true; } /// Extends the active selection to [global] at the current granularity. void _updateSelection(Offset global) { - final anchor = _granularAnchor; - if (_granularity == 1 || anchor == null) { + final before = controller.selection; + _dragGlobal = global; + _extendSelectionAt(global); + _maybeBodySelectionDragHaptic(before); + _driveAutoscroll(); + } + + /// Android selection-click ticks while expanding via body / long-press drag. + /// + /// Handle drags already tick through [SelectionOverlay.selectionEndpoints] + /// when the overlay reports an in-progress handle drag; body long-press + /// (especially with deferred handles) never sets those flags, so we emit + /// the same CLOCK_TICK here when the model selection actually changes. + void _maybeBodySelectionDragHaptic(MarkdownSelection? before) { + if (_dragIsHandle) return; + if (defaultTargetPlatform != TargetPlatform.android) return; + if (controller.selection case final after? when after != before) { + HapticFeedback.selectionClick(); + } + } + + void _extendSelectionAt(Offset global) { + if (_granularity == 1) { controller.extendToGlobal(global); - } else { - controller.extendSelectionGranular(anchor, global, - word: _granularity == 2); + return; + } + if (_granularAnchor case final anchor?) { + controller.extendSelectionGranular( + anchor, + global, + word: _granularity == 2, + ); + return; } + controller.extendToGlobal(global); } /// Whether a Shift-click should extend (rather than replace) the selection. bool get _shiftHeld => HardwareKeyboard.instance.isShiftPressed; + /// Mouse-only "precise" pointer — matches [SelectableRegion]. + static bool _isPrecisePointer(PointerDeviceKind? kind) => + kind == PointerDeviceKind.mouse; + + /// Touch recognizer joins the arena only on selectable text, or while chrome + /// must still receive taps (active range / toolbar) to dismiss or extend. + bool _shouldArmTouchSelectionGesture(Offset global) { + if (controller.hitsSelectableContent(global)) return true; + if (controller.selection case final sel? when !sel.isCollapsed) { + return true; + } + return _contextMenuController.isShown; + } + + /// With an active range, take horizontal drags eagerly (block dismissible / + /// nested horizontal scroll). With no selection, yield to those ancestors. + bool get _shouldEagerVictoryOnTouchDrag => switch (controller.selection) { + final sel? when !sel.isCollapsed => true, + _ => false, + }; + void _handleTapDown(TapDragDownDetails d) { + _lastPointerDeviceKind = d.kind; _focusNode.requestFocus(); - hideToolbar(); + final effective = _effectiveConsecutiveTapCount(d.consecutiveTapCount); + // SelectableRegion selects the word/block on the multi-tap *down* so + // chrome and a following drag are ready without waiting for tap-up. + if (effective >= 2) { + if (!_beginSelection(d.globalPosition, d.consecutiveTapCount)) return; + hideToolbar(); + } else if (_isPrecisePointer(d.kind)) { + hideToolbar(); + } } void _handleTapUp(TapDragUpDetails d) { + _lastPointerDeviceKind = d.kind; + _stopAutoscroll(); + final effective = _effectiveConsecutiveTapCount(d.consecutiveTapCount); + + // iOS: tap on an active selection toggles the toolbar. + if (defaultTargetPlatform == TargetPlatform.iOS && + effective <= 1 && + _positionIsOnActiveSelection(d.globalPosition)) { + _toggleToolbar(); + return; + } + // Shift-click extends the existing selection to the tapped point. - if (_shiftHeld && - d.consecutiveTapCount <= 1 && - controller.selection != null) { - _granularity = 1; - _granularAnchor = null; - controller.extendToGlobal(d.globalPosition); + if (_shiftHeld && effective <= 1) { + if (controller.selection case final _?) { + _granularity = 1; + _granularAnchor = null; + controller.extendToGlobal(d.globalPosition); + return; + } + } + + if (effective <= 1) { + // Touch / non-precise: dismiss immediately (no double-tap arena wait). + // Mouse: collapse to a caret at the click, matching prior desktop UX. + if (!_isPrecisePointer(d.kind)) { + hideToolbar(); + clearSelection(); + return; + } + _beginSelection(d.globalPosition, d.consecutiveTapCount); return; } - _beginSelection(d.globalPosition, d.consecutiveTapCount); + + // Multi-tap: range was set on tapDown; refresh handles/toolbar on tapUp. + if (controller.selection case final sel? when !sel.isCollapsed) { + // Keep the non-collapsed range from tapDown. + } else if (!_beginSelection(d.globalPosition, d.consecutiveTapCount)) { + return; + } + _refreshSelectionChrome(); } void _handleDragStart(TapDragStartDetails d) { + _lastPointerDeviceKind = d.kind; + final effective = _effectiveConsecutiveTapCount(d.consecutiveTapCount); + // Single-finger body drag selects only with a precise pointer; touch + // vertical scroll must remain free (SelectableRegion contract). + if (effective <= 1 && !_isPrecisePointer(d.kind)) { + return; + } _focusNode.requestFocus(); hideToolbar(); - if (_shiftHeld && - d.consecutiveTapCount <= 1 && - controller.selection != null) { - _granularity = 1; - _granularAnchor = null; - controller.extendToGlobal(d.globalPosition); + _dragIsHandle = false; + _autoscrollSession.reset(); + if (_shiftHeld && effective <= 1) { + if (controller.selection case final _?) { + // Extending an existing range may clamp through gaps. + _dragGlobal = d.globalPosition; + _granularity = 1; + _granularAnchor = null; + controller.extendToGlobal(d.globalPosition); + _driveAutoscroll(); + return; + } + } + // New selection start must hit markdown (no chrome / empty-space clamp). + if (!controller.hitsSelectableContent(d.globalPosition)) { return; } + // Arm drag before mutating selection so overlay sync is pointer-sync. + _dragGlobal = d.globalPosition; + // Multi-tap may already have anchored on tapDown; still re-begin so a + // drag that starts without a clean tapUp keeps the right granularity. _beginSelection(d.globalPosition, d.consecutiveTapCount); + _driveAutoscroll(); + } + + void _handleDragUpdate(TapDragUpdateDetails d) { + // Touch single-finger drag intentionally skips [_handleDragStart] arming. + if (_dragGlobal == null) return; + _updateSelection(d.globalPosition); + } + + bool _onScrollNotification(ScrollNotification notification) { + _onScrollAffectingChrome(); + return false; + } + + /// Parent/ancestor scrolls via [ScrollNotificationObserver] (Scaffold). + void _onAncestorScrollNotification(ScrollNotification notification) { + _onScrollAffectingChrome(); + } + + /// Absolute [contextMenuAnchors] go stale when content scrolls; handles + /// still track via [LeaderLayer] on each surface, but overlay geometry + /// needs a refresh after scroll-driven layout. When the selection leaves + /// the host viewport entirely, temporarily remove the menu (keeping the + /// restore intent); bring it back when selection paint re-enters view. + void _onScrollAffectingChrome() { + _refreshToolbarForVisibleGeometry(); + // Scroll notifications can run before child layout finishes (common when + // the scope itself was outside the viewport cache). Retry next frame. + _scheduleToolbarGeometryRefresh(); + _syncOverlay(); + } + + bool _onSizeChanged(SizeChangedLayoutNotification notification) { + // [SizeChangedLayoutNotifier] dispatches from performLayout. Any geometry + // walk ([localToGlobal], handle endpoints, toolbar clip) must wait until + // after layout — including the drag-time overlay sync path. + _scheduleToolbarGeometryRefresh(); + _syncOverlay(); + return false; + } + + void _scheduleToolbarGeometryRefresh() { + if (!controller.toolbarWanted || _toolbarGeometryRefreshScheduled) { + return; + } + _toolbarGeometryRefreshScheduled = true; + SchedulerBinding.instance.addPostFrameCallback((_) { + _toolbarGeometryRefreshScheduled = false; + if (!mounted || !controller.toolbarWanted) return; + _refreshToolbarForVisibleGeometry(); + }); + } + + /// Shows, rebuilds, or suppresses the toolbar from visible selection + /// geometry. + /// + /// Must not run during layout/build — geometry walks use [localToGlobal]. + void _refreshToolbarForVisibleGeometry() { + if (!controller.toolbarWanted) return; + final phase = SchedulerBinding.instance.schedulerPhase; + if (phase == SchedulerPhase.persistentCallbacks || + phase == SchedulerPhase.midFrameMicrotasks) { + _scheduleToolbarGeometryRefresh(); + return; + } + _lastSecondaryTapDown = null; + if (_visibleSelectionBounds() case null) { + _contextMenuController.remove(); + return; + } + if (!_contextMenuController.isShown) { + // Restore after a geometry suppress (or a deferred [showToolbar]). + if (widget.contextMenuBuilder case final builder?) { + if (Overlay.maybeOf(context, rootOverlay: true) == null) return; + _contextMenuController.show( + context: context, + contextMenuBuilder: (context) => builder(context, this), + ); + } + return; + } + _markContextMenuNeedsBuild(); + } + + /// Rebuilds the context menu when it is safe to dirty the overlay entry. + /// + /// [ContextMenuController.markNeedsBuild] must not run during build/layout + /// (e.g. a [ScrollNotification] bubbling while a list tile rebuilds). Match + /// [SelectionOverlay.markNeedsBuild]: defer to the next frame. + void _markContextMenuNeedsBuild() { + if (!_contextMenuController.isShown) return; + final phase = SchedulerBinding.instance.schedulerPhase; + if (phase == SchedulerPhase.persistentCallbacks || + phase == SchedulerPhase.midFrameMicrotasks) { + if (_toolbarBuildScheduled) return; + _toolbarBuildScheduled = true; + SchedulerBinding.instance.addPostFrameCallback((_) { + _toolbarBuildScheduled = false; + if (!mounted || !_contextMenuController.isShown) return; + _contextMenuController.markNeedsBuild(); + }); + return; + } + _contextMenuController.markNeedsBuild(); + } + + void _handleSecondaryTapUp(TapUpDetails d) { + _focusNode.requestFocus(); + final onSelection = _positionIsOnActiveSelection(d.globalPosition); + final previousSecondary = _previousSecondaryTapAnchor; + final toolbarWasVisible = toolbarIsVisible; + // Common paths: on-selection keeps the range and shows/toggles toolbar; + // off-selection follows platform select/collapse. + switch (defaultTargetPlatform) { + case TargetPlatform.android: + case TargetPlatform.fuchsia: + case TargetPlatform.windows: + if (onSelection) { + showToolbar(d.globalPosition); + _previousSecondaryTapAnchor = d.globalPosition; + return; + } + controller.startAtGlobal(d.globalPosition); + showToolbar(d.globalPosition); + case TargetPlatform.linux: + if (toolbarWasVisible) { + hideToolbar(); + _previousSecondaryTapAnchor = d.globalPosition; + return; + } + if (!onSelection) { + controller.startAtGlobal(d.globalPosition); + } + showToolbar(d.globalPosition); + case TargetPlatform.macOS: + if (toolbarWasVisible) { + if (previousSecondary case final anchor? + when anchor == d.globalPosition) { + hideToolbar(); + _previousSecondaryTapAnchor = d.globalPosition; + return; + } + } + if (!onSelection) { + controller.selectWordAtGlobal(d.globalPosition); + } + showToolbar(d.globalPosition); + case TargetPlatform.iOS: + if (!onSelection) { + controller.selectWordAtGlobal(d.globalPosition); + } + showToolbar(d.globalPosition); + } + _previousSecondaryTapAnchor = d.globalPosition; } Map get _gestures => { - // Mouse / stylus / trackpad: taps (single clears, double selects a - // word, triple a block) and drags, with word/block-granular drags and - // Shift-click extension — all from one recognizer so consecutive-tap - // counting stays intact. + // Mouse: taps + pan drag. Consecutive-tap counting stays on one + // recognizer (SelectableRegion mouse path). TapAndPanGestureRecognizer: GestureRecognizerFactoryWithHandlers( () => TapAndPanGestureRecognizer( supportedDevices: const { PointerDeviceKind.mouse, - PointerDeviceKind.stylus, - PointerDeviceKind.invertedStylus, - PointerDeviceKind.trackpad, }, ), (recognizer) => recognizer @@ -559,55 +1385,105 @@ class MarkdownSelectionScopeState extends State { ..onTapDown = _handleTapDown ..onTapUp = _handleTapUp ..onDragStart = _handleDragStart - ..onDragUpdate = ((d) => _updateSelection(d.globalPosition)), + ..onDragUpdate = _handleDragUpdate + ..onDragEnd = ((_) => _onBodyDragEnd()), ), - // Touch: a double-tap selects the word under the finger and pops the - // toolbar (the familiar mobile gesture). Tap-based, so it never steals - // a scroll drag from an enclosing list. - DoubleTapGestureRecognizer: - GestureRecognizerFactoryWithHandlers( - () => DoubleTapGestureRecognizer( - supportedDevices: const { - PointerDeviceKind.touch - }, + // Touch / stylus / trackpad: consecutive taps + horizontal drag-to- + // extend, without a DoubleTapGestureRecognizer arena (no ~300ms + // single-tap delay). Only joins the arena on selectable content (or + // while a selection/toolbar is active) so wrapping a full dismissible + // / scrollable page does not steal edge swipes on chrome. With an + // active selection, eagerly wins horizontal drag (blocks dismissible); + // otherwise yields to competing [HorizontalDragGestureRecognizer]s. + _ContentGatedTapAndHorizontalDragGestureRecognizer: + GestureRecognizerFactoryWithHandlers< + _ContentGatedTapAndHorizontalDragGestureRecognizer>( + () => _ContentGatedTapAndHorizontalDragGestureRecognizer( + shouldArm: _shouldArmTouchSelectionGesture, + shouldEagerVictoryOnDrag: () => _shouldEagerVictoryOnTouchDrag, + supportedDevices: PointerDeviceKind.values + .where((d) => d != PointerDeviceKind.mouse) + .toSet(), ), (recognizer) => recognizer - ..onDoubleTapDown = ((d) => _lastDoubleTapDown = d.globalPosition) - ..onDoubleTap = (() { - final pos = _lastDoubleTapDown; - if (pos == null) return; - _focusNode.requestFocus(); - controller.selectWordAtGlobal(pos); - showToolbar(); - }), + ..dragStartBehavior = DragStartBehavior.down + ..onTapDown = _handleTapDown + ..onTapUp = _handleTapUp + ..onDragStart = _handleDragStart + ..onDragUpdate = _handleDragUpdate + ..onDragEnd = ((_) => _onBodyDragEnd()), + ), + // Secondary-only (any device): right-click opens the context toolbar. + // Distinct type key so it can coexist with other recognizers in the + // map. + _SecondaryTapGestureRecognizer: GestureRecognizerFactoryWithHandlers< + _SecondaryTapGestureRecognizer>( + () => _SecondaryTapGestureRecognizer(), + (recognizer) => recognizer + ..onSecondaryTapDown = + ((d) => _lastSecondaryTapDown = d.globalPosition) + ..onSecondaryTapUp = _handleSecondaryTapUp, ), // Touch: long-press selects the word under the finger, then drags // extend by word (a plain swipe still scrolls an enclosing list). - LongPressGestureRecognizer: - GestureRecognizerFactoryWithHandlers( - () => LongPressGestureRecognizer(), + // Content-gated like the tap/drag recognizer so chrome swipes are not + // held in the arena when the host wraps a dismissible page. + _ContentGatedLongPressGestureRecognizer: + GestureRecognizerFactoryWithHandlers< + _ContentGatedLongPressGestureRecognizer>( + () => _ContentGatedLongPressGestureRecognizer( + shouldArm: _shouldArmTouchSelectionGesture, + supportedDevices: const { + PointerDeviceKind.touch, + PointerDeviceKind.stylus, + PointerDeviceKind.invertedStylus, + }, + ), (recognizer) => recognizer ..onLongPressStart = ((d) { + _lastPointerDeviceKind = PointerDeviceKind.touch; + // Host may wrap chrome + markdown; only arm on a real text hit. + if (!controller.hitsSelectableContent(d.globalPosition)) { + return; + } _focusNode.requestFocus(); hideToolbar(); + _dragIsHandle = false; + _autoscrollSession.reset(); + // Android shows handles on press-end; other platforms on start. + _deferHandleShow = + defaultTargetPlatform == TargetPlatform.android; + // Match TextField long-press: Android vibrate (LONG_PRESS) via + // Feedback.forLongPress; iOS heavy-impact. selectionClick alone + // is CLOCK_TICK and is easy to miss when handles are deferred. + Feedback.forLongPress(context); + // Arm drag before beginSelection so overlay sync is pointer-sync. + _dragGlobal = d.globalPosition; _beginSelection(d.globalPosition, 2); + // Magnifier still tracks during the press even when handles wait. + _selectionOverlay?.showMagnifier( + _magnifierInfo(d.globalPosition, isStart: false), + ); + _driveAutoscroll(); }) - ..onLongPressMoveUpdate = - ((d) => _updateSelection(d.globalPosition)) - ..onLongPressEnd = ((_) => showToolbar()), - ), - // Secondary-only: right-click opens the context toolbar. With no - // primary callbacks this recognizer ignores primary taps, so it never - // competes with the TapAndPan recognizer above. - TapGestureRecognizer: - GestureRecognizerFactoryWithHandlers( - () => TapGestureRecognizer(), - (recognizer) => recognizer - ..onSecondaryTapDown = - ((d) => _lastSecondaryTapDown = d.globalPosition) - ..onSecondaryTapUp = ((d) { - _focusNode.requestFocus(); - showToolbar(d.globalPosition); + ..onLongPressMoveUpdate = ((d) { + if (_dragGlobal == null) return; + _updateSelection(d.globalPosition); + _selectionOverlay?.updateMagnifier( + _magnifierInfo(d.globalPosition, isStart: false), + ); + }) + ..onLongPressEnd = ((_) { + if (_dragGlobal == null && controller.selection == null) { + return; + } + _stopAutoscroll(); + _selectionOverlay?.hideMagnifier(); + _deferHandleShow = false; + _updateHandlesAndOverlay(); + if (controller.selection case final sel? when !sel.isCollapsed) { + showToolbar(); + } }), ), }; @@ -694,13 +1570,77 @@ class MarkdownSelectionScopeState extends State { actions: _actions, child: Focus( focusNode: _focusNode, - child: RawGestureDetector( - behavior: HitTestBehavior.translucent, - gestures: _gestures, - child: widget.child, + child: NotificationListener( + onNotification: _onSizeChanged, + child: NotificationListener( + onNotification: _onScrollNotification, + child: RawGestureDetector( + behavior: HitTestBehavior.translucent, + gestures: _gestures, + excludeFromSemantics: true, + child: SizeChangedLayoutNotifier(child: widget.child), + ), + ), ), ), ), ); } } + +/// Distinct [TapGestureRecognizer] subclass used as the map key for the +/// secondary-only right-click recognizer in [RawGestureDetector]'s gestures. +final class _SecondaryTapGestureRecognizer extends TapGestureRecognizer { + _SecondaryTapGestureRecognizer(); +} + +/// [TapAndHorizontalDragGestureRecognizer] that skips the arena when the +/// pointer is not on selectable markdown (and no selection/toolbar needs +/// tap handling). Lets ancestor dismiss / horizontal-drag gestures win on +/// chrome when a host wraps an entire page. With an active selection, +/// [eagerVictoryOnDrag] is refreshed on each pointer down so horizontal +/// swipes stay with selection chrome instead of dismissible. +final class _ContentGatedTapAndHorizontalDragGestureRecognizer + extends TapAndHorizontalDragGestureRecognizer { + _ContentGatedTapAndHorizontalDragGestureRecognizer({ + required this.shouldArm, + required this.shouldEagerVictoryOnDrag, + super.supportedDevices, + }); + + /// Return true to track this pointer; false leaves the arena to ancestors. + final bool Function(Offset globalPosition) shouldArm; + + /// When true, claim horizontal drags immediately (active selection). + final bool Function() shouldEagerVictoryOnDrag; + + @override + void addAllowedPointer(PointerDownEvent event) { + if (!shouldArm(event.position)) { + return; + } + eagerVictoryOnDrag = shouldEagerVictoryOnDrag(); + super.addAllowedPointer(event); + } +} + +/// [LongPressGestureRecognizer] that skips the arena off selectable content +/// (same gate as [_ContentGatedTapAndHorizontalDragGestureRecognizer]). +final class _ContentGatedLongPressGestureRecognizer + extends LongPressGestureRecognizer { + _ContentGatedLongPressGestureRecognizer({ + required this.shouldArm, + super.supportedDevices, + }); + + /// Return true to track this pointer; false leaves the arena to ancestors. + final bool Function(Offset globalPosition) shouldArm; + + @override + void addAllowedPointer(PointerDownEvent event) { + if (!shouldArm(event.position)) { + return; + } + super.addAllowedPointer(event); + } +} diff --git a/lib/src/theme.dart b/lib/src/theme.dart index bca6016..e978ee5 100644 --- a/lib/src/theme.dart +++ b/lib/src/theme.dart @@ -289,7 +289,7 @@ class MarkdownThemeData implements ThemeExtension { ); @override - ThemeExtension copyWith({ + MarkdownThemeData copyWith({ TextDirection? textDirection, TextScaler? textScaler, TextStyle? textStyle, diff --git a/test/selection/selection_autoscroll_test.dart b/test/selection/selection_autoscroll_test.dart new file mode 100644 index 0000000..351b596 --- /dev/null +++ b/test/selection/selection_autoscroll_test.dart @@ -0,0 +1,699 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_md/src/selection_autoscroll.dart'; +import 'package:flutter_test/flutter_test.dart'; + +Widget _scrollHost({ + required ScrollController controller, + required GlobalKey hostKey, + double height = 200, +}) => + MediaQuery( + data: const MediaQueryData(size: Size(800, 600)), + child: Directionality( + textDirection: TextDirection.ltr, + child: Align( + alignment: Alignment.topLeft, + child: SizedBox( + width: 400, + height: height, + child: ListView( + controller: controller, + children: [ + SizedBox( + height: 800, + child: ColoredBox( + key: hostKey, + color: const Color(0xFF000000), + ), + ), + ], + ), + ), + ), + ), + ); + +void main() { + const config = MarkdownSelectionAutoscrollConfig( + edgeZone: 40, + maxVelocity: 100, + topPad: 10, + bottomPad: 10, + useMediaQueryPadding: false, + ); + + group('markdownAutoscrollVelocity', () { + test('is zero in the middle of the clip', () { + expect( + markdownAutoscrollVelocityInViewport( + localY: 200, + viewportHeight: 400, + config: config, + resolvedTopPad: 10, + resolvedBottomPad: 10, + ), + 0, + ); + }); + + test('scrolls toward min at the top band with quadratic depth', () { + expect( + markdownAutoscrollVelocityInViewport( + localY: 30, + viewportHeight: 400, + config: config, + resolvedTopPad: 10, + resolvedBottomPad: 10, + ), + closeTo(-25, 0.001), + ); + expect( + markdownAutoscrollVelocityInViewport( + localY: 10, + viewportHeight: 400, + config: config, + resolvedTopPad: 10, + resolvedBottomPad: 10, + ), + closeTo(-100, 0.001), + ); + }); + + test('scrolls toward max at the bottom band with quadratic depth', () { + expect( + markdownAutoscrollVelocityInViewport( + localY: 370, + viewportHeight: 400, + config: config, + resolvedTopPad: 10, + resolvedBottomPad: 10, + ), + closeTo(25, 0.001), + ); + expect( + markdownAutoscrollVelocityInViewport( + localY: 390, + viewportHeight: 400, + config: config, + resolvedTopPad: 10, + resolvedBottomPad: 10, + ), + closeTo(100, 0.001), + ); + }); + + test('disabled config always returns zero', () { + expect( + markdownAutoscrollVelocity( + localY: 0, + clipTopLocalY: 0, + clipHeight: 400, + config: MarkdownSelectionAutoscrollConfig.disabled, + ), + 0, + ); + }); + + test('mid-viewport host clip does not fire at scrollable bottom', () { + // Host union sits mid-list: clip is [100, 200] in a 400 viewport. + // Finger at the scrollable bottom must not activate. + expect( + markdownAutoscrollVelocity( + localY: 398, + clipTopLocalY: 100, + clipHeight: 100, + config: const MarkdownSelectionAutoscrollConfig( + edgeZone: 40, + maxVelocity: 100, + useMediaQueryPadding: false, + ), + ), + 0, + ); + // Finger in the host-clip bottom band does activate. + expect( + markdownAutoscrollVelocity( + localY: 195, + clipTopLocalY: 100, + clipHeight: 100, + config: const MarkdownSelectionAutoscrollConfig( + edgeZone: 40, + maxVelocity: 100, + useMediaQueryPadding: false, + ), + ), + greaterThan(0), + ); + }); + test('allows edgeZone overshoot past the clip outer edge', () { + // Finger just past clip bottom still activates; far chrome does not. + expect( + markdownAutoscrollVelocity( + localY: 220, + clipTopLocalY: 100, + clipHeight: 100, + config: const MarkdownSelectionAutoscrollConfig( + edgeZone: 40, + maxVelocity: 100, + useMediaQueryPadding: false, + ), + ), + greaterThan(0), + ); + expect( + markdownAutoscrollVelocity( + localY: 398, + clipTopLocalY: 100, + clipHeight: 100, + config: const MarkdownSelectionAutoscrollConfig( + edgeZone: 40, + maxVelocity: 100, + useMediaQueryPadding: false, + ), + ), + 0, + ); + }); + }); + + testWidgets( + 'past padded viewport still scrolls when host union extends', + (tester) async { + final controller = ScrollController(); + final hostKey = GlobalKey(); + await tester.pumpWidget( + _scrollHost(controller: controller, hostKey: hostKey), + ); + await tester.pumpAndSettle(); + + final hostContext = tester.element(find.byKey(hostKey)); + final listBox = tester.renderObject(find.byType(Scrollable)); + final origin = listBox.localToGlobal(Offset.zero); + + Duration? last; + final before = controller.offset; + // Finger below the padded clip while content still continues past it. + // Within edgeZone past the pad → velocity outer band still scrolls. + final result = applyMarkdownSelectionAutoscroll( + globalPosition: origin + Offset(10, listBox.size.height + 20), + context: hostContext, + config: const MarkdownSelectionAutoscrollConfig( + edgeZone: 40, + maxVelocity: 2000, + useMediaQueryPadding: false, + ), + lastTimestamp: null, + storeTimestamp: (value) => last = value, + hostUnionTopGlobalY: origin.dy - 50, + hostUnionBottomGlobalY: origin.dy + 2000, + ); + + expect(result, MarkdownAutoscrollResult.scrolled); + expect(controller.offset, greaterThan(before)); + expect(last, isNotNull); + }, + ); + + testWidgets( + 'far past padded viewport still scrolls when host union extends', + (tester) async { + final controller = ScrollController(); + final hostKey = GlobalKey(); + await tester.pumpWidget( + _scrollHost(controller: controller, hostKey: hostKey), + ); + await tester.pumpAndSettle(); + + final hostContext = tester.element(find.byKey(hostKey)); + final listBox = tester.renderObject(find.byType(Scrollable)); + final origin = listBox.localToGlobal(Offset.zero); + + Duration? last; + final before = controller.offset; + // Beyond edgeZone past the pad → max velocity while host still extends. + const edgeZone = 40.0; + final result = applyMarkdownSelectionAutoscroll( + globalPosition: + origin + Offset(10, listBox.size.height + edgeZone + 10), + context: hostContext, + config: const MarkdownSelectionAutoscrollConfig( + edgeZone: edgeZone, + maxVelocity: 2000, + useMediaQueryPadding: false, + ), + lastTimestamp: null, + storeTimestamp: (value) => last = value, + hostUnionTopGlobalY: origin.dy - 50, + hostUnionBottomGlobalY: origin.dy + 2000, + ); + + expect(result, MarkdownAutoscrollResult.scrolled); + expect(controller.offset, greaterThan(before)); + expect(last, isNotNull); + }, + ); + + testWidgets( + 'far past padded viewport idles when host union does not extend', + (tester) async { + final controller = ScrollController(); + final hostKey = GlobalKey(); + await tester.pumpWidget( + _scrollHost(controller: controller, hostKey: hostKey), + ); + await tester.pumpAndSettle(); + + final hostContext = tester.element(find.byKey(hostKey)); + final listBox = tester.renderObject(find.byType(Scrollable)); + final origin = listBox.localToGlobal(Offset.zero); + + final before = controller.offset; + const edgeZone = 40.0; + final result = applyMarkdownSelectionAutoscroll( + globalPosition: + origin + Offset(10, listBox.size.height + edgeZone + 10), + context: hostContext, + config: const MarkdownSelectionAutoscrollConfig( + edgeZone: edgeZone, + maxVelocity: 2000, + useMediaQueryPadding: false, + ), + lastTimestamp: null, + storeTimestamp: (_) {}, + // Mid-viewport host flush with / inside the pad — no endward scroll. + hostUnionTopGlobalY: origin.dy + 80, + hostUnionBottomGlobalY: origin.dy + 200, + ); + + expect(result, MarkdownAutoscrollResult.idle); + expect(controller.offset, before); + }, + ); + + testWidgets( + 'past viewport does not scroll when host union ends mid-viewport', + (tester) async { + final controller = ScrollController(); + final hostKey = GlobalKey(); + await tester.pumpWidget( + _scrollHost(controller: controller, hostKey: hostKey), + ); + await tester.pumpAndSettle(); + + final hostContext = tester.element(find.byKey(hostKey)); + final listBox = tester.renderObject(find.byType(Scrollable)); + final origin = listBox.localToGlobal(Offset.zero); + + final result = applyMarkdownSelectionAutoscroll( + globalPosition: origin + Offset(10, listBox.size.height - 2), + context: hostContext, + config: const MarkdownSelectionAutoscrollConfig( + edgeZone: 40, + maxVelocity: 2000, + useMediaQueryPadding: false, + ), + lastTimestamp: null, + storeTimestamp: (_) {}, + hostUnionTopGlobalY: origin.dy + 40, + hostUnionBottomGlobalY: origin.dy + 100, + ); + + expect(result, isNot(MarkdownAutoscrollResult.scrolled)); + expect(controller.offset, 0); + }, + ); + + testWidgets( + 'applyMarkdownSelectionAutoscroll jumps when host union extends below', + (tester) async { + final controller = ScrollController(); + final hostKey = GlobalKey(); + await tester.pumpWidget( + _scrollHost(controller: controller, hostKey: hostKey), + ); + await tester.pumpAndSettle(); + + final hostContext = tester.element(find.byKey(hostKey)); + final listBox = tester.renderObject(find.byType(Scrollable)); + final origin = listBox.localToGlobal(Offset.zero); + + Duration? last; + final before = controller.offset; + final result = applyMarkdownSelectionAutoscroll( + globalPosition: origin + Offset(10, listBox.size.height - 2), + context: hostContext, + config: const MarkdownSelectionAutoscrollConfig( + edgeZone: 40, + maxVelocity: 2000, + useMediaQueryPadding: false, + ), + lastTimestamp: null, + storeTimestamp: (value) => last = value, + hostUnionTopGlobalY: origin.dy - 50, + hostUnionBottomGlobalY: origin.dy + 2000, + ); + + expect(result, MarkdownAutoscrollResult.scrolled); + expect(controller.offset, greaterThan(before)); + expect(last, isNotNull); + }, + ); + + testWidgets( + 'suppresses when host-union top is already visible (no list runaway)', + (tester) async { + final controller = ScrollController(); + final hostKey = GlobalKey(); + await tester.pumpWidget( + _scrollHost(controller: controller, hostKey: hostKey), + ); + await tester.pumpAndSettle(); + + final hostContext = tester.element(find.byKey(hostKey)); + final listBox = tester.renderObject(find.byType(Scrollable)); + final origin = listBox.localToGlobal(Offset.zero); + final contentTop = origin.dy; + final contentBottom = contentTop + 400; + + Duration? last; + final result = applyMarkdownSelectionAutoscroll( + globalPosition: origin + const Offset(10, 2), + context: hostContext, + config: const MarkdownSelectionAutoscrollConfig( + edgeZone: 40, + maxVelocity: 2000, + useMediaQueryPadding: false, + ), + lastTimestamp: null, + storeTimestamp: (value) => last = value, + hostUnionTopGlobalY: contentTop, + hostUnionBottomGlobalY: contentBottom, + ); + + expect(result, MarkdownAutoscrollResult.suppressed); + expect(controller.offset, 0); + expect(last, isNull); + }, + ); + + testWidgets( + 'hard-stops toward start when host-union top is already below pad', + (tester) async { + final controller = ScrollController(); + final hostKey = GlobalKey(); + await tester.pumpWidget( + _scrollHost(controller: controller, hostKey: hostKey), + ); + await tester.pumpAndSettle(); + controller.jumpTo(200); + await tester.pumpAndSettle(); + + final hostContext = tester.element(find.byKey(hostKey)); + final listBox = tester.renderObject(find.byType(Scrollable)); + final origin = listBox.localToGlobal(Offset.zero); + + // Host starts mid-viewport (headers above). Top band must not scroll + // into non-host chrome. + final result = applyMarkdownSelectionAutoscroll( + globalPosition: origin + const Offset(10, 2), + context: hostContext, + config: const MarkdownSelectionAutoscrollConfig( + edgeZone: 40, + maxVelocity: 2000, + useMediaQueryPadding: false, + ), + lastTimestamp: null, + storeTimestamp: (_) {}, + hostUnionTopGlobalY: origin.dy + 80, + hostUnionBottomGlobalY: origin.dy + 2000, + ); + + expect(result, MarkdownAutoscrollResult.suppressed); + expect(controller.offset, 200); + }, + ); + + testWidgets( + 'scrolls toward start when host-union top extends above the pad', + (tester) async { + final controller = ScrollController(); + final hostKey = GlobalKey(); + await tester.pumpWidget( + _scrollHost(controller: controller, hostKey: hostKey), + ); + await tester.pumpAndSettle(); + controller.jumpTo(200); + await tester.pumpAndSettle(); + + final hostContext = tester.element(find.byKey(hostKey)); + final listBox = tester.renderObject(find.byType(Scrollable)); + final origin = listBox.localToGlobal(Offset.zero); + + Duration? last; + final before = controller.offset; + final result = applyMarkdownSelectionAutoscroll( + globalPosition: origin + const Offset(10, 2), + context: hostContext, + config: const MarkdownSelectionAutoscrollConfig( + edgeZone: 40, + maxVelocity: 2000, + useMediaQueryPadding: false, + ), + lastTimestamp: null, + storeTimestamp: (value) => last = value, + hostUnionTopGlobalY: origin.dy - 120, + hostUnionBottomGlobalY: origin.dy + 2000, + ); + + expect(result, MarkdownAutoscrollResult.scrolled); + expect(controller.offset, lessThan(before)); + expect(last, isNotNull); + }, + ); + + testWidgets( + 'pulls host back when entirely below the padded viewport', + (tester) async { + final controller = ScrollController(); + final hostKey = GlobalKey(); + await tester.pumpWidget( + _scrollHost(controller: controller, hostKey: hostKey), + ); + await tester.pumpAndSettle(); + + final hostContext = tester.element(find.byKey(hostKey)); + final listBox = tester.renderObject(find.byType(Scrollable)); + final origin = listBox.localToGlobal(Offset.zero); + + Duration? last; + final before = controller.offset; + // Host fully below the pad (former clipHeight<=0 death). Bottom band + // must still pull it back into view. + final result = applyMarkdownSelectionAutoscroll( + globalPosition: origin + Offset(10, listBox.size.height - 2), + context: hostContext, + config: const MarkdownSelectionAutoscrollConfig( + edgeZone: 40, + maxVelocity: 2000, + useMediaQueryPadding: false, + ), + lastTimestamp: null, + storeTimestamp: (value) => last = value, + hostUnionTopGlobalY: origin.dy + listBox.size.height + 20, + hostUnionBottomGlobalY: origin.dy + listBox.size.height + 800, + ); + + expect(result, MarkdownAutoscrollResult.scrolled); + expect(controller.offset, greaterThan(before)); + expect(last, isNotNull); + }, + ); + + testWidgets( + 'suppresses at host-union bottom even when scrollable still has extent', + (tester) async { + final controller = ScrollController(); + final hostKey = GlobalKey(); + await tester.pumpWidget( + _scrollHost(controller: controller, hostKey: hostKey), + ); + await tester.pumpAndSettle(); + + final hostContext = tester.element(find.byKey(hostKey)); + final listBox = tester.renderObject(find.byType(Scrollable)); + final origin = listBox.localToGlobal(Offset.zero); + + // Host ends mid-viewport; finger in the *padded viewport* bottom band. + // ListView still has chrome below — must hard-stop at the union. + final result = applyMarkdownSelectionAutoscroll( + globalPosition: origin + Offset(10, listBox.size.height - 2), + context: hostContext, + config: const MarkdownSelectionAutoscrollConfig( + edgeZone: 40, + maxVelocity: 2000, + useMediaQueryPadding: false, + ), + lastTimestamp: null, + storeTimestamp: (_) {}, + hostUnionTopGlobalY: origin.dy, + hostUnionBottomGlobalY: origin.dy + 100, + ); + expect(result, MarkdownAutoscrollResult.suppressed); + expect(controller.offset, 0); + }, + ); + + testWidgets( + 'arming gate blocks scroll after hard stop until leave and re-enter', + (tester) async { + final controller = ScrollController(); + final hostKey = GlobalKey(); + await tester.pumpWidget( + _scrollHost(controller: controller, hostKey: hostKey), + ); + await tester.pumpAndSettle(); + + final hostContext = tester.element(find.byKey(hostKey)); + final listBox = tester.renderObject(find.byType(Scrollable)); + final origin = listBox.localToGlobal(Offset.zero); + final session = MarkdownAutoscrollSession(); + // Padded-viewport bottom band with a mid-viewport host (hard-stop). + final edgePos = origin + Offset(10, listBox.size.height - 2); + final midPos = origin + const Offset(10, 50); + + // Hard-stop at flush union bottom → disarms toward end. + final stopped = applyMarkdownSelectionAutoscroll( + globalPosition: edgePos, + context: hostContext, + config: const MarkdownSelectionAutoscrollConfig( + edgeZone: 40, + maxVelocity: 2000, + useMediaQueryPadding: false, + ), + lastTimestamp: null, + storeTimestamp: (_) {}, + hostUnionTopGlobalY: origin.dy, + hostUnionBottomGlobalY: origin.dy + 100, + session: session, + ); + expect(stopped, MarkdownAutoscrollResult.suppressed); + expect(session.isDisarmedTowardEnd, isTrue); + + // Still in band with content that *could* scroll — gate must suppress. + final gated = applyMarkdownSelectionAutoscroll( + globalPosition: origin + Offset(10, listBox.size.height - 2), + context: hostContext, + config: const MarkdownSelectionAutoscrollConfig( + edgeZone: 40, + maxVelocity: 2000, + useMediaQueryPadding: false, + ), + lastTimestamp: null, + storeTimestamp: (_) {}, + hostUnionTopGlobalY: origin.dy - 50, + hostUnionBottomGlobalY: origin.dy + 2000, + session: session, + ); + expect(gated, MarkdownAutoscrollResult.suppressed); + expect(controller.offset, 0); + + // Leave the band → re-arm. + final left = applyMarkdownSelectionAutoscroll( + globalPosition: midPos, + context: hostContext, + config: const MarkdownSelectionAutoscrollConfig( + edgeZone: 40, + maxVelocity: 2000, + useMediaQueryPadding: false, + ), + lastTimestamp: null, + storeTimestamp: (_) {}, + hostUnionTopGlobalY: origin.dy - 50, + hostUnionBottomGlobalY: origin.dy + 2000, + session: session, + ); + expect(left, MarkdownAutoscrollResult.idle); + expect(session.isDisarmedTowardEnd, isFalse); + + // Re-enter → may scroll again. + final before = controller.offset; + final resumed = applyMarkdownSelectionAutoscroll( + globalPosition: origin + Offset(10, listBox.size.height - 2), + context: hostContext, + config: const MarkdownSelectionAutoscrollConfig( + edgeZone: 40, + maxVelocity: 2000, + useMediaQueryPadding: false, + ), + lastTimestamp: null, + storeTimestamp: (_) {}, + hostUnionTopGlobalY: origin.dy - 50, + hostUnionBottomGlobalY: origin.dy + 2000, + session: session, + ); + expect(resumed, MarkdownAutoscrollResult.scrolled); + expect(controller.offset, greaterThan(before)); + }, + ); + + testWidgets( + 'keeps scrolling after full selection until host union clears bottomPad', + (tester) async { + final controller = ScrollController(); + final hostKey = GlobalKey(); + await tester.pumpWidget( + _scrollHost(controller: controller, hostKey: hostKey), + ); + await tester.pumpAndSettle(); + + final hostContext = tester.element(find.byKey(hostKey)); + final listBox = tester.renderObject(find.byType(Scrollable)); + final origin = listBox.localToGlobal(Offset.zero); + + Duration? last; + final before = controller.offset; + // With bottomPad 104, the padded clip ends at localY≈96; sit in that + // clip's bottom band while the host union still extends below. + final result = applyMarkdownSelectionAutoscroll( + globalPosition: origin + const Offset(10, 90), + context: hostContext, + config: const MarkdownSelectionAutoscrollConfig( + edgeZone: 40, + maxVelocity: 2000, + bottomPad: 104, + useMediaQueryPadding: false, + ), + lastTimestamp: null, + storeTimestamp: (value) => last = value, + hostUnionTopGlobalY: origin.dy - 50, + hostUnionBottomGlobalY: origin.dy + 500, + ); + + expect(result, MarkdownAutoscrollResult.scrolled); + expect(controller.offset, greaterThan(before)); + expect(last, isNotNull); + }, + ); + + testWidgets('useMediaQueryPadding uses viewPadding', (tester) async { + await tester.pumpWidget( + const MediaQuery( + data: MediaQueryData( + padding: EdgeInsets.only(top: 10, bottom: 20), + viewPadding: EdgeInsets.only(top: 24, bottom: 48), + ), + child: SizedBox(), + ), + ); + final context = tester.element(find.byType(SizedBox)); + final (top, bottom) = resolveMarkdownAutoscrollPads( + config: const MarkdownSelectionAutoscrollConfig( + topPad: 8, + bottomPad: 4, + ), + context: context, + ); + expect(top, 32); // 8 + max(10, 24) + expect(bottom, 52); // 4 + max(20, 48) + }); +} diff --git a/test/selection/selection_handle_endpoints_test.dart b/test/selection/selection_handle_endpoints_test.dart new file mode 100644 index 0000000..f185cc3 --- /dev/null +++ b/test/selection/selection_handle_endpoints_test.dart @@ -0,0 +1,210 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_md/flutter_md.dart'; +import 'package:flutter_md/src/render/markdown_render_object.dart'; +import 'package:flutter_test/flutter_test.dart'; + +Widget _wrap(MarkdownSelectionController controller, Widget child) => + MaterialApp( + home: Scaffold( + body: MarkdownSelectionScope(controller: controller, child: child), + ), + ); + +class _Doc extends StatelessWidget { + const _Doc(this.id); + final String id; + + @override + Widget build(BuildContext context) { + final controller = MarkdownSelectionScope.of(context); + final model = controller.documents.firstWhere((d) => d.id == id).model; + return MarkdownWidget(markdown: model, documentId: id); + } +} + +Offset _leaderSeparation(MarkdownHandleEndpoints e) { + final start = Offset(e.startGlobal.left, e.startGlobal.bottom); + final end = Offset(e.endGlobal.right, e.endGlobal.bottom); + return start - end; +} + +void main() { + group('selectionHandleEndpoints', () { + testWidgets( + 'multi-line selection keeps start/end leaders apart after scroll', + (tester) async { + debugDefaultTargetPlatformOverride = TargetPlatform.android; + try { + final md = Markdown.fromString( + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. ' + 'Sed do eiusmod tempor incididunt ut labore et dolore magna ' + 'aliqua. Ut enim ad minim veniam, quis nostrud exercitation ' + 'ullamco laboris.', + ); + final controller = MarkdownSelectionController() + ..setDocuments( + [MarkdownDocumentRef(id: 'd', model: md)], + ); + final scroll = ScrollController(); + + await tester.pumpWidget(_wrap( + controller, + SizedBox( + height: 180, + child: ListView( + controller: scroll, + children: const [ + SizedBox(height: 120), + SizedBox(width: 280, child: _Doc('d')), + SizedBox(height: 400), + ], + ), + ), + )); + await tester.pumpAndSettle(); + + controller.selectAll(); + await tester.pump(); + + expect(controller.getText().length, greaterThan(40)); + expect( + _leaderSeparation(controller.selectionHandleEndpoints()!).distance, + greaterThan(24), + reason: 'initial multi-line leaders must not coincide', + ); + + scroll.jumpTo(80); + await tester.pumpAndSettle(); + expect( + _leaderSeparation(controller.selectionHandleEndpoints()!).distance, + greaterThan(24), + reason: 'leaders must stay apart after scroll', + ); + + scroll.jumpTo(160); + await tester.pumpAndSettle(); + expect( + _leaderSeparation(controller.selectionHandleEndpoints()!).distance, + greaterThan(24), + reason: 'leaders must stay apart after further scroll', + ); + + expect(find.byType(CompositedTransformFollower), findsNWidgets(2)); + expect(tester.takeException(), isNull); + } finally { + debugDefaultTargetPlatformOverride = null; + } + }, + ); + + testWidgets( + 'word-granular reverse expand keeps leaders apart across soft wrap', + (tester) async { + debugDefaultTargetPlatformOverride = TargetPlatform.android; + try { + final md = Markdown.fromString( + 'Curabitur pretium tincidunt lacus. Nulla gravida orci a odio. ' + 'Nullam varius, turpis et commodo pharetra, est eros bibendum ' + 'elit, nec luctus magna felis sollicitudin mauris.', + ); + final controller = MarkdownSelectionController() + ..setDocuments( + [MarkdownDocumentRef(id: 'd', model: md)], + ); + + await tester.pumpWidget(_wrap( + controller, + const Align( + alignment: Alignment.topLeft, + child: SizedBox(width: 220, child: _Doc('d')), + ), + )); + await tester.pumpAndSettle(); + + final rect = tester.getRect(find.byType(MarkdownWidget)); + // Long-press a word near the right of the first visual line, then + // drag left/up so extendSelectionGranular reverses directed edges. + final press = rect.topLeft + Offset(rect.width - 24, 10); + final gesture = await tester.startGesture(press); + await tester.pump(const Duration(milliseconds: 600)); + await gesture.moveBy(const Offset(-160, -4)); + await tester.pump(); + await gesture.moveBy(const Offset(-40, 40)); + await tester.pump(); + await gesture.up(); + await tester.pumpAndSettle(); + + final endpoints = controller.selectionHandleEndpoints(); + expect(endpoints, isNotNull); + expect(controller.selection!.isCollapsed, isFalse); + expect( + _leaderSeparation(endpoints!).distance, + greaterThan(16), + reason: 'left+right Material handles must not stack (peanut)', + ); + expect(find.byType(CompositedTransformFollower), findsNWidgets(2)); + expect(tester.takeException(), isNull); + } finally { + debugDefaultTargetPlatformOverride = null; + } + }, + ); + + testWidgets( + 'unmount clears handle layer links on the detached render object', + (tester) async { + debugDefaultTargetPlatformOverride = TargetPlatform.android; + try { + final key = GlobalKey(); + final md = Markdown.fromString( + 'Vivamus facilisis, erat vitae egestas rutrum, justo erat ' + 'malesuada purus.', + ); + final controller = MarkdownSelectionController() + ..setDocuments( + [MarkdownDocumentRef(id: 'd', model: md)], + ); + + await tester.pumpWidget(_wrap( + controller, + Align( + alignment: Alignment.topLeft, + child: SizedBox( + width: 400, + child: MarkdownWidget( + key: key, + markdown: md, + documentId: 'd', + ), + ), + ), + )); + await tester.pumpAndSettle(); + + controller.selectAll(); + await tester.pump(); + + final ro = + key.currentContext!.findRenderObject()! as MarkdownRenderObject; + expect(ro.debugHasSelectionHandleLeaders, isTrue); + + await tester.pumpWidget(_wrap( + controller, + const SizedBox.shrink(), + )); + await tester.pump(); + + expect( + ro.debugHasSelectionHandleLeaders, + isFalse, + reason: 'detach must clear leader links so they cannot linger', + ); + expect(tester.takeException(), isNull); + } finally { + debugDefaultTargetPlatformOverride = null; + } + }, + ); + }); +} diff --git a/test/selection/selection_handles_test.dart b/test/selection/selection_handles_test.dart index 2989a9d..a93493d 100644 --- a/test/selection/selection_handles_test.dart +++ b/test/selection/selection_handles_test.dart @@ -145,7 +145,7 @@ void main() { reason: 'moving the end keeps a prefix of the line'); }); - testWidgets('a handle drag past the other edge never collapses', + testWidgets('a handle drag onto the other edge nudges to one character', (tester) async { final md = Markdown.fromString('Hello selectable world'); final controller = MarkdownSelectionController() @@ -161,29 +161,86 @@ void main() { )); await tester.pumpAndSettle(); - controller.selectAll(); + // Select a mid-word range so both edges can be dragged onto each other. + controller.selectWordAtGlobal( + tester.getTopLeft(find.byType(MarkdownWidget)) + const Offset(80, 8), + ); await tester.pump(); - const full = 'Hello selectable world'; - final before = controller.selection; + expect(controller.getText(), 'selectable'); + expect(controller.selection!.isCollapsed, isFalse); + final beforeLen = controller.getText().length; final tl = tester.getTopLeft(find.byType(MarkdownWidget)); - // Drag the START edge past the END (far right) — would collapse, so the - // move is dropped and the selection is left untouched. - controller.moveSelectionEdgeToGlobal(tl + const Offset(399, 8), - isStart: true); + // Drag the directed START (base) onto the END — must nudge, not collapse. + final endEndpoints = controller.selectionHandleEndpoints()!; + controller.moveSelectionEdgeToGlobal( + Offset( + endEndpoints.endGlobal.center.dx, + endEndpoints.endGlobal.center.dy, + ), + isStart: true, + ); + await tester.pump(); + expect(controller.selection!.isCollapsed, isFalse, + reason: 'directed cross nudges to a one-char minimum'); + expect(controller.getText().length, greaterThan(0)); + expect(controller.getText().length, lessThanOrEqualTo(beforeLen)); + + // Symmetric: drag END onto START. + controller.selectWordAtGlobal(tl + const Offset(80, 8)); + await tester.pump(); + final startEndpoints = controller.selectionHandleEndpoints()!; + controller.moveSelectionEdgeToGlobal( + Offset( + startEndpoints.startGlobal.center.dx, + startEndpoints.startGlobal.center.dy, + ), + isStart: false, + ); await tester.pump(); - expect(controller.selection, before, - reason: 'a collapsing move is a no-op'); - expect(controller.getText(), full); expect(controller.selection!.isCollapsed, isFalse); + expect(controller.getText().isNotEmpty, isTrue); + }); - // Symmetric: drag the END edge past the START (far left) — also dropped. - controller.moveSelectionEdgeToGlobal(tl + const Offset(1, 8), - isStart: false); + testWidgets('directed edges may cross and flip reading-order handle sides', + (tester) async { + final md = Markdown.fromString('Hello selectable world'); + final controller = MarkdownSelectionController() + ..setDocuments( + [MarkdownDocumentRef(id: 'd', model: md)]); + + await tester.pumpWidget(_wrap( + controller, + const Align( + alignment: Alignment.topLeft, + child: SizedBox(width: 400, child: _Doc('d')), + ), + )); + await tester.pumpAndSettle(); + + final tl = tester.getTopLeft(find.byType(MarkdownWidget)); + controller.selectWordAtGlobal(tl + const Offset(80, 8)); await tester.pump(); - expect(controller.selection, before); - expect(controller.getText(), full); + expect(controller.getText(), 'selectable'); + expect(controller.isSelectionReversed, isFalse); + + // Drag the start (base) past the end into the following word. + final end = controller.selectionHandleEndpoints()!.endGlobal; + controller.moveSelectionEdgeToGlobal( + Offset(end.right + 50, end.center.dy), + isStart: true, + ); + await tester.pump(); + expect(controller.selection!.isCollapsed, isFalse); + expect(controller.isSelectionReversed, isTrue, + reason: 'base may sit after extent in reading order'); + final endpoints = controller.selectionHandleEndpoints(); + expect(endpoints, isNotNull); + // Handles stay on directed base/extent — start handle follows base, + // which is now on the right. + expect( + endpoints!.startGlobal.left, greaterThan(endpoints.endGlobal.left)); }); testWidgets('touch platform shows draggable handles for a selection', @@ -222,6 +279,87 @@ void main() { } }); + testWidgets( + 'end handle stays on the soft-wrap line end, not next-line start', + (tester) async { + final md = Markdown.fromString( + 'The quick brown fox jumps over the lazy dog and then keeps ' + 'running across the whole meadow without stopping at all today.', + ); + final controller = MarkdownSelectionController() + ..setDocuments( + [MarkdownDocumentRef(id: 'd', model: md)]); + + await tester.pumpWidget(_wrap( + controller, + const Align( + alignment: Alignment.topLeft, + child: SizedBox(width: 400, child: _Doc('d')), + ), + )); + await tester.pumpAndSettle(); + + final rect = tester.getRect(find.byType(MarkdownWidget)); + // Select from the start to the far right of the first visual line so the + // end edge lands on a soft-wrap boundary. + await _mouseDrag( + tester, + rect.topLeft + const Offset(2, 8), + rect.topLeft + Offset(rect.width - 4, 8), + ); + expect(controller.selection, isNotNull); + expect(controller.selection!.isCollapsed, isFalse); + + final endpoints = controller.selectionHandleEndpoints(); + expect(endpoints, isNotNull); + // Upstream affinity / box geometry must keep the end handle on the + // first line's right edge, not snapped to x≈0 of the next line. + expect(endpoints!.endLocal.left, greaterThan(rect.width / 2)); + expect(tester.takeException(), isNull); + }); + + testWidgets('reverse drag keeps handles on directed base/extent', + (tester) async { + debugDefaultTargetPlatformOverride = TargetPlatform.android; + try { + final md = Markdown.fromString('Hello selectable world'); + final controller = MarkdownSelectionController() + ..setDocuments( + [MarkdownDocumentRef(id: 'd', model: md)]); + + await tester.pumpWidget(_wrap( + controller, + const Align( + alignment: Alignment.topLeft, + child: SizedBox(width: 400, child: _Doc('d')), + ), + )); + await tester.pumpAndSettle(); + + final rect = tester.getRect(find.byType(MarkdownWidget)); + // Drag right-to-left so base is after extent in reading order. + await _mouseDrag( + tester, + rect.topLeft + Offset(rect.width - 4, 8), + rect.topLeft + const Offset(4, 8), + ); + expect(controller.selection, isNotNull); + expect(controller.selection!.isCollapsed, isFalse); + expect(controller.isSelectionReversed, isTrue, + reason: 'authored direction is reverse'); + + final endpoints = controller.selectionHandleEndpoints(); + expect(endpoints, isNotNull); + // Directed start handle follows base (right); end follows extent + // (left). + expect( + endpoints!.startGlobal.left, greaterThan(endpoints.endGlobal.left)); + expect(tester.takeException(), isNull); + } finally { + debugDefaultTargetPlatformOverride = null; + } + }); + testWidgets('desktop platform shows no selection handles', (tester) async { debugDefaultTargetPlatformOverride = TargetPlatform.linux; try { @@ -251,5 +389,73 @@ void main() { debugDefaultTargetPlatformOverride = null; } }); + + testWidgets( + 'handles stay when one selection-end surface unmounts', + (tester) async { + debugDefaultTargetPlatformOverride = TargetPlatform.android; + try { + final controller = MarkdownSelectionController() + ..setDocuments([ + for (var i = 0; i < 10; i++) + MarkdownDocumentRef( + id: 'm$i', + model: Markdown.fromString('Message number $i'), + order: i, + ), + ]); + final scroll = ScrollController(); + + await tester.pumpWidget(_wrap( + controller, + SizedBox( + height: 200, + child: ListView.builder( + controller: scroll, + itemCount: 10, + itemBuilder: (_, i) => SizedBox( + height: 80, + child: _Doc('m$i'), + ), + ), + ), + )); + await tester.pumpAndSettle(); + + // Span all docs so after virtualizing m0 away, later surfaces still + // paint the selection and can host a proxied start handle. + controller.selectAll(); + await tester.pump(); + expect(controller.getText(), contains('Message number 0')); + expect(controller.getText(), contains('Message number 9')); + expect(controller.selectionHandleEndpoints(), isNotNull); + expect(find.byType(CompositedTransformFollower), findsWidgets); + + scroll.jumpTo(80.0 * 7); + await tester.pumpAndSettle(); + expect( + find.byWidgetPredicate( + (w) => w is MarkdownWidget && w.documentId == 'm0', + ), + findsNothing, + ); + expect( + find.byWidgetPredicate( + (w) => w is MarkdownWidget && w.documentId == 'm9', + ), + findsOneWidget, + ); + + // Model selection intact; start handle proxied onto still-mounted + // selected spans (m7–m9). + expect(controller.getText(), contains('Message number 0')); + expect(controller.selectionHandleEndpoints(), isNotNull); + expect(find.byType(CompositedTransformFollower), findsWidgets); + expect(tester.takeException(), isNull); + } finally { + debugDefaultTargetPlatformOverride = null; + } + }, + ); }); } diff --git a/test/selection/selection_test.dart b/test/selection/selection_test.dart index b592025..0689929 100644 --- a/test/selection/selection_test.dart +++ b/test/selection/selection_test.dart @@ -1,7 +1,49 @@ +import 'dart:ui' show Offset, Rect, TextAffinity; + import 'package:flutter/painting.dart' show TextRange; +import 'package:flutter/rendering.dart' show LayerLink; import 'package:flutter_md/flutter_md.dart'; import 'package:flutter_test/flutter_test.dart'; +class _FakeSurface implements MarkdownSelectionSurface { + _FakeSurface(this.documentId); + + @override + final Object documentId; + + @override + Rect get globalBounds => Rect.zero; + + @override + MarkdownPosition? positionForGlobal(Offset globalPosition) => null; + + @override + (MarkdownPosition, TextAffinity)? hitForGlobal(Offset globalPosition) => null; + + @override + Rect? caretRectFor(MarkdownPosition position, TextAffinity affinity) => null; + + @override + (int, int, int)? wordBoundaryForGlobal(Offset globalPosition) => null; + + @override + List globalSelectionRects() => const []; + + @override + List localSelectionRects() => const []; + + @override + void setSelectionHandleLayers({ + LayerLink? startLink, + Offset? startLocal, + LayerLink? endLink, + Offset? endLocal, + }) {} + + @override + void repaintSelection() {} +} + class _UpperFormatter implements MarkdownSelectionFormatter { const _UpperFormatter(); @override @@ -149,12 +191,61 @@ void main() { expect(c.rangeFor('missing', 0), isNull); }); + test('rangeFor ignores spans with an unregistered endpoint', () { + // Surfaces can hit-test a body that was never putDocument'd. Ordering + // used to treat missing ids as -1 and paint every registered doc. + final c = two() + ..selection = const MarkdownSelection( + base: MarkdownPosition(documentId: 'a', blockIndex: 0, offset: 0), + extent: MarkdownPosition( + documentId: 'ghost', + blockIndex: 0, + offset: 3, + ), + ); + expect(c.rangeFor('a', 0), isNull); + expect(c.rangeFor('b', 0), isNull); + expect(c.rangeFor('ghost', 0), isNull); + expect(c.selectedContent().documents, isEmpty); + }); + test('removeDocument drops a touching selection', () { final c = two()..selection = full; c.removeDocument('a'); expect(c.selection, isNull); }); + test('removeDocument defers while a surface is mounted', () { + final c = MarkdownSelectionController()..putDocument('a', docA, order: 0); + final surface = _FakeSurface('a'); + c.attachSurface(surface); + + c.removeDocument('a'); + expect(c.documentCount, 1); + + c.selection = const MarkdownSelection( + base: MarkdownPosition(documentId: 'a', blockIndex: 0, offset: 0), + extent: MarkdownPosition(documentId: 'a', blockIndex: 0, offset: 5), + ); + expect(c.rangeFor('a', 0), const TextRange(start: 0, end: 5)); + + c.detachSurface(surface); + expect(c.documentCount, 0); + expect(c.selection, isNull); + }); + + test('putDocument cancels a deferred remove', () { + final c = MarkdownSelectionController()..putDocument('a', docA, order: 0); + final surface = _FakeSurface('a'); + c.attachSurface(surface); + c.removeDocument('a'); + expect(c.documentCount, 1); + + c.putDocument('a', docA, order: 0); + c.detachSurface(surface); + expect(c.documentCount, 1); + }); + test('notifies listeners on selection change', () { final c = two(); var n = 0; diff --git a/test/selection/selection_widget_test.dart b/test/selection/selection_widget_test.dart index f23d061..6633876 100644 --- a/test/selection/selection_widget_test.dart +++ b/test/selection/selection_widget_test.dart @@ -425,6 +425,276 @@ void main() { expect(tester.takeException(), isNull); }); + testWidgets('double-tap re-selects the same word and refreshes the toolbar', + (tester) async { + final controller = MarkdownSelectionController() + ..setDocuments([ + MarkdownDocumentRef( + id: 'd', model: Markdown.fromString('Hello selectable world')), + ]); + final tl = await pumpParagraph(tester, controller); + final pos = tl + const Offset(8, 8); + + await tester.tapAt(pos); + await tester.pump(const Duration(milliseconds: 40)); + await tester.tapAt(pos); + await tester.pumpAndSettle(); + expect(controller.getText(), 'Hello'); + final state = tester.state( + find.byType(MarkdownSelectionScope)); + expect(state.toolbarIsVisible, isTrue); + + state.hideToolbar(); + await tester.pumpAndSettle(); + expect(state.toolbarIsVisible, isFalse); + + // Same word again — must refresh chrome even though the range equals. + await tester.pump(const Duration(milliseconds: 500)); + await tester.tapAt(pos); + await tester.pump(const Duration(milliseconds: 40)); + await tester.tapAt(pos); + await tester.pumpAndSettle(); + + expect(controller.getText(), 'Hello'); + expect(state.toolbarIsVisible, isTrue, + reason: 'equal-range double-tap still re-shows the toolbar'); + expect(tester.takeException(), isNull); + }); + + testWidgets('expand drag hides the toolbar until drag end', (tester) async { + final controller = MarkdownSelectionController() + ..setDocuments([ + MarkdownDocumentRef( + id: 'd', + model: Markdown.fromString('Hello selectable world again')), + ]); + final tl = await pumpParagraph(tester, controller); + final pos = tl + const Offset(8, 8); + + // Establish a selection + toolbar via touch double-tap. + await tester.tapAt(pos); + await tester.pump(const Duration(milliseconds: 40)); + await tester.tapAt(pos); + await tester.pumpAndSettle(); + final state = tester.state( + find.byType(MarkdownSelectionScope)); + expect(state.toolbarIsVisible, isTrue); + + // Long-press expand hides the menu for the duration of the drag. + final gesture = await tester.startGesture(pos); + await tester.pump(const Duration(seconds: 1)); // long-press fire + await tester.pumpAndSettle(); + expect(state.toolbarIsVisible, isFalse, + reason: 'toolbar hides while expanding via long-press drag'); + + await gesture.moveBy(const Offset(80, 0)); + await tester.pump(); + expect(state.toolbarIsVisible, isFalse); + + await gesture.up(); + await tester.pumpAndSettle(); + expect(controller.selection!.isCollapsed, isFalse); + expect(state.toolbarIsVisible, isTrue, + reason: 'toolbar may return when expand drag ends'); + expect(tester.takeException(), isNull); + }); + + testWidgets( + 'Android long-press fires forLongPress haptic while handles deferred', + (tester) async { + debugDefaultTargetPlatformOverride = TargetPlatform.android; + final log = []; + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.platform, + (call) async { + log.add(call); + return null; + }, + ); + try { + final controller = MarkdownSelectionController() + ..setDocuments([ + MarkdownDocumentRef( + id: 'd', + model: Markdown.fromString('Hello selectable world again'), + ), + ]); + final tl = await pumpParagraph(tester, controller); + final pos = tl + const Offset(8, 8); + + final gesture = await tester.startGesture(pos); + await tester.pump(const Duration(seconds: 1)); + await tester.pump(); + + expect( + log.where( + (c) => + c.method == 'HapticFeedback.vibrate' && c.arguments == null, + ), + isNotEmpty, + reason: 'Feedback.forLongPress → vibrate on Android', + ); + expect(controller.selection, isNotNull); + expect(controller.selection!.isCollapsed, isFalse); + // Handles stay deferred until press-end on Android. + expect(find.byType(CompositedTransformFollower), findsNothing); + + final startTicks = log + .where( + (c) => + c.method == 'HapticFeedback.vibrate' && + c.arguments == 'HapticFeedbackType.selectionClick', + ) + .length; + + // Drag across later words — each range change should CLOCK_TICK. + await gesture.moveBy(const Offset(160, 0)); + await tester.pump(); + await gesture.moveBy(const Offset(80, 0)); + await tester.pump(); + + final dragTicks = log + .where( + (c) => + c.method == 'HapticFeedback.vibrate' && + c.arguments == 'HapticFeedbackType.selectionClick', + ) + .length; + expect(dragTicks, greaterThan(startTicks), + reason: 'long-press drag emits selectionClick on range change'); + + await gesture.up(); + await tester.pumpAndSettle(); + expect(find.byType(CompositedTransformFollower), findsWidgets); + expect(tester.takeException(), isNull); + } finally { + tester.binding.defaultBinaryMessenger + .setMockMethodCallHandler(SystemChannels.platform, null); + debugDefaultTargetPlatformOverride = null; + } + }, + ); + + testWidgets('losing focus while resumed clears the selection', + (tester) async { + final focus = FocusNode(); + addTearDown(focus.dispose); + final controller = MarkdownSelectionController() + ..setDocuments([ + MarkdownDocumentRef( + id: 'd', model: Markdown.fromString('Hello selectable world')), + ]); + + await tester.pumpWidget(MaterialApp( + home: Scaffold( + body: MarkdownSelectionScope( + controller: controller, + focusNode: focus, + child: const Align( + alignment: Alignment.topLeft, + child: SizedBox(width: 400, child: _Doc('d')), + ), + ), + ), + )); + await tester.pumpAndSettle(); + + controller.selectAll(); + focus.requestFocus(); + await tester.pump(); + expect(controller.getText(), isNotEmpty); + expect(focus.hasFocus, isTrue); + + // Ensure the binding reports resumed (SelectableRegion P2 contract). + tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed); + focus.unfocus(); + await tester.pump(); + expect(controller.getText(), isEmpty, + reason: 'focus loss while resumed clears selection'); + expect(tester.takeException(), isNull); + }); + + testWidgets( + 'secondary-click on an active selection keeps the range ' + 'and shows toolbar', (tester) async { + debugDefaultTargetPlatformOverride = TargetPlatform.windows; + try { + final controller = MarkdownSelectionController() + ..setDocuments([ + MarkdownDocumentRef( + id: 'd', model: Markdown.fromString('Hello selectable world')), + ]); + final tl = await pumpParagraph(tester, controller); + controller.selectAll(); + await tester.pumpAndSettle(); + final selected = controller.getText(); + expect(selected, isNotEmpty); + + final center = tl + const Offset(40, 8); + await tester.tapAt(center, buttons: kSecondaryButton); + await tester.pumpAndSettle(); + + expect(controller.getText(), selected, + reason: 'secondary-click on selection keeps the range'); + final state = tester.state( + find.byType(MarkdownSelectionScope)); + expect(state.toolbarIsVisible, isTrue); + expect(tester.takeException(), isNull); + } finally { + debugDefaultTargetPlatformOverride = null; + } + }); + + testWidgets('macOS consecutive taps cap at paragraph (4th stays block)', + (tester) async { + debugDefaultTargetPlatformOverride = TargetPlatform.macOS; + try { + final controller = MarkdownSelectionController() + ..setDocuments([ + MarkdownDocumentRef( + id: 'd', model: Markdown.fromString('Hello selectable world')), + ]); + final tl = await pumpParagraph(tester, controller); + final pos = tl + const Offset(8, 8); + + await _clicks(tester, pos, 4); + expect(controller.getText(), 'Hello selectable world', + reason: 'taps past 3 stay at block granularity on macOS'); + expect(tester.takeException(), isNull); + } finally { + debugDefaultTargetPlatformOverride = null; + } + }); + + testWidgets('double-click drag extends by word', (tester) async { + final controller = MarkdownSelectionController() + ..setDocuments([ + MarkdownDocumentRef( + id: 'd', + model: Markdown.fromString('Hello selectable world today')), + ]); + final tl = await pumpParagraph(tester, controller); + + // Double-click on "Hello", then drag toward "selectable". + final from = tl + const Offset(8, 8); + final to = tl + const Offset(100, 8); + final g = await tester.startGesture(from, kind: PointerDeviceKind.mouse); + await g.up(); + await tester.pump(const Duration(milliseconds: 40)); + final g2 = await tester.startGesture(from, kind: PointerDeviceKind.mouse); + await tester.pump(const Duration(milliseconds: 50)); + await g2.moveTo(to); + await tester.pump(const Duration(milliseconds: 50)); + await g2.up(); + await tester.pumpAndSettle(); + + final text = controller.getText(); + expect(text, contains('Hello')); + expect(text, contains('selectable'), + reason: 'drag after double-tap grows by word'); + expect(tester.takeException(), isNull); + }); + testWidgets('triple-click selects the whole block', (tester) async { final controller = MarkdownSelectionController() ..setDocuments([ @@ -433,25 +703,891 @@ void main() { ]); final tl = await pumpParagraph(tester, controller); - await _clicks(tester, tl + const Offset(8, 8), 3); - expect(controller.getText(), 'Hello selectable world'); - expect(tester.takeException(), isNull); - }); + await _clicks(tester, tl + const Offset(8, 8), 3); + expect(controller.getText(), 'Hello selectable world'); + expect(tester.takeException(), isNull); + }); + + testWidgets('single click clears an existing selection', (tester) async { + final controller = MarkdownSelectionController() + ..setDocuments([ + MarkdownDocumentRef( + id: 'd', model: Markdown.fromString('Hello selectable world')), + ]); + final tl = await pumpParagraph(tester, controller); + + await _clicks(tester, tl + const Offset(8, 8), 2); + expect(controller.getText(), isNotEmpty); + + await _clicks(tester, tl + const Offset(8, 8), 1); + expect(controller.getText(), isEmpty, + reason: 'a single click collapses the selection'); + expect(tester.takeException(), isNull); + }); + + testWidgets('touch single tap dismisses an active selection', + (tester) async { + final controller = MarkdownSelectionController() + ..setDocuments([ + MarkdownDocumentRef( + id: 'd', model: Markdown.fromString('Hello selectable world')), + ]); + final tl = await pumpParagraph(tester, controller); + final pos = tl + const Offset(8, 8); + + // Establish a ranged selection via touch double-tap. + await tester.tapAt(pos); + await tester.pump(const Duration(milliseconds: 40)); + await tester.tapAt(pos); + await tester.pumpAndSettle(); + expect(controller.getText(), 'Hello'); + expect(controller.selection!.isCollapsed, isFalse); + + // Single tap dismisses immediately (TapAndHorizontalDrag consecutive + // count — no DoubleTapGestureRecognizer arena wait). + await tester.pump(const Duration(milliseconds: 40)); + await tester.tapAt(pos); + await tester.pumpAndSettle(); + + expect(controller.selection, anyOf(isNull, isA())); + expect(controller.getText(), isEmpty, + reason: 'touch tap dismisses so the user is never stuck'); + final state = tester.state( + find.byType(MarkdownSelectionScope)); + expect(state.toolbarIsVisible, isFalse); + expect(tester.takeException(), isNull); + }); + + testWidgets( + 'long-press / double-tap on chrome does not start a selection', + (tester) async { + debugDefaultTargetPlatformOverride = TargetPlatform.android; + try { + final controller = MarkdownSelectionController() + ..setDocuments([ + MarkdownDocumentRef( + id: 'd', + model: Markdown.fromString('Hello selectable world'), + ), + ]); + + await tester.pumpWidget(_wrap( + controller, + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ElevatedButton( + key: const Key('chrome'), + onPressed: () {}, + child: const Text('Action'), + ), + const SizedBox(height: 80), + const SizedBox(width: 400, child: _Doc('d')), + ], + ), + )); + await tester.pumpAndSettle(); + + final buttonCenter = + tester.getCenter(find.byKey(const Key('chrome'))); + + // Long-press on the button must not clamp onto nearest markdown. + final longPress = await tester.startGesture(buttonCenter); + await tester.pump(const Duration(seconds: 1)); + await tester.pump(); + expect(controller.selection, isNull); + expect(controller.getText(), isEmpty); + await longPress.up(); + await tester.pumpAndSettle(); + expect(controller.selection, isNull); + + // Double-tap on chrome likewise. + await tester.tapAt(buttonCenter); + await tester.pump(const Duration(milliseconds: 40)); + await tester.tapAt(buttonCenter); + await tester.pumpAndSettle(); + expect(controller.selection, isNull); + expect(controller.getText(), isEmpty); + + // Control: long-press on the markdown still selects. + final mdTl = tester.getTopLeft(find.byType(MarkdownWidget)); + final onText = mdTl + const Offset(8, 8); + final onMd = await tester.startGesture(onText); + await tester.pump(const Duration(seconds: 1)); + await tester.pump(); + expect(controller.getText(), 'Hello'); + await onMd.up(); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + } finally { + debugDefaultTargetPlatformOverride = null; + } + }, + ); + + testWidgets( + 'horizontal drag on chrome yields to an ancestor drag recognizer', + (tester) async { + // Simulates DismissiblePage competing with a host that wraps the page. + debugDefaultTargetPlatformOverride = TargetPlatform.android; + try { + var ancestorDrags = 0; + final controller = MarkdownSelectionController() + ..setDocuments([ + MarkdownDocumentRef( + id: 'd', + model: Markdown.fromString('Hello selectable world'), + ), + ]); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: RawGestureDetector( + behavior: HitTestBehavior.translucent, + gestures: { + HorizontalDragGestureRecognizer: + GestureRecognizerFactoryWithHandlers< + HorizontalDragGestureRecognizer>( + HorizontalDragGestureRecognizer.new, + (HorizontalDragGestureRecognizer instance) { + instance.onStart = (_) => ancestorDrags++; + }, + ), + }, + child: MarkdownSelectionScope( + controller: controller, + child: const Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + SizedBox(height: 120, key: Key('chrome')), + SizedBox(width: 400, child: _Doc('d')), + ], + ), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + final chrome = tester.getCenter(find.byKey(const Key('chrome'))); + final gesture = await tester.startGesture(chrome); + await tester.pump(const Duration(milliseconds: 20)); + await gesture.moveBy(const Offset(80, 0)); + await tester.pump(); + await gesture.up(); + await tester.pumpAndSettle(); + + expect(ancestorDrags, greaterThan(0), + reason: 'content-gated touch recognizer must not steal chrome ' + 'horizontal swipes from ancestors (dismissible / nested scroll)'); + expect(controller.selection, isNull); + expect(tester.takeException(), isNull); + } finally { + debugDefaultTargetPlatformOverride = null; + } + }, + ); + + testWidgets( + 'horizontal drag with an active selection blocks an ancestor drag ' + 'recognizer', + (tester) async { + debugDefaultTargetPlatformOverride = TargetPlatform.android; + try { + var ancestorDrags = 0; + final controller = MarkdownSelectionController() + ..setDocuments([ + MarkdownDocumentRef( + id: 'd', + model: Markdown.fromString('Hello selectable world'), + ), + ]); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: RawGestureDetector( + behavior: HitTestBehavior.translucent, + gestures: { + HorizontalDragGestureRecognizer: + GestureRecognizerFactoryWithHandlers< + HorizontalDragGestureRecognizer>( + HorizontalDragGestureRecognizer.new, + (HorizontalDragGestureRecognizer instance) { + instance.onStart = (_) => ancestorDrags++; + }, + ), + }, + child: MarkdownSelectionScope( + controller: controller, + child: const Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + SizedBox(height: 120, key: Key('chrome')), + SizedBox(width: 400, child: _Doc('d')), + ], + ), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + controller.selectAll(); + await tester.pumpAndSettle(); + expect(controller.selection, isNotNull); + expect(controller.selection!.isCollapsed, isFalse); + + final chrome = tester.getCenter(find.byKey(const Key('chrome'))); + final gesture = await tester.startGesture(chrome); + await tester.pump(const Duration(milliseconds: 20)); + await gesture.moveBy(const Offset(80, 0)); + await tester.pump(); + await gesture.up(); + await tester.pumpAndSettle(); + + expect(ancestorDrags, 0, + reason: 'active selection must eagerly claim horizontal drag ' + 'so dismissible does not run'); + expect(tester.takeException(), isNull); + } finally { + debugDefaultTargetPlatformOverride = null; + } + }, + ); + + testWidgets( + 'drag through a gap still extends after a valid markdown start', + (tester) async { + final controller = MarkdownSelectionController() + ..setDocuments([ + MarkdownDocumentRef( + id: 'a', + model: Markdown.fromString('Alpha word here'), + ), + MarkdownDocumentRef( + id: 'b', + model: Markdown.fromString('Beta word there'), + ), + ]); + + await tester.pumpWidget(_wrap( + controller, + const Align( + alignment: Alignment.topLeft, + child: SizedBox( + width: 400, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _Doc('a'), + SizedBox(height: 48, key: Key('gap')), + _Doc('b'), + ], + ), + ), + ), + )); + await tester.pumpAndSettle(); + + final aTl = tester.getTopLeft(find.byWidgetPredicate( + (w) => w is MarkdownWidget && w.documentId == 'a', + )); + final bTl = tester.getTopLeft(find.byWidgetPredicate( + (w) => w is MarkdownWidget && w.documentId == 'b', + )); + final gapCenter = tester.getCenter(find.byKey(const Key('gap'))); + + // Starting in the gap must miss. + await _clicks(tester, gapCenter, 2); + expect(controller.selection, isNull); + + // Start on A, drag through the gap into B — clamp extend still works. + final from = aTl + const Offset(8, 8); + final to = bTl + const Offset(40, 8); + await _mouseDrag(tester, from, to); + expect(controller.getText(), contains('Alpha')); + expect(controller.getText(), contains('Beta')); + expect(tester.takeException(), isNull); + }, + ); + + testWidgets('visible toolbar re-anchors when selection changes', + (tester) async { + final controller = MarkdownSelectionController() + ..setDocuments([ + MarkdownDocumentRef( + id: 'd', + model: Markdown.fromString('Hello selectable world again')), + ]); + await pumpParagraph(tester, controller); + + controller.selectAll(); + await tester.pumpAndSettle(); + final state = tester.state( + find.byType(MarkdownSelectionScope)); + state.showToolbar(); + await tester.pumpAndSettle(); + expect(state.toolbarIsVisible, isTrue); + + final toolbarFinder = find.byType(AdaptiveTextSelectionToolbar); + expect(toolbarFinder, findsOneWidget); + final before = tester + .widget(toolbarFinder) + .anchors + .primaryAnchor; + + final tl = tester.getTopLeft(find.byType(MarkdownWidget)); + controller.moveSelectionEdgeToGlobal(tl + const Offset(40, 8), + isStart: false); + await tester.pumpAndSettle(); + + expect(state.toolbarIsVisible, isTrue, + reason: 'toolbar stays up while the range shrinks'); + expect(toolbarFinder, findsOneWidget); + final after = tester + .widget(toolbarFinder) + .anchors + .primaryAnchor; + expect(after, isNot(equals(before)), + reason: 'mounted toolbar origin tracks the live selection bounds'); + expect(tester.takeException(), isNull); + }); + + testWidgets( + 'visible toolbar re-anchors when an ancestor ListView scrolls ' + '(scope inside scrollable; ScrollNotificationObserver)', + (tester) async { + final scrollController = ScrollController(); + addTearDown(scrollController.dispose); + final controller = MarkdownSelectionController() + ..setDocuments([ + MarkdownDocumentRef( + id: 'd', + model: Markdown.fromString('Hello selectable world again'), + ), + ]); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: ListView( + controller: scrollController, + children: [ + const SizedBox(height: 200), + MarkdownSelectionScope( + controller: controller, + child: const SizedBox(width: 400, child: _Doc('d')), + ), + const SizedBox(height: 1200), + ], + ), + ), + ), + ); + await tester.pumpAndSettle(); + + controller.selectAll(); + await tester.pumpAndSettle(); + final state = tester.state( + find.byType(MarkdownSelectionScope), + ); + state.showToolbar(); + await tester.pumpAndSettle(); + expect(state.toolbarIsVisible, isTrue); + + final toolbarFinder = find.byType(AdaptiveTextSelectionToolbar); + expect(toolbarFinder, findsOneWidget); + final before = tester + .widget(toolbarFinder) + .anchors + .primaryAnchor; + + scrollController.jumpTo(120); + await tester.pumpAndSettle(); + + expect(state.toolbarIsVisible, isTrue); + expect(toolbarFinder, findsOneWidget); + final after = tester + .widget(toolbarFinder) + .anchors + .primaryAnchor; + expect(after, isNot(equals(before)), + reason: + 'toolbar follows parent scroll via ScrollNotificationObserver'); + expect(after.dy, closeTo(before.dy - 120, 1.0)); + expect(tester.takeException(), isNull); + }, + ); + + testWidgets( + 'toolbar hides when selection scrolls fully out of the host viewport', + (tester) async { + final scrollController = ScrollController(); + addTearDown(scrollController.dispose); + final controller = MarkdownSelectionController() + ..setDocuments([ + MarkdownDocumentRef( + id: 'd', + model: Markdown.fromString('Hello selectable world again'), + ), + ]); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: MarkdownSelectionScope( + controller: controller, + child: ListView( + controller: scrollController, + children: const [ + SizedBox(width: 400, child: _Doc('d')), + SizedBox(height: 2000), + ], + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + controller.selectAll(); + await tester.pumpAndSettle(); + final state = tester.state( + find.byType(MarkdownSelectionScope), + ); + state.showToolbar(); + await tester.pumpAndSettle(); + expect(state.toolbarIsVisible, isTrue); + expect(find.byType(AdaptiveTextSelectionToolbar), findsOneWidget); + + // Push the selection well above the viewport. + scrollController.jumpTo(800); + await tester.pumpAndSettle(); + + expect(state.toolbarIsVisible, isFalse, + reason: 'off-screen selection must not keep a sunk bottom toolbar'); + expect(find.byType(AdaptiveTextSelectionToolbar), findsNothing); + expect(controller.selection, isNotNull, + reason: 'hiding the menu must not clear the selection'); + + // Scroll the selection back into view — toolbar should restore. + scrollController.jumpTo(0); + await tester.pumpAndSettle(); + + expect(state.toolbarIsVisible, isTrue, + reason: 'toolbar restores when selection re-enters the host'); + expect(find.byType(AdaptiveTextSelectionToolbar), findsOneWidget); + + // Intentional dismiss must not restore on a later scroll. + state.hideToolbar(); + await tester.pumpAndSettle(); + scrollController.jumpTo(800); + await tester.pumpAndSettle(); + scrollController.jumpTo(0); + await tester.pumpAndSettle(); + expect(state.toolbarIsVisible, isFalse, + reason: 'intentional hideToolbar clears scroll-restore intent'); + expect(tester.takeException(), isNull); + }, + ); + + testWidgets( + 'toolbar anchors stay inside the host when selection is partly ' + 'off-screen', + (tester) async { + final scrollController = ScrollController(); + addTearDown(scrollController.dispose); + final controller = MarkdownSelectionController() + ..setDocuments([ + MarkdownDocumentRef( + id: 'd', + model: Markdown.fromString( + 'Line one of a tall selection block\n' + 'Line two of a tall selection block\n' + 'Line three of a tall selection block\n' + 'Line four of a tall selection block\n' + 'Line five of a tall selection block\n' + 'Line six of a tall selection block\n' + 'Line seven of a tall selection block\n' + 'Line eight of a tall selection block', + ), + ), + ]); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SizedBox( + height: 240, + child: MarkdownSelectionScope( + controller: controller, + child: ListView( + controller: scrollController, + children: const [ + SizedBox(width: 400, child: _Doc('d')), + SizedBox(height: 800), + ], + ), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + controller.selectAll(); + await tester.pumpAndSettle(); + final state = tester.state( + find.byType(MarkdownSelectionScope), + ); + state.showToolbar(); + await tester.pumpAndSettle(); + expect(state.toolbarIsVisible, isTrue); + + // Nudge so part of the selection is above the clip, part still in view. + scrollController.jumpTo(40); + await tester.pumpAndSettle(); + + expect(state.toolbarIsVisible, isTrue); + final host = tester.getRect(find.byType(MarkdownSelectionScope)); + final anchors = state.contextMenuAnchors; + expect(host.contains(anchors.primaryAnchor), isTrue, + reason: 'primary anchor must stay inside the visible host'); + expect(anchors.secondaryAnchor, isNotNull); + expect(host.contains(anchors.secondaryAnchor!), isTrue, + reason: 'secondary anchor must stay inside the visible host'); + expect(anchors.primaryAnchor.dy, isNot(equals(host.bottom)), + reason: 'must not fall back to the host bottom edge'); + expect(tester.takeException(), isNull); + }, + ); + + testWidgets( + 'toolbar pins near the top when a tall selection fills the viewport', + (tester) async { + final scrollController = ScrollController(); + addTearDown(scrollController.dispose); + final controller = MarkdownSelectionController() + ..setDocuments([ + MarkdownDocumentRef( + id: 'd', + model: Markdown.fromString( + List.generate( + 40, + (i) => 'Paragraph $i with enough text to wrap a bit.', + ).join('\n\n'), + ), + ), + ]); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SizedBox( + height: 320, + child: MarkdownSelectionScope( + controller: controller, + child: ListView( + controller: scrollController, + children: const [ + SizedBox(width: 400, child: _Doc('d')), + ], + ), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + controller.selectAll(); + await tester.pumpAndSettle(); + final state = tester.state( + find.byType(MarkdownSelectionScope), + ); + state.showToolbar(); + await tester.pumpAndSettle(); - testWidgets('single click clears an existing selection', (tester) async { + // Middle of the document — selection paint fills the clip; edges are + // off-screen. + final maxScroll = scrollController.position.maxScrollExtent; + expect(maxScroll, greaterThan(200), + reason: 'fixture must be taller than the viewport'); + scrollController.jumpTo(maxScroll / 2); + await tester.pumpAndSettle(); + + expect(state.toolbarIsVisible, isTrue); + final host = tester.getRect(find.byType(MarkdownSelectionScope)); + final anchors = state.contextMenuAnchors; + expect(anchors.secondaryAnchor, isNotNull); + expect( + anchors.secondaryAnchor!.dy - anchors.primaryAnchor.dy, + lessThan(host.height * 0.35), + reason: 'tall mid-selection must not use full-height top+bottom ' + 'anchors (that sinks the menu to the host bottom)', + ); + expect(anchors.primaryAnchor.dy, lessThan(host.center.dy), + reason: 'primary stays in the upper half of the host'); + expect(anchors.secondaryAnchor!.dy, lessThan(host.bottom - 24), + reason: 'secondary must not sit on the host bottom edge'); + expect(tester.takeException(), isNull); + }, + ); + + testWidgets( + 'toolbar sticks to a visible bottom endpoint when the top is off-screen', + (tester) async { + final scrollController = ScrollController(); + addTearDown(scrollController.dispose); + final controller = MarkdownSelectionController() + ..setDocuments([ + MarkdownDocumentRef( + id: 'd', + model: Markdown.fromString( + List.generate( + 40, + (i) => 'Paragraph $i with enough text to wrap a bit.', + ).join('\n\n'), + ), + ), + ]); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SizedBox( + height: 320, + child: MarkdownSelectionScope( + controller: controller, + child: ListView( + controller: scrollController, + children: const [ + SizedBox(width: 400, child: _Doc('d')), + ], + ), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + controller.selectAll(); + await tester.pumpAndSettle(); + + // Keep the extent (bottom) on screen; scroll the base off the top. + final maxScroll = scrollController.position.maxScrollExtent; + expect(maxScroll, greaterThan(200)); + scrollController.jumpTo(maxScroll); + await tester.pumpAndSettle(); + + final state = tester.state( + find.byType(MarkdownSelectionScope), + ); + state.showToolbar(); + await tester.pumpAndSettle(); + + final host = tester.getRect(find.byType(MarkdownSelectionScope)); + final anchors = state.contextMenuAnchors; + final endRect = controller.selectionEndpointGlobalRect(base: false); + final startRect = controller.selectionEndpointGlobalRect(base: true); + expect(endRect, isNotNull); + expect(host.inflate(8).overlaps(endRect!), isTrue, + reason: 'fixture keeps the extent caret in the host'); + expect( + startRect == null || !host.inflate(8).overlaps(startRect), + isTrue, + reason: 'fixture scrolls the base caret off-screen', + ); + expect(anchors.secondaryAnchor, isNotNull); + final belowAnchor = anchors.secondaryAnchor!; + expect( + (belowAnchor - endRect.bottomCenter).distance, + lessThan(2.0), + reason: 'single visible bottom endpoint owns the below anchor', + ); + expect(belowAnchor.dy, greaterThan(host.center.dy - 40), + reason: 'must not top-pin while the bottom edge is in view'); + expect(tester.takeException(), isNull); + }, + ); + + testWidgets( + 'size-changed layout does not read geometry during performLayout', + (tester) async { + final controller = MarkdownSelectionController() + ..setDocuments([ + MarkdownDocumentRef( + id: 'd', + model: Markdown.fromString('Hello selectable world again'), + ), + ]); + var height = 240.0; + late void Function(VoidCallback) setHeight; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: StatefulBuilder( + builder: (context, setState) { + setHeight = setState; + return SizedBox( + height: height, + child: MarkdownSelectionScope( + controller: controller, + child: const Align( + alignment: Alignment.topLeft, + child: SizedBox(width: 400, child: _Doc('d')), + ), + ), + ); + }, + ), + ), + ), + ); + await tester.pumpAndSettle(); + + controller.selectAll(); + await tester.pumpAndSettle(); + final state = tester.state( + find.byType(MarkdownSelectionScope), + ); + state.showToolbar(); + await tester.pumpAndSettle(); + expect(state.toolbarIsVisible, isTrue); + + // Resize without a tap (toolbar overlay would eat pointer events). + setHeight(() => height = 180); + await tester.pumpAndSettle(); + + expect(tester.takeException(), isNull, + reason: 'SizeChangedLayoutNotification must not sync-read ' + 'RenderBox.size via toolbar geometry during layout'); + expect(state.toolbarIsVisible, isTrue); + }, + ); + + testWidgets( + 'toolbar hides when a scope inside an ancestor ListView scrolls away', + (tester) async { + final scrollController = ScrollController(); + addTearDown(scrollController.dispose); + final controller = MarkdownSelectionController() + ..setDocuments([ + MarkdownDocumentRef( + id: 'd', + model: Markdown.fromString('Hello selectable world again'), + ), + ]); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: ListView( + controller: scrollController, + children: [ + const SizedBox(height: 200), + MarkdownSelectionScope( + controller: controller, + child: const SizedBox(width: 400, child: _Doc('d')), + ), + const SizedBox(height: 2000), + ], + ), + ), + ), + ); + await tester.pumpAndSettle(); + + controller.selectAll(); + await tester.pumpAndSettle(); + final state = tester.state( + find.byType(MarkdownSelectionScope), + ); + state.showToolbar(); + await tester.pumpAndSettle(); + expect(state.toolbarIsVisible, isTrue); + + scrollController.jumpTo(1200); + await tester.pumpAndSettle(); + + expect(find.byType(AdaptiveTextSelectionToolbar), findsNothing); + expect(controller.toolbarWanted, isTrue, + reason: 'scroll suppress keeps restore intent on the controller'); + + scrollController.jumpTo(0); + await tester.pumpAndSettle(); + + // Scope may have remounted after leaving the viewport cache — read the + // live state, not the pre-scroll handle. + final restored = tester.state( + find.byType(MarkdownSelectionScope), + ); + expect(restored.toolbarIsVisible, isTrue, + reason: 'toolbar restores when the scope scrolls back into view'); + expect(find.byType(AdaptiveTextSelectionToolbar), findsOneWidget); + expect(tester.takeException(), isNull); + }, + ); + + testWidgets('dragging near a list edge autoscrolls the ancestor', + (tester) async { final controller = MarkdownSelectionController() ..setDocuments([ - MarkdownDocumentRef( - id: 'd', model: Markdown.fromString('Hello selectable world')), + for (var i = 0; i < 12; i++) + MarkdownDocumentRef( + id: 'm$i', + model: Markdown.fromString('Message number $i with more text'), + order: i, + ), ]); - final tl = await pumpParagraph(tester, controller); + final scroll = ScrollController(); - await _clicks(tester, tl + const Offset(8, 8), 2); - expect(controller.getText(), isNotEmpty); + await tester.pumpWidget(MaterialApp( + home: Scaffold( + body: MarkdownSelectionScope( + controller: controller, + autoscroll: const MarkdownSelectionAutoscrollConfig( + edgeZone: 80, + maxVelocity: 2000, + useMediaQueryPadding: false, + ), + child: SizedBox( + height: 240, + child: ListView.builder( + controller: scroll, + itemCount: 12, + itemBuilder: (_, i) => SizedBox( + height: 80, + child: _Doc('m$i'), + ), + ), + ), + ), + ), + )); + await tester.pumpAndSettle(); + expect(scroll.offset, 0); - await _clicks(tester, tl + const Offset(8, 8), 1); - expect(controller.getText(), isEmpty, - reason: 'a single click collapses the selection'); + final listRect = tester.getRect(find.byType(ListView)); + final first = find.byType(MarkdownWidget).first; + final from = tester.getTopLeft(first) + const Offset(4, 8); + final g = await tester.startGesture(from, kind: PointerDeviceKind.mouse); + await tester.pump(const Duration(milliseconds: 50)); + await g.moveTo(Offset(listRect.center.dx, listRect.bottom - 4)); + for (var i = 0; i < 30; i++) { + await tester.pump(const Duration(milliseconds: 16)); + } + await g.up(); + await tester.pumpAndSettle(); + + expect(scroll.offset, greaterThan(0), + reason: 'edge-zone drag scrolls the descendant ListView'); expect(tester.takeException(), isNull); }); @@ -574,12 +1710,72 @@ void main() { debugDefaultTargetPlatformOverride = null; } }); + + testWidgets('controller.selectAll starts the toolbar without a gesture', + (tester) async { + debugDefaultTargetPlatformOverride = TargetPlatform.linux; + try { + final md = Markdown.fromString('Programmatic select all'); + final controller = MarkdownSelectionController() + ..setDocuments( + [MarkdownDocumentRef(id: 'd', model: md)]); + + await tester.pumpWidget( + _wrap(controller, const SizedBox(width: 400, child: _Doc('d')))); + await tester.pumpAndSettle(); + + expect(find.byType(AdaptiveTextSelectionToolbar), findsNothing); + + controller.selectAll(); + await tester.pumpAndSettle(); + + expect(controller.toolbarWanted, isTrue); + expect(controller.getText(), 'Programmatic select all'); + final state = tester.state( + find.byType(MarkdownSelectionScope)); + expect(state.toolbarIsVisible, isTrue); + expect(find.text('Copy'), findsOneWidget); + } finally { + debugDefaultTargetPlatformOverride = null; + } + }); + + testWidgets('assigning controller.selection starts the toolbar', + (tester) async { + debugDefaultTargetPlatformOverride = TargetPlatform.linux; + try { + final md = Markdown.fromString('Assign selection range'); + final controller = MarkdownSelectionController() + ..setDocuments( + [MarkdownDocumentRef(id: 'd', model: md)]); + + await tester.pumpWidget( + _wrap(controller, const SizedBox(width: 400, child: _Doc('d')))); + await tester.pumpAndSettle(); + + controller.selection = const MarkdownSelection( + base: MarkdownPosition(documentId: 'd', blockIndex: 0, offset: 0), + extent: MarkdownPosition(documentId: 'd', blockIndex: 0, offset: 6), + ); + await tester.pumpAndSettle(); + + expect(controller.toolbarWanted, isTrue); + expect(controller.getText(), 'Assign'); + final state = tester.state( + find.byType(MarkdownSelectionScope)); + expect(state.toolbarIsVisible, isTrue); + } finally { + debugDefaultTargetPlatformOverride = null; + } + }); }); group('selection highlight', () { testWidgets('paints over an opaque code-block background', (tester) async { - // Regression: the highlight used to draw BENEATH the block picture, so a - // code fence's opaque background hid it. It now draws on top. + // Code fences paint opaque chrome inside the content Picture. The + // under-content highlight pass would be hidden; a second pass paints + // above only those blocks (`selectionHighlightAboveCachedContent`). + // Normal paragraphs keep highlight *under* glyphs (SelectionArea order). final md = Markdown.fromString('```\ncode\n```'); final controller = MarkdownSelectionController() ..setDocuments( @@ -632,6 +1828,117 @@ void main() { expect(r, greaterThan(b + 20)); expect(tester.takeException(), isNull); }); + + testWidgets('paints over table zebra row backgrounds', (tester) async { + final md = Markdown.fromString( + '| A | B |\n| --- | --- |\n| 1 | 2 |\n| 3 | 4 |\n| 5 | 6 |\n', + ); + final controller = MarkdownSelectionController() + ..setDocuments( + [MarkdownDocumentRef(id: 'd', model: md)]); + + await tester.pumpWidget(MaterialApp( + home: Scaffold( + body: MarkdownSelectionScope( + controller: controller, + selectionColor: const Color(0x80FF0000), + child: const Align( + alignment: Alignment.topLeft, + child: RepaintBoundary( + key: Key('capture'), + child: SizedBox(width: 400, child: _Doc('d')), + ), + ), + ), + ), + )); + await tester.pumpAndSettle(); + + final len = markdownBlockRenderedText(md.blocks.first).length; + controller.selection = MarkdownSelection( + base: const MarkdownPosition(documentId: 'd', blockIndex: 0, offset: 0), + extent: MarkdownPosition(documentId: 'd', blockIndex: 0, offset: len), + ); + await tester.pumpAndSettle(); + + // Highlight covers glyph boxes only (cell padding has zebra fill alone). + // Even data rows (r % 2 == 0, r != 0) paint zebra under the content + // Picture; sample a mid-table glyph so the above-pass tint is required. + late final int r, g, b; + await tester.runAsync(() async { + final boundary = tester.renderObject( + find.byKey(const Key('capture'))); + final image = boundary.toImageSync(); + final width = image.width; + final height = image.height; + final data = await image.toByteData(); + image.dispose(); + // Row 2 of 4 (first zebra data row): ~62% down; cell pad is 8px. + const x = 10; + final y = (height * 5 ~/ 8).clamp(0, height - 1); + final i = (y * width + x) * 4; + r = data!.getUint8(i); + g = data.getUint8(i + 1); + b = data.getUint8(i + 2); + }); + + expect(r, greaterThan(g + 20), + reason: 'selection tint must show through zebra row fills'); + expect(r, greaterThan(b + 20)); + expect(tester.takeException(), isNull); + }); + + testWidgets('paints over inline monospace backgrounds', (tester) async { + // Only monospace so the sample cannot land on plain selected text. + final md = Markdown.fromString('`benchmarks?`'); + final controller = MarkdownSelectionController() + ..setDocuments( + [MarkdownDocumentRef(id: 'd', model: md)]); + + await tester.pumpWidget(MaterialApp( + home: Scaffold( + body: MarkdownSelectionScope( + controller: controller, + selectionColor: const Color(0x80FF0000), + child: const Align( + alignment: Alignment.topLeft, + child: RepaintBoundary( + key: Key('capture'), + child: SizedBox(width: 400, child: _Doc('d')), + ), + ), + ), + ), + )); + await tester.pumpAndSettle(); + + final len = markdownBlockRenderedText(md.blocks.first).length; + controller.selection = MarkdownSelection( + base: const MarkdownPosition(documentId: 'd', blockIndex: 0, offset: 0), + extent: MarkdownPosition(documentId: 'd', blockIndex: 0, offset: len), + ); + await tester.pumpAndSettle(); + + late final int r, g, b; + await tester.runAsync(() async { + final boundary = tester.renderObject( + find.byKey(const Key('capture'))); + final image = boundary.toImageSync(); + final width = image.width; + final data = await image.toByteData(); + image.dispose(); + const x = 12, y = 8; + final i = (y * width + x) * 4; + r = data!.getUint8(i); + g = data.getUint8(i + 1); + b = data.getUint8(i + 2); + }); + + expect(r, greaterThan(g + 20), + reason: 'the red highlight must tint inline monospace chrome'); + expect(r, greaterThan(b + 20)); + expect(tester.takeException(), isNull); + }); }); group('selection cursor', () { diff --git a/test/theme/theme_test.dart b/test/theme/theme_test.dart index aedde4f..a760017 100644 --- a/test/theme/theme_test.dart +++ b/test/theme/theme_test.dart @@ -63,7 +63,7 @@ void main() => group('MarkdownThemeData', () { textStyle: const TextStyle(fontSize: 14), onLinkTap: (_, __) => called = true, ); - final copy = theme.copyWith() as MarkdownThemeData; + final copy = theme.copyWith(); expect(copy.onLinkTap, isNotNull); copy.onLinkTap!('t', 'u'); expect(called, isTrue); @@ -73,7 +73,7 @@ void main() => group('MarkdownThemeData', () { final copy = base().copyWith( linkColor: Colors.green, linkStyle: const TextStyle(fontStyle: FontStyle.italic), - ) as MarkdownThemeData; + ); expect(copy.linkColor, Colors.green); expect(copy.linkStyle?.fontStyle, FontStyle.italic); }); diff --git a/test/unit_test.dart b/test/unit_test.dart index f6dce08..a760f53 100644 --- a/test/unit_test.dart +++ b/test/unit_test.dart @@ -1,21 +1,22 @@ import 'package:flutter_test/flutter_test.dart'; +import 'highlight/highlight_test.dart' as highlight_test; +import 'nodes/nodes_test.dart' as nodes_test; import 'parser/block_test.dart' as block_test; import 'parser/edge_cases_test.dart' as edge_cases_test; import 'parser/gfm_test.dart' as gfm_test; import 'parser/golden_test.dart' as golden_test; -import 'nodes/nodes_test.dart' as nodes_test; import 'parser/inline_test.dart' as inline_test; import 'parser/math_test.dart' as math_test; import 'parser/parser_test.dart' as parser_test; import 'parser/regression_test.dart' as regression_test; import 'parser/streaming_test.dart' as streaming_test; import 'selection/markup_formatter_test.dart' as markup_formatter_test; +import 'selection/selection_autoscroll_test.dart' as selection_autoscroll_test; import 'selection/selection_handles_test.dart' as selection_handles_test; import 'selection/selection_keyboard_test.dart' as selection_keyboard_test; import 'selection/selection_test.dart' as selection_test; import 'selection/selection_widget_test.dart' as selection_widget_test; -import 'highlight/highlight_test.dart' as highlight_test; import 'theme/theme_test.dart' as theme_test; import 'widget/render_test.dart' as render_test; import 'widget/widget_test.dart' as widget_test; @@ -38,6 +39,7 @@ void main() => group('Unit', () { selection_widget_test.main(); selection_keyboard_test.main(); selection_handles_test.main(); + selection_autoscroll_test.main(); render_test.main(); widget_test.main(); }); From a41636f42e774e755b6d4600cec2843c368e4828 Mon Sep 17 00:00:00 2001 From: Emil Date: Fri, 11 Sep 2026 20:12:46 +0500 Subject: [PATCH 02/19] feat: add clampMarkdownToLines for painter-measured line budgets Expose a public measure-then-clip helper that cuts on real text line boundaries so hosts can size overflow chrome without half-line crops. --- CHANGELOG.md | 6 +++ lib/flutter_md.dart | 1 + lib/src/line_clamp.dart | 61 +++++++++++++++++++++ test/line_clamp/line_clamp_test.dart | 80 ++++++++++++++++++++++++++++ test/unit_test.dart | 5 ++ 5 files changed, 153 insertions(+) create mode 100644 lib/src/line_clamp.dart create mode 100644 test/line_clamp/line_clamp_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 3f9dcb7..1a007cf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ ## Unreleased +### Line clamp +- **ADDED**: `clampMarkdownToLines` / `MarkdownLineClamp` — painter-measured + height for the first N visual text lines at a given width (line-boundary cut; + spacers/dividers add height without spending the line budget). Callers size a + clipped box; the renderer itself has no line budget. + ### Registry / multi-body selection - **FIXED**: `removeDocument` defers while a surface for that id is still mounted and flushes on `detachSurface`; a later `putDocument` cancels the pending diff --git a/lib/flutter_md.dart b/lib/flutter_md.dart index ab6ecf7..ba3ba53 100644 --- a/lib/flutter_md.dart +++ b/lib/flutter_md.dart @@ -1,5 +1,6 @@ library; +export 'src/line_clamp.dart'; export 'src/markdown.dart'; export 'src/nodes.dart'; export 'src/parser.dart'; diff --git a/lib/src/line_clamp.dart b/lib/src/line_clamp.dart new file mode 100644 index 0000000..ea6ba99 --- /dev/null +++ b/lib/src/line_clamp.dart @@ -0,0 +1,61 @@ +import 'markdown.dart'; +import 'render/markdown_painter.dart'; +import 'theme.dart'; + +/// Where a markdown document has to be cut to show a limited number of lines. +final class MarkdownLineClamp { + /// Creates a clamp result. + const MarkdownLineClamp({ + required this.height, + required this.clampedHeight, + }); + + /// Full content height at the measured width. + final double height; + + /// Height holding at most the requested lines, ending on a line boundary. + /// Equals [height] when the whole document fits. + final double clampedHeight; + + /// Whether the line budget leaves content out. + bool get overflows => clampedHeight < height; + + @override + String toString() => + 'MarkdownLineClamp(height: $height, clampedHeight: $clampedHeight)'; +} + +/// Measures [markdown] at [maxWidth] and reports where its first [maxLines] +/// text lines end. +/// +/// Lays the document out on its own so a caller can size a clipped box before +/// the renderer runs — the renderer has no line budget of its own. Cutting on +/// a line boundary is what keeps a clipped view from showing half a line. +/// +/// Line counting follows the rules of [MarkdownPainter.textLineBottoms]: blank +/// lines and dividers add height without spending the budget, so a clamp never +/// ends on trailing blank space. +MarkdownLineClamp clampMarkdownToLines({ + required Markdown markdown, + required MarkdownThemeData theme, + required double maxWidth, + required int maxLines, +}) { + final painter = MarkdownPainter(markdown: markdown, theme: theme); + try { + final height = painter.layout(maxWidth: maxWidth).height; + if (maxLines <= 0) { + return MarkdownLineClamp(height: height, clampedHeight: 0); + } + final lines = painter.textLineBottoms(); + if (lines.length <= maxLines) { + return MarkdownLineClamp(height: height, clampedHeight: height); + } + return MarkdownLineClamp( + height: height, + clampedHeight: lines[maxLines - 1], + ); + } finally { + painter.dispose(); + } +} diff --git a/test/line_clamp/line_clamp_test.dart b/test/line_clamp/line_clamp_test.dart new file mode 100644 index 0000000..d525d88 --- /dev/null +++ b/test/line_clamp/line_clamp_test.dart @@ -0,0 +1,80 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_md/flutter_md.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() => group('clampMarkdownToLines', () { + // Height 2.0 keeps every line exactly 20px tall, so expectations read as + // line counts. + final theme = MarkdownThemeData( + textStyle: const TextStyle(fontSize: 10, height: 2), + ); + + MarkdownLineClamp clamp(String source, int maxLines, + {double width = 300}) => + clampMarkdownToLines( + markdown: Markdown.fromString(source), + theme: theme, + maxWidth: width, + maxLines: maxLines, + ); + + test('content within the budget is not clamped', () { + final result = clamp('one\n\ntwo', 6); + + expect(result.overflows, isFalse); + expect(result.clampedHeight, result.height); + }); + + test('cuts on the boundary of the last allowed line', () { + final result = clamp('one\ntwo\nthree\nfour', 2); + + expect(result.overflows, isTrue); + expect(result.clampedHeight, 40); + expect(result.height, 80); + }); + + test('counts wrapped lines of a single paragraph', () { + // 10px glyphs at width 100 wrap roughly every 10 characters. + final result = clamp('word ' * 40, 3, width: 100); + + expect(result.overflows, isTrue); + expect(result.clampedHeight, 60); + }); + + test('counts list items as lines', () { + final result = clamp('- one\n- two\n- three', 2); + + expect(result.overflows, isTrue); + expect(result.clampedHeight, 40); + }); + + test('blank lines add height without spending the budget', () { + final withBlank = clamp('one\n\ntwo\nthree', 2); + final withoutBlank = clamp('one\ntwo\nthree', 2); + + expect(withBlank.overflows, isTrue); + // Two text lines plus the blank line between them. + expect( + withBlank.clampedHeight, greaterThan(withoutBlank.clampedHeight)); + }); + + test('dividers add height without spending the budget', () { + final withDivider = clamp('one\n\n---\n\ntwo\nthree', 2); + final withoutDivider = clamp('one\ntwo\nthree', 2); + + expect(withDivider.overflows, isTrue); + expect(withDivider.clampedHeight, + greaterThan(withoutDivider.clampedHeight)); + }); + + test('a clamp of zero lines keeps nothing', () { + expect(clamp('one\ntwo', 0).clampedHeight, 0); + }); + + test('empty markdown has no height', () { + final result = clamp('', 6); + + expect(result.height, 0); + expect(result.overflows, isFalse); + }); + }); diff --git a/test/unit_test.dart b/test/unit_test.dart index a760f53..7a035b5 100644 --- a/test/unit_test.dart +++ b/test/unit_test.dart @@ -1,6 +1,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'highlight/highlight_test.dart' as highlight_test; +import 'line_clamp/line_clamp_test.dart' as line_clamp_test; import 'nodes/nodes_test.dart' as nodes_test; import 'parser/block_test.dart' as block_test; import 'parser/edge_cases_test.dart' as edge_cases_test; @@ -13,6 +14,8 @@ import 'parser/regression_test.dart' as regression_test; import 'parser/streaming_test.dart' as streaming_test; import 'selection/markup_formatter_test.dart' as markup_formatter_test; import 'selection/selection_autoscroll_test.dart' as selection_autoscroll_test; +import 'selection/selection_handle_endpoints_test.dart' + as selection_handle_endpoints_test; import 'selection/selection_handles_test.dart' as selection_handles_test; import 'selection/selection_keyboard_test.dart' as selection_keyboard_test; import 'selection/selection_test.dart' as selection_test; @@ -39,7 +42,9 @@ void main() => group('Unit', () { selection_widget_test.main(); selection_keyboard_test.main(); selection_handles_test.main(); + selection_handle_endpoints_test.main(); selection_autoscroll_test.main(); + line_clamp_test.main(); render_test.main(); widget_test.main(); }); From 1686337e1b55bc6288b59a9c13eed55c28b37ae5 Mon Sep 17 00:00:00 2001 From: Emil Date: Fri, 11 Sep 2026 20:12:46 +0500 Subject: [PATCH 03/19] chore(example): bump Flutter/SDK tooling and refresh demo tabs Raise the example app floor to current Flutter tooling, drop the stale smoke test, and tidy chat/highlight demo formatting. --- .../android/app/src/main/AndroidManifest.xml | 3 +- .../gradle/wrapper/gradle-wrapper.properties | 2 +- example/android/settings.gradle.kts | 4 +- example/ios/Flutter/AppFrameworkInfo.plist | 2 +- example/ios/Runner.xcodeproj/project.pbxproj | 9 +- example/ios/Runner/AppDelegate.swift | 5 +- example/lib/tabs/chat_tab.dart | 171 +++++++++--------- example/lib/tabs/highlight_tab.dart | 20 +- .../macos/Runner.xcodeproj/project.pbxproj | 6 +- example/pubspec.yaml | 4 +- example/test/smoke_test.dart | 53 ------ 11 files changed, 130 insertions(+), 149 deletions(-) delete mode 100644 example/test/smoke_test.dart diff --git a/example/android/app/src/main/AndroidManifest.xml b/example/android/app/src/main/AndroidManifest.xml index 5687c1c..864fe94 100644 --- a/example/android/app/src/main/AndroidManifest.xml +++ b/example/android/app/src/main/AndroidManifest.xml @@ -2,7 +2,8 @@ + android:icon="@mipmap/ic_launcher" + android:enableOnBackInvokedCallback="false"> CFBundleVersion 1.0 MinimumOSVersion - 12.0 + 13.0 diff --git a/example/ios/Runner.xcodeproj/project.pbxproj b/example/ios/Runner.xcodeproj/project.pbxproj index 79d81e5..c927f01 100644 --- a/example/ios/Runner.xcodeproj/project.pbxproj +++ b/example/ios/Runner.xcodeproj/project.pbxproj @@ -346,7 +346,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = iphoneos; @@ -362,6 +362,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = FNMRCDFK9V; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( @@ -472,7 +473,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = iphoneos; @@ -523,7 +524,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = iphoneos; @@ -541,6 +542,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = FNMRCDFK9V; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( @@ -563,6 +565,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = FNMRCDFK9V; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( diff --git a/example/ios/Runner/AppDelegate.swift b/example/ios/Runner/AppDelegate.swift index 6266644..26f0ede 100644 --- a/example/ios/Runner/AppDelegate.swift +++ b/example/ios/Runner/AppDelegate.swift @@ -8,6 +8,9 @@ import UIKit didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? ) -> Bool { GeneratedPluginRegistrant.register(with: self) - return super.application(application, didFinishLaunchingWithOptions: launchOptions) + return super.application( + application, + didFinishLaunchingWithOptions: launchOptions + ) } } diff --git a/example/lib/tabs/chat_tab.dart b/example/lib/tabs/chat_tab.dart index b7a99f1..1cb8e79 100644 --- a/example/lib/tabs/chat_tab.dart +++ b/example/lib/tabs/chat_tab.dart @@ -56,9 +56,9 @@ class _ChatTabState extends State { } List _refs() => [ - for (final (i, m) in _messages.indexed) - MarkdownDocumentRef(id: m.id, model: m.markdown, order: i), - ]; + for (final (i, m) in _messages.indexed) + MarkdownDocumentRef(id: m.id, model: m.markdown, order: i), + ]; Future _copy() async { final text = _controller.getText(); @@ -88,11 +88,16 @@ class _ChatTabState extends State { _streamCursor = 0; _streamParser.reset(); setState(() => _messages.add(_Msg(id, false, const Markdown.empty()))); - _controller.putDocument(id, const Markdown.empty(), - order: _messages.length - 1); + _controller.putDocument( + id, + const Markdown.empty(), + order: _messages.length - 1, + ); _scrollToBottom(); - _streamTimer = - Timer.periodic(const Duration(milliseconds: 55), (_) => _tick(id)); + _streamTimer = Timer.periodic( + const Duration(milliseconds: 55), + (_) => _tick(id), + ); } void _tick(String id) { @@ -149,29 +154,28 @@ class _ChatTabState extends State { @override Widget build(BuildContext context) => Column( - children: [ - Expanded( - child: MarkdownSelectionScope( - controller: _controller, - child: ListView.builder( - controller: _scroll, - padding: - const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - itemCount: _messages.length, - itemBuilder: (context, i) => _Bubble(message: _messages[i]), - ), - ), + children: [ + Expanded( + child: MarkdownSelectionScope( + controller: _controller, + child: ListView.builder( + controller: _scroll, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + itemCount: _messages.length, + itemBuilder: (context, i) => _Bubble(message: _messages[i]), ), - _SelectionBar( - controller: _controller, - isStreaming: _isStreaming, - onCopy: _copy, - onSelectAll: _controller.selectAll, - onClear: _controller.clear, - onStream: _toggleStream, - ), - ], - ); + ), + ), + _SelectionBar( + controller: _controller, + isStreaming: _isStreaming, + onCopy: _copy, + onSelectAll: _controller.selectAll, + onClear: _controller.clear, + onStream: _toggleStream, + ), + ], + ); } class _SelectionBar extends StatelessWidget { @@ -193,55 +197,55 @@ class _SelectionBar extends StatelessWidget { @override Widget build(BuildContext context) => Material( - elevation: 8, - child: SafeArea( - top: false, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6), - child: Row( - children: [ - Expanded( - child: AnimatedBuilder( - animation: controller, - builder: (context, _) { - final n = controller.getText().length; - return Text( - n == 0 - ? 'Drag / long-press-drag across messages · ' - 'Ctrl/Cmd+C copy · right-click or long-press ' - 'for the toolbar · handles on touch' - : 'Selected $n characters across messages', - style: Theme.of(context).textTheme.bodySmall, - ); - }, - ), - ), - IconButton( - tooltip: 'Select all', - onPressed: onSelectAll, - icon: const Icon(Icons.select_all), - ), - IconButton( - tooltip: 'Clear selection', - onPressed: onClear, - icon: const Icon(Icons.clear), - ), - TextButton.icon( - onPressed: onStream, - icon: Icon(isStreaming ? Icons.stop : Icons.bolt), - label: Text(isStreaming ? 'Stop' : 'Stream'), - ), - const SizedBox(width: 4), - FilledButton.icon( - onPressed: onCopy, - icon: const Icon(Icons.copy), - label: const Text('Copy'), - ), - ], + elevation: 8, + child: SafeArea( + top: false, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6), + child: Row( + children: [ + Expanded( + child: AnimatedBuilder( + animation: controller, + builder: (context, _) { + final n = controller.getText().length; + return Text( + n == 0 + ? 'Drag / long-press-drag across messages · ' + 'Ctrl/Cmd+C copy · right-click or long-press ' + 'for the toolbar · handles on touch' + : 'Selected $n characters across messages', + style: Theme.of(context).textTheme.bodySmall, + ); + }, + ), ), - ), + IconButton( + tooltip: 'Select all', + onPressed: onSelectAll, + icon: const Icon(Icons.select_all), + ), + IconButton( + tooltip: 'Clear selection', + onPressed: onClear, + icon: const Icon(Icons.clear), + ), + TextButton.icon( + onPressed: onStream, + icon: Icon(isStreaming ? Icons.stop : Icons.bolt), + label: Text(isStreaming ? 'Stop' : 'Stream'), + ), + const SizedBox(width: 4), + FilledButton.icon( + onPressed: onCopy, + icon: const Icon(Icons.copy), + label: const Text('Copy'), + ), + ], ), - ); + ), + ), + ); } class _Bubble extends StatelessWidget { @@ -256,8 +260,9 @@ class _Bubble extends StatelessWidget { return Padding( padding: const EdgeInsets.symmetric(vertical: 5), child: Row( - mainAxisAlignment: - isUser ? MainAxisAlignment.end : MainAxisAlignment.start, + mainAxisAlignment: isUser + ? MainAxisAlignment.end + : MainAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start, children: [ if (!isUser) ...[ @@ -268,7 +273,8 @@ class _Bubble extends StatelessWidget { child: Container( padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), constraints: BoxConstraints( - maxWidth: MediaQuery.sizeOf(context).width * 0.78), + maxWidth: MediaQuery.sizeOf(context).width * 0.78, + ), decoration: BoxDecoration( color: isUser ? scheme.primaryContainer @@ -283,7 +289,9 @@ class _Bubble extends StatelessWidget { child: message.markdown.isEmpty ? const _TypingDots() : MarkdownWidget( - markdown: message.markdown, documentId: message.id), + markdown: message.markdown, + documentId: message.id, + ), ), ), if (isUser) ...[ @@ -378,7 +386,8 @@ class _Msg { /// [StreamingMarkdownParser] can *freeze* each completed block: once a blank /// line proves a paragraph or list is done, it is never re-parsed again — only /// the final, still-growing block is. -const String _streamAnswer = 'Absolutely — here is a streamed reply.\n' +const String _streamAnswer = + 'Absolutely — here is a streamed reply.\n' '\n' 'Because the selection is anchored on the **immutable model**, it stays ' 'put while these words arrive one at a time.\n' diff --git a/example/lib/tabs/highlight_tab.dart b/example/lib/tabs/highlight_tab.dart index bf5fa0d..b97556c 100644 --- a/example/lib/tabs/highlight_tab.dart +++ b/example/lib/tabs/highlight_tab.dart @@ -16,6 +16,8 @@ class HighlightTab extends StatefulWidget { } class _HighlightTabState extends State { + late final MarkdownSelectionController _controller = + MarkdownSelectionController(); // Built once; the whole registry is shared between both theme variants. final SyntaxHighlighter _dark = MarkdownHighlighter( languages: allHighlightLanguages, @@ -30,9 +32,18 @@ class _HighlightTabState extends State { final ScrollController _scrollController = ScrollController(); + @override + void initState() { + super.initState(); + _controller.setDocuments([ + MarkdownDocumentRef(id: 'main', model: _doc), + ]); + } + @override void dispose() { _scrollController.dispose(); + _controller.dispose(); super.dispose(); } @@ -49,7 +60,14 @@ class _HighlightTabState extends State { child: SingleChildScrollView( controller: _scrollController, padding: const EdgeInsets.all(16), - child: MarkdownWidget(markdown: _doc, theme: theme), + child: MarkdownSelectionScope( + controller: _controller, + child: MarkdownWidget( + markdown: _doc, + documentId: 'main', + theme: theme, + ), + ), ), ); } diff --git a/example/macos/Runner.xcodeproj/project.pbxproj b/example/macos/Runner.xcodeproj/project.pbxproj index f3177e6..bd91f7c 100644 --- a/example/macos/Runner.xcodeproj/project.pbxproj +++ b/example/macos/Runner.xcodeproj/project.pbxproj @@ -461,7 +461,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 10.14; + MACOSX_DEPLOYMENT_TARGET = 10.15; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = macosx; SWIFT_COMPILATION_MODE = wholemodule; @@ -543,7 +543,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 10.14; + MACOSX_DEPLOYMENT_TARGET = 10.15; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = macosx; @@ -593,7 +593,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 10.14; + MACOSX_DEPLOYMENT_TARGET = 10.15; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = macosx; SWIFT_COMPILATION_MODE = wholemodule; diff --git a/example/pubspec.yaml b/example/pubspec.yaml index 1e02322..82e2d4c 100644 --- a/example/pubspec.yaml +++ b/example/pubspec.yaml @@ -26,8 +26,8 @@ platforms: # ============================================================================== environment: - sdk: ">=3.6.0 <4.0.0" - flutter: ">=3.29.0" + sdk: ">=3.11.0" + flutter: ">=3.41.7" dependencies: flutter: diff --git a/example/test/smoke_test.dart b/example/test/smoke_test.dart deleted file mode 100644 index 31d82c2..0000000 --- a/example/test/smoke_test.dart +++ /dev/null @@ -1,53 +0,0 @@ -import 'package:flutter/gestures.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_md/flutter_md.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:md_example/main.dart'; -import 'package:md_example/tabs/highlight_tab.dart'; - -void main() { - testWidgets('all tabs build and selection drags do not crash', - (tester) async { - await tester.pumpWidget(ThemeModel( - notifier: ValueNotifier(ThemeMode.light), - child: const App(), - )); - await tester.pumpAndSettle(); - - // Editor tab renders the preview. - expect(find.byType(EditorTab), findsOneWidget); - expect(tester.takeException(), isNull); - - // Selection tab: drag across the first Markdown widget. - await tester.tap(find.text('Selection')); - await tester.pumpAndSettle(); - final md = find.byType(MarkdownWidget).first; - final g = await tester.startGesture( - tester.getTopLeft(md) + const Offset(2, 4), - kind: PointerDeviceKind.mouse); - await tester.pump(const Duration(milliseconds: 150)); - await g.moveTo(tester.getCenter(md)); - await tester.pump(const Duration(milliseconds: 150)); - await g.up(); - await tester.pumpAndSettle(); - expect(tester.takeException(), isNull); - - // The live Plain-vs-Markdown preview appears once something is selected. - expect(find.text('Plain (default)'), findsOneWidget); - // "Copy as Markdown" shows as both the preview panel title and the button. - expect(find.text('Copy as Markdown'), findsWidgets); - - // Chat tab builds and the Copy button is present. - await tester.tap(find.text('Chat')); - await tester.pumpAndSettle(); - expect(find.text('Copy'), findsWidgets); - expect(tester.takeException(), isNull); - - // Highlight tab: every showcased language renders without crashing. - await tester.tap(find.text('Highlight')); - await tester.pumpAndSettle(); - expect(find.byType(HighlightTab), findsOneWidget); - expect(find.byType(MarkdownWidget), findsOneWidget); - expect(tester.takeException(), isNull); - }); -} From 876810248fd4a8301331cbe12a9146869336f8c8 Mon Sep 17 00:00:00 2001 From: Emil Date: Sun, 13 Sep 2026 03:32:30 +0500 Subject: [PATCH 04/19] feat(render): add cursorResolver hook and localBoxesForRange query Co-authored-by: Cursor --- CHANGELOG.md | 10 ++ docs/rendering.md | 4 + docs/selection.md | 3 + lib/src/render/markdown_painter.dart | 65 +++++++ lib/src/render/markdown_render_object.dart | 96 +++++++--- lib/src/selection.dart | 13 ++ lib/src/theme.dart | 30 ++++ lib/src/widget.dart | 14 +- test/selection/selection_test.dart | 105 ++++++++++- test/selection/selection_widget_test.dart | 194 +++++++++++++++++++++ test/theme/theme_test.dart | 80 +++++++++ test/widget/widget_test.dart | 20 +++ 12 files changed, 611 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a007cf..5f7cce0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ ## Unreleased +### 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. + ### Line clamp - **ADDED**: `clampMarkdownToLines` / `MarkdownLineClamp` — painter-measured height for the first N visual text lines at a given width (line-boundary cut; diff --git a/docs/rendering.md b/docs/rendering.md index 35b856e..a8e5f17 100644 --- a/docs/rendering.md +++ b/docs/rendering.md @@ -190,6 +190,10 @@ Render-override hooks: dropping text-bearing spans shifts the painter's offset space vs the model, so the highlight stays right but copied text can misalign. Avoid dropping text-bearing spans when selection is enabled.** +- **`cursorResolver`** `MouseCursor? Function(Offset, int?, MD$Block?)` — + dynamically resolves hover mouse cursors per local offset, hit block index, + and hit block model, falling back to default link/text/defer cursors when null. + Can also be supplied directly to `MarkdownWidget.cursorResolver`. Styling: `textStyle`, per-level `h1Style..h6Style` (+ cached `headingStyleFor`), `textStyleFor(MD$Style)` (cached mapping of the bitmask → bold/italic/underline/ diff --git a/docs/selection.md b/docs/selection.md index 76c8464..acce22b 100644 --- a/docs/selection.md +++ b/docs/selection.md @@ -68,6 +68,9 @@ onto a live render object, **implemented by `MarkdownRenderObject`**: - `MarkdownPosition? positionForGlobal(Offset)` — global point → logical position - `List globalSelectionRects()` / `localSelectionRects()` — highlight rects (screen / content-local); used for handles, magnifier, toolbar anchor +- `localBoxesForRange(int blockIndex, int startOffset, int endOffset)` — + content-local line bounding boxes for a character range within a block, + queried directly from cached block painters without re-layout - `setSelectionHandleLayers({startLink, startLocal, endLink, endLocal})` — the handle `LayerLink`s the surface paints so the overlay's handles follow content - `repaintSelection()` — repaint just the highlight; safe during build diff --git a/lib/src/render/markdown_painter.dart b/lib/src/render/markdown_painter.dart index 4fcc3a7..8ae9090 100644 --- a/lib/src/render/markdown_painter.dart +++ b/lib/src/render/markdown_painter.dart @@ -218,6 +218,37 @@ class MarkdownPainter { return painter.isLinkAtLocal(blockLocal); } + /// Resolves the source block index and [MD$Block] at [local], or `null` if + /// the point lies outside any painted block. + (int, MD$Block)? blockAtLocal(Offset local) { + if (_needsLayout || _isEmpty || _blockPainters.isEmpty) return null; + if (local.dx < 0 || + local.dx >= _size.width || + local.dy < 0 || + local.dy >= _size.height) { + return null; + } + final idx = _blockIndexForDy(local.dy); + if (idx < 0 || idx >= _blockPainters.length) return null; + final sourceIndex = _sourceIndices[idx]; + if (sourceIndex < 0 || sourceIndex >= _markdown.blocks.length) return null; + return (sourceIndex, _markdown.blocks[sourceIndex]); + } + + /// Whether the content under [local] belongs to a selectable block. + bool isSelectableAtLocal(Offset local) { + if (_needsLayout || _isEmpty || _blockPainters.isEmpty) return false; + if (local.dx < 0 || + local.dx >= _size.width || + local.dy < 0 || + local.dy >= _size.height) { + return false; + } + final idx = _blockIndexForDy(local.dy); + if (idx < 0 || idx >= _blockPainters.length) return false; + return _blockPainters[idx] is SelectableBlockPainter; + } + /// Paints the selection highlight of every selectable block, using [rangeOf] /// to look up the selected rendered range for a source block index. /// @@ -264,6 +295,40 @@ class MarkdownPainter { return out; } + /// Content-local rectangles covering the rendered text range + /// `[startOffset, endOffset]` within the block at [blockIndex] (a source + /// index in [Markdown.blocks]). + /// + /// Queries the cached block painter directly without re-layout, shifting + /// rects by the block's vertical offset. Returns an empty list when the + /// block is not painted, not selectable, or if `startOffset >= endOffset`. + List localBoxesForRange( + int blockIndex, + int startOffset, + int endOffset, + ) { + if (_needsLayout || _isEmpty || _blockPainters.isEmpty) { + return const []; + } + if (startOffset >= endOffset) return const []; + for (var i = 0; i < _blockPainters.length; i++) { + if (_sourceIndices[i] != blockIndex) continue; + final painter = _blockPainters[i]; + if (painter is! SelectableBlockPainter) return const []; + final len = painter.renderedText.length; + final start = startOffset.clamp(0, len); + final end = endOffset.clamp(0, len); + if (start >= end) return const []; + final top = _blockOffsets[i]; + final boxes = painter.boxesForRange(start, end); + if (boxes.isEmpty) return const []; + return [ + for (final rect in boxes) rect.shift(Offset(0, top)), + ]; + } + return const []; + } + /// Update the painter with new values. /// If the values are the same, /// no update is required and the method returns false. diff --git a/lib/src/render/markdown_render_object.dart b/lib/src/render/markdown_render_object.dart index a1b6b19..253903b 100644 --- a/lib/src/render/markdown_render_object.dart +++ b/lib/src/render/markdown_render_object.dart @@ -24,7 +24,10 @@ class MarkdownRenderObject extends RenderBox MarkdownRenderObject({ required Markdown markdown, required MarkdownThemeData theme, - }) : _painter = MarkdownPainter( + MarkdownCursorResolver? cursorResolver, + }) : _theme = theme, + _cursorResolver = cursorResolver, + _painter = MarkdownPainter( markdown: markdown, theme: theme, ); @@ -32,6 +35,12 @@ class MarkdownRenderObject extends RenderBox /// Painter for rendering markdown content. final MarkdownPainter _painter; + MarkdownThemeData _theme; + MarkdownCursorResolver? _cursorResolver; + + MarkdownCursorResolver? get _effectiveCursorResolver => + _cursorResolver ?? _theme.cursorResolver; + /// The selection controller this render object participates in, if any. MarkdownSelectionController? _controller; @@ -166,6 +175,14 @@ class MarkdownRenderObject extends RenderBox return _painter.selectionBoxes((s) => controller.rangeFor(id, s)); } + @override + List localBoxesForRange( + int blockIndex, + int startOffset, + int endOffset, + ) => + _painter.localBoxesForRange(blockIndex, startOffset, endOffset); + @override void setSelectionHandleLayers({ LayerLink? startLink, @@ -204,26 +221,59 @@ class MarkdownRenderObject extends RenderBox if (!_disposed && attached) markNeedsPaint(); } - // --- MouseTrackerAnnotation (I-beam cursor over selectable text) --- + // --- MouseTrackerAnnotation (cursor over links and selectable text) --- - /// Whether the pointer is currently hovering an actionable link (updated in - /// [handleEvent]); drives the click (hand) cursor. - bool _hoverLink = false; + /// Currently resolved hover cursor, or null when pointer is not hovering. + MouseCursor? _hoverCursor; - /// Presents the click (hand) cursor over links, the text (I-beam) cursor - /// while this document participates in a selection controller (so users see - /// the content is selectable), and otherwise defers to what is behind it. - @override - MouseCursor get cursor { - if (_hoverLink) return SystemMouseCursors.click; - return _controller != null ? SystemMouseCursors.text : MouseCursor.defer; + MouseCursor _resolveCursorAt(Offset local) { + final resolver = _effectiveCursorResolver; + if (resolver != null) { + final hit = _painter.blockAtLocal(local); + final (blockIndex, block) = hit ?? (null, null); + final custom = resolver(local, blockIndex, block); + if (custom != null) return custom; + } + if (_painter.isLinkAtLocal(local)) { + return SystemMouseCursors.click; + } + if (_controller != null && _painter.isSelectableAtLocal(local)) { + return SystemMouseCursors.text; + } + return MouseCursor.defer; + } + + void _updateHoverCursor(Offset local) { + final next = _resolveCursorAt(local); + if (next != _hoverCursor) { + _hoverCursor = next; + if (!_disposed && attached) markNeedsPaint(); + } + } + + void _clearHoverCursor() { + if (_hoverCursor != null) { + _hoverCursor = null; + if (!_disposed && attached) markNeedsPaint(); + } } + /// Presents the dynamically resolved cursor if hovered, the click (hand) + /// cursor over links, the text (I-beam) cursor while this document + /// participates in a selection controller (so users see the content is + /// selectable), and otherwise defers to what is behind it. + @override + MouseCursor get cursor => + _hoverCursor ?? + (_controller != null ? SystemMouseCursors.text : MouseCursor.defer); + @override void Function(PointerEnterEvent)? get onEnter => null; @override - void Function(PointerExitEvent)? get onExit => null; + void Function(PointerExitEvent)? get onExit => _handlePointerExit; + + void _handlePointerExit(PointerExitEvent event) => _clearHoverCursor(); @override bool get validForMouseTracker => !_disposed && attached; @@ -294,15 +344,12 @@ class MarkdownRenderObject extends RenderBox @override void handleEvent(PointerEvent event, BoxHitTestEntry entry) { - // Track hover so the cursor can switch to the hand over links. A repaint is - // what prompts MouseTracker to re-read [cursor] (same mechanism as - // RenderMouseRegion); we only repaint when the link state actually flips. + // Track hover so the cursor can switch to custom resolved cursors or the + // hand over links. A repaint prompts MouseTracker to re-read [cursor] (same + // mechanism as RenderMouseRegion); we only repaint when the cursor state + // actually flips. if (event is PointerHoverEvent) { - final link = _painter.isLinkAtLocal(event.localPosition); - if (link != _hoverLink) { - _hoverLink = link; - if (!_disposed && attached) markNeedsPaint(); - } + _updateHoverCursor(event.localPosition); } _painter.handleEvent(event); } @@ -333,7 +380,13 @@ class MarkdownRenderObject extends RenderBox void update({ required Markdown markdown, required MarkdownThemeData theme, + MarkdownCursorResolver? cursorResolver, }) { + _theme = theme; + if (_cursorResolver != cursorResolver) { + _cursorResolver = cursorResolver; + _clearHoverCursor(); + } if (_painter.update( markdown: markdown, theme: theme, @@ -357,6 +410,7 @@ class MarkdownRenderObject extends RenderBox // Drop leaders before leaving the tree so a detached surface cannot keep // LayerLinks alive and produce ghost / double handles after remount churn. _clearSelectionHandleLayers(); + _clearHoverCursor(); super.detach(); } diff --git a/lib/src/selection.dart b/lib/src/selection.dart index a5bc32c..355597a 100644 --- a/lib/src/selection.dart +++ b/lib/src/selection.dart @@ -498,6 +498,19 @@ abstract interface class MarkdownSelectionSurface { /// used to anchor handle leader layers. List localSelectionRects(); + /// Content-local rectangles covering the rendered text range + /// `[startOffset, endOffset]` within the block at [blockIndex] (a source + /// index in `Markdown.blocks`). + /// + /// Queries the underlying block painter directly without re-layout. Returns + /// an empty list when the block is not mounted, not selectable, or the range + /// is empty. + List localBoxesForRange( + int blockIndex, + int startOffset, + int endOffset, + ); + /// Sets the selection-handle leader layers this surface paints, so the /// scope's `SelectionOverlay` handles follow the content. Pass a [startLink] /// or [endLink] with its content-local anchor; pass null to remove a handle. diff --git a/lib/src/theme.dart b/lib/src/theme.dart index e978ee5..99c7b94 100644 --- a/lib/src/theme.dart +++ b/lib/src/theme.dart @@ -5,6 +5,22 @@ import 'package:flutter/material.dart'; import '../flutter_md.dart'; import 'highlight/engine.dart'; +/// Signature for dynamically resolving the [MouseCursor] at a content-local +/// [localOffset] within a Markdown render surface. +/// +/// [blockIndex] is the source index of the hit block in `Markdown.blocks`, or +/// `null` if the offset does not fall within any block. +/// +/// [block] is the hit [MD$Block] model, or `null` if no block was hit. +/// +/// Returning `null` falls back to default cursor resolution (click for links, +/// text for selectable content, basic/defer otherwise). +typedef MarkdownCursorResolver = MouseCursor? Function( + Offset localOffset, + int? blockIndex, + MD$Block? block, +); + /// {@template markdown_theme_data} /// Theme data for Markdown widgets. /// {@endtemplate} @@ -34,6 +50,7 @@ class MarkdownThemeData implements ThemeExtension { this.builder, this.onLinkTap, this.highlighter, + this.cursorResolver, }) : _headingStyles = List.filled(8, null), _textStyles = HashMap(); @@ -62,6 +79,7 @@ class MarkdownThemeData implements ThemeExtension { BlockPainter? Function(MD$Block block, MarkdownThemeData theme)? builder, void Function(String title, String url)? onLinkTap, SyntaxHighlighter? highlighter, + MarkdownCursorResolver? cursorResolver, }) { return MarkdownThemeData( textStyle: textStyle ?? @@ -93,6 +111,7 @@ class MarkdownThemeData implements ThemeExtension { builder: builder, onLinkTap: onLinkTap, highlighter: highlighter, + cursorResolver: cursorResolver, ); } @@ -204,6 +223,14 @@ class MarkdownThemeData implements ThemeExtension { /// the exact set of languages you support so unused grammars tree-shake away. final SyntaxHighlighter? highlighter; + /// An optional callback to dynamically resolve the mouse cursor based on + /// hover offset, hit block index, and hit block model. + /// + /// When `null`, or when the callback returns `null`, the renderer falls back + /// to its default cursors (hand cursor for actionable links, I-beam text + /// cursor for selectable documents, or deferred cursor otherwise). + final MarkdownCursorResolver? cursorResolver; + final List _headingStyles; /// Returns a [TextStyle] for the given heading level. @@ -312,6 +339,7 @@ class MarkdownThemeData implements ThemeExtension { BlockPainter? Function(MD$Block block, MarkdownThemeData theme)? builder, void Function(String title, String url)? onLinkTap, SyntaxHighlighter? highlighter, + MarkdownCursorResolver? cursorResolver, }) => MarkdownThemeData( textDirection: textDirection ?? this.textDirection, @@ -338,6 +366,7 @@ class MarkdownThemeData implements ThemeExtension { builder: builder ?? this.builder, onLinkTap: onLinkTap ?? this.onLinkTap, highlighter: highlighter ?? this.highlighter, + cursorResolver: cursorResolver ?? this.cursorResolver, ); @override @@ -374,6 +403,7 @@ class MarkdownThemeData implements ThemeExtension { builder: t < 0.5 ? builder : other?.builder, onLinkTap: t < 0.5 ? onLinkTap : other?.onLinkTap, highlighter: t < 0.5 ? highlighter : other?.highlighter, + cursorResolver: t < 0.5 ? cursorResolver : other?.cursorResolver, ); } diff --git a/lib/src/widget.dart b/lib/src/widget.dart index 1d65e8a..774c570 100644 --- a/lib/src/widget.dart +++ b/lib/src/widget.dart @@ -16,6 +16,7 @@ class MarkdownWidget extends LeafRenderObjectWidget { this.theme, this.controller, this.documentId, + this.cursorResolver, super.key, // ignore: unused_element }); @@ -34,6 +35,12 @@ class MarkdownWidget extends LeafRenderObjectWidget { /// must register this document's model with the controller. final Object? documentId; + /// An optional callback to dynamically resolve the mouse cursor based on + /// hover offset, hit block index, and hit block model. + /// + /// When non-null, overrides [MarkdownThemeData.cursorResolver]. + final MarkdownCursorResolver? cursorResolver; + MarkdownThemeData _resolveTheme(BuildContext context) => theme ?? MarkdownTheme.maybeOf(context) ?? @@ -53,6 +60,7 @@ class MarkdownWidget extends LeafRenderObjectWidget { RenderObject createRenderObject(BuildContext context) => MarkdownRenderObject( markdown: markdown, theme: _resolveTheme(context), + cursorResolver: cursorResolver, )..updateSelection(_resolveController(context), documentId); @override @@ -61,7 +69,11 @@ class MarkdownWidget extends LeafRenderObjectWidget { MarkdownRenderObject renderObject, ) { renderObject - ..update(markdown: markdown, theme: _resolveTheme(context)) + ..update( + markdown: markdown, + theme: _resolveTheme(context), + cursorResolver: cursorResolver, + ) ..updateSelection(_resolveController(context), documentId); } } diff --git a/test/selection/selection_test.dart b/test/selection/selection_test.dart index 0689929..232bf25 100644 --- a/test/selection/selection_test.dart +++ b/test/selection/selection_test.dart @@ -1,8 +1,9 @@ import 'dart:ui' show Offset, Rect, TextAffinity; -import 'package:flutter/painting.dart' show TextRange; +import 'package:flutter/painting.dart' show TextRange, TextStyle; import 'package:flutter/rendering.dart' show LayerLink; import 'package:flutter_md/flutter_md.dart'; +import 'package:flutter_md/src/render.dart' show MarkdownPainter; import 'package:flutter_test/flutter_test.dart'; class _FakeSurface implements MarkdownSelectionSurface { @@ -32,6 +33,14 @@ class _FakeSurface implements MarkdownSelectionSurface { @override List localSelectionRects() => const []; + @override + List localBoxesForRange( + int blockIndex, + int startOffset, + int endOffset, + ) => + const []; + @override void setSelectionHandleLayers({ LayerLink? startLink, @@ -288,4 +297,98 @@ void main() { expect(word('a===b', 2), (1, 4)); }); }); + + group('localBoxesForRange', () { + final theme = MarkdownThemeData( + textStyle: const TextStyle(fontSize: 14), + ); + + test('returns non-empty bounding boxes for valid character range', () { + final doc = Markdown.fromString('Hello world'); + final painter = MarkdownPainter(markdown: doc, theme: theme); + painter.layout(maxWidth: 400); + + final boxes = painter.localBoxesForRange(0, 0, 5); + expect(boxes, isNotEmpty); + expect(boxes.first.left, greaterThanOrEqualTo(0)); + expect(boxes.first.width, greaterThan(0)); + expect(boxes.first.height, greaterThan(0)); + }); + + test('shifts boxes by vertical block offset for subsequent blocks', () { + final doc = Markdown.fromString('First paragraph\n\nSecond paragraph'); + final painter = MarkdownPainter(markdown: doc, theme: theme); + painter.layout(maxWidth: 400); + + final firstBoxes = painter.localBoxesForRange(0, 0, 5); + // Block index 2 is the second paragraph (index 1 is MD$Spacer). + final secondBoxes = painter.localBoxesForRange(2, 0, 6); + expect(firstBoxes, isNotEmpty); + expect(secondBoxes, isNotEmpty); + expect(secondBoxes.first.top, greaterThan(firstBoxes.first.bottom)); + }); + + test('returns multiple boxes for soft-wrapped text', () { + final doc = Markdown.fromString( + 'A very long line of text that wraps into multiple lines ' + 'when constrained to a narrow width.', + ); + final painter = MarkdownPainter(markdown: doc, theme: theme); + painter.layout(maxWidth: 80); + + final fullLen = doc.blocks.first.text.length; + final boxes = painter.localBoxesForRange(0, 0, fullLen); + expect(boxes.length, greaterThan(1)); + }); + + test('returns empty list for empty or inverted range', () { + final doc = Markdown.fromString('Hello world'); + final painter = MarkdownPainter(markdown: doc, theme: theme); + painter.layout(maxWidth: 400); + + expect(painter.localBoxesForRange(0, 5, 5), isEmpty); + expect(painter.localBoxesForRange(0, 5, 2), isEmpty); + }); + + test('clamps out-of-bounds start and end offsets gracefully', () { + final doc = Markdown.fromString('Hello'); + final painter = MarkdownPainter(markdown: doc, theme: theme); + painter.layout(maxWidth: 400); + + final boxes = painter.localBoxesForRange(0, -10, 100); + expect(boxes, isNotEmpty); + final normalBoxes = painter.localBoxesForRange(0, 0, 5); + expect(boxes.first, normalBoxes.first); + }); + + test('returns empty list for invalid or non-selectable block index', () { + final doc = Markdown.fromString('Hello\n\n---\n\nWorld'); + final painter = MarkdownPainter(markdown: doc, theme: theme); + painter.layout(maxWidth: 400); + + // Block index 2 is divider (---). + expect(painter.localBoxesForRange(2, 0, 3), isEmpty); + // Non-existent block index. + expect(painter.localBoxesForRange(99, 0, 3), isEmpty); + expect(painter.localBoxesForRange(-1, 0, 3), isEmpty); + }); + + test('returns empty list before layout completes', () { + final doc = Markdown.fromString('Hello world'); + final painter = MarkdownPainter(markdown: doc, theme: theme); + expect(painter.localBoxesForRange(0, 0, 5), isEmpty); + }); + + test('blockAtLocal respects horizontal and vertical bounds', () { + final doc = Markdown.fromString('Hello'); + final painter = MarkdownPainter(markdown: doc, theme: theme); + painter.layout(maxWidth: 400); + + expect(painter.blockAtLocal(const Offset(-5, 5)), isNull); + expect(painter.blockAtLocal(const Offset(405, 5)), isNull); + expect(painter.blockAtLocal(const Offset(5, -5)), isNull); + expect(painter.blockAtLocal(const Offset(5, 500)), isNull); + expect(painter.blockAtLocal(const Offset(5, 5)), isNotNull); + }); + }); } diff --git a/test/selection/selection_widget_test.dart b/test/selection/selection_widget_test.dart index 6633876..1b0f9bd 100644 --- a/test/selection/selection_widget_test.dart +++ b/test/selection/selection_widget_test.dart @@ -59,6 +59,29 @@ void main() { expect(tester.takeException(), isNull); }); + testWidgets('MarkdownSelectionSurface exposes localBoxesForRange', + (tester) async { + final md = Markdown.fromString('Hello selectable world'); + final controller = MarkdownSelectionController() + ..setDocuments( + [MarkdownDocumentRef(id: 'd', model: md)]); + + await tester.pumpWidget(_wrap( + controller, + const Align( + alignment: Alignment.topLeft, + child: SizedBox(width: 400, child: _Doc('d')), + ), + )); + await tester.pumpAndSettle(); + + final surface = controller.mountedSurfaces.first; + final boxes = surface.localBoxesForRange(0, 0, 5); + expect(boxes, isNotEmpty); + expect(boxes.first.width, greaterThan(0)); + expect(boxes.first.height, greaterThan(0)); + }); + testWidgets('drag spans two MarkdownWidgets with a document separator', (tester) async { final a = Markdown.fromString('First message body'); @@ -2033,6 +2056,177 @@ void main() { SystemMouseCursors.basic, ); }); + + testWidgets('cursorResolver overrides hover cursor with custom cursor', + (tester) async { + await tester.pumpWidget(MaterialApp( + home: Scaffold( + body: Align( + alignment: Alignment.topLeft, + child: SizedBox( + width: 400, + child: MarkdownWidget( + markdown: Markdown.fromString('Custom cursor text'), + cursorResolver: (offset, blockIndex, block) => + SystemMouseCursors.grab, + ), + ), + ), + ), + )); + await tester.pumpAndSettle(); + + final gesture = + await tester.createGesture(kind: PointerDeviceKind.mouse, pointer: 1); + await gesture.addPointer(location: Offset.zero); + addTearDown(gesture.removePointer); + await gesture.moveTo(tester.getCenter(find.byType(MarkdownWidget))); + await tester.pumpAndSettle(); + + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + SystemMouseCursors.grab, + ); + }); + + testWidgets('cursorResolver receives block details and updates dynamically', + (tester) async { + final doc = Markdown.fromString('# Heading\n\nSecond block text'); + final seenIndices = []; + final seenBlocks = []; + + await tester.pumpWidget(MaterialApp( + home: Scaffold( + body: Align( + alignment: Alignment.topLeft, + child: SizedBox( + width: 400, + child: MarkdownWidget( + markdown: doc, + cursorResolver: (offset, blockIndex, block) { + seenIndices.add(blockIndex); + seenBlocks.add(block); + if (block is MD$Heading) { + return SystemMouseCursors.grab; + } + return SystemMouseCursors.cell; + }, + ), + ), + ), + ), + )); + await tester.pumpAndSettle(); + + final gesture = + await tester.createGesture(kind: PointerDeviceKind.mouse, pointer: 1); + await gesture.addPointer(location: Offset.zero); + addTearDown(gesture.removePointer); + + // Hover over heading (near the top). + final widgetTopLeft = tester.getTopLeft(find.byType(MarkdownWidget)); + await gesture.moveTo(widgetTopLeft + const Offset(10, 10)); + await tester.pumpAndSettle(); + + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + SystemMouseCursors.grab, + ); + expect(seenIndices, contains(0)); + expect(seenBlocks.any((b) => b is MD$Heading), isTrue); + + // Hover over second paragraph (further down). + final widgetBottom = tester.getBottomLeft(find.byType(MarkdownWidget)); + await gesture.moveTo(widgetBottom - const Offset(-10, 10)); + await tester.pumpAndSettle(); + + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + SystemMouseCursors.cell, + ); + expect(seenIndices, contains(2)); + expect(seenBlocks.any((b) => b is MD$Paragraph), isTrue); + }); + + testWidgets('cursorResolver returning null falls back to default cursors', + (tester) async { + await tester.pumpWidget(MaterialApp( + home: Scaffold( + body: MarkdownTheme( + data: MarkdownThemeData( + textStyle: const TextStyle(fontSize: 14), + onLinkTap: (_, __) {}, + cursorResolver: (offset, blockIndex, block) => null, + ), + child: Align( + alignment: Alignment.topLeft, + child: SizedBox( + width: 400, + child: MarkdownWidget( + markdown: + Markdown.fromString('[click me](https://example.com)'), + ), + ), + ), + ), + ), + )); + await tester.pumpAndSettle(); + + final gesture = + await tester.createGesture(kind: PointerDeviceKind.mouse, pointer: 1); + await gesture.addPointer(location: Offset.zero); + addTearDown(gesture.removePointer); + + await gesture.moveTo( + tester.getTopLeft(find.byType(MarkdownWidget)) + const Offset(8, 8)); + await tester.pumpAndSettle(); + + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + SystemMouseCursors.click, + ); + }); + + testWidgets('MarkdownWidget.cursorResolver overrides theme cursorResolver', + (tester) async { + await tester.pumpWidget(MaterialApp( + home: Scaffold( + body: MarkdownTheme( + data: MarkdownThemeData( + textStyle: const TextStyle(fontSize: 14), + cursorResolver: (offset, blockIndex, block) => + SystemMouseCursors.forbidden, + ), + child: Align( + alignment: Alignment.topLeft, + child: SizedBox( + width: 400, + child: MarkdownWidget( + markdown: Markdown.fromString('Text here'), + cursorResolver: (offset, blockIndex, block) => + SystemMouseCursors.grab, + ), + ), + ), + ), + ), + )); + await tester.pumpAndSettle(); + + final gesture = + await tester.createGesture(kind: PointerDeviceKind.mouse, pointer: 1); + await gesture.addPointer(location: Offset.zero); + addTearDown(gesture.removePointer); + + await gesture.moveTo(tester.getCenter(find.byType(MarkdownWidget))); + await tester.pumpAndSettle(); + + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + SystemMouseCursors.grab, + ); + }); }); } diff --git a/test/theme/theme_test.dart b/test/theme/theme_test.dart index a760017..6d01dce 100644 --- a/test/theme/theme_test.dart +++ b/test/theme/theme_test.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter/src/services/mouse_cursor.dart'; import 'package:flutter_md/flutter_md.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -93,6 +94,85 @@ void main() => group('MarkdownThemeData', () { }); }); + group('cursorResolver', () { + test('accepts resolver in constructor', () { + SystemMouseCursor resolver( + Offset offset, int? idx, MD$Block? block) => + SystemMouseCursors.click; + final theme = MarkdownThemeData( + textStyle: const TextStyle(fontSize: 14), + cursorResolver: resolver, + ); + expect(theme.cursorResolver, same(resolver)); + }); + + test('preserves cursorResolver in copyWith', () { + SystemMouseCursor resolver( + Offset offset, int? idx, MD$Block? block) => + SystemMouseCursors.click; + final theme = MarkdownThemeData( + textStyle: const TextStyle(fontSize: 14), + cursorResolver: resolver, + ); + final copy = theme.copyWith(); + expect(copy.cursorResolver, same(resolver)); + }); + + test('overrides cursorResolver in copyWith', () { + SystemMouseCursor r1(Offset offset, int? idx, MD$Block? block) => + SystemMouseCursors.click; + SystemMouseCursor r2(Offset offset, int? idx, MD$Block? block) => + SystemMouseCursors.text; + final theme = MarkdownThemeData( + textStyle: const TextStyle(fontSize: 14), + cursorResolver: r1, + ); + final copy = theme.copyWith(cursorResolver: r2); + expect(copy.cursorResolver, same(r2)); + }); + + test('lerp selects resolver based on t', () { + SystemMouseCursor r1(Offset offset, int? idx, MD$Block? block) => + SystemMouseCursors.click; + SystemMouseCursor r2(Offset offset, int? idx, MD$Block? block) => + SystemMouseCursors.text; + final a = MarkdownThemeData( + textStyle: const TextStyle(fontSize: 14), + cursorResolver: r1, + ); + final b = MarkdownThemeData( + textStyle: const TextStyle(fontSize: 14), + cursorResolver: r2, + ); + final midA = a.lerp(b, 0.4) as MarkdownThemeData; + final midB = a.lerp(b, 0.6) as MarkdownThemeData; + expect(midA.cursorResolver, same(r1)); + expect(midB.cursorResolver, same(r2)); + }); + + testWidgets('mergeTheme passes cursorResolver', (tester) async { + SystemMouseCursor resolver( + Offset offset, int? idx, MD$Block? block) => + SystemMouseCursors.click; + late MarkdownThemeData derived; + await tester.pumpWidget( + MaterialApp( + theme: ThemeData.light(), + home: Builder( + builder: (context) { + derived = MarkdownThemeData.mergeTheme( + Theme.of(context), + cursorResolver: resolver, + ); + return const SizedBox(); + }, + ), + ), + ); + expect(derived.cursorResolver, same(resolver)); + }); + }); + testWidgets('mergeTheme derives from ThemeData', (tester) async { late MarkdownThemeData derived; await tester.pumpWidget( diff --git a/test/widget/widget_test.dart b/test/widget/widget_test.dart index fa75705..2203234 100644 --- a/test/widget/widget_test.dart +++ b/test/widget/widget_test.dart @@ -3,6 +3,7 @@ // branch that builds a default `MarkdownThemeData` from the build context in // both `createRenderObject` and `updateRenderObject`. import 'package:flutter/material.dart'; +import 'package:flutter/src/services/mouse_cursor.dart'; import 'package:flutter_md/flutter_md.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -63,5 +64,24 @@ void main() { expect(tester.takeException(), isNull); expect(heightOf(tester), greaterThan(0)); }); + + testWidgets('MarkdownWidget honors cursorResolver', (tester) async { + SystemMouseCursor resolver(Offset offset, int? idx, MD$Block? block) => + SystemMouseCursors.click; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SizedBox( + width: 300, + child: MarkdownWidget( + markdown: Markdown.fromString('Hello'), + cursorResolver: resolver, + ), + ), + ), + ), + ); + expect(tester.takeException(), isNull); + }); }); } From 72575be5bd4d21028d3f2167fbde0a4d7e3ea60e Mon Sep 17 00:00:00 2001 From: Emil Date: Sun, 13 Sep 2026 04:04:15 +0500 Subject: [PATCH 05/19] feat(selection): align desktop toolbar and right-click context menu with stock Flutter Co-authored-by: Cursor --- CHANGELOG.md | 43 ++-- lib/src/selection.dart | 31 ++- lib/src/selection_scope.dart | 104 +++++--- test/selection/selection_handles_test.dart | 2 +- test/selection/selection_widget_test.dart | 281 ++++++++++++++++++++- 5 files changed, 399 insertions(+), 62 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5f7cce0..b790f4a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,10 +1,11 @@ ## Unreleased ### 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**: `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 @@ -17,22 +18,22 @@ clipped box; the renderer itself has no line budget. ### Registry / multi-body selection -- **FIXED**: `removeDocument` defers while a surface for that id is still mounted +- **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). + 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. +- **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. ### Selection chrome (SelectionArea parity) -- **ADDED**: Read-only selection chrome on the controller / `MarkdownSelectionScope` - path brought up to Flutter `SelectableRegion` / `SelectionArea` quality without - remounting SelectionArea: +- **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`. @@ -48,6 +49,18 @@ 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. ### Selection engine rework - **ADDED**: Edge-zone autoscroll while dragging (body / handle / long-press) @@ -62,8 +75,6 @@ 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**: Programmatic `controller.selectAll()` / assigning a non-collapsed - `controller.selection` starts the toolbar lifecycle without a gesture. - **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 diff --git a/lib/src/selection.dart b/lib/src/selection.dart index 355597a..76279bf 100644 --- a/lib/src/selection.dart +++ b/lib/src/selection.dart @@ -6,6 +6,14 @@ import 'package:flutter/rendering.dart' show LayerLink; import 'markdown.dart'; import 'nodes.dart'; +bool get _isDesktopPlatform => switch (defaultTargetPlatform) { + TargetPlatform.linux || + TargetPlatform.macOS || + TargetPlatform.windows => + true, + _ => false, + }; + /// Rendered plain text of a single [MD$Block] — the concatenation of its span /// texts, in the coordinate space that `TextPainter.getPositionForOffset` /// indexes. This is the single source of truth shared by selection extraction @@ -696,9 +704,10 @@ class MarkdownSelectionController extends ChangeNotifier { /// The current selection, or null when nothing is selected. /// - /// Assigning a non-collapsed range marks [toolbarWanted] so a mounted - /// [MarkdownSelectionScope] starts (or restores) the toolbar without a - /// gesture. Gesture-driven updates go through [_commitSelection] instead. + /// On mobile platforms, assigning a non-collapsed range marks [toolbarWanted] + /// so a mounted [MarkdownSelectionScope] shows the context menu with copy + /// options. On desktop platforms, [toolbarWanted] remains false to match + /// standard OS selection behavior. MarkdownSelection? get selection => _selection; set selection(MarkdownSelection? value) { if (value == _selection) return; @@ -708,7 +717,9 @@ class MarkdownSelectionController extends ChangeNotifier { _baseAffinity = TextAffinity.downstream; _extentAffinity = TextAffinity.downstream; } else { - toolbarWanted = true; + if (!_isDesktopPlatform) { + toolbarWanted = true; + } _group?._claim(this); } notifyListeners(); @@ -1030,16 +1041,18 @@ class MarkdownSelectionController extends ChangeNotifier { /// Selects everything across every registered document. /// - /// Marks [toolbarWanted] so a mounted scope shows the context menu without - /// waiting for a gesture (same chrome lifecycle as assigning [selection]). + /// On mobile platforms, marks [toolbarWanted] so a mounted scope shows + /// the context menu without waiting for a gesture (since mobile users lack + /// physical copy shortcuts). On desktop platforms, [toolbarWanted] remains + /// false to match standard OS selection behavior. void selectAll() { if (_docs.isEmpty) return; final first = _docs.first, last = _docs.last; if (first.model.blocks.isEmpty || last.model.blocks.isEmpty) return; final lastBlock = last.model.blocks.length - 1; - // Before [_commitSelection]'s notify so a listening scope restores chrome - // on the same turn (not only after a later gesture). - toolbarWanted = true; + if (!_isDesktopPlatform) { + toolbarWanted = true; + } _commitSelection( MarkdownSelection( base: MarkdownPosition(documentId: first.id, blockIndex: 0, offset: 0), diff --git a/lib/src/selection_scope.dart b/lib/src/selection_scope.dart index 5bcc02a..526a0ae 100644 --- a/lib/src/selection_scope.dart +++ b/lib/src/selection_scope.dart @@ -278,9 +278,10 @@ class MarkdownSelectionScopeState extends State if (sel != _lastSelection) { _lastSelection = sel; widget.onSelectionChanged?.call(sel); - // Selection moved — drop a frozen right-click anchor so the toolbar - // tracks the live selection bounds instead. - if (_contextMenuController.isShown) { + // Selection moved — on mobile, drop a frozen right-click anchor so + // the toolbar tracks the live selection bounds instead. On desktop, + // preserve the user's secondary-click anchor. + if (_contextMenuController.isShown && !_isDesktopPlatform) { _lastSecondaryTapDown = null; } } @@ -288,12 +289,9 @@ class MarkdownSelectionScopeState extends State if (sel case final current? when !current.isCollapsed) { if (_dragGlobal == null) { // Outside a drag: restore when [toolbarWanted] (scroll remount / - // programmatic selectAll / selection=), else start chrome for keyboard - // / other _commitSelection ranges. + // geometry change). if (controller.toolbarWanted) { _refreshToolbarForVisibleGeometry(); - } else { - showToolbar(); } } } else { @@ -315,6 +313,14 @@ class MarkdownSelectionScopeState extends State _ => false, }; + bool get _isDesktopPlatform => switch (Theme.of(context).platform) { + TargetPlatform.linux || + TargetPlatform.macOS || + TargetPlatform.windows => + true, + _ => false, + }; + TextSelectionControls get _effectiveControls => widget.selectionControls ?? switch (Theme.of(context).platform) { @@ -419,7 +425,7 @@ class MarkdownSelectionScopeState extends State toolbarLayerLink: _toolbarLink, magnifierConfiguration: _effectiveMagnifier, ); - // Android long-press: keep overlay for magnifier, show handles on + // Android long-press: keep overlay for magnifier, show handles on // end. if (!_deferHandleShow) { overlay.showHandles(); @@ -516,6 +522,9 @@ class MarkdownSelectionScopeState extends State void _onBodyDragEnd() { _stopAutoscroll(); + if (_isDesktopPlatform || _isPrecisePointer(_lastPointerDeviceKind)) { + return; + } if (controller.selection case final sel? when !sel.isCollapsed) { showToolbar(); } @@ -780,6 +789,12 @@ class MarkdownSelectionScopeState extends State } if (_toolbarClipBounds() case final clip?) { if (_visibleSelectionBoundsIn(clip) case final visible?) { + if (_isDesktopPlatform) { + return TextSelectionToolbarAnchors( + primaryAnchor: visible.bottomLeft, + secondaryAnchor: visible.bottomLeft, + ); + } return _anchorsForVisibleSelection(visible, clip); } } @@ -787,7 +802,9 @@ class MarkdownSelectionScopeState extends State // the menu when geometry leaves the host. final host = _hostGlobalBounds(); return TextSelectionToolbarAnchors( - primaryAnchor: host.isEmpty ? Offset.zero : host.center, + primaryAnchor: host.isEmpty + ? Offset.zero + : (_isDesktopPlatform ? host.topLeft : host.center), ); } @@ -961,8 +978,10 @@ class MarkdownSelectionScopeState extends State if (widget.contextMenuBuilder case final builder?) { if (Overlay.maybeOf(context, rootOverlay: true) == null) return; controller.toolbarWanted = true; - _lastSecondaryTapDown = location; - if (location == null) { + if (location != null) { + _lastSecondaryTapDown = location; + } + if (_lastSecondaryTapDown == null) { if (_visibleSelectionBounds() case null) { _contextMenuController.remove(); return; @@ -1023,11 +1042,13 @@ class MarkdownSelectionScopeState extends State /// Forces handles + toolbar to refresh even when the range is unchanged /// (double-tap re-select must not be a no-op). - void _refreshSelectionChrome({Offset? toolbarAt}) { + void _refreshSelectionChrome({Offset? toolbarAt, bool showToolbar = true}) { _syncOverlay(); _updateHandlesAndOverlay(); - if (controller.selection case final sel? when !sel.isCollapsed) { - showToolbar(toolbarAt); + if (showToolbar) { + if (controller.selection case final sel? when !sel.isCollapsed) { + this.showToolbar(toolbarAt); + } } } @@ -1178,7 +1199,8 @@ class MarkdownSelectionScopeState extends State } else if (!_beginSelection(d.globalPosition, d.consecutiveTapCount)) { return; } - _refreshSelectionChrome(); + final showToolbarOnTap = !_isDesktopPlatform && !_isPrecisePointer(d.kind); + _refreshSelectionChrome(showToolbar: showToolbarOnTap); } void _handleDragStart(TapDragStartDetails d) { @@ -1238,6 +1260,12 @@ class MarkdownSelectionScopeState extends State /// the host viewport entirely, temporarily remove the menu (keeping the /// restore intent); bring it back when selection paint re-enters view. void _onScrollAffectingChrome() { + if (_isDesktopPlatform) { + if (toolbarIsVisible) { + hideToolbar(); + } + return; + } _refreshToolbarForVisibleGeometry(); // Scroll notifications can run before child layout finishes (common when // the scope itself was outside the viewport cache). Retry next frame. @@ -1266,7 +1294,7 @@ class MarkdownSelectionScopeState extends State }); } - /// Shows, rebuilds, or suppresses the toolbar from visible selection + /// Shows, rebuilds, or suppresses the toolbar from visible selection /// geometry. /// /// Must not run during layout/build — geometry walks use [localToGlobal]. @@ -1278,7 +1306,9 @@ class MarkdownSelectionScopeState extends State _scheduleToolbarGeometryRefresh(); return; } - _lastSecondaryTapDown = null; + if (!_isDesktopPlatform) { + _lastSecondaryTapDown = null; + } if (_visibleSelectionBounds() case null) { _contextMenuController.remove(); return; @@ -1324,8 +1354,30 @@ class MarkdownSelectionScopeState extends State final onSelection = _positionIsOnActiveSelection(d.globalPosition); final previousSecondary = _previousSecondaryTapAnchor; final toolbarWasVisible = toolbarIsVisible; - // Common paths: on-selection keeps the range and shows/toggles toolbar; - // off-selection follows platform select/collapse. + + // Desktop: right-click opens/toggles the context toolbar without mutating + // the selection (no select-word or collapse-to-caret side-effects). + if (_isDesktopPlatform) { + if (toolbarWasVisible) { + if (defaultTargetPlatform == TargetPlatform.macOS) { + if (previousSecondary case final anchor? + when anchor == d.globalPosition) { + hideToolbar(); + _previousSecondaryTapAnchor = d.globalPosition; + return; + } + } else if (defaultTargetPlatform == TargetPlatform.linux) { + hideToolbar(); + _previousSecondaryTapAnchor = d.globalPosition; + return; + } + } + showToolbar(d.globalPosition); + _previousSecondaryTapAnchor = d.globalPosition; + return; + } + + // Mobile / touch platforms: switch (defaultTargetPlatform) { case TargetPlatform.android: case TargetPlatform.fuchsia: @@ -1348,18 +1400,6 @@ class MarkdownSelectionScopeState extends State } showToolbar(d.globalPosition); case TargetPlatform.macOS: - if (toolbarWasVisible) { - if (previousSecondary case final anchor? - when anchor == d.globalPosition) { - hideToolbar(); - _previousSecondaryTapAnchor = d.globalPosition; - return; - } - } - if (!onSelection) { - controller.selectWordAtGlobal(d.globalPosition); - } - showToolbar(d.globalPosition); case TargetPlatform.iOS: if (!onSelection) { controller.selectWordAtGlobal(d.globalPosition); @@ -1414,7 +1454,7 @@ class MarkdownSelectionScopeState extends State ..onDragEnd = ((_) => _onBodyDragEnd()), ), // Secondary-only (any device): right-click opens the context toolbar. - // Distinct type key so it can coexist with other recognizers in the + // Distinct type key so it can coexist with other recognizers in the // map. _SecondaryTapGestureRecognizer: GestureRecognizerFactoryWithHandlers< _SecondaryTapGestureRecognizer>( diff --git a/test/selection/selection_handles_test.dart b/test/selection/selection_handles_test.dart index a93493d..58e951c 100644 --- a/test/selection/selection_handles_test.dart +++ b/test/selection/selection_handles_test.dart @@ -350,7 +350,7 @@ void main() { final endpoints = controller.selectionHandleEndpoints(); expect(endpoints, isNotNull); - // Directed start handle follows base (right); end follows extent + // Directed start handle follows base (right); end follows extent // (left). expect( endpoints!.startGlobal.left, greaterThan(endpoints.endGlobal.left)); diff --git a/test/selection/selection_widget_test.dart b/test/selection/selection_widget_test.dart index 1b0f9bd..6e17eb3 100644 --- a/test/selection/selection_widget_test.dart +++ b/test/selection/selection_widget_test.dart @@ -425,6 +425,50 @@ void main() { expect(tester.takeException(), isNull); }); + testWidgets('desktop mouse drag and double-click do not show toolbar', + (tester) async { + debugDefaultTargetPlatformOverride = TargetPlatform.macOS; + try { + final controller = MarkdownSelectionController() + ..setDocuments([ + MarkdownDocumentRef( + id: 'd', model: Markdown.fromString('Hello selectable world')), + ]); + final tl = await pumpParagraph(tester, controller); + + // 1. Mouse drag selects without showing toolbar. + await _mouseDrag( + tester, tl + const Offset(2, 6), tl + const Offset(120, 6)); + expect(controller.getText(), isNotEmpty); + final state = tester.state( + find.byType(MarkdownSelectionScope)); + expect(state.toolbarIsVisible, isFalse, + reason: 'desktop mouse drag must not pop the toolbar'); + + // Clear selection by clicking. + await _clicks(tester, tl + const Offset(2, 6), 1); + expect(state.toolbarIsVisible, isFalse); + + await tester.pump(const Duration(milliseconds: 600)); + + // 2. Mouse double-click selects word without showing toolbar. + await _clicks(tester, tl + const Offset(8, 8), 2); + expect(controller.getText(), 'Hello'); + expect(state.toolbarIsVisible, isFalse, + reason: 'desktop double-click must not pop the toolbar'); + + await tester.pump(const Duration(milliseconds: 600)); + + // 3. Mouse triple-click selects block without showing toolbar. + await _clicks(tester, tl + const Offset(8, 8), 3); + expect(controller.getText(), 'Hello selectable world'); + expect(state.toolbarIsVisible, isFalse, + reason: 'desktop triple-click must not pop the toolbar'); + } finally { + debugDefaultTargetPlatformOverride = null; + } + }); + testWidgets('touch double-tap selects a word and shows the toolbar', (tester) async { final controller = MarkdownSelectionController() @@ -668,6 +712,80 @@ void main() { } }); + testWidgets( + 'secondary-click on desktop without selection shows toolbar ' + 'without selecting text', (tester) async { + debugDefaultTargetPlatformOverride = TargetPlatform.macOS; + try { + final controller = MarkdownSelectionController() + ..setDocuments([ + MarkdownDocumentRef( + id: 'd', model: Markdown.fromString('Hello selectable world')), + ]); + final tl = await pumpParagraph(tester, controller); + + final wordPos = tl + const Offset(8, 8); + final g = await tester.startGesture(wordPos, + kind: PointerDeviceKind.mouse, buttons: kSecondaryMouseButton); + await g.up(); + await tester.pumpAndSettle(); + + expect(controller.selection, isNull, + reason: + 'desktop right click without selection must not select text'); + final state = tester.state( + find.byType(MarkdownSelectionScope)); + expect(state.toolbarIsVisible, isTrue); + expect(state.contextMenuAnchors.primaryAnchor, wordPos); + expect(find.byType(AdaptiveTextSelectionToolbar), findsOneWidget); + expect( + find.descendant( + of: find.byType(AdaptiveTextSelectionToolbar), + matching: find.textContaining(RegExp(r'Select [aA]ll')), + ), + findsOneWidget); + expect(find.text('Copy'), findsNothing); + } finally { + debugDefaultTargetPlatformOverride = null; + } + }); + + testWidgets( + 'desktop context menu preserves right-click anchor after Select all', + (tester) async { + debugDefaultTargetPlatformOverride = TargetPlatform.macOS; + try { + final controller = MarkdownSelectionController() + ..setDocuments([ + MarkdownDocumentRef( + id: 'd', model: Markdown.fromString('Hello selectable world')), + ]); + final tl = await pumpParagraph(tester, controller); + + final wordPos = tl + const Offset(40, 8); + final g = await tester.startGesture(wordPos, + kind: PointerDeviceKind.mouse, buttons: kSecondaryMouseButton); + await g.up(); + await tester.pumpAndSettle(); + + final state = tester.state( + find.byType(MarkdownSelectionScope)); + expect(state.toolbarIsVisible, isTrue); + expect(state.contextMenuAnchors.primaryAnchor, wordPos); + + // Tap "Select All" from the desktop context menu. + await tester.tap(find.textContaining(RegExp(r'Select [aA]ll'))); + await tester.pumpAndSettle(); + + expect(controller.getText(), 'Hello selectable world'); + expect(state.toolbarIsVisible, isTrue); + expect(state.contextMenuAnchors.primaryAnchor, wordPos, + reason: 'desktop context menu must keep its right-click anchor'); + } finally { + debugDefaultTargetPlatformOverride = null; + } + }); + testWidgets('macOS consecutive taps cap at paragraph (4th stays block)', (tester) async { debugDefaultTargetPlatformOverride = TargetPlatform.macOS; @@ -1146,6 +1264,68 @@ void main() { }, ); + testWidgets( + 'desktop context menu dismisses when enclosing scrollable scrolls', + (tester) async { + debugDefaultTargetPlatformOverride = TargetPlatform.macOS; + try { + final scrollController = ScrollController(); + addTearDown(scrollController.dispose); + final controller = MarkdownSelectionController() + ..setDocuments([ + MarkdownDocumentRef( + id: 'd', + model: Markdown.fromString('Hello selectable world again'), + ), + ]); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: ListView( + controller: scrollController, + children: [ + const SizedBox(height: 200), + MarkdownSelectionScope( + controller: controller, + child: const SizedBox(width: 400, child: _Doc('d')), + ), + const SizedBox(height: 1200), + ], + ), + ), + ), + ); + await tester.pumpAndSettle(); + + controller.selectAll(); + await tester.pumpAndSettle(); + + final docPos = tester.getTopLeft(find.byType(MarkdownWidget)) + + const Offset(8, 8); + final g = await tester.startGesture(docPos, + kind: PointerDeviceKind.mouse, buttons: kSecondaryMouseButton); + await g.up(); + await tester.pumpAndSettle(); + + final state = tester.state( + find.byType(MarkdownSelectionScope), + ); + expect(state.toolbarIsVisible, isTrue); + expect(find.byType(AdaptiveTextSelectionToolbar), findsOneWidget); + + scrollController.jumpTo(50); + await tester.pumpAndSettle(); + + expect(state.toolbarIsVisible, isFalse, + reason: 'scrolling on desktop must dismiss the context menu'); + expect(find.byType(AdaptiveTextSelectionToolbar), findsNothing); + } finally { + debugDefaultTargetPlatformOverride = null; + } + }, + ); + testWidgets( 'toolbar hides when selection scrolls fully out of the host viewport', (tester) async { @@ -1734,8 +1914,9 @@ void main() { } }); - testWidgets('controller.selectAll starts the toolbar without a gesture', - (tester) async { + testWidgets( + 'controller.selectAll on desktop updates selection without ' + 'showing toolbar', (tester) async { debugDefaultTargetPlatformOverride = TargetPlatform.linux; try { final md = Markdown.fromString('Programmatic select all'); @@ -1752,18 +1933,49 @@ void main() { controller.selectAll(); await tester.pumpAndSettle(); - expect(controller.toolbarWanted, isTrue); + expect(controller.toolbarWanted, isFalse); expect(controller.getText(), 'Programmatic select all'); + final state = tester.state( + find.byType(MarkdownSelectionScope)); + expect(state.toolbarIsVisible, isFalse); + expect(find.byType(AdaptiveTextSelectionToolbar), findsNothing); + } finally { + debugDefaultTargetPlatformOverride = null; + } + }); + + testWidgets('controller.selectAll on mobile shows the toolbar with options', + (tester) async { + debugDefaultTargetPlatformOverride = TargetPlatform.android; + try { + final md = Markdown.fromString('Mobile select all'); + final controller = MarkdownSelectionController() + ..setDocuments( + [MarkdownDocumentRef(id: 'd', model: md)]); + + await tester.pumpWidget( + _wrap(controller, const SizedBox(width: 400, child: _Doc('d')))); + await tester.pumpAndSettle(); + + expect(find.byType(AdaptiveTextSelectionToolbar), findsNothing); + + controller.selectAll(); + await tester.pumpAndSettle(); + + expect(controller.toolbarWanted, isTrue); + expect(controller.getText(), 'Mobile select all'); final state = tester.state( find.byType(MarkdownSelectionScope)); expect(state.toolbarIsVisible, isTrue); + expect(find.byType(AdaptiveTextSelectionToolbar), findsOneWidget); expect(find.text('Copy'), findsOneWidget); } finally { debugDefaultTargetPlatformOverride = null; } }); - testWidgets('assigning controller.selection starts the toolbar', + testWidgets( + 'assigning controller.selection on desktop does not show toolbar', (tester) async { debugDefaultTargetPlatformOverride = TargetPlatform.linux; try { @@ -1782,11 +1994,72 @@ void main() { ); await tester.pumpAndSettle(); + expect(controller.toolbarWanted, isFalse); + expect(controller.getText(), 'Assign'); + final state = tester.state( + find.byType(MarkdownSelectionScope)); + expect(state.toolbarIsVisible, isFalse); + expect(find.byType(AdaptiveTextSelectionToolbar), findsNothing); + } finally { + debugDefaultTargetPlatformOverride = null; + } + }); + + testWidgets('assigning controller.selection on mobile shows the toolbar', + (tester) async { + debugDefaultTargetPlatformOverride = TargetPlatform.android; + try { + final md = Markdown.fromString('Assign on mobile'); + final controller = MarkdownSelectionController() + ..setDocuments( + [MarkdownDocumentRef(id: 'd', model: md)]); + + await tester.pumpWidget( + _wrap(controller, const SizedBox(width: 400, child: _Doc('d')))); + await tester.pumpAndSettle(); + + controller.selection = const MarkdownSelection( + base: MarkdownPosition(documentId: 'd', blockIndex: 0, offset: 0), + extent: MarkdownPosition(documentId: 'd', blockIndex: 0, offset: 6), + ); + await tester.pumpAndSettle(); + expect(controller.toolbarWanted, isTrue); expect(controller.getText(), 'Assign'); final state = tester.state( find.byType(MarkdownSelectionScope)); expect(state.toolbarIsVisible, isTrue); + expect(find.byType(AdaptiveTextSelectionToolbar), findsOneWidget); + expect(find.text('Copy'), findsOneWidget); + } finally { + debugDefaultTargetPlatformOverride = null; + } + }); + + testWidgets('keyboard select all does not show toolbar on desktop', + (tester) async { + debugDefaultTargetPlatformOverride = TargetPlatform.linux; + try { + final md = Markdown.fromString('Keyboard select all'); + final controller = MarkdownSelectionController() + ..setDocuments( + [MarkdownDocumentRef(id: 'd', model: md)]); + + await tester.pumpWidget( + _wrap(controller, const SizedBox(width: 400, child: _Doc('d')))); + await tester.pumpAndSettle(); + + Actions.invoke( + tester.element(find.byType(MarkdownWidget)), + const SelectAllTextIntent(SelectionChangedCause.keyboard), + ); + await tester.pumpAndSettle(); + + expect(controller.getText(), 'Keyboard select all'); + final state = tester.state( + find.byType(MarkdownSelectionScope)); + expect(state.toolbarIsVisible, isFalse); + expect(find.byType(AdaptiveTextSelectionToolbar), findsNothing); } finally { debugDefaultTargetPlatformOverride = null; } From ab9dc9b276a334ad56600332c1f8c2277faeee14 Mon Sep 17 00:00:00 2001 From: Emil Date: Sun, 13 Sep 2026 17:01:29 +0500 Subject: [PATCH 06/19] feat(selection): add enableTouchGestures parameter to MarkdownSelectionScope Introduce a new boolean parameter, enableTouchGestures, to the MarkdownSelectionScope widget, allowing control over touch, stylus, and trackpad selection gestures. This enhances flexibility for scenarios where touch input should be disabled, while maintaining keyboard and mouse interactions. Update the focus handling logic to respect this new parameter. --- lib/src/selection_scope.dart | 254 +++++++++++++++++++---------------- 1 file changed, 137 insertions(+), 117 deletions(-) diff --git a/lib/src/selection_scope.dart b/lib/src/selection_scope.dart index 526a0ae..6b2b5e6 100644 --- a/lib/src/selection_scope.dart +++ b/lib/src/selection_scope.dart @@ -67,6 +67,7 @@ class MarkdownSelectionScope extends StatefulWidget { required this.child, this.focusNode, this.enabled = true, + this.enableTouchGestures = true, this.selectionColor, this.contextMenuBuilder = defaultContextMenuBuilder, this.magnifierConfiguration, @@ -89,6 +90,15 @@ class MarkdownSelectionScope extends StatefulWidget { /// the scope is inert (but still exposes the controller to descendants). final bool enabled; + /// Whether touch / stylus / trackpad selection gestures are armed. + /// + /// Defaults to true. Set to false when an enclosing viewport owns touch + /// entry (for example a chat list where double-tap is desktop/mouse-only, + /// long-press is message-then-text, and tap must reach code/link chrome). + /// Mouse drag / double-click / triple-click, selection handles, magnifier, + /// context toolbar, and keyboard shortcuts remain active when [enabled]. + final bool enableTouchGestures; + /// The selection highlight color. Defaults to the ambient /// `DefaultSelectionStyle`/`TextSelectionTheme` color. final Color? selectionColor; @@ -255,6 +265,9 @@ class MarkdownSelectionScopeState extends State } void _handleFocusChanged() { + // When touch gestures are host-owned, do not clear on focus loss — the + // enclosing viewport may steal focus while keeping the range alive. + if (!widget.enableTouchGestures) return; if (_focusNode.hasFocus) return; // Keep the range while a pointer drag is active — list rebuilds during // autoscroll can steal focus without an intentional dismiss. @@ -1409,124 +1422,131 @@ class MarkdownSelectionScopeState extends State _previousSecondaryTapAnchor = d.globalPosition; } - Map get _gestures => - { - // Mouse: taps + pan drag. Consecutive-tap counting stays on one - // recognizer (SelectableRegion mouse path). - TapAndPanGestureRecognizer: - GestureRecognizerFactoryWithHandlers( - () => TapAndPanGestureRecognizer( - supportedDevices: const { - PointerDeviceKind.mouse, - }, - ), - (recognizer) => recognizer - ..dragStartBehavior = DragStartBehavior.down - ..onTapDown = _handleTapDown - ..onTapUp = _handleTapUp - ..onDragStart = _handleDragStart - ..onDragUpdate = _handleDragUpdate - ..onDragEnd = ((_) => _onBodyDragEnd()), - ), - // Touch / stylus / trackpad: consecutive taps + horizontal drag-to- - // extend, without a DoubleTapGestureRecognizer arena (no ~300ms - // single-tap delay). Only joins the arena on selectable content (or - // while a selection/toolbar is active) so wrapping a full dismissible - // / scrollable page does not steal edge swipes on chrome. With an - // active selection, eagerly wins horizontal drag (blocks dismissible); - // otherwise yields to competing [HorizontalDragGestureRecognizer]s. - _ContentGatedTapAndHorizontalDragGestureRecognizer: - GestureRecognizerFactoryWithHandlers< - _ContentGatedTapAndHorizontalDragGestureRecognizer>( - () => _ContentGatedTapAndHorizontalDragGestureRecognizer( - shouldArm: _shouldArmTouchSelectionGesture, - shouldEagerVictoryOnDrag: () => _shouldEagerVictoryOnTouchDrag, - supportedDevices: PointerDeviceKind.values - .where((d) => d != PointerDeviceKind.mouse) - .toSet(), - ), - (recognizer) => recognizer - ..dragStartBehavior = DragStartBehavior.down - ..onTapDown = _handleTapDown - ..onTapUp = _handleTapUp - ..onDragStart = _handleDragStart - ..onDragUpdate = _handleDragUpdate - ..onDragEnd = ((_) => _onBodyDragEnd()), - ), - // Secondary-only (any device): right-click opens the context toolbar. - // Distinct type key so it can coexist with other recognizers in the - // map. - _SecondaryTapGestureRecognizer: GestureRecognizerFactoryWithHandlers< - _SecondaryTapGestureRecognizer>( - () => _SecondaryTapGestureRecognizer(), - (recognizer) => recognizer - ..onSecondaryTapDown = - ((d) => _lastSecondaryTapDown = d.globalPosition) - ..onSecondaryTapUp = _handleSecondaryTapUp, - ), - // Touch: long-press selects the word under the finger, then drags - // extend by word (a plain swipe still scrolls an enclosing list). - // Content-gated like the tap/drag recognizer so chrome swipes are not - // held in the arena when the host wraps a dismissible page. - _ContentGatedLongPressGestureRecognizer: - GestureRecognizerFactoryWithHandlers< - _ContentGatedLongPressGestureRecognizer>( - () => _ContentGatedLongPressGestureRecognizer( - shouldArm: _shouldArmTouchSelectionGesture, - supportedDevices: const { - PointerDeviceKind.touch, - PointerDeviceKind.stylus, - PointerDeviceKind.invertedStylus, - }, - ), - (recognizer) => recognizer - ..onLongPressStart = ((d) { - _lastPointerDeviceKind = PointerDeviceKind.touch; - // Host may wrap chrome + markdown; only arm on a real text hit. - if (!controller.hitsSelectableContent(d.globalPosition)) { - return; - } - _focusNode.requestFocus(); - hideToolbar(); - _dragIsHandle = false; - _autoscrollSession.reset(); - // Android shows handles on press-end; other platforms on start. - _deferHandleShow = - defaultTargetPlatform == TargetPlatform.android; - // Match TextField long-press: Android vibrate (LONG_PRESS) via - // Feedback.forLongPress; iOS heavy-impact. selectionClick alone - // is CLOCK_TICK and is easy to miss when handles are deferred. - Feedback.forLongPress(context); - // Arm drag before beginSelection so overlay sync is pointer-sync. - _dragGlobal = d.globalPosition; - _beginSelection(d.globalPosition, 2); - // Magnifier still tracks during the press even when handles wait. - _selectionOverlay?.showMagnifier( - _magnifierInfo(d.globalPosition, isStart: false), - ); - _driveAutoscroll(); - }) - ..onLongPressMoveUpdate = ((d) { - if (_dragGlobal == null) return; - _updateSelection(d.globalPosition); - _selectionOverlay?.updateMagnifier( - _magnifierInfo(d.globalPosition, isStart: false), - ); - }) - ..onLongPressEnd = ((_) { - if (_dragGlobal == null && controller.selection == null) { - return; - } - _stopAutoscroll(); - _selectionOverlay?.hideMagnifier(); - _deferHandleShow = false; - _updateHandlesAndOverlay(); - if (controller.selection case final sel? when !sel.isCollapsed) { - showToolbar(); - } - }), + Map get _gestures { + final map = { + // Mouse: taps + pan drag. Consecutive-tap counting stays on one + // recognizer (SelectableRegion mouse path). Double-click / triple-click + // word and block selection live here — desktop/mouse only. + TapAndPanGestureRecognizer: + GestureRecognizerFactoryWithHandlers( + () => TapAndPanGestureRecognizer( + supportedDevices: const { + PointerDeviceKind.mouse, + }, ), - }; + (recognizer) => recognizer + ..dragStartBehavior = DragStartBehavior.down + ..onTapDown = _handleTapDown + ..onTapUp = _handleTapUp + ..onDragStart = _handleDragStart + ..onDragUpdate = _handleDragUpdate + ..onDragEnd = ((_) => _onBodyDragEnd()), + ), + // Secondary-only (any device): right-click opens the context toolbar. + // Distinct type key so it can coexist with other recognizers in the + // map. + _SecondaryTapGestureRecognizer: GestureRecognizerFactoryWithHandlers< + _SecondaryTapGestureRecognizer>( + () => _SecondaryTapGestureRecognizer(), + (recognizer) => recognizer + ..onSecondaryTapDown = + ((d) => _lastSecondaryTapDown = d.globalPosition) + ..onSecondaryTapUp = _handleSecondaryTapUp, + ), + }; + + if (!widget.enableTouchGestures) return map; + + // Touch / stylus / trackpad: consecutive taps + horizontal drag-to- + // extend, without a DoubleTapGestureRecognizer arena (no ~300ms + // single-tap delay). Only joins the arena on selectable content (or + // while a selection/toolbar is active) so wrapping a full dismissible + // / scrollable page does not steal edge swipes on chrome. With an + // active selection, eagerly wins horizontal drag (blocks dismissible); + // otherwise yields to competing [HorizontalDragGestureRecognizer]s. + map[_ContentGatedTapAndHorizontalDragGestureRecognizer] = + GestureRecognizerFactoryWithHandlers< + _ContentGatedTapAndHorizontalDragGestureRecognizer>( + () => _ContentGatedTapAndHorizontalDragGestureRecognizer( + shouldArm: _shouldArmTouchSelectionGesture, + shouldEagerVictoryOnDrag: () => _shouldEagerVictoryOnTouchDrag, + supportedDevices: PointerDeviceKind.values + .where((d) => d != PointerDeviceKind.mouse) + .toSet(), + ), + (recognizer) => recognizer + ..dragStartBehavior = DragStartBehavior.down + ..onTapDown = _handleTapDown + ..onTapUp = _handleTapUp + ..onDragStart = _handleDragStart + ..onDragUpdate = _handleDragUpdate + ..onDragEnd = ((_) => _onBodyDragEnd()), + ); + + // Touch: long-press selects the word under the finger, then drags + // extend by word (a plain swipe still scrolls an enclosing list). + // Content-gated like the tap/drag recognizer so chrome swipes are not + // held in the arena when the host wraps a dismissible page. + map[_ContentGatedLongPressGestureRecognizer] = + GestureRecognizerFactoryWithHandlers< + _ContentGatedLongPressGestureRecognizer>( + () => _ContentGatedLongPressGestureRecognizer( + shouldArm: _shouldArmTouchSelectionGesture, + supportedDevices: const { + PointerDeviceKind.touch, + PointerDeviceKind.stylus, + PointerDeviceKind.invertedStylus, + }, + ), + (recognizer) => recognizer + ..onLongPressStart = ((d) { + _lastPointerDeviceKind = PointerDeviceKind.touch; + // Host may wrap chrome + markdown; only arm on a real text hit. + if (!controller.hitsSelectableContent(d.globalPosition)) { + return; + } + _focusNode.requestFocus(); + hideToolbar(); + _dragIsHandle = false; + _autoscrollSession.reset(); + // Android shows handles on press-end; other platforms on start. + _deferHandleShow = + defaultTargetPlatform == TargetPlatform.android; + // Match TextField long-press: Android vibrate (LONG_PRESS) via + // Feedback.forLongPress; iOS heavy-impact. selectionClick alone + // is CLOCK_TICK and is easy to miss when handles are deferred. + Feedback.forLongPress(context); + // Arm drag before beginSelection so overlay sync is pointer-sync. + _dragGlobal = d.globalPosition; + _beginSelection(d.globalPosition, 2); + // Magnifier still tracks during the press even when handles wait. + _selectionOverlay?.showMagnifier( + _magnifierInfo(d.globalPosition, isStart: false), + ); + _driveAutoscroll(); + }) + ..onLongPressMoveUpdate = ((d) { + if (_dragGlobal == null) return; + _updateSelection(d.globalPosition); + _selectionOverlay?.updateMagnifier( + _magnifierInfo(d.globalPosition, isStart: false), + ); + }) + ..onLongPressEnd = ((_) { + if (_dragGlobal == null && controller.selection == null) { + return; + } + _stopAutoscroll(); + _selectionOverlay?.hideMagnifier(); + _deferHandleShow = false; + _updateHandlesAndOverlay(); + if (controller.selection case final sel? when !sel.isCollapsed) { + showToolbar(); + } + }), + ); + return map; + } late final Map> _actions = >{ CopySelectionTextIntent: CallbackAction( From 93a87915ccc383448663fe773088f5c25c4efb99 Mon Sep 17 00:00:00 2001 From: Emil Date: Mon, 14 Sep 2026 00:26:08 +0500 Subject: [PATCH 07/19] feat(selection): surface vs glyph hit-testing and fenced chrome links Separate selectable surface bounds from glyph-tight ink for pan/cursor arbitration, expose isLinkAtGlobal for painter-owned copy chrome, and clear mouse selection on non-text taps when a live range exists. Co-authored-by: Cursor --- CHANGELOG.md | 35 ++ docs/selection.md | 46 +- lib/src/render/block_painter.dart | 48 +- lib/src/render/blocks/code.dart | 3 +- lib/src/render/markdown_painter.dart | 8 +- lib/src/render/markdown_render_object.dart | 16 + lib/src/selection.dart | 36 +- lib/src/selection_scope.dart | 233 ++++++++-- lib/src/theme.dart | 24 +- .../selection_handle_endpoints_test.dart | 12 +- test/selection/selection_handles_test.dart | 37 +- test/selection/selection_test.dart | 3 + test/selection/selection_widget_test.dart | 423 +++++++++++++++++- 13 files changed, 835 insertions(+), 89 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b790f4a..fc1bb2e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,40 @@ ## Unreleased +### 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`. + +### 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. +- **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). +- **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 diff --git a/docs/selection.md b/docs/selection.md index acce22b..4a4d94f 100644 --- a/docs/selection.md +++ b/docs/selection.md @@ -143,19 +143,40 @@ exposes the controller to descendant `MarkdownWidget`s via an inherited widget. `MarkdownSelectionScopeState` is **public** so a custom toolbar can drive it. Params: `controller` (required), `child`, `focusNode`, `enabled` (false ⇒ inert -but still exposes the controller), `selectionColor`, `contextMenuBuilder` (null ⇒ -no toolbar), `magnifierConfiguration`, `selectionControls`, `onSelectionChanged`. +gestures/handles/toolbar/shortcuts but still exposes the controller; flipping +back to true with an existing range + `toolbarWanted` restores handles/toolbar), +`enableTouchGestures` (false ⇒ no touch/stylus/trackpad selection recognizers — +mouse multi-click, handles, toolbar, and keyboard remain; focus loss also does +not clear the range so a host viewport can steal focus without dismissing), +`enableTouchConsecutiveTaps` (false with touch gestures on ⇒ long-press → word +→ drag-extend only; touch multi-tap / horizontal-drag stay off so an enclosing +chat viewport keeps taps; focus loss also retains the range), +`ownsSelectionChrome` (optional host gate: return true only for document ids +this scope may paint handles/toolbar for — chat per-body mounts share one +controller and must not each paint a duplicate handle pair), +`canStartSelectionAt` +(optional host gate: return false so link/code chrome taps are not claimed by +the scope), `selectionColor`, `contextMenuBuilder` (null ⇒ no toolbar), +`magnifierConfiguration`, `selectionControls`, `onSelectionChanged`. Statics: `MarkdownSelectionScope.of/maybeOf` (→ controller), `stateOf` (→ state). -- **Gestures:** mouse uses `TapAndPanGestureRecognizer`; touch/stylus/trackpad - use a content-gated `TapAndHorizontalDragGestureRecognizer` (SelectableRegion - split) so consecutive-tap counting stays on one recognizer with **no** - `DoubleTapGestureRecognizer` arena delay — **single** tap dismisses/clears on - touch (caret on mouse), **double** selects the word on tap-down and shows - chrome on tap-up, **triple** selects the block, `Shift`-click extends, and a - drag after a double/triple keeps word/block granularity. The touch recognizer - and long-press only join the arena on a selectable hit (or while a non-collapsed - selection / toolbar is up). With an active selection, `eagerVictoryOnDrag` is +- **Gestures:** mouse uses a content-/host-gated `TapAndPanGestureRecognizer`; + touch/stylus/trackpad use a content-gated `TapAndHorizontalDragGestureRecognizer` + (SelectableRegion split) so consecutive-tap counting stays on one recognizer + with **no** `DoubleTapGestureRecognizer` arena delay — **single** tap + dismisses/clears on touch (caret on mouse), **double** selects the word on + tap-down and shows chrome on tap-up, **triple** selects the block, `Shift`-click + extends, and a drag after a double/triple keeps word/block granularity. The + touch recognizer and long-press only join the arena on a selectable hit + inside a mounted surface’s bounds (`hitsSelectableContent` — tdesktop-style + bubble Inside, including padding / empty line gutter; I-beam / link hits stay + glyph-tight via `hitsSelectableGlyphs`), or while a non-collapsed selection / + toolbar is up, and only when + `enableTouchGestures` is true. When `enableTouchConsecutiveTaps` is false, + only the long-press recognizer is added. When `canStartSelectionAt` returns false at the + pointer, mouse/touch start recognizers skip the arena (except while an active + range/toolbar still needs dismiss/extend taps on touch). With an active + selection, `eagerVictoryOnDrag` is on so horizontal swipes stay with selection (block dismissible); without a selection it stays off so edge swipes on chrome reach ancestors. Horizontal motion still yields vertical scrolling to ancestor scrollables. A @@ -244,7 +265,8 @@ Statics: `MarkdownSelectionScope.of/maybeOf` (→ controller), `stateOf` (→ st synchronously from that callback (including the drag-time handle sync path) runs during `performLayout` and asserts `sizeAccessAllowed`. Secondary-tap freeze is cleared when the selection moves. Focus loss while the app is - resumed clears the selection. + resumed clears the selection — except when `enableTouchGestures` is false, + where the host may steal focus while keeping the range alive. - **`MarkdownSelectionGroup`:** pass the same group to several controllers and at most one has an active selection — a new non-collapsed selection clears the diff --git a/lib/src/render/block_painter.dart b/lib/src/render/block_painter.dart index e0e5d79..16d2f9a 100644 --- a/lib/src/render/block_painter.dart +++ b/lib/src/render/block_painter.dart @@ -81,6 +81,13 @@ abstract interface class SelectableBlockPainter implements BlockPainter { /// the block-local [local] point — used to show the click (hand) cursor. bool isLinkAtLocal(Offset local); + /// Whether [local] lies over rendered glyph ink (line boxes), not empty + /// layout gutter inside the block's max-width. + /// + /// Gesture starts and hover I-beam must use this; drag-extend may still map + /// nearby points via [positionAndAffinityForLocal]. + bool hitsRenderedTextAt(Offset local); + /// When true, the selection highlight is also painted *above* the cached /// content picture so an opaque block background (e.g. a code fence) does /// not hide it. Default text blocks leave this false so glyphs stay on top @@ -146,7 +153,14 @@ mixin SelectableTextBlock implements SelectableBlockPainter { @override bool isLinkAtLocal(Offset local) => + hitsRenderedTextAt(local) && _spanHasRecognizerAt(selectionPainter, local - selectionOrigin); + + @override + bool hitsRenderedTextAt(Offset local) => _painterHitsGlyphBoxes( + selectionPainter, + local - selectionOrigin, + ); } /// One selectable text run inside a multi-painter block (a list item or a table @@ -266,9 +280,27 @@ mixin MultiPainterSelectable implements SelectableBlockPainter { @override bool isLinkAtLocal(Offset local) { + for (final fragment in fragments) { + if (!(fragment.origin & fragment.painter.size).contains(local)) { + continue; + } + final inner = local - fragment.origin; + // Prefer glyph-tight link hits inside the fragment layout box. + if (_painterHitsGlyphBoxes(fragment.painter, inner) && + _spanHasRecognizerAt(fragment.painter, inner)) { + return true; + } + } + return false; + } + + @override + bool hitsRenderedTextAt(Offset local) { + // Lists/tables: each fragment's layout box is the hit target (cell / item + // run). Paragraph gutters use glyph boxes via [SelectableTextBlock]. for (final fragment in fragments) { if ((fragment.origin & fragment.painter.size).contains(local)) { - return _spanHasRecognizerAt(fragment.painter, local - fragment.origin); + return true; } } return false; @@ -290,6 +322,20 @@ mixin MultiPainterSelectable implements SelectableBlockPainter { } } +/// Whether [local] (text-painter coordinates) lies inside any glyph line box +/// of [painter]'s full rendered range. +bool _painterHitsGlyphBoxes(TextPainter painter, Offset local) { + final plain = painter.plainText; + if (plain.isEmpty) return false; + final boxes = painter.getBoxesForSelection( + TextSelection(baseOffset: 0, extentOffset: plain.length), + ); + for (final box in boxes) { + if (box.toRect().contains(local)) return true; + } + return false; +} + /// Whether the span under a text-local [local] point in [painter] carries a tap /// recognizer (i.e. an actionable link). False for points outside the text box. bool _spanHasRecognizerAt(TextPainter painter, Offset local) { diff --git a/lib/src/render/blocks/code.dart b/lib/src/render/blocks/code.dart index 6920395..f35fd3d 100644 --- a/lib/src/render/blocks/code.dart +++ b/lib/src/render/blocks/code.dart @@ -46,7 +46,8 @@ class BlockPainter$Code with SelectableTextBlock implements BlockPainter { MarkdownThemeData theme, ) { final baseStyle = theme.textStyle.copyWith( - fontFamily: 'monospace', + fontFamily: kMonospaceFontFamily, + fontFamilyFallback: kMonospaceFontFamilyFallback, fontSize: theme.textStyle.fontSize ?? kDefaultFontSize, ); final highlighter = theme.highlighter; diff --git a/lib/src/render/markdown_painter.dart b/lib/src/render/markdown_painter.dart index 8ae9090..8e604a4 100644 --- a/lib/src/render/markdown_painter.dart +++ b/lib/src/render/markdown_painter.dart @@ -235,7 +235,8 @@ class MarkdownPainter { return (sourceIndex, _markdown.blocks[sourceIndex]); } - /// Whether the content under [local] belongs to a selectable block. + /// Whether the content under [local] belongs to selectable **glyph** ink + /// (not empty max-width gutter to the right of a short line). bool isSelectableAtLocal(Offset local) { if (_needsLayout || _isEmpty || _blockPainters.isEmpty) return false; if (local.dx < 0 || @@ -246,7 +247,10 @@ class MarkdownPainter { } final idx = _blockIndexForDy(local.dy); if (idx < 0 || idx >= _blockPainters.length) return false; - return _blockPainters[idx] is SelectableBlockPainter; + final painter = _blockPainters[idx]; + if (painter is! SelectableBlockPainter) return false; + final blockLocal = Offset(local.dx, local.dy - _blockOffsets[idx]); + return painter.hitsRenderedTextAt(blockLocal); } /// Paints the selection highlight of every selectable block, using [rangeOf] diff --git a/lib/src/render/markdown_render_object.dart b/lib/src/render/markdown_render_object.dart index 253903b..1c93ab2 100644 --- a/lib/src/render/markdown_render_object.dart +++ b/lib/src/render/markdown_render_object.dart @@ -143,6 +143,22 @@ class MarkdownRenderObject extends RenderBox ); } + @override + bool hitsSelectableGlyphs(Offset globalPosition) { + if (_documentId == null) return false; + final bounds = globalBounds; + if (bounds.isEmpty || !bounds.contains(globalPosition)) return false; + return _painter.isSelectableAtLocal(globalToLocal(globalPosition)); + } + + @override + bool isLinkAtGlobal(Offset globalPosition) { + if (_documentId == null) return false; + final bounds = globalBounds; + if (bounds.isEmpty || !bounds.contains(globalPosition)) return false; + return _painter.isLinkAtLocal(globalToLocal(globalPosition)); + } + @override Rect? caretRectFor(MarkdownPosition position, TextAffinity affinity) { final id = _documentId; diff --git a/lib/src/selection.dart b/lib/src/selection.dart index 76279bf..ac15226 100644 --- a/lib/src/selection.dart +++ b/lib/src/selection.dart @@ -486,6 +486,14 @@ abstract interface class MarkdownSelectionSurface { /// Like [positionForGlobal] but also returns soft-wrap [TextAffinity]. (MarkdownPosition, TextAffinity)? hitForGlobal(Offset globalPosition); + /// Whether [globalPosition] lies over selectable rendered glyphs on this + /// surface (tight line boxes — not empty max-width gutter). + bool hitsSelectableGlyphs(Offset globalPosition); + + /// Whether [globalPosition] lies over actionable chrome or a tappable link + /// (fenced copy header / bottom bar, or a hyperlink span) on this surface. + bool isLinkAtGlobal(Offset globalPosition); + /// Content-local caret rect for [position] with [affinity], or null when this /// surface does not own that position / cannot place a caret. Rect? caretRectFor(MarkdownPosition position, TextAffinity affinity); @@ -927,11 +935,18 @@ class MarkdownSelectionController extends ChangeNotifier { clampToNearest: !requireContainment, ); if (resolved == null) return null; - return resolved.$1.hitForGlobal(resolved.$2); + final (surface, point) = resolved; + // Strict containment = inside a mounted surface (bubble / document bounds), + // matching tdesktop PointState::Inside. Glyph-tight checks stay on + // [hitsSelectableGlyphs] for I-beam / link hit-testing only. + return surface.hitForGlobal(point); } /// Whether [globalPosition] lies inside a mounted selectable surface's - /// bounds (no nearest-neighbor clamp). Used to gate gesture *starts*. + /// bounds (no nearest-neighbor clamp). Used to gate gesture *starts* — + /// tdesktop parity: press inside the bubble arms text selection even on + /// padding / empty line gutter; I-beam stays glyph-tight via + /// [hitsSelectableGlyphs] / `isSelectableAtLocal`. bool hitsSelectableContent(Offset globalPosition) { for (final surface in _surfaces.values) { final bounds = surface.globalBounds; @@ -940,6 +955,23 @@ class MarkdownSelectionController extends ChangeNotifier { return false; } + /// Whether [globalPosition] lies over rendered glyph ink (tight line boxes). + bool hitsSelectableGlyphs(Offset globalPosition) { + for (final surface in _surfaces.values) { + if (surface.hitsSelectableGlyphs(globalPosition)) return true; + } + return false; + } + + /// Whether [globalPosition] lies over a tappable link or fenced copy chrome + /// (desktop header / mobile bottom bar) on any mounted surface. + bool isLinkAtGlobal(Offset globalPosition) { + for (final surface in _surfaces.values) { + if (surface.isLinkAtGlobal(globalPosition)) return true; + } + return false; + } + /// Resolves the surface a global point belongs to. /// /// When [clampToNearest] is true (default), a miss picks the vertically diff --git a/lib/src/selection_scope.dart b/lib/src/selection_scope.dart index 6b2b5e6..e3a70bc 100644 --- a/lib/src/selection_scope.dart +++ b/lib/src/selection_scope.dart @@ -68,6 +68,9 @@ class MarkdownSelectionScope extends StatefulWidget { this.focusNode, this.enabled = true, this.enableTouchGestures = true, + this.enableTouchConsecutiveTaps = true, + this.canStartSelectionAt, + this.ownsSelectionChrome, this.selectionColor, this.contextMenuBuilder = defaultContextMenuBuilder, this.magnifierConfiguration, @@ -87,18 +90,51 @@ class MarkdownSelectionScope extends StatefulWidget { final FocusNode? focusNode; /// Whether selection gestures, handles and shortcuts are active. When false - /// the scope is inert (but still exposes the controller to descendants). + /// the scope is inert (but still exposes the controller to descendants): + /// no gestures, handles, toolbar overlay, or shortcuts. Flipping back to + /// true with an existing non-collapsed range restores handles; when + /// [MarkdownSelectionController.toolbarWanted] is set, restores the toolbar. final bool enabled; /// Whether touch / stylus / trackpad selection gestures are armed. /// - /// Defaults to true. Set to false when an enclosing viewport owns touch - /// entry (for example a chat list where double-tap is desktop/mouse-only, - /// long-press is message-then-text, and tap must reach code/link chrome). - /// Mouse drag / double-click / triple-click, selection handles, magnifier, - /// context toolbar, and keyboard shortcuts remain active when [enabled]. + /// Defaults to true. Set to false when an enclosing viewport owns all touch + /// entry. Mouse drag / double-click / triple-click, selection handles, + /// magnifier, context toolbar, and keyboard shortcuts remain active when + /// [enabled]. + /// + /// When false, focus loss also does **not** clear the selection — the host + /// viewport may steal focus while keeping the range alive. The same focus + /// retention applies when [enableTouchConsecutiveTaps] is false (long-press + /// only): taps stay with the enclosing host. final bool enableTouchGestures; + /// Whether touch consecutive-tap / horizontal-drag selection is armed. + /// + /// Defaults to true. Requires [enableTouchGestures]. When false, the touch + /// long-press → word → drag-extend recognizer still arms, but the touch + /// tap / multi-tap / horizontal-drag recognizer does not — so double-tap + /// cannot enter text while long-press remains continuous. Mouse multi-click + /// is unaffected. + final bool enableTouchConsecutiveTaps; + + /// Optional host gate for starting a body selection at a global point. + /// + /// When this returns false, mouse/touch selection recognizers do not claim + /// the pointer and [_beginSelection] refuses — so enclosing hosts can own + /// taps on chrome (links, code headers) without the scope eating the click. + /// Null means every selectable-content hit may start selection. + final bool Function(Offset globalPosition)? canStartSelectionAt; + + /// Optional host gate for which document ids this scope may paint handles / + /// toolbar for. + /// + /// When several scopes share one [controller] (chat per-body mounts), only + /// the scope that owns the selection’s document should show chrome — otherwise + /// each enabled scope paints a duplicate handle pair. Null means this scope + /// may show chrome for any non-collapsed selection (single-scope hosts). + final bool Function(Object documentId)? ownsSelectionChrome; + /// The selection highlight color. Defaults to the ambient /// `DefaultSelectionStyle`/`TextSelectionTheme` color. final Color? selectionColor; @@ -248,6 +284,21 @@ class MarkdownSelectionScopeState extends State if (oldWidget.selectionColor != widget.selectionColor) { _applySelectionColor(); } + // Host may enable the scope after arming a subject / range (chat mobile + // message-then-text). Sync handles and restore the toolbar when chrome + // becomes eligible. Disabling clears visible chrome but keeps + // [toolbarWanted] so a later enable can restore. + if (oldWidget.enabled != widget.enabled) { + if (widget.enabled) { + _syncOverlay(); + if (controller.toolbarWanted) { + _refreshToolbarForVisibleGeometry(); + } + } else { + _clearHandles(); + _contextMenuController.remove(); + } + } } @override @@ -265,9 +316,12 @@ class MarkdownSelectionScopeState extends State } void _handleFocusChanged() { - // When touch gestures are host-owned, do not clear on focus loss — the - // enclosing viewport may steal focus while keeping the range alive. - if (!widget.enableTouchGestures) return; + // When touch entry is host-owned (all touch off, or long-press-only), do + // not clear on focus loss — the enclosing viewport may steal focus while + // keeping the range alive. + if (!widget.enableTouchGestures || !widget.enableTouchConsecutiveTaps) { + return; + } if (_focusNode.hasFocus) return; // Keep the range while a pointer drag is active — list rebuilds during // autoscroll can steal focus without an intentional dismiss. @@ -302,8 +356,9 @@ class MarkdownSelectionScopeState extends State if (sel case final current? when !current.isCollapsed) { if (_dragGlobal == null) { // Outside a drag: restore when [toolbarWanted] (scroll remount / - // geometry change). - if (controller.toolbarWanted) { + // geometry change). Skip while disabled — chrome comes back on + // the enabled flip in [didUpdateWidget]. + if (widget.enabled && controller.toolbarWanted) { _refreshToolbarForVisibleGeometry(); } } @@ -316,15 +371,27 @@ class MarkdownSelectionScopeState extends State // --- native handles + magnifier ------------------------------------------ - bool get _handlesEnabled => - widget.enabled && - switch (Theme.of(context).platform) { - TargetPlatform.android || - TargetPlatform.iOS || - TargetPlatform.fuchsia => - true, - _ => false, - }; + bool get _handlesEnabled { + if (!widget.enabled) return false; + if (!_ownsChromeForCurrentSelection) return false; + return switch (Theme.of(context).platform) { + TargetPlatform.android || + TargetPlatform.iOS || + TargetPlatform.fuchsia => + true, + _ => false, + }; + } + + /// Whether this scope may paint handles/toolbar for the live selection. + bool get _ownsChromeForCurrentSelection { + final owns = widget.ownsSelectionChrome; + if (owns == null) return true; + return switch (controller.selection) { + final sel? when !sel.isCollapsed => owns(sel.base.documentId), + _ => false, + }; + } bool get _isDesktopPlatform => switch (Theme.of(context).platform) { TargetPlatform.linux || @@ -994,6 +1061,15 @@ class MarkdownSelectionScopeState extends State if (location != null) { _lastSecondaryTapDown = location; } + // Inert while disabled — keep [toolbarWanted] for the enable flip. + if (!widget.enabled) { + _contextMenuController.remove(); + return; + } + if (!_ownsChromeForCurrentSelection) { + _contextMenuController.remove(); + return; + } if (_lastSecondaryTapDown == null) { if (_visibleSelectionBounds() case null) { _contextMenuController.remove(); @@ -1076,6 +1152,9 @@ class MarkdownSelectionScopeState extends State if (!controller.hitsSelectableContent(global)) { return false; } + if (widget.canStartSelectionAt?.call(global) == false) { + return false; + } final effective = _effectiveConsecutiveTapCount(tapCount); _granularity = switch (effective) { <= 1 => 1, @@ -1143,6 +1222,13 @@ class MarkdownSelectionScopeState extends State /// Touch recognizer joins the arena only on selectable text, or while chrome /// must still receive taps (active range / toolbar) to dismiss or extend. bool _shouldArmTouchSelectionGesture(Offset global) { + if (widget.canStartSelectionAt?.call(global) == false) { + // Still arm while chrome must receive taps to dismiss/extend. + if (controller.selection case final sel? when !sel.isCollapsed) { + return true; + } + return _contextMenuController.isShown; + } if (controller.hitsSelectableContent(global)) return true; if (controller.selection case final sel? when !sel.isCollapsed) { return true; @@ -1150,6 +1236,18 @@ class MarkdownSelectionScopeState extends State return _contextMenuController.isShown; } + bool _shouldArmMouseSelectionGesture(Offset global) { + // No host gate → legacy always-join mouse arena (SelectableRegion path). + final gate = widget.canStartSelectionAt; + if (gate == null) return true; + if (!gate(global)) return false; + return controller.hitsSelectableContent(global) || + switch (controller.selection) { + final sel? when !sel.isCollapsed => true, + _ => _contextMenuController.isShown, + }; + } + /// With an active range, take horizontal drags eagerly (block dismissible / /// nested horizontal scroll). With no selection, yield to those ancestors. bool get _shouldEagerVictoryOnTouchDrag => switch (controller.selection) { @@ -1197,12 +1295,20 @@ class MarkdownSelectionScopeState extends State if (effective <= 1) { // Touch / non-precise: dismiss immediately (no double-tap arena wait). // Mouse: collapse to a caret at the click, matching prior desktop UX. + // Miss outside selectable surface with an established range: clear + // (tdesktop empty Selecting / needTextSelectionClear parity) — do not + // leave the old range when beginSelection refuses. if (!_isPrecisePointer(d.kind)) { hideToolbar(); clearSelection(); return; } - _beginSelection(d.globalPosition, d.consecutiveTapCount); + if (!_beginSelection(d.globalPosition, d.consecutiveTapCount)) { + if (controller.selection case final sel? when !sel.isCollapsed) { + hideToolbar(); + clearSelection(); + } + } return; } @@ -1312,7 +1418,11 @@ class MarkdownSelectionScopeState extends State /// /// Must not run during layout/build — geometry walks use [localToGlobal]. void _refreshToolbarForVisibleGeometry() { - if (!controller.toolbarWanted) return; + if (!widget.enabled || !controller.toolbarWanted) return; + if (!_ownsChromeForCurrentSelection) { + _contextMenuController.remove(); + return; + } final phase = SchedulerBinding.instance.schedulerPhase; if (phase == SchedulerPhase.persistentCallbacks || phase == SchedulerPhase.midFrameMicrotasks) { @@ -1426,10 +1536,13 @@ class MarkdownSelectionScopeState extends State final map = { // Mouse: taps + pan drag. Consecutive-tap counting stays on one // recognizer (SelectableRegion mouse path). Double-click / triple-click - // word and block selection live here — desktop/mouse only. - TapAndPanGestureRecognizer: - GestureRecognizerFactoryWithHandlers( - () => TapAndPanGestureRecognizer( + // word and block selection live here — desktop/mouse only. Content- + // gated so host chrome (links / code headers) can win the arena. + _ContentGatedTapAndPanGestureRecognizer: + GestureRecognizerFactoryWithHandlers< + _ContentGatedTapAndPanGestureRecognizer>( + () => _ContentGatedTapAndPanGestureRecognizer( + shouldArm: _shouldArmMouseSelectionGesture, supportedDevices: const { PointerDeviceKind.mouse, }, @@ -1445,8 +1558,8 @@ class MarkdownSelectionScopeState extends State // Secondary-only (any device): right-click opens the context toolbar. // Distinct type key so it can coexist with other recognizers in the // map. - _SecondaryTapGestureRecognizer: GestureRecognizerFactoryWithHandlers< - _SecondaryTapGestureRecognizer>( + _SecondaryTapGestureRecognizer: + GestureRecognizerFactoryWithHandlers<_SecondaryTapGestureRecognizer>( () => _SecondaryTapGestureRecognizer(), (recognizer) => recognizer ..onSecondaryTapDown = @@ -1464,24 +1577,28 @@ class MarkdownSelectionScopeState extends State // / scrollable page does not steal edge swipes on chrome. With an // active selection, eagerly wins horizontal drag (blocks dismissible); // otherwise yields to competing [HorizontalDragGestureRecognizer]s. - map[_ContentGatedTapAndHorizontalDragGestureRecognizer] = - GestureRecognizerFactoryWithHandlers< - _ContentGatedTapAndHorizontalDragGestureRecognizer>( - () => _ContentGatedTapAndHorizontalDragGestureRecognizer( - shouldArm: _shouldArmTouchSelectionGesture, - shouldEagerVictoryOnDrag: () => _shouldEagerVictoryOnTouchDrag, - supportedDevices: PointerDeviceKind.values - .where((d) => d != PointerDeviceKind.mouse) - .toSet(), - ), - (recognizer) => recognizer - ..dragStartBehavior = DragStartBehavior.down - ..onTapDown = _handleTapDown - ..onTapUp = _handleTapUp - ..onDragStart = _handleDragStart - ..onDragUpdate = _handleDragUpdate - ..onDragEnd = ((_) => _onBodyDragEnd()), - ); + // Chat hosts set [enableTouchConsecutiveTaps] false so taps stay with + // the viewport while long-press below still owns continuous text entry. + if (widget.enableTouchConsecutiveTaps) { + map[_ContentGatedTapAndHorizontalDragGestureRecognizer] = + GestureRecognizerFactoryWithHandlers< + _ContentGatedTapAndHorizontalDragGestureRecognizer>( + () => _ContentGatedTapAndHorizontalDragGestureRecognizer( + shouldArm: _shouldArmTouchSelectionGesture, + shouldEagerVictoryOnDrag: () => _shouldEagerVictoryOnTouchDrag, + supportedDevices: PointerDeviceKind.values + .where((d) => d != PointerDeviceKind.mouse) + .toSet(), + ), + (recognizer) => recognizer + ..dragStartBehavior = DragStartBehavior.down + ..onTapDown = _handleTapDown + ..onTapUp = _handleTapUp + ..onDragStart = _handleDragStart + ..onDragUpdate = _handleDragUpdate + ..onDragEnd = ((_) => _onBodyDragEnd()), + ); + } // Touch: long-press selects the word under the finger, then drags // extend by word (a plain swipe still scrolls an enclosing list). @@ -1510,8 +1627,7 @@ class MarkdownSelectionScopeState extends State _dragIsHandle = false; _autoscrollSession.reset(); // Android shows handles on press-end; other platforms on start. - _deferHandleShow = - defaultTargetPlatform == TargetPlatform.android; + _deferHandleShow = defaultTargetPlatform == TargetPlatform.android; // Match TextField long-press: Android vibrate (LONG_PRESS) via // Feedback.forLongPress; iOS heavy-impact. selectionClick alone // is CLOCK_TICK and is easy to miss when handles are deferred. @@ -1654,6 +1770,27 @@ final class _SecondaryTapGestureRecognizer extends TapGestureRecognizer { _SecondaryTapGestureRecognizer(); } +/// Mouse [TapAndPanGestureRecognizer] that skips the arena when the host gate +/// refuses selection at the pointer (links / code chrome). +final class _ContentGatedTapAndPanGestureRecognizer + extends TapAndPanGestureRecognizer { + _ContentGatedTapAndPanGestureRecognizer({ + required this.shouldArm, + super.supportedDevices, + }); + + /// Return true to track this pointer; false leaves the arena to ancestors. + final bool Function(Offset globalPosition) shouldArm; + + @override + void addAllowedPointer(PointerDownEvent event) { + if (!shouldArm(event.position)) { + return; + } + super.addAllowedPointer(event); + } +} + /// [TapAndHorizontalDragGestureRecognizer] that skips the arena when the /// pointer is not on selectable markdown (and no selection/toolbar needs /// tap handling). Lets ancestor dismiss / horizontal-drag gestures win on diff --git a/lib/src/theme.dart b/lib/src/theme.dart index 99c7b94..792351b 100644 --- a/lib/src/theme.dart +++ b/lib/src/theme.dart @@ -21,6 +21,21 @@ typedef MarkdownCursorResolver = MouseCursor? Function( MD$Block? block, ); +/// Primary system monospace face for inline/`code` and fenced blocks. +/// +/// Prefer a real family over the CSS generic `'monospace'`, which Flutter does +/// not map on most platforms (so spans fall back to the body proportional font). +const String kMonospaceFontFamily = 'Menlo'; + +/// Fallbacks after [kMonospaceFontFamily] (Android maps `'monospace'`). +const List kMonospaceFontFamilyFallback = [ + 'Monaco', + 'Consolas', + 'Courier New', + 'Courier', + 'monospace', +]; + /// {@template markdown_theme_data} /// Theme data for Markdown widgets. /// {@endtemplate} @@ -294,7 +309,14 @@ class MarkdownThemeData implements ThemeExtension { TextDecoration.lineThrough, _ => null, }, - fontFamily: style.contains(MD$Style.monospace) ? 'monospace' : null, + // CSS generic `'monospace'` does not resolve on most Flutter + // targets (notably Apple); use a real system stack instead. + fontFamily: style.contains(MD$Style.monospace) + ? kMonospaceFontFamily + : null, + fontFamilyFallback: style.contains(MD$Style.monospace) + ? kMonospaceFontFamilyFallback + : null, color: switch (style) { var s when s.contains(MD$Style.link) => linkColor, _ => null, diff --git a/test/selection/selection_handle_endpoints_test.dart b/test/selection/selection_handle_endpoints_test.dart index f185cc3..fdf8a11 100644 --- a/test/selection/selection_handle_endpoints_test.dart +++ b/test/selection/selection_handle_endpoints_test.dart @@ -122,10 +122,16 @@ void main() { )); await tester.pumpAndSettle(); - final rect = tester.getRect(find.byType(MarkdownWidget)); - // Long-press a word near the right of the first visual line, then + final surface = controller.mountedSurfaces.first; + final box = surface as RenderBox; + final lineBoxes = surface.localBoxesForRange(0, 0, 20); + expect(lineBoxes, isNotEmpty); + final line = lineBoxes.first; + // Long-press near the end of the first visual line's glyphs, then // drag left/up so extendSelectionGranular reverses directed edges. - final press = rect.topLeft + Offset(rect.width - 24, 10); + final press = box.localToGlobal( + Offset(line.right - 4, line.center.dy), + ); final gesture = await tester.startGesture(press); await tester.pump(const Duration(milliseconds: 600)); await gesture.moveBy(const Offset(-160, -4)); diff --git a/test/selection/selection_handles_test.dart b/test/selection/selection_handles_test.dart index 58e951c..c73ac6c 100644 --- a/test/selection/selection_handles_test.dart +++ b/test/selection/selection_handles_test.dart @@ -299,13 +299,23 @@ void main() { )); await tester.pumpAndSettle(); - final rect = tester.getRect(find.byType(MarkdownWidget)); - // Select from the start to the far right of the first visual line so the - // end edge lands on a soft-wrap boundary. + final surface = controller.mountedSurfaces.first; + final box = surface as RenderBox; + final text = markdownBlockRenderedText(md.blocks.first); + final all = surface.localBoxesForRange(0, 0, text.length); + expect(all, isNotEmpty); + final lineY = all.first.center.dy; + final firstLine = + all.where((b) => (b.center.dy - lineY).abs() < 1.0).toList(); + expect(firstLine, isNotEmpty); + final left = firstLine.map((b) => b.left).reduce((a, b) => a < b ? a : b); + final right = + firstLine.map((b) => b.right).reduce((a, b) => a > b ? a : b); + // Select across the first visual line's glyphs (not empty max-width gutter). await _mouseDrag( tester, - rect.topLeft + const Offset(2, 8), - rect.topLeft + Offset(rect.width - 4, 8), + box.localToGlobal(Offset(left + 2, lineY)), + box.localToGlobal(Offset(right - 2, lineY)), ); expect(controller.selection, isNotNull); expect(controller.selection!.isCollapsed, isFalse); @@ -314,7 +324,7 @@ void main() { expect(endpoints, isNotNull); // Upstream affinity / box geometry must keep the end handle on the // first line's right edge, not snapped to x≈0 of the next line. - expect(endpoints!.endLocal.left, greaterThan(rect.width / 2)); + expect(endpoints!.endLocal.left, greaterThan(box.size.width / 2)); expect(tester.takeException(), isNull); }); @@ -336,12 +346,21 @@ void main() { )); await tester.pumpAndSettle(); - final rect = tester.getRect(find.byType(MarkdownWidget)); + final surface = controller.mountedSurfaces.first; + final box = surface as RenderBox; + const text = 'Hello selectable world'; + final startBoxes = + surface.localBoxesForRange(0, text.length - 5, text.length); + final endBoxes = surface.localBoxesForRange(0, 0, 5); + expect(startBoxes, isNotEmpty); + expect(endBoxes, isNotEmpty); // Drag right-to-left so base is after extent in reading order. await _mouseDrag( tester, - rect.topLeft + Offset(rect.width - 4, 8), - rect.topLeft + const Offset(4, 8), + box.localToGlobal( + Offset(startBoxes.first.right - 1, startBoxes.first.center.dy), + ), + box.localToGlobal(endBoxes.first.center), ); expect(controller.selection, isNotNull); expect(controller.selection!.isCollapsed, isFalse); diff --git a/test/selection/selection_test.dart b/test/selection/selection_test.dart index 232bf25..aff3cb5 100644 --- a/test/selection/selection_test.dart +++ b/test/selection/selection_test.dart @@ -21,6 +21,9 @@ class _FakeSurface implements MarkdownSelectionSurface { @override (MarkdownPosition, TextAffinity)? hitForGlobal(Offset globalPosition) => null; + @override + bool hitsSelectableGlyphs(Offset globalPosition) => false; + @override Rect? caretRectFor(MarkdownPosition position, TextAffinity affinity) => null; diff --git a/test/selection/selection_widget_test.dart b/test/selection/selection_widget_test.dart index 6e17eb3..204b95d 100644 --- a/test/selection/selection_widget_test.dart +++ b/test/selection/selection_widget_test.dart @@ -318,10 +318,23 @@ void main() { )); await tester.pumpAndSettle(); - final tl = tester.getTopLeft(find.byType(MarkdownWidget)); - final br = tester.getBottomRight(find.byType(MarkdownWidget)); - await _mouseDrag( - tester, tl + const Offset(2, 2), br - const Offset(2, 2)); + final surface = controller.mountedSurfaces.first; + final box = surface as RenderBox; + final cells = surface.localBoxesForRange(0, 0, 1); + expect(cells, isNotEmpty); + final from = box.localToGlobal( + Offset(cells.first.left + 1, cells.first.center.dy), + ); + final last = surface.localBoxesForRange( + 0, + 'A\tB\n1\t2\n3\t'.length, + 'A\tB\n1\t2\n3\t4'.length, + ); + expect(last, isNotEmpty); + final to = box.localToGlobal( + Offset(last.first.right - 1, last.first.center.dy), + ); + await _mouseDrag(tester, from, to); expect(controller.getText(), markdownBlockRenderedText(table)); expect(controller.getText(), 'A\tB\n1\t2\n3\t4'); @@ -343,10 +356,21 @@ void main() { )); await tester.pumpAndSettle(); - final tl = tester.getTopLeft(find.byType(MarkdownWidget)); - final br = tester.getBottomRight(find.byType(MarkdownWidget)); + final surface = controller.mountedSurfaces.first; + final box = surface as RenderBox; + final first = surface.localBoxesForRange(0, 0, 5); + final last = surface.localBoxesForRange( + 0, + 'alpha\nbeta\n'.length, + 'alpha\nbeta\ngamma'.length, + ); + expect(first, isNotEmpty); + expect(last, isNotEmpty); await _mouseDrag( - tester, tl + const Offset(2, 2), br - const Offset(2, 2)); + tester, + box.localToGlobal(Offset(first.first.left + 1, first.first.center.dy)), + box.localToGlobal(Offset(last.first.right - 1, last.first.center.dy)), + ); expect(controller.getText(), 'alpha\nbeta\ngamma'); expect(tester.takeException(), isNull); @@ -369,10 +393,19 @@ void main() { )); await tester.pumpAndSettle(); - final tl = tester.getTopLeft(find.byType(MarkdownWidget)); - final br = tester.getBottomRight(find.byType(MarkdownWidget)); + final surface = controller.mountedSurfaces.first; + final box = surface as RenderBox; + final intro = surface.localBoxesForRange(0, 0, 5); + final outroBlock = md.blocks.length - 1; + final outroLen = markdownBlockRenderedText(md.blocks[outroBlock]).length; + final outro = surface.localBoxesForRange(outroBlock, 0, outroLen); + expect(intro, isNotEmpty); + expect(outro, isNotEmpty); await _mouseDrag( - tester, tl + const Offset(2, 2), br - const Offset(2, 2)); + tester, + box.localToGlobal(Offset(intro.first.left + 1, intro.first.center.dy)), + box.localToGlobal(Offset(outro.first.right - 1, outro.first.center.dy)), + ); expect(controller.getText(), 'Intro line\nA\tB\n1\t2\nOutro line'); expect(tester.takeException(), isNull); @@ -866,6 +899,51 @@ void main() { expect(tester.takeException(), isNull); }); + testWidgets( + 'mouse click in empty line gutter clears an existing selection', + (tester) async { + final model = Markdown.fromString('Hi'); + final controller = MarkdownSelectionController() + ..setDocuments([ + MarkdownDocumentRef(id: 'd', model: model), + ]); + await tester.pumpWidget(_wrap( + controller, + const Align( + alignment: Alignment.topLeft, + child: SizedBox(width: 400, child: _Doc('d')), + ), + )); + await tester.pumpAndSettle(); + + final surface = controller.mountedSurfaces.first; + final boxes = surface.localBoxesForRange(0, 0, 2); + expect(boxes, isNotEmpty); + final glyph = boxes.first; + final gutterLocal = Offset(glyph.right + 80, glyph.center.dy); + final gutterGlobal = + (surface as RenderBox).localToGlobal(gutterLocal); + expect(controller.hitsSelectableContent(gutterGlobal), isTrue); + expect(controller.hitsSelectableGlyphs(gutterGlobal), isFalse); + + // Establish a ranged selection on the glyphs. + final glyphGlobal = + (surface as RenderBox).localToGlobal(glyph.center); + await _clicks(tester, glyphGlobal, 2); + expect(controller.getText(), isNotEmpty); + expect(controller.selection!.isCollapsed, isFalse); + + // Click empty horizontal gutter — must dismiss (surface Inside). + await _clicks(tester, gutterGlobal, 1); + expect( + controller.getText(), + isEmpty, + reason: 'gutter click clears like tdesktop empty Selecting', + ); + expect(tester.takeException(), isNull); + }, + ); + testWidgets('touch single tap dismisses an active selection', (tester) async { final controller = MarkdownSelectionController() @@ -2267,6 +2345,65 @@ void main() { ); }); + testWidgets( + 'gutter past end of short line is not selectable / not I-beam', + (tester) async { + final model = Markdown.fromString('Hi'); + final controller = MarkdownSelectionController() + ..setDocuments([ + MarkdownDocumentRef(id: 'd', model: model), + ]); + await tester.pumpWidget(_wrap( + controller, + const Align( + alignment: Alignment.topLeft, + child: SizedBox(width: 400, child: _Doc('d')), + ), + )); + await tester.pumpAndSettle(); + + final surface = controller.mountedSurfaces.first; + final boxes = surface.localBoxesForRange(0, 0, 2); + expect(boxes, isNotEmpty); + final glyph = boxes.first; + // Far to the right of the glyphs, still inside the wide surface. + final gutterLocal = Offset(glyph.right + 80, glyph.center.dy); + expect( + surface.globalBounds.width, + greaterThan(glyph.right + 80), + reason: 'surface must be wider than the short line', + ); + final gutterGlobal = + (surface as RenderBox).localToGlobal(gutterLocal); + + expect( + controller.hitsSelectableContent(gutterGlobal), + isTrue, + reason: 'surface bounds still arm text gestures (tdesktop Inside)', + ); + expect( + controller.hitsSelectableGlyphs(gutterGlobal), + isFalse, + reason: 'empty max-width gutter is not glyph ink', + ); + + final gesture = await tester.createGesture( + kind: PointerDeviceKind.mouse, + pointer: 1, + ); + await gesture.addPointer(location: Offset.zero); + addTearDown(gesture.removePointer); + await gesture.moveTo(gutterGlobal); + await tester.pumpAndSettle(); + + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + isNot(SystemMouseCursors.text), + reason: 'I-beam must not cover empty horizontal gutter', + ); + }, + ); + testWidgets('an actionable link shows the click (hand) cursor', (tester) async { await tester.pumpWidget(MaterialApp( @@ -2501,6 +2638,272 @@ void main() { ); }); }); + + group('host gates for chat', () { + testWidgets( + 'canStartSelectionAt false refuses mouse selection start', + (tester) async { + final controller = MarkdownSelectionController() + ..setDocuments([ + MarkdownDocumentRef( + id: 'd', + model: Markdown.fromString('Hello selectable world'), + ), + ]); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: MarkdownSelectionScope( + controller: controller, + canStartSelectionAt: (_) => false, + child: const Align( + alignment: Alignment.topLeft, + child: SizedBox(width: 400, child: _Doc('d')), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + final tl = tester.getTopLeft(find.byType(MarkdownWidget)); + final br = tester.getBottomRight(find.byType(MarkdownWidget)); + await _mouseDrag( + tester, + tl + const Offset(1, 3), + br - const Offset(1, 3), + ); + + expect(controller.selection, isNull); + expect(controller.getText(), isEmpty); + expect(tester.takeException(), isNull); + }, + ); + + testWidgets( + 'canStartSelectionAt true still allows mouse selection start', + (tester) async { + final controller = MarkdownSelectionController() + ..setDocuments([ + MarkdownDocumentRef( + id: 'd', + model: Markdown.fromString('Hello selectable world'), + ), + ]); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: MarkdownSelectionScope( + controller: controller, + canStartSelectionAt: (_) => true, + child: const Align( + alignment: Alignment.topLeft, + child: SizedBox(width: 400, child: _Doc('d')), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + final tl = tester.getTopLeft(find.byType(MarkdownWidget)); + await _clicks(tester, tl + const Offset(8, 8), 2); + + expect(controller.getText(), 'Hello'); + expect(tester.takeException(), isNull); + }, + ); + + testWidgets( + 'enableTouchGestures false: touch multi-tap does not select; ' + 'mouse multi-click still does', + (tester) async { + final controller = MarkdownSelectionController() + ..setDocuments([ + MarkdownDocumentRef( + id: 'd', + model: Markdown.fromString('Hello selectable world'), + ), + ]); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: MarkdownSelectionScope( + controller: controller, + enableTouchGestures: false, + child: const Align( + alignment: Alignment.topLeft, + child: SizedBox(width: 400, child: _Doc('d')), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + final tl = tester.getTopLeft(find.byType(MarkdownWidget)); + final pos = tl + const Offset(8, 8); + + await tester.tapAt(pos); + await tester.pump(const Duration(milliseconds: 40)); + await tester.tapAt(pos); + await tester.pumpAndSettle(); + + expect(controller.selection, isNull, + reason: 'touch double-tap must not enter text selection'); + expect(controller.getText(), isEmpty); + + await _clicks(tester, pos, 2); + expect(controller.getText(), 'Hello', + reason: 'mouse double-click remains desktop entry'); + + await tester.pump(const Duration(milliseconds: 600)); + await _clicks(tester, pos, 3); + expect(controller.getText(), 'Hello selectable world', + reason: 'mouse triple-click still selects the block'); + expect(tester.takeException(), isNull); + }, + ); + + testWidgets( + 'enableTouchConsecutiveTaps false: touch multi-tap refuses; ' + 'long-press still selects', + (tester) async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + try { + final controller = MarkdownSelectionController() + ..setDocuments([ + MarkdownDocumentRef( + id: 'd', + model: Markdown.fromString('Hello selectable world'), + ), + ]); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: MarkdownSelectionScope( + controller: controller, + enableTouchGestures: true, + enableTouchConsecutiveTaps: false, + child: const Align( + alignment: Alignment.topLeft, + child: SizedBox(width: 400, child: _Doc('d')), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + final tl = tester.getTopLeft(find.byType(MarkdownWidget)); + final pos = tl + const Offset(8, 8); + + await tester.tapAt(pos); + await tester.pump(const Duration(milliseconds: 40)); + await tester.tapAt(pos); + await tester.pumpAndSettle(); + + expect(controller.selection, isNull, + reason: 'touch double-tap must not enter when consecutive taps off'); + + final gesture = await tester.startGesture(pos); + await tester.pump(kLongPressTimeout + kPressTimeout); + await tester.pump(); + await gesture.up(); + await tester.pumpAndSettle(); + + expect(controller.getText(), isNotEmpty, + reason: 'touch long-press must still select a word'); + expect(tester.takeException(), isNull); + } finally { + debugDefaultTargetPlatformOverride = null; + } + }, + ); + + testWidgets( + 'enabling scope with existing range restores handles and toolbar', + (tester) async { + debugDefaultTargetPlatformOverride = TargetPlatform.android; + try { + final controller = MarkdownSelectionController() + ..setDocuments([ + MarkdownDocumentRef( + id: 'd', + model: Markdown.fromString('Hello selectable world'), + ), + ]); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: _ToggleEnabledScope(controller: controller), + ), + ), + ); + await tester.pumpAndSettle(); + + controller.selectAll(); + await tester.pumpAndSettle(); + expect(controller.selection, isNotNull); + expect(controller.selection!.isCollapsed, isFalse); + expect(controller.toolbarWanted, isTrue); + + // Disabled: no handle followers / toolbar chrome. + expect(find.byType(CompositedTransformFollower), findsNothing); + expect(find.byType(AdaptiveTextSelectionToolbar), findsNothing); + + final toggle = tester.state<_ToggleEnabledScopeState>( + find.byType(_ToggleEnabledScope), + ); + toggle.enable(); + await tester.pumpAndSettle(); + + expect(find.byType(CompositedTransformFollower), findsNWidgets(2), + reason: 'handles restore when the scope becomes enabled'); + final state = tester.state( + find.byType(MarkdownSelectionScope), + ); + expect(state.toolbarIsVisible, isTrue, + reason: 'toolbar restores when toolbarWanted is set'); + expect(find.byType(AdaptiveTextSelectionToolbar), findsOneWidget); + expect(tester.takeException(), isNull); + } finally { + debugDefaultTargetPlatformOverride = null; + } + }, + ); + }); +} + +/// Host that flips [MarkdownSelectionScope.enabled] after a range exists. +class _ToggleEnabledScope extends StatefulWidget { + const _ToggleEnabledScope({required this.controller}); + + final MarkdownSelectionController controller; + + @override + State<_ToggleEnabledScope> createState() => _ToggleEnabledScopeState(); +} + +class _ToggleEnabledScopeState extends State<_ToggleEnabledScope> { + bool _enabled = false; + + void enable() => setState(() => _enabled = true); + + @override + Widget build(BuildContext context) => MarkdownSelectionScope( + controller: widget.controller, + enabled: _enabled, + child: const Align( + alignment: Alignment.topLeft, + child: SizedBox(width: 400, child: _Doc('d')), + ), + ); } /// A MarkdownWidget that resolves its controller from the ambient scope. From 981bfaf1d72f787b3c18c392ba7477b4970ad320 Mon Sep 17 00:00:00 2001 From: Emil Date: Mon, 14 Sep 2026 05:37:26 +0500 Subject: [PATCH 08/19] fix: nest fenced code in quotes/alerts and keep multi-scope handles Re-parse quote/alert bodies that contain fences into nested blocks so code stays MD$Code (not inline monospace), and apply quoteStyle to nested prose. Also clear only this scope's handle LayerLinks so sibling mounts sharing a controller keep leaders after settle. Co-authored-by: Cursor --- CHANGELOG.md | 20 ++ docs/parser.md | 12 +- docs/selection.md | 10 +- lib/src/markdown.dart | 26 ++- lib/src/nodes.dart | 22 ++ lib/src/parser.dart | 58 ++++- lib/src/render/blocks/alert.dart | 209 +++++++++++++---- lib/src/render/blocks/quote.dart | 252 +++++++++++++++++---- lib/src/render/markdown_painter.dart | 10 + lib/src/render/markdown_render_object.dart | 20 ++ lib/src/selection.dart | 114 +++++++--- lib/src/selection_scope.dart | 101 ++++++--- lib/src/theme.dart | 3 +- test/parser/block_test.dart | 39 ++++ test/parser/gfm_test.dart | 14 ++ test/parser/golden_test.dart | 16 +- test/parser/streaming_test.dart | 8 +- test/selection/selection_handles_test.dart | 109 ++++++++- test/selection/selection_test.dart | 19 ++ test/selection/selection_widget_test.dart | 53 ++++- test/widget/render_test.dart | 44 +++- 21 files changed, 969 insertions(+), 190 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fc1bb2e..949d21f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ ## Unreleased +### 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. + ### 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 @@ -30,6 +41,15 @@ - **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 / diff --git a/docs/parser.md b/docs/parser.md index e90b1c1..51a9790 100644 --- a/docs/parser.md +++ b/docs/parser.md @@ -47,8 +47,8 @@ signature — a breaking change to every override. |---|---|---| | `MD$Paragraph` | `paragraph` | `text`, `List spans` | | `MD$Heading` | `heading` | `int level` (1–6), `text`, `spans` | -| `MD$Quote` | `quote` | `int indent` (**always 1** — nested `>>` not modeled yet), `text`, `spans` | -| `MD$Alert` | `alert` | `MD$AlertType alert`, `text` (body), `spans` (body only) | +| `MD$Quote` | `quote` | `int indent` (**always 1** — nested `>>` not modeled yet), `text`, `spans` (leaf) **or** `blocks` (when the body contains a fence / nested structure; then `spans` is empty) | +| `MD$Alert` | `alert` | `MD$AlertType alert`, `text` (body), `spans` (body only) **or** `blocks` when the body contains a fence | | `MD$Code` | `code` | `String? language` (may be `''`), `text` (raw, never span-parsed) | | `MD$List` | `list` | `text` (raw slice), `List items` | | `MD$Divider` | `divider` | none; `text == '---'` | @@ -131,7 +131,10 @@ Block-level: - **Tables** with a delimiter row encoding per-column alignment (`:--`, `:-:`, `--:`); malformed/ragged tables fall back to paragraph. - **Alerts** — a blockquote whose first line is `[!NOTE|TIP|IMPORTANT|WARNING|CAUTION]`; - unknown markers fall back to a plain quote. + unknown markers fall back to a plain quote. Alert/quote bodies that contain a + fenced code line (` ``` ` / `~~~`) are **re-parsed as nested blocks** + (`MD$Quote.blocks` / `MD$Alert.blocks`) so fences stay `MD$Code` instead of + being misread as inline monospace by `_parseInlineSpans`. - **Thematic breaks** `---`/`***`/`___` (3+ markers); **ATX headings** `#`–`######` (`#hashtag` and 7+ `#` are paragraphs; trailing `#` stripped); **fenced code** ` ``` ` / `~~~` with a language label; unterminated fences run to EOF. @@ -148,7 +151,8 @@ Block-level: Tree assembly is a recursive `traverse` over a flat list with a shared mutable offset closure (note the intermediate record misspells the field `intent`). - `MD$Quote.indent` is hardcoded to 1; alert bodies are trimmed (`skip(1).join('\n').trim()`) - while quote bodies keep raw `join('\n')`. + while quote bodies keep raw `join('\n')`. Plain quotes/alerts stay leaf `spans`; + a body that contains a fence line is re-parsed into `blocks` (spans empty). - `MD$Code.language` can be `''` (not null) for a bare fence; `MD$Code.text` is raw. - `MD$Table.alignments` may be shorter than the column count — always use `alignmentFor(i)`, never index directly. diff --git a/docs/selection.md b/docs/selection.md index 4a4d94f..652f9aa 100644 --- a/docs/selection.md +++ b/docs/selection.md @@ -73,6 +73,9 @@ onto a live render object, **implemented by `MarkdownRenderObject`**: queried directly from cached block painters without re-layout - `setSelectionHandleLayers({startLink, startLocal, endLink, endLocal})` — the handle `LayerLink`s the surface paints so the overlay's handles follow content +- `hasSelectionHandleLeaders` — whether any handle leaders are currently attached +- `clearSelectionHandleLayersIfLinked({startLink, endLink})` — clear leaders only + when they reference those links (sibling scopes sharing a controller) - `repaintSelection()` — repaint just the highlight; safe during build The controller keeps a `Map` keyed by @@ -129,7 +132,9 @@ hit-testing (the space `TextPainter.getPositionForOffset` indexes). A selectable block painter's `renderedText`/fragment offsets **must** match it: - paragraph/heading/quote/alert → concatenated span text. **Alert = body only; - the title contributes nothing.** + the title contributes nothing.** When `MD$Quote.blocks` / `MD$Alert.blocks` is + non-empty (fenced code inside `>`), rendered text is the nested children's + rendered texts joined with `\n`. - code → raw `text`. - **list** → items depth-first, joined by `'\n'` unconditionally (one line per item, even empty ones); checkbox glyphs contribute nothing. @@ -153,7 +158,8 @@ not clear the range so a host viewport can steal focus without dismissing), chat viewport keeps taps; focus loss also retains the range), `ownsSelectionChrome` (optional host gate: return true only for document ids this scope may paint handles/toolbar for — chat per-body mounts share one -controller and must not each paint a duplicate handle pair), +controller and must not each paint a duplicate handle pair; non-owning / +disabled siblings clear only their own handle leaders, never foreign ones), `canStartSelectionAt` (optional host gate: return false so link/code chrome taps are not claimed by the scope), `selectionColor`, `contextMenuBuilder` (null ⇒ no toolbar), diff --git a/lib/src/markdown.dart b/lib/src/markdown.dart index 3645738..c130a52 100644 --- a/lib/src/markdown.dart +++ b/lib/src/markdown.dart @@ -67,13 +67,27 @@ final class Markdown { for (final span in spans) { buffer.write(span.text); } - case MD$Quote(:List spans): - for (final span in spans) { - buffer.write(span.text); + case MD$Quote(:List spans, :List blocks): + if (blocks.isEmpty) { + for (final span in spans) { + buffer.write(span.text); + } + } else { + for (var j = 0; j < blocks.length; j++) { + if (j > 0) buffer.writeln(); + buffer.write(blocks[j].text); + } } - case MD$Alert(:List spans): - for (final span in spans) { - buffer.write(span.text); + case MD$Alert(:List spans, :List blocks): + if (blocks.isEmpty) { + for (final span in spans) { + buffer.write(span.text); + } + } else { + for (var j = 0; j < blocks.length; j++) { + if (j > 0) buffer.writeln(); + buffer.write(blocks[j].text); + } } case MD$Code(:String text): buffer.write(text); diff --git a/lib/src/nodes.dart b/lib/src/nodes.dart index 818b0b3..d5e7173 100644 --- a/lib/src/nodes.dart +++ b/lib/src/nodes.dart @@ -375,10 +375,15 @@ final class MD$Heading extends MD$Block { final class MD$Quote extends MD$Block { /// Creates a new instance of [MD$Quote]. /// {@macro markdown_block} + /// + /// When the quote body contains fenced code (or other nested block + /// structure), [blocks] holds the children and [spans] is empty. Plain + /// quotes keep the historical leaf shape: non-empty [spans], empty [blocks]. const MD$Quote({ required this.indent, required this.text, required this.spans, + this.blocks = const [], }); @override @@ -393,8 +398,14 @@ final class MD$Quote extends MD$Block { final String text; /// The inline text spans within the quote. + /// + /// Empty when [blocks] is non-empty (nested block structure). final List spans; + /// Nested block children when the quote body was re-parsed as blocks + /// (e.g. a fenced code block inside `>`). Empty for leaf inline quotes. + final List blocks; + @override T map({ required T Function(MD$Paragraph p) paragraph, @@ -418,10 +429,15 @@ final class MD$Quote extends MD$Block { final class MD$Alert extends MD$Block { /// Creates a new instance of [MD$Alert]. /// {@macro markdown_block} + /// + /// When the alert body contains fenced code (or other nested block + /// structure), [blocks] holds the children and [spans] is empty. Plain + /// alert bodies keep non-empty [spans] and empty [blocks]. const MD$Alert({ required this.alert, required this.text, required this.spans, + this.blocks = const [], }); @override @@ -434,8 +450,14 @@ final class MD$Alert extends MD$Block { final String text; /// The inline text spans within the alert body. + /// + /// Empty when [blocks] is non-empty (nested block structure). final List spans; + /// Nested block children when the alert body was re-parsed as blocks + /// (e.g. a fenced code block inside `> [!NOTE]`). Empty for leaf bodies. + final List blocks; + @override T map({ required T Function(MD$Paragraph p) paragraph, diff --git a/lib/src/parser.dart b/lib/src/parser.dart index a52cdb0..9ca1d7d 100644 --- a/lib/src/parser.dart +++ b/lib/src/parser.dart @@ -141,6 +141,16 @@ class MarkdownDecoder extends Converter { /// blockquote line, matching the historical trim behavior. static String _stripQuoteMarker(String line) => line.substring(1).trim(); + /// Whether any line opens (or closes) a fenced code block (` ``` ` / `~~~`). + /// Used to decide when a quote/alert body must be re-parsed as nested blocks + /// instead of being fed to [_parseInlineSpans]. + static bool _linesContainFence(Iterable lines) { + for (final line in lines) { + if (line.startsWith('```') || line.startsWith('~~~')) return true; + } + return false; + } + /// Parses the per-column alignment from a table delimiter row such as /// `| :--- | :--: | ---: |`. Returns `null` when [line] is not a valid /// delimiter row (which is also used to reject malformed tables). @@ -268,21 +278,47 @@ class MarkdownDecoder extends Converter { : null; if (alertType != null) { // The alert body is everything after the marker line. - final body = quoteLines.skip(1).join('\n').trim(); - pushBlock(MD$Alert( - alert: alertType, - text: body, - spans: _parseInlineSpans(body, math: math), - )); + final bodyLines = quoteLines.skip(1).toList(growable: false); + final body = bodyLines.join('\n').trim(); + // Fenced code inside alerts must be nested blocks — feeding the + // body to [_parseInlineSpans] pairs fence backticks as monospace. + if (_linesContainFence(bodyLines)) { + final nested = convert(body).blocks; + pushBlock(MD$Alert( + alert: alertType, + text: body, + spans: const [], + blocks: nested, + )); + } else { + pushBlock(MD$Alert( + alert: alertType, + text: body, + spans: _parseInlineSpans(body, math: math), + )); + } } else { final text = quoteLines.join('\n'); // TODO(plugfox): Implement indentation for quotes // Mike Matiunin , 16 June 2025 - pushBlock(MD$Quote( - indent: 1, // Indentation level for quotes - text: text, - spans: _parseInlineSpans(text, math: math), - )); + // CommonMark/GFM: strip `>` then re-parse the body as blocks so a + // fenced code block inside a quote stays a code block — not inline + // ``monospace`` from backtick pairing in [_parseInlineSpans]. + if (_linesContainFence(quoteLines)) { + final nested = convert(text).blocks; + pushBlock(MD$Quote( + indent: 1, + text: text, + spans: const [], + blocks: nested, + )); + } else { + pushBlock(MD$Quote( + indent: 1, // Indentation level for quotes + text: text, + spans: _parseInlineSpans(text, math: math), + )); + } } if (i + count == length) break; // Last line is quote/alert i = j - 1; // Skip the consumed lines diff --git a/lib/src/render/blocks/alert.dart b/lib/src/render/blocks/alert.dart index df2155f..309c463 100644 --- a/lib/src/render/blocks/alert.dart +++ b/lib/src/render/blocks/alert.dart @@ -15,22 +15,25 @@ import '../span_builder.dart'; /// A class for painting a GitHub-style alert (admonition) block in markdown. class BlockPainter$Alert - with ParagraphGestureHandler, SelectableTextBlock + with ParagraphGestureHandler, MultiPainterSelectable implements BlockPainter { - @override - TextPainter get selectionPainter => bodyPainter; - @override - Offset get selectionOrigin => _bodyOrigin; - /// Creates an alert painter of kind [alert] with body [spans], styled by - /// [theme]. + /// [theme]. When [children] is non-empty, those painters replace the leaf + /// body painter (fenced code inside `> [!NOTE]`, etc.). BlockPainter$Alert({ required this.alert, required List spans, required this.theme, - }) : _accent = theme.alertColorFor(alert), - _selectionHighlightAboveCachedContent = - markdownSpansPaintOpaqueBackground(spans), + List children = const [], + }) : _children = children, + _accent = theme.alertColorFor(alert), + _selectionHighlightAboveCachedContent = children.isNotEmpty + ? children.any( + (c) => + c is SelectableBlockPainter && + c.selectionHighlightAboveCachedContent, + ) + : markdownSpansPaintOpaqueBackground(spans), titlePainter = TextPainter( text: TextSpan( text: alert.title, @@ -43,12 +46,20 @@ class BlockPainter$Alert textDirection: theme.textDirection, textScaler: theme.textScaler, ), - bodyPainter = TextPainter( - text: paragraphFromMarkdownSpans(spans: spans, theme: theme), - textAlign: TextAlign.start, - textDirection: theme.textDirection, - textScaler: theme.textScaler, - ); + _bodyPainter = children.isEmpty + ? TextPainter( + text: paragraphFromMarkdownSpans(spans: spans, theme: theme), + textAlign: TextAlign.start, + textDirection: theme.textDirection, + textScaler: theme.textScaler, + ) + : null; + + /// Nested body painters when the alert model carries [MD$Alert.blocks]. + final List _children; + + /// Leaf body painter when there are no nested children. + final TextPainter? _bodyPainter; /// Alert chrome and optional inline monospace/highlight fills sit in the /// content [Picture]. @@ -68,9 +79,6 @@ class BlockPainter$Alert /// Painter for the alert title (e.g. "Note"). final TextPainter titlePainter; - /// Painter for the alert body content. - final TextPainter bodyPainter; - /// Padding around the alert's content, inside its rounded background. static const double padding = 10.0; @@ -90,25 +98,62 @@ class BlockPainter$Alert Size get size => _size; Size _size = Size.zero; + @override + List get fragments => _fragments; + List _fragments = const []; + + @override + String get renderedText => _renderedText; + String _renderedText = ''; + /// Whether the body has any content to paint. bool _hasBody = false; + /// Top of the body column within the alert (below the title). + double _bodyTop = 0; + + /// Vertical offsets of each nested child within the body column. + List _childOffsets = const []; + /// Last span hit by the tap down event. TextSpan? _lastSpan; - Offset get _bodyOrigin => - Offset(_contentLeft, padding + titlePainter.height + titleGap); + Offset get _bodyOrigin => Offset(_contentLeft, _bodyTop); TextSpan? _spanForPosition(Offset localPosition) { if (!_hasBody) return null; - final local = localPosition - _bodyOrigin; - if (local.dx < 0 || - local.dy < 0 || - local.dx > bodyPainter.width || - local.dy > bodyPainter.height) return null; - final position = bodyPainter.getPositionForOffset(local); - final span = bodyPainter.text?.getSpanForPosition(position); - return span is TextSpan ? span : null; + if (_bodyPainter != null) { + final local = localPosition - _bodyOrigin; + if (local.dx < 0 || + local.dy < 0 || + local.dx > _bodyPainter.width || + local.dy > _bodyPainter.height) { + return null; + } + final position = _bodyPainter.getPositionForOffset(local); + final span = _bodyPainter.text?.getSpanForPosition(position); + return span is TextSpan ? span : null; + } + for (var i = 0; i < _children.length; i++) { + final child = _children[i]; + final origin = Offset(_contentLeft, _bodyTop + _childOffsets[i]); + if (!(origin & child.size).contains(localPosition)) continue; + final inner = localPosition - origin; + if (child case final SelectableTextBlock textBlock) { + final painter = textBlock.selectionPainter; + final local = inner - textBlock.selectionOrigin; + if (local.dx < 0 || + local.dy < 0 || + local.dx > painter.width || + local.dy > painter.height) { + continue; + } + final position = painter.getPositionForOffset(local); + final span = painter.text?.getSpanForPosition(position); + return span is TextSpan ? span : null; + } + } + return null; } @override @@ -121,8 +166,9 @@ class BlockPainter$Alert if (_lastSpan == null) return; final span = _spanForPosition(event.localPosition); if (span != null && _lastSpan == span) { - if (span case TextSpan(recognizer: TapGestureRecognizer(:var onTap))) + if (span case TextSpan(recognizer: TapGestureRecognizer(:var onTap))) { onTap?.call(); + } } _lastSpan = null; } @@ -131,21 +177,86 @@ class BlockPainter$Alert Size layout(double width) { final available = math.max(0.0, width - _contentLeft - padding); titlePainter.layout(minWidth: 0, maxWidth: available); - bodyPainter.layout(minWidth: 0, maxWidth: available); - _hasBody = bodyPainter.text?.toPlainText().isNotEmpty ?? false; + _bodyTop = padding + titlePainter.height + titleGap; - final contentWidth = math.max( - titlePainter.width, - _hasBody ? bodyPainter.width : 0.0, - ); + double bodyWidth = 0; + double bodyHeight = 0; + if (_bodyPainter != null) { + _bodyPainter.layout(minWidth: 0, maxWidth: available); + _hasBody = _bodyPainter.text?.toPlainText().isNotEmpty ?? false; + if (_hasBody) { + bodyWidth = _bodyPainter.width; + bodyHeight = _bodyPainter.height; + _fragments = [ + SelectableFragment(_bodyPainter, _bodyOrigin, 0), + ]; + _renderedText = _bodyPainter.plainText; + } else { + _fragments = const []; + _renderedText = ''; + } + _childOffsets = const []; + } else { + final offsets = List.filled(_children.length, 0.0); + var y = 0.0; + for (var i = 0; i < _children.length; i++) { + offsets[i] = y; + final childSize = _children[i].layout(available); + bodyWidth = math.max(bodyWidth, childSize.width); + y += childSize.height; + } + _childOffsets = offsets; + bodyHeight = y; + _hasBody = bodyHeight > 0; + _rebuildFragments(); + } + + final contentWidth = math.max(titlePainter.width, bodyWidth); final contentHeight = - titlePainter.height + (_hasBody ? titleGap + bodyPainter.height : 0.0); + titlePainter.height + (_hasBody ? titleGap + bodyHeight : 0.0); return _size = Size( _contentLeft + contentWidth + padding, contentHeight + padding * 2, ); } + void _rebuildFragments() { + final frags = []; + final parts = []; + var textStart = 0; + for (var i = 0; i < _children.length; i++) { + final child = _children[i]; + final origin = Offset(_contentLeft, _bodyTop + _childOffsets[i]); + if (child is MultiPainterSelectable) { + final text = child.renderedText; + if (text.isNotEmpty) { + for (final f in child.fragments) { + frags.add(SelectableFragment( + f.painter, + origin + f.origin, + textStart + f.textStart, + )); + } + parts.add(text); + textStart += text.length + 1; + } + } else if (child case final SelectableTextBlock textBlock) { + final text = textBlock.renderedText; + if (text.isNotEmpty) { + frags.add(SelectableFragment( + textBlock.selectionPainter, + origin + textBlock.selectionOrigin, + textStart, + )); + parts.add(text); + textStart += text.length + 1; + } + } + } + _renderedText = parts.join('\n'); + _fragments = frags; + } + @override void paint(Canvas canvas, Size size, double offset) { if (size.width < _size.width) return; @@ -170,14 +281,32 @@ class BlockPainter$Alert ); titlePainter.paint(canvas, Offset(_contentLeft, offset + padding)); - if (_hasBody) { - bodyPainter.paint(canvas, _bodyOrigin + Offset(0, offset)); + if (!_hasBody) return; + + if (_bodyPainter != null) { + _bodyPainter.paint(canvas, _bodyOrigin + Offset(0, offset)); + return; } + + canvas.save(); + canvas.translate(_contentLeft, 0); + final childWidth = size.width - _contentLeft - padding; + for (var i = 0; i < _children.length; i++) { + _children[i].paint( + canvas, + Size(childWidth, _children[i].size.height), + offset + _bodyTop + _childOffsets[i], + ); + } + canvas.restore(); } @override void dispose() { titlePainter.dispose(); - bodyPainter.dispose(); + _bodyPainter?.dispose(); + for (final child in _children) { + child.dispose(); + } } } diff --git a/lib/src/render/blocks/quote.dart b/lib/src/render/blocks/quote.dart index 07623a3..08d0e35 100644 --- a/lib/src/render/blocks/quote.dart +++ b/lib/src/render/blocks/quote.dart @@ -14,32 +14,41 @@ import '../block_painter.dart'; import '../span_builder.dart'; /// A class for painting a quote block in markdown. +/// +/// Leaf quotes (inline [spans] only) paint a single [TextPainter]. Quotes with +/// nested [children] (e.g. fenced code inside `>`) stack those painters under +/// the accent bar and expose them as [MultiPainterSelectable] fragments. class BlockPainter$Quote - with ParagraphGestureHandler, SelectableTextBlock + with ParagraphGestureHandler, MultiPainterSelectable implements BlockPainter { - @override - TextPainter get selectionPainter => painter; - @override - Offset get selectionOrigin => Offset(lineIndent + indent * lineIndent, 0); - /// Creates a quote painter for [spans] nested [indent] levels deep, styled - /// by [theme]. + /// by [theme]. When [children] is non-empty, those painters are stacked + /// instead of [spans]. BlockPainter$Quote({ required List spans, required this.indent, required this.theme, - }) : _selectionHighlightAboveCachedContent = - markdownSpansPaintOpaqueBackground(spans), - painter = TextPainter( - text: paragraphFromMarkdownSpans( - spans: spans, - theme: theme, - textStyle: theme.quoteStyle ?? theme.textStyle, - ), - textAlign: TextAlign.start, - textDirection: theme.textDirection, - textScaler: theme.textScaler, - ), + List children = const [], + }) : _children = children, + _selectionHighlightAboveCachedContent = children.isNotEmpty + ? children.any( + (c) => + c is SelectableBlockPainter && + c.selectionHighlightAboveCachedContent, + ) + : markdownSpansPaintOpaqueBackground(spans), + _leafPainter = children.isEmpty + ? TextPainter( + text: paragraphFromMarkdownSpans( + spans: spans, + theme: theme, + textStyle: theme.quoteStyle ?? theme.textStyle, + ), + textAlign: TextAlign.start, + textDirection: theme.textDirection, + textScaler: theme.textScaler, + ) + : null, linePaint = Paint() ..color = theme.dividerColor ?? const Color(0x7F7F7F7F) // Gray color for the line. @@ -47,7 +56,14 @@ class BlockPainter$Quote ..strokeWidth = 4.0 ..style = PaintingStyle.fill; - /// Inline monospace/highlight fills live in the content [Picture]. + /// Nested block painters when the quote model carries [MD$Quote.blocks]. + final List _children; + + /// Leaf glyph painter when there are no nested children. + final TextPainter? _leafPainter; + + /// Inline monospace/highlight fills (or nested code chrome) live in the + /// content [Picture]. @override bool get selectionHighlightAboveCachedContent => _selectionHighlightAboveCachedContent; @@ -56,15 +72,34 @@ class BlockPainter$Quote /// The theme used to style the quote. final MarkdownThemeData theme; - /// The text painter that owns the quote's glyphs. - final TextPainter painter; - /// Nesting depth of the quote (0 for a top-level quote). final int indent; /// Horizontal space taken by each nesting level's accent bar. static const double lineIndent = 10.0; + /// Theme for a nested [child] under a quote: prose blocks inherit + /// [MarkdownThemeData.quoteStyle] as their base [MarkdownThemeData.textStyle] + /// (matching leaf quotes). Code/tables/spacers/nested quotes keep [theme] so + /// chrome and recursive quote handling stay correct. + static MarkdownThemeData inheritFrom( + MarkdownThemeData theme, + MD$Block child, + ) => + switch (child) { + MD$Code() || + MD$Table() || + MD$Divider() || + MD$Spacer() || + MD$Quote() || + MD$Alert() => + theme, + MD$Paragraph() || + MD$Heading() || + MD$List() => + theme.copyWith(textStyle: theme.quoteStyle ?? theme.textStyle), + }; + /// Paint used for the vertical accent bar(s) on the left. final Paint linePaint; @@ -72,40 +107,146 @@ class BlockPainter$Quote Size get size => _size; Size _size = Size.zero; + @override + List get fragments => _fragments; + List _fragments = const []; + + @override + String get renderedText => _renderedText; + String _renderedText = ''; + + /// Vertical offsets of each nested child within the quote content column. + List _childOffsets = const []; + /// Last span hit by the tap down event. TextSpan? _lastSpan; + double get _contentLeft => lineIndent + indent * lineIndent; + + InlineSpan? _spanForPosition(Offset localPosition) { + if (_leafPainter != null) { + final local = localPosition - Offset(_contentLeft, 0); + if (local.dx < 0 || + local.dy < 0 || + local.dx > _leafPainter.width || + local.dy > _leafPainter.height) { + return null; + } + final position = _leafPainter.getPositionForOffset(local); + return _leafPainter.text?.getSpanForPosition(position); + } + for (var i = 0; i < _children.length; i++) { + final child = _children[i]; + final origin = Offset(_contentLeft, _childOffsets[i]); + final rect = origin & child.size; + if (!rect.contains(localPosition)) continue; + final inner = localPosition - origin; + if (child is BlockPainter$Quote) { + return child._spanForPosition(inner); + } + if (child case final SelectableTextBlock textBlock) { + final painter = textBlock.selectionPainter; + final local = inner - textBlock.selectionOrigin; + if (local.dx < 0 || + local.dy < 0 || + local.dx > painter.width || + local.dy > painter.height) { + continue; + } + final position = painter.getPositionForOffset(local); + return painter.text?.getSpanForPosition(position); + } + } + return null; + } + @override void handleTapDown(PointerDownEvent event) { - _lastSpan = null; // Reset the span on tap down. - final span = hitTestInlineSpanWithPointerEvent(event, painter); + _lastSpan = null; + final span = _spanForPosition(event.localPosition); if (span case TextSpan textSpan) _lastSpan = textSpan; } @override void handleTapUp(PointerUpEvent event) { - if (_lastSpan == null) return; // No span was hit on tap down. - final span = hitTestInlineSpanWithPointerEvent(event, painter); + if (_lastSpan == null) return; + final span = _spanForPosition(event.localPosition); if (span != null && _lastSpan == span) { - // If the span is the same as the one hit on tap down, - // call the tap recognizer. - if (span case TextSpan(recognizer: TapGestureRecognizer(:var onTap))) + if (span case TextSpan(recognizer: TapGestureRecognizer(:var onTap))) { onTap?.call(); + } } - _lastSpan = null; // Clear the span after handling the tap. + _lastSpan = null; } @override Size layout(double width) { - // Adjust width for indentation. - painter.layout( - minWidth: 0, - maxWidth: math.max(width - lineIndent - indent * lineIndent, 0), - ); - return _size = Size( - painter.size.width + lineIndent + indent * lineIndent, - painter.size.height, - ); + final contentWidth = math.max(width - _contentLeft, 0.0); + if (_leafPainter != null) { + _leafPainter.layout(minWidth: 0, maxWidth: contentWidth); + _size = Size( + _leafPainter.size.width + _contentLeft, + _leafPainter.size.height, + ); + _fragments = [ + SelectableFragment(_leafPainter, Offset(_contentLeft, 0), 0), + ]; + _renderedText = _leafPainter.plainText; + _childOffsets = const []; + return _size; + } + + final offsets = List.filled(_children.length, 0.0); + var y = 0.0; + var maxChildWidth = 0.0; + for (var i = 0; i < _children.length; i++) { + offsets[i] = y; + final childSize = _children[i].layout(contentWidth); + maxChildWidth = math.max(maxChildWidth, childSize.width); + y += childSize.height; + } + _childOffsets = offsets; + _size = Size(maxChildWidth + _contentLeft, y); + _rebuildFragments(); + return _size; + } + + void _rebuildFragments() { + final frags = []; + final parts = []; + var textStart = 0; + for (var i = 0; i < _children.length; i++) { + final child = _children[i]; + final origin = Offset(_contentLeft, _childOffsets[i]); + if (child is MultiPainterSelectable) { + final text = child.renderedText; + if (text.isNotEmpty) { + for (final f in child.fragments) { + frags.add(SelectableFragment( + f.painter, + origin + f.origin, + textStart + f.textStart, + )); + } + parts.add(text); + textStart += text.length + 1; + } + } else if (child case final SelectableTextBlock textBlock) { + final text = textBlock.renderedText; + if (text.isNotEmpty) { + frags.add(SelectableFragment( + textBlock.selectionPainter, + origin + textBlock.selectionOrigin, + textStart, + )); + parts.add(text); + textStart += text.length + 1; + } + } + } + // Drop the trailing +1 from the last non-empty part. + _renderedText = parts.join('\n'); + _fragments = frags; } @override @@ -114,7 +255,7 @@ class BlockPainter$Quote if (size.width < _size.width) return; // --- Draw vertical lines --- // - for (var i = 1; i <= indent; i++) + for (var i = 1; i <= indent; i++) { canvas.drawLine( Offset( i * lineIndent, @@ -126,18 +267,31 @@ class BlockPainter$Quote ), linePaint, ); + } + + if (_leafPainter != null) { + _leafPainter.paint(canvas, Offset(_contentLeft, offset)); + return; + } - painter.paint( - canvas, - Offset( - lineIndent + indent * lineIndent, - offset, - ), - ); + canvas.save(); + canvas.translate(_contentLeft, 0); + final childWidth = size.width - _contentLeft; + for (var i = 0; i < _children.length; i++) { + _children[i].paint( + canvas, + Size(childWidth, _children[i].size.height), + offset + _childOffsets[i], + ); + } + canvas.restore(); } @override void dispose() { - painter.dispose(); + _leafPainter?.dispose(); + for (final child in _children) { + child.dispose(); + } } } diff --git a/lib/src/render/markdown_painter.dart b/lib/src/render/markdown_painter.dart index 8e604a4..6cb44da 100644 --- a/lib/src/render/markdown_painter.dart +++ b/lib/src/render/markdown_painter.dart @@ -83,6 +83,13 @@ class MarkdownPainter { spans: q.spans, indent: q.indent, theme: theme, + children: [ + for (final child in q.blocks) + _defaultBlockBuilder( + child, + BlockPainter$Quote.inheritFrom(theme, child), + ), + ], ), code: (c) => BlockPainter$Code( language: c.language, @@ -106,6 +113,9 @@ class MarkdownPainter { alert: a.alert, spans: a.spans, theme: theme, + children: [ + for (final child in a.blocks) _defaultBlockBuilder(child, theme), + ], ), spacer: (s) => BlockPainter$Spacer( count: s.count, diff --git a/lib/src/render/markdown_render_object.dart b/lib/src/render/markdown_render_object.dart index 1c93ab2..dd211c5 100644 --- a/lib/src/render/markdown_render_object.dart +++ b/lib/src/render/markdown_render_object.dart @@ -223,6 +223,26 @@ class MarkdownRenderObject extends RenderBox markNeedsPaint(); } + @override + bool get hasSelectionHandleLeaders => + _startHandleLink != null || _endHandleLink != null; + + @override + void clearSelectionHandleLayersIfLinked({ + required LayerLink startLink, + required LayerLink endLink, + }) { + final clearStart = identical(_startHandleLink, startLink); + final clearEnd = identical(_endHandleLink, endLink); + if (!clearStart && !clearEnd) return; + setSelectionHandleLayers( + startLink: clearStart ? null : _startHandleLink, + startLocal: clearStart ? null : _startHandleLocal, + endLink: clearEnd ? null : _endHandleLink, + endLocal: clearEnd ? null : _endHandleLocal, + ); + } + void _clearSelectionHandleLayers() { _startHandleLink = null; _startHandleLocal = null; diff --git a/lib/src/selection.dart b/lib/src/selection.dart index ac15226..b712487 100644 --- a/lib/src/selection.dart +++ b/lib/src/selection.dart @@ -25,8 +25,12 @@ bool get _isDesktopPlatform => switch (defaultTargetPlatform) { String markdownBlockRenderedText(MD$Block block) => block.map( paragraph: (p) => _spans(p.spans), heading: (h) => _spans(h.spans), - quote: (q) => _spans(q.spans), - alert: (a) => _spans(a.spans), + quote: (q) => q.blocks.isEmpty + ? _spans(q.spans) + : _nestedBlocksRenderedText(q.blocks), + alert: (a) => a.blocks.isEmpty + ? _spans(a.spans) + : _nestedBlocksRenderedText(a.blocks), code: (c) => c.text, list: (l) { final parts = []; @@ -41,6 +45,15 @@ String markdownBlockRenderedText(MD$Block block) => block.map( spacer: (_) => '', ); +String _nestedBlocksRenderedText(List blocks) { + final parts = []; + for (final block in blocks) { + final text = markdownBlockRenderedText(block); + if (text.isNotEmpty) parts.add(text); + } + return parts.join('\n'); +} + String _spans(List spans) { final buffer = StringBuffer(); for (final span in spans) buffer.write(span.text); @@ -324,9 +337,15 @@ final class MarkdownMarkupFormatter implements MarkdownSelectionFormatter { return seg.block.map( paragraph: (p) => _spans(p.spans), heading: (h) => '${'#' * h.level.clamp(1, 6)} ${_spans(h.spans)}', - quote: (q) => _prefixLines(_spans(q.spans), '> '), + quote: (q) => _prefixLines( + q.blocks.isEmpty + ? _spans(q.spans) + : _nestedBlocksRenderedText(q.blocks), + '> ', + ), alert: (a) => - '> [!${a.alert.marker}]\n${_prefixLines(_spans(a.spans), '> ')}', + // ignore: lines_longer_than_80_chars + '> [!${a.alert.marker}]\n${_prefixLines(a.blocks.isEmpty ? _spans(a.spans) : _nestedBlocksRenderedText(a.blocks), '> ')}', code: (c) => '```${c.language ?? ''}\n${c.text}\n```', list: (l) => _list(l.items, 0), table: _table, @@ -537,6 +556,17 @@ abstract interface class MarkdownSelectionSurface { Offset? endLocal, }); + /// Whether this surface currently paints any selection-handle leader layers. + bool get hasSelectionHandleLeaders; + + /// Clears handle leaders only when they currently reference [startLink] / + /// [endLink]. Leaves foreign leaders intact so sibling scopes that share one + /// controller cannot strip each other's chrome. + void clearSelectionHandleLayersIfLinked({ + required LayerLink startLink, + required LayerLink endLink, + }); + /// Requests a repaint of just the selection highlight (e.g. after a color /// change). Safe to call during a build phase (schedules paint, not build). void repaintSelection(); @@ -636,8 +666,10 @@ class MarkdownSelectionController extends ChangeNotifier { set selectionColor(Color? value) { if (value == _selectionColor) return; _selectionColor = value; - for (final surface in _surfaces.values) { - surface.repaintSelection(); + for (final stack in _surfaceStacks.values) { + for (final surface in stack) { + surface.repaintSelection(); + } } } @@ -649,8 +681,19 @@ class MarkdownSelectionController extends ChangeNotifier { /// size and show up during drags. final Map _indexById = {}; - final Map _surfaces = - {}; + /// Per-document attach stack. Several render objects may register under the + /// same [MarkdownSelectionSurface.documentId] at once (crossfade, recycle, + /// overlapping mounts). A single-slot map would let a later attach overwrite + /// the earlier surface and then clear the entry on detach, leaving the + /// surviving mount unhittable until remount. Oldest attach stays primary. + final Map> _surfaceStacks = + >{}; + + MarkdownSelectionSurface? _surfaceFor(Object documentId) { + final stack = _surfaceStacks[documentId]; + if (stack == null || stack.isEmpty) return null; + return stack.first; + } /// Ids whose [removeDocument] was deferred because a surface is still /// mounted. Flushed in [detachSurface]; cancelled by a later [putDocument]. @@ -688,7 +731,7 @@ class MarkdownSelectionController extends ChangeNotifier { if (sel == null || sel.isCollapsed) return null; final pos = base ? sel.base : sel.extent; final affinity = base ? _baseAffinity : _extentAffinity; - final surface = _surfaces[pos.documentId]; + final surface = _surfaceFor(pos.documentId); if (surface == null) return null; final local = surface.caretRectFor(pos, affinity); if (local == null) return null; @@ -821,7 +864,7 @@ class MarkdownSelectionController extends ChangeNotifier { /// When a surface for [id] is still mounted, the remove is deferred until /// [detachSurface] so hit-testing cannot outlive the registry entry. void removeDocument(Object id) { - if (_surfaces.containsKey(id)) { + if (_surfaceStacks.containsKey(id)) { _pendingRemoveIds.add(id); return; } @@ -889,8 +932,19 @@ class MarkdownSelectionController extends ChangeNotifier { /// Registers a mounted [surface] for hit-testing. Called on RenderObject /// attach. + /// + /// Several surfaces may share one [MarkdownSelectionSurface.documentId]. + /// They stack in attach order; the oldest remains the primary hit-test + /// target so a later overlapping mount cannot clear the entry when it + /// detaches and leave an earlier mount unhittable. void attachSurface(MarkdownSelectionSurface surface) { - _surfaces[surface.documentId] = surface; + final stack = _surfaceStacks.putIfAbsent( + surface.documentId, + () => [], + ); + if (!stack.contains(surface)) { + stack.add(surface); + } // Do not read globalBounds here — attach runs before layout; localToGlobal // throws "not in the same render tree" / NEEDS-LAYOUT. } @@ -898,16 +952,22 @@ class MarkdownSelectionController extends ChangeNotifier { /// Unregisters a [surface]. Called on RenderObject detach/dispose. void detachSurface(MarkdownSelectionSurface surface) { final id = surface.documentId; - if (identical(_surfaces[id], surface)) { - _surfaces.remove(id); - if (_pendingRemoveIds.contains(id)) { - _forceRemoveDocument(id); - } + final stack = _surfaceStacks[id]; + if (stack == null) return; + stack.remove(surface); + if (stack.isNotEmpty) return; + _surfaceStacks.remove(id); + if (_pendingRemoveIds.contains(id)) { + _forceRemoveDocument(id); } } - /// The currently mounted surfaces. - Iterable get mountedSurfaces => _surfaces.values; + /// The currently mounted primary surfaces (oldest attach per document id). + Iterable get mountedSurfaces sync* { + for (final stack in _surfaceStacks.values) { + if (stack.isNotEmpty) yield stack.first; + } + } /// Maps a global point to a logical position by asking mounted surfaces. /// @@ -948,7 +1008,7 @@ class MarkdownSelectionController extends ChangeNotifier { /// padding / empty line gutter; I-beam stays glyph-tight via /// [hitsSelectableGlyphs] / `isSelectableAtLocal`. bool hitsSelectableContent(Offset globalPosition) { - for (final surface in _surfaces.values) { + for (final surface in mountedSurfaces) { final bounds = surface.globalBounds; if (!bounds.isEmpty && bounds.contains(globalPosition)) return true; } @@ -957,7 +1017,7 @@ class MarkdownSelectionController extends ChangeNotifier { /// Whether [globalPosition] lies over rendered glyph ink (tight line boxes). bool hitsSelectableGlyphs(Offset globalPosition) { - for (final surface in _surfaces.values) { + for (final surface in mountedSurfaces) { if (surface.hitsSelectableGlyphs(globalPosition)) return true; } return false; @@ -966,7 +1026,7 @@ class MarkdownSelectionController extends ChangeNotifier { /// Whether [globalPosition] lies over a tappable link or fenced copy chrome /// (desktop header / mobile bottom bar) on any mounted surface. bool isLinkAtGlobal(Offset globalPosition) { - for (final surface in _surfaces.values) { + for (final surface in mountedSurfaces) { if (surface.isLinkAtGlobal(globalPosition)) return true; } return false; @@ -984,7 +1044,7 @@ class MarkdownSelectionController extends ChangeNotifier { }) { MarkdownSelectionSurface? nearest; var bestDistance = double.infinity; - for (final surface in _surfaces.values) { + for (final surface in mountedSurfaces) { final bounds = surface.globalBounds; // A zero-area surface (an empty document, or one that lays out to zero // width/height) has nothing to select and would invert the clamp below. @@ -1441,7 +1501,7 @@ class MarkdownSelectionController extends ChangeNotifier { if (startDoc < 0 || endDoc < 0) return const []; final out = []; for (var d = startDoc; d <= endDoc; d++) { - final surface = _surfaces[_docs[d].id]; + final surface = _surfaceFor(_docs[d].id); if (surface == null) continue; out.addAll(surface.globalSelectionRects()); } @@ -1465,8 +1525,8 @@ class MarkdownSelectionController extends ChangeNotifier { final sel = _selection; if (sel == null || sel.isCollapsed) return null; - MarkdownSelectionSurface? startSurface = _surfaces[sel.base.documentId]; - MarkdownSelectionSurface? endSurface = _surfaces[sel.extent.documentId]; + MarkdownSelectionSurface? startSurface = _surfaceFor(sel.base.documentId); + MarkdownSelectionSurface? endSurface = _surfaceFor(sel.extent.documentId); Rect? startLocal = startSurface?.caretRectFor(sel.base, _baseAffinity); Rect? endLocal = endSurface?.caretRectFor(sel.extent, _extentAffinity); @@ -1557,7 +1617,7 @@ class MarkdownSelectionController extends ChangeNotifier { if (forStartEdge) { for (var d = startDoc; d <= endDoc; d++) { - final surface = _surfaces[_docs[d].id]; + final surface = _surfaceFor(_docs[d].id); if (surface == null) continue; final locals = surface.localSelectionRects(); if (locals.isEmpty) continue; @@ -1574,7 +1634,7 @@ class MarkdownSelectionController extends ChangeNotifier { } } else { for (var d = endDoc; d >= startDoc; d--) { - final surface = _surfaces[_docs[d].id]; + final surface = _surfaceFor(_docs[d].id); if (surface == null) continue; final locals = surface.localSelectionRects(); if (locals.isEmpty) continue; diff --git a/lib/src/selection_scope.dart b/lib/src/selection_scope.dart index e3a70bc..f5a2f22 100644 --- a/lib/src/selection_scope.dart +++ b/lib/src/selection_scope.dart @@ -129,10 +129,12 @@ class MarkdownSelectionScope extends StatefulWidget { /// Optional host gate for which document ids this scope may paint handles / /// toolbar for. /// - /// When several scopes share one [controller] (chat per-body mounts), only - /// the scope that owns the selection’s document should show chrome — otherwise - /// each enabled scope paints a duplicate handle pair. Null means this scope - /// may show chrome for any non-collapsed selection (single-scope hosts). + /// When several scopes share one [controller], only the scope that owns the + /// selection’s document should show chrome — otherwise each enabled scope + /// paints a duplicate handle pair. Disabled / non-owning siblings dispose + /// only their own overlay and leaders; they must not strip leaders belonging + /// to the owning scope. Null means this scope may show chrome for any + /// non-collapsed selection (single-scope hosts). final bool Function(Object documentId)? ownsSelectionChrome; /// The selection highlight color. Defaults to the ambient @@ -140,7 +142,9 @@ class MarkdownSelectionScope extends StatefulWidget { final Color? selectionColor; /// Builds the context menu (toolbar). Defaults to an adaptive Copy / - /// Select-all toolbar; pass null to disable the toolbar entirely. + /// Select-all toolbar; pass null to disable the toolbar **and** omit the + /// secondary-tap recognizer so an enclosing host can own right-click + /// without competing in the gesture arena. final MarkdownSelectionContextMenuBuilder? contextMenuBuilder; /// Magnifier configuration for touch selection/handle drags. Defaults to the @@ -284,10 +288,9 @@ class MarkdownSelectionScopeState extends State if (oldWidget.selectionColor != widget.selectionColor) { _applySelectionColor(); } - // Host may enable the scope after arming a subject / range (chat mobile - // message-then-text). Sync handles and restore the toolbar when chrome - // becomes eligible. Disabling clears visible chrome but keeps - // [toolbarWanted] so a later enable can restore. + // Host may enable the scope after arming a subject / range. Sync handles + // and restore the toolbar when chrome becomes eligible. Disabling clears + // visible chrome but keeps [toolbarWanted] so a later enable can restore. if (oldWidget.enabled != widget.enabled) { if (widget.enabled) { _syncOverlay(); @@ -525,24 +528,16 @@ class MarkdownSelectionScopeState extends State } /// Assigns the two handle leader layers to their owning surfaces (and clears - /// them everywhere else) in a single pass, so nothing repaints needlessly. - void _applyHandles(MarkdownHandleEndpoints? e) { - final startSurface = e?.startSurface; - final endSurface = e?.endSurface; - final startLocal = switch (e) { - null => null, - final endpoints => Offset( - endpoints.startLocal.left, - endpoints.startLocal.bottom, - ), - }; - final endLocal = switch (e) { - null => null, - final endpoints => Offset( - endpoints.endLocal.right, - endpoints.endLocal.bottom, - ), - }; + /// them on every other mounted surface) in a single pass, so nothing + /// repaints needlessly. + /// + /// Only the scope that currently owns chrome should call this. Sibling + /// scopes that share the controller clear via [_clearOwnHandleLeaders]. + void _applyHandles(MarkdownHandleEndpoints e) { + final startSurface = e.startSurface; + final endSurface = e.endSurface; + final startLocal = Offset(e.startLocal.left, e.startLocal.bottom); + final endLocal = Offset(e.endLocal.right, e.endLocal.bottom); for (final surface in controller.mountedSurfaces) { final isStart = identical(surface, startSurface); final isEnd = identical(surface, endSurface); @@ -555,8 +550,22 @@ class MarkdownSelectionScopeState extends State } } + /// Drops leaders that currently reference this scope's [LayerLink]s only. + /// + /// Sibling scopes that share one controller must not clear foreign leaders. + /// Identity-filtered clear keeps the owning scope's handle attachment intact + /// while this scope tears down its own overlay. + void _clearOwnHandleLeaders() { + for (final surface in controller.mountedSurfaces) { + surface.clearSelectionHandleLayersIfLinked( + startLink: _startHandleLink, + endLink: _endHandleLink, + ); + } + } + void _clearHandles() { - _applyHandles(null); + _clearOwnHandleLeaders(); _selectionOverlay?.hide(); _selectionOverlay?.dispose(); _selectionOverlay = null; @@ -1047,6 +1056,20 @@ class MarkdownSelectionScopeState extends State /// Whether the toolbar is currently visible. bool get toolbarIsVisible => _contextMenuController.isShown; + + /// Whether start/end handle leader layers are attached for the live + /// selection endpoints. + /// + /// False when the range is missing or collapsed, or leaders are not linked + /// on the endpoint surfaces. Useful for hosts that mount one scope per + /// document on a shared controller and need to observe handle attachment. + bool get selectionHandleLeadersAttached { + return switch (controller.selectionHandleEndpoints()) { + null => false, + final endpoints => endpoints.startSurface.hasSelectionHandleLeaders && + endpoints.endSurface.hasSelectionHandleLeaders, + }; + } /// Shows the context toolbar. [location] anchors it at a point (e.g. the /// right-click position); otherwise it anchors to the selection. @@ -1555,18 +1578,23 @@ class MarkdownSelectionScopeState extends State ..onDragUpdate = _handleDragUpdate ..onDragEnd = ((_) => _onBodyDragEnd()), ), - // Secondary-only (any device): right-click opens the context toolbar. - // Distinct type key so it can coexist with other recognizers in the - // map. - _SecondaryTapGestureRecognizer: + }; + + // Secondary-only (any device): right-click opens the context toolbar. + // Distinct type key so it can coexist with other recognizers in the + // map. Omit when [contextMenuBuilder] is null so an enclosing host + // (e.g. chat viewport message menu) can own the full-slot secondary + // without competing in the gesture arena. + if (widget.contextMenuBuilder != null) { + map[_SecondaryTapGestureRecognizer] = GestureRecognizerFactoryWithHandlers<_SecondaryTapGestureRecognizer>( () => _SecondaryTapGestureRecognizer(), (recognizer) => recognizer ..onSecondaryTapDown = ((d) => _lastSecondaryTapDown = d.globalPosition) ..onSecondaryTapUp = _handleSecondaryTapUp, - ), - }; + ); + } if (!widget.enableTouchGestures) return map; @@ -1577,8 +1605,9 @@ class MarkdownSelectionScopeState extends State // / scrollable page does not steal edge swipes on chrome. With an // active selection, eagerly wins horizontal drag (blocks dismissible); // otherwise yields to competing [HorizontalDragGestureRecognizer]s. - // Chat hosts set [enableTouchConsecutiveTaps] false so taps stay with - // the viewport while long-press below still owns continuous text entry. + // Hosts may set [enableTouchConsecutiveTaps] false so taps stay with an + // enclosing gesture owner while long-press below still owns continuous + // text entry. if (widget.enableTouchConsecutiveTaps) { map[_ContentGatedTapAndHorizontalDragGestureRecognizer] = GestureRecognizerFactoryWithHandlers< diff --git a/lib/src/theme.dart b/lib/src/theme.dart index 792351b..3965b8a 100644 --- a/lib/src/theme.dart +++ b/lib/src/theme.dart @@ -24,7 +24,8 @@ typedef MarkdownCursorResolver = MouseCursor? Function( /// Primary system monospace face for inline/`code` and fenced blocks. /// /// Prefer a real family over the CSS generic `'monospace'`, which Flutter does -/// not map on most platforms (so spans fall back to the body proportional font). +/// not map on most platforms (so spans fall back to the body proportional +/// font). const String kMonospaceFontFamily = 'Menlo'; /// Fallbacks after [kMonospaceFontFamily] (Android maps `'monospace'`). diff --git a/test/parser/block_test.dart b/test/parser/block_test.dart index 652cce4..00ee4f6 100644 --- a/test/parser/block_test.dart +++ b/test/parser/block_test.dart @@ -62,6 +62,45 @@ void main() => group('Block parsing', () { isA().having((s) => s.style, 'style', MD$Style.bold)), ); }); + + test('fenced code inside a quote is a nested MD\$Code', () { + final q = _blocks( + '> ```yaml\n' + '> webview_flutter: ^4.0.7\n' + '> ```', + ).single as MD$Quote; + expect(q.spans, isEmpty); + expect(q.blocks, hasLength(1)); + final code = q.blocks.single as MD$Code; + expect(code.language, 'yaml'); + expect(code.text, 'webview_flutter: ^4.0.7'); + expect( + q.spans.where((s) => s.style.contains(MD$Style.monospace)), + isEmpty, + ); + }); + + test('tilde fence inside a quote is nested code', () { + final q = _blocks('> ~~~\n> hi\n> ~~~').single as MD$Quote; + expect(q.blocks.single, isA()); + expect((q.blocks.single as MD$Code).text, 'hi'); + }); + + test('prose plus fenced code inside a quote', () { + final q = _blocks( + '> before\n' + '> ```\n' + '> code\n' + '> ```\n' + '> after', + ).single as MD$Quote; + expect(q.blocks.whereType(), hasLength(2)); + expect(q.blocks.whereType(), hasLength(1)); + expect( + (q.blocks.whereType().single).text, + 'code', + ); + }); }); group('Fenced code', () { diff --git a/test/parser/gfm_test.dart b/test/parser/gfm_test.dart index 81da1c9..9804398 100644 --- a/test/parser/gfm_test.dart +++ b/test/parser/gfm_test.dart @@ -78,6 +78,20 @@ void main() => group('GFM extensions', () { expect(md.blocks.single, isA()); }); + test('fenced code inside an alert is a nested MD\$Code', () { + final md = markdownDecoder.convert( + '> [!NOTE]\n' + '> ```dart\n' + '> print(1);\n' + '> ```', + ); + final alert = md.blocks.single as MD$Alert; + expect(alert.spans, isEmpty); + expect(alert.blocks.single, isA()); + expect((alert.blocks.single as MD$Code).language, 'dart'); + expect((alert.blocks.single as MD$Code).text, 'print(1);'); + }); + test('alert type exposes a human-readable title', () { expect(MD$AlertType.note.title, 'Note'); expect(MD$AlertType.important.title, 'Important'); diff --git a/test/parser/golden_test.dart b/test/parser/golden_test.dart index 0470619..ba8ec2d 100644 --- a/test/parser/golden_test.dart +++ b/test/parser/golden_test.dart @@ -133,11 +133,23 @@ void _serializeBlock(MD$Block block, StringBuffer out, int depth) { }, quote: (q) { out.writeln('${pad}Q indent=${q.indent}'); - _serializeSpans(q.spans, out, depth + 1); + if (q.blocks.isEmpty) { + _serializeSpans(q.spans, out, depth + 1); + } else { + for (final child in q.blocks) { + _serializeBlock(child, out, depth + 1); + } + } }, alert: (a) { out.writeln('${pad}ALERT ${a.alert.name}'); - _serializeSpans(a.spans, out, depth + 1); + if (a.blocks.isEmpty) { + _serializeSpans(a.spans, out, depth + 1); + } else { + for (final child in a.blocks) { + _serializeBlock(child, out, depth + 1); + } + } }, code: (c) { out.writeln('${pad}CODE lang=${_q(c.language ?? '')} ' diff --git a/test/parser/streaming_test.dart b/test/parser/streaming_test.dart index b2bf590..bafc430 100644 --- a/test/parser/streaming_test.dart +++ b/test/parser/streaming_test.dart @@ -307,8 +307,12 @@ String _sig(Markdown md) { String _blockSig(MD$Block block) => block.map( paragraph: (p) => 'P|${_spans(p.spans)}', heading: (h) => 'H${h.level}|${_spans(h.spans)}', - quote: (q) => 'Q${q.indent}|${_spans(q.spans)}', - alert: (a) => 'A[${a.alert.marker}]|${_spans(a.spans)}', + quote: (q) => q.blocks.isEmpty + ? 'Q${q.indent}|${_spans(q.spans)}' + : 'Q${q.indent}|{${q.blocks.map(_blockSig).join(',')}}', + alert: (a) => a.blocks.isEmpty + ? 'A[${a.alert.marker}]|${_spans(a.spans)}' + : 'A[${a.alert.marker}]|{${a.blocks.map(_blockSig).join(',')}}', code: (c) => 'C[${c.language}]<<${c.text}>>', list: (l) => 'L|${l.items.map(_itemSig).join(';')}', divider: (_) => 'DIV', diff --git a/test/selection/selection_handles_test.dart b/test/selection/selection_handles_test.dart index c73ac6c..e1d2000 100644 --- a/test/selection/selection_handles_test.dart +++ b/test/selection/selection_handles_test.dart @@ -311,7 +311,8 @@ void main() { final left = firstLine.map((b) => b.left).reduce((a, b) => a < b ? a : b); final right = firstLine.map((b) => b.right).reduce((a, b) => a > b ? a : b); - // Select across the first visual line's glyphs (not empty max-width gutter). + // Select across the first visual line's glyphs (not empty max-width + // gutter). await _mouseDrag( tester, box.localToGlobal(Offset(left + 2, lineY)), @@ -476,5 +477,111 @@ void main() { } }, ); + + testWidgets( + 'sibling ownsSelectionChrome scopes keep leaders after settles', + (tester) async { + debugDefaultTargetPlatformOverride = TargetPlatform.android; + try { + final a = Markdown.fromString('Hello selectable world and more'); + final b = Markdown.fromString('Second document body text here'); + final controller = MarkdownSelectionController() + ..setDocuments([ + MarkdownDocumentRef(id: 'a', model: a), + MarkdownDocumentRef(id: 'b', model: b), + ]); + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(platform: TargetPlatform.android), + home: Scaffold( + body: Column( + children: [ + MarkdownSelectionScope( + controller: controller, + ownsSelectionChrome: (id) => id == 'a', + contextMenuBuilder: (context, state) => + AdaptiveTextSelectionToolbar.buttonItems( + buttonItems: const [], + anchors: state.contextMenuAnchors, + ), + child: MarkdownWidget( + markdown: a, + documentId: 'a', + controller: controller, + ), + ), + // Disabled sibling still listens on the shared controller — + // must not strip leaders belonging to scope A. + MarkdownSelectionScope( + controller: controller, + enabled: false, + ownsSelectionChrome: (id) => id == 'b', + child: MarkdownWidget( + markdown: b, + documentId: 'b', + controller: controller, + ), + ), + ], + ), + ), + ), + ); + await tester.pumpAndSettle(); + + controller.selectAll(); + // Keep selection on document a only. + final text = markdownBlockRenderedText(a.blocks.first); + controller.selection = MarkdownSelection( + base: const MarkdownPosition( + documentId: 'a', + blockIndex: 0, + offset: 0, + ), + extent: MarkdownPosition( + documentId: 'a', + blockIndex: 0, + offset: text.length, + ), + ); + controller.toolbarWanted = true; + await tester.pumpAndSettle(); + + final owner = tester.state( + find.byType(MarkdownSelectionScope).first, + ); + expect(owner.selectionHandleLeadersAttached, isTrue); + expect(find.byType(CompositedTransformFollower), findsNWidgets(2)); + + final tl = tester.getTopLeft(find.byType(MarkdownWidget).first); + controller.moveSelectionEdgeToGlobal( + tl + const Offset(140, 12), + isStart: false, + ); + await tester.pumpAndSettle(); + expect( + owner.selectionHandleLeadersAttached, + isTrue, + reason: 'first non-collapsed settle must keep handle leaders', + ); + + controller.moveSelectionEdgeToGlobal( + tl + const Offset(90, 12), + isStart: false, + ); + await tester.pumpAndSettle(); + expect( + owner.selectionHandleLeadersAttached, + isTrue, + reason: 'second settle must keep handle leaders (dual-scope)', + ); + expect(controller.selection!.isCollapsed, isFalse); + expect(tester.takeException(), isNull); + } finally { + debugDefaultTargetPlatformOverride = null; + } + }, + ); }); } diff --git a/test/selection/selection_test.dart b/test/selection/selection_test.dart index aff3cb5..e5d4009 100644 --- a/test/selection/selection_test.dart +++ b/test/selection/selection_test.dart @@ -24,6 +24,9 @@ class _FakeSurface implements MarkdownSelectionSurface { @override bool hitsSelectableGlyphs(Offset globalPosition) => false; + @override + bool isLinkAtGlobal(Offset globalPosition) => false; + @override Rect? caretRectFor(MarkdownPosition position, TextAffinity affinity) => null; @@ -52,6 +55,15 @@ class _FakeSurface implements MarkdownSelectionSurface { Offset? endLocal, }) {} + @override + bool get hasSelectionHandleLeaders => false; + + @override + void clearSelectionHandleLayersIfLinked({ + required LayerLink startLink, + required LayerLink endLink, + }) {} + @override void repaintSelection() {} } @@ -107,6 +119,13 @@ void main() { .blocks .firstWhere((b) => b.type == 'list'); expect(markdownBlockRenderedText(emptyLead), '\nDone'); + + final quotedCode = Markdown.fromString( + '> ```dart\n' + '> print(1);\n' + '> ```', + ).blocks.single; + expect(markdownBlockRenderedText(quotedCode), 'print(1);'); }); test('cross-document extraction with default formatter', () { diff --git a/test/selection/selection_widget_test.dart b/test/selection/selection_widget_test.dart index 204b95d..4496fff 100644 --- a/test/selection/selection_widget_test.dart +++ b/test/selection/selection_widget_test.dart @@ -714,6 +714,47 @@ void main() { expect(tester.takeException(), isNull); }); + testWidgets( + 'null contextMenuBuilder omits secondary recognizer ' + '(no toolbar on right-click)', (tester) async { + debugDefaultTargetPlatformOverride = TargetPlatform.macOS; + try { + final controller = MarkdownSelectionController() + ..setDocuments([ + MarkdownDocumentRef( + id: 'd', model: Markdown.fromString('Hello selectable world')), + ]); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: MarkdownSelectionScope( + controller: controller, + contextMenuBuilder: null, + child: const Align( + alignment: Alignment.topLeft, + child: SizedBox(width: 400, child: _Doc('d')), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + final tl = tester.getTopLeft(find.byType(MarkdownWidget)); + await tester.tapAt(tl + const Offset(40, 8), + buttons: kSecondaryButton); + await tester.pumpAndSettle(); + + final state = tester.state( + find.byType(MarkdownSelectionScope)); + expect(state.toolbarIsVisible, isFalse); + expect(find.byType(AdaptiveTextSelectionToolbar), findsNothing); + expect(tester.takeException(), isNull); + } finally { + debugDefaultTargetPlatformOverride = null; + } + }); + testWidgets( 'secondary-click on an active selection keeps the range ' 'and shows toolbar', (tester) async { @@ -921,14 +962,12 @@ void main() { expect(boxes, isNotEmpty); final glyph = boxes.first; final gutterLocal = Offset(glyph.right + 80, glyph.center.dy); - final gutterGlobal = - (surface as RenderBox).localToGlobal(gutterLocal); + final gutterGlobal = (surface as RenderBox).localToGlobal(gutterLocal); expect(controller.hitsSelectableContent(gutterGlobal), isTrue); expect(controller.hitsSelectableGlyphs(gutterGlobal), isFalse); // Establish a ranged selection on the glyphs. - final glyphGlobal = - (surface as RenderBox).localToGlobal(glyph.center); + final glyphGlobal = (surface as RenderBox).localToGlobal(glyph.center); await _clicks(tester, glyphGlobal, 2); expect(controller.getText(), isNotEmpty); expect(controller.selection!.isCollapsed, isFalse); @@ -2373,8 +2412,7 @@ void main() { greaterThan(glyph.right + 80), reason: 'surface must be wider than the short line', ); - final gutterGlobal = - (surface as RenderBox).localToGlobal(gutterLocal); + final gutterGlobal = (surface as RenderBox).localToGlobal(gutterLocal); expect( controller.hitsSelectableContent(gutterGlobal), @@ -2808,7 +2846,8 @@ void main() { await tester.pumpAndSettle(); expect(controller.selection, isNull, - reason: 'touch double-tap must not enter when consecutive taps off'); + reason: + 'touch double-tap must not enter when consecutive taps off'); final gesture = await tester.startGesture(pos); await tester.pump(kLongPressTimeout + kPressTimeout); diff --git a/test/widget/render_test.dart b/test/widget/render_test.dart index 02bd4ce..da0faa8 100644 --- a/test/widget/render_test.dart +++ b/test/widget/render_test.dart @@ -78,15 +78,55 @@ void main() { }); }); - testWidgets('task lists render without exceptions', (tester) async { + testWidgets('quote with nested fenced code renders', (tester) async { await _pumpMarkdown( tester, - '- [ ] todo\n- [x] done\n- normal item\n - [ ] nested todo', + '> Before the fence.\n' + '>\n' + '> ```bash\n' + '> echo ok\n' + '> ```\n' + '>\n' + '> After the fence.', + theme: MarkdownThemeData( + textStyle: const TextStyle(color: Color(0xFFFFFFFF), fontSize: 14), + quoteStyle: const TextStyle(color: Color(0xFF9E9E9E), fontSize: 14), + ), ); expect(tester.takeException(), isNull); expect(_mdSize(tester).height, greaterThan(0)); }); + test('nested quote prose children inherit quoteStyle', () { + const quoteColor = Color(0xFF9E9E9E); + const bodyColor = Color(0xFFFFFFFF); + final theme = MarkdownThemeData( + textStyle: const TextStyle(color: bodyColor, fontSize: 14), + quoteStyle: const TextStyle(color: quoteColor, fontSize: 14), + ); + final md = Markdown.fromString( + '> prose line\n' + '> ```\n' + '> code\n' + '> ```', + ); + final quote = md.blocks.single as MD$Quote; + final para = quote.blocks.whereType().single; + final code = quote.blocks.whereType().single; + + final proseTheme = BlockPainter$Quote.inheritFrom(theme, para); + final codeTheme = BlockPainter$Quote.inheritFrom(theme, code); + expect(proseTheme.textStyle.color, quoteColor); + expect(codeTheme.textStyle.color, bodyColor); + + final painter = BlockPainter$Paragraph( + spans: para.spans, + theme: proseTheme, + )..layout(400); + final root = painter.painter.text! as TextSpan; + expect(root.style?.color, quoteColor); + }); + testWidgets('aligned tables render without exceptions', (tester) async { await _pumpMarkdown( tester, From dcc4f3a03dc790ffc993ab94bed1c2c0a3e33b92 Mon Sep 17 00:00:00 2001 From: Emil Date: Mon, 14 Sep 2026 17:14:13 +0500 Subject: [PATCH 09/19] chore: dart formatting --- .../benchmark/parser_benchmark.dart | 4 +- .../integration_test/scroll_perf_test.dart | 3 +- .../test/render_benchmark_test.dart | 9 +- .../tool/summarize_timeline.dart | 3 +- example/lib/main.dart | 324 +++++++++--------- example/lib/tabs/lorem_tab.dart | 282 +++++++-------- lib/src/selection_scope.dart | 2 +- lib/src/theme.dart | 2 +- test/selection/selection_handles_test.dart | 2 +- test/selection/selection_widget_test.dart | 3 +- 10 files changed, 332 insertions(+), 302 deletions(-) diff --git a/benchmark_compare/benchmark/parser_benchmark.dart b/benchmark_compare/benchmark/parser_benchmark.dart index 7c6de6e..b721695 100644 --- a/benchmark_compare/benchmark/parser_benchmark.dart +++ b/benchmark_compare/benchmark/parser_benchmark.dart @@ -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; } } diff --git a/benchmark_compare/integration_test/scroll_perf_test.dart b/benchmark_compare/integration_test/scroll_perf_test.dart index eabb9ac..b55f800 100644 --- a/benchmark_compare/integration_test/scroll_perf_test.dart +++ b/benchmark_compare/integration_test/scroll_perf_test.dart @@ -35,8 +35,7 @@ final Map _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. diff --git a/benchmark_compare/test/render_benchmark_test.dart b/benchmark_compare/test/render_benchmark_test.dart index a11f2e5..1d91773 100644 --- a/benchmark_compare/test/render_benchmark_test.dart +++ b/benchmark_compare/test/render_benchmark_test.dart @@ -152,8 +152,13 @@ void _printTables() { final docs = renderCorpus.keys.toList(); final libs = styledLibraries.keys.toList(); - _matrix('Render (end-to-end: parse + build + layout + paint), us/op — ' - 'lower is better', docs, libs, _results, (v) => v.toStringAsFixed(1)); + _matrix( + 'Render (end-to-end: parse + build + layout + paint), us/op — ' + 'lower is better', + docs, + libs, + _results, + (v) => v.toStringAsFixed(1)); // Relative-to-flutter_md view (how many times slower each library is). const base = 'flutter_md'; diff --git a/benchmark_compare/tool/summarize_timeline.dart b/benchmark_compare/tool/summarize_timeline.dart index 43b36a2..0faed62 100644 --- a/benchmark_compare/tool/summarize_timeline.dart +++ b/benchmark_compare/tool/summarize_timeline.dart @@ -103,8 +103,7 @@ void main() { final row = StringBuffer('|'); for (final lib in _libs) { final s = summaries[lib]; - final v = - s == null ? null : _num(s, 'average_frame_build_time_millis'); + final v = s == null ? null : _num(s, 'average_frame_build_time_millis'); final cell = v == null ? '-' : '${(v / baseBuild).toStringAsFixed(2)}x'; row.write(' ${cell.padLeft(16)} |'); } diff --git a/example/lib/main.dart b/example/lib/main.dart index ec3ef34..eda603d 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -10,11 +10,14 @@ import 'tabs/highlight_tab.dart'; import 'tabs/lorem_tab.dart'; void main() => runZonedGuarded( - () => runApp(ThemeModel( - notifier: ValueNotifier(ThemeMode.dark), - child: const App())), - (e, s) => print(e), - ); + () => runApp( + ThemeModel( + notifier: ValueNotifier(ThemeMode.dark), + child: const App(), + ), + ), + (e, s) => print(e), +); /// {@template app} /// App widget. @@ -25,32 +28,32 @@ class App extends StatelessWidget { @override Widget build(BuildContext context) => MaterialApp( - title: 'Markdown', - themeMode: ThemeModel.of(context).value, - theme: ThemeData.light(), - darkTheme: ThemeData.dark(), - home: const HomeScreen(), - builder: (context, child) => MarkdownTheme( - data: MarkdownThemeData.mergeTheme( - Theme.of(context), - // Exclude images from the markdown rendering, - // so they are not rendered in the output. - // Because image spans are not supported yet. - spanFilter: (span) => !span.style.contains(MD$Style.image), - onLinkTap: (title, url) { - ScaffoldMessenger.maybeOf(context) - ?..clearSnackBars() - ..showSnackBar( - SnackBar( - content: Text('Link "$title" tapped: $url'), - duration: const Duration(seconds: 2), - ), - ); - }, - ), - child: child!, - ), - ); + title: 'Markdown', + themeMode: ThemeModel.of(context).value, + theme: ThemeData.light(), + darkTheme: ThemeData.dark(), + home: const HomeScreen(), + builder: (context, child) => MarkdownTheme( + data: MarkdownThemeData.mergeTheme( + Theme.of(context), + // Exclude images from the markdown rendering, + // so they are not rendered in the output. + // Because image spans are not supported yet. + spanFilter: (span) => !span.style.contains(MD$Style.image), + onLinkTap: (title, url) { + ScaffoldMessenger.maybeOf(context) + ?..clearSnackBars() + ..showSnackBar( + SnackBar( + content: Text('Link "$title" tapped: $url'), + duration: const Duration(seconds: 2), + ), + ); + }, + ), + child: child!, + ), + ); } /// {@template theme_model} @@ -63,28 +66,32 @@ class ThemeModel extends InheritedNotifier> { /// The state from the closest instance of this class /// that encloses the given context, if any. /// e.g. `Theme.maybeOf(context)`. - static ValueNotifier? maybeOf(BuildContext context, - {bool listen = true}) => - listen - ? context.dependOnInheritedWidgetOfExactType()?.notifier - : context.getInheritedWidgetOfExactType()?.notifier; + static ValueNotifier? maybeOf( + BuildContext context, { + bool listen = true, + }) => listen + ? context.dependOnInheritedWidgetOfExactType()?.notifier + : context.getInheritedWidgetOfExactType()?.notifier; static Never _notFoundInheritedWidgetOfExactType() => throw ArgumentError( - 'Out of scope, not found inherited widget ' - 'a ThemeModel of the exact type', - 'out_of_scope', - ); + 'Out of scope, not found inherited widget ' + 'a ThemeModel of the exact type', + 'out_of_scope', + ); /// The state from the closest instance of this class /// that encloses the given context. /// e.g. `Theme.of(context)` - static ValueNotifier of(BuildContext context, - {bool listen = true}) => + static ValueNotifier of( + BuildContext context, { + bool listen = true, + }) => maybeOf(context, listen: listen) ?? _notFoundInheritedWidgetOfExactType(); @override bool updateShouldNotify( - covariant InheritedNotifier> oldWidget) { + covariant InheritedNotifier> oldWidget, + ) { return !identical(notifier, oldWidget.notifier); } } @@ -115,40 +122,41 @@ class _HomeScreenState extends State @override Widget build(BuildContext context) => Scaffold( - appBar: AppBar( - centerTitle: true, - title: const Text('Markdown'), - actions: [ - Switch.adaptive( - value: ThemeModel.of(context).value == ThemeMode.dark, - onChanged: (value) { - ThemeModel.of(context).value = - value ? ThemeMode.dark : ThemeMode.light; - }, - ), - ], - bottom: TabBar( - controller: _tabs, - tabs: const [ - Tab(text: 'Editor', icon: Icon(Icons.edit)), - Tab(text: 'Selection', icon: Icon(Icons.text_fields)), - Tab(text: 'Chat', icon: Icon(Icons.chat_bubble_outline)), - Tab(text: 'Highlight', icon: Icon(Icons.code)), - ], - ), - ), - body: SafeArea( - child: TabBarView( - controller: _tabs, - children: const [ - EditorTab(), - LoremTab(), - ChatTab(), - HighlightTab(), - ], - ), + appBar: AppBar( + centerTitle: true, + title: const Text('Markdown'), + actions: [ + Switch.adaptive( + value: ThemeModel.of(context).value == ThemeMode.dark, + onChanged: (value) { + ThemeModel.of(context).value = value + ? ThemeMode.dark + : ThemeMode.light; + }, ), - ); + ], + bottom: TabBar( + controller: _tabs, + tabs: const [ + Tab(text: 'Editor', icon: Icon(Icons.edit)), + Tab(text: 'Selection', icon: Icon(Icons.text_fields)), + Tab(text: 'Chat', icon: Icon(Icons.chat_bubble_outline)), + Tab(text: 'Highlight', icon: Icon(Icons.code)), + ], + ), + ), + body: SafeArea( + child: TabBarView( + controller: _tabs, + children: const [ + EditorTab(), + LoremTab(), + ChatTab(), + HighlightTab(), + ], + ), + ), + ); } /// {@template editor_tab} @@ -165,18 +173,22 @@ class EditorTab extends StatefulWidget { /// State for widget EditorTab. class _EditorTabState extends State { final MultiChildLayoutDelegate _layoutDelegate = _HomeScreenLayoutDelegate(); - final TextEditingController _inputController = - TextEditingController(text: _markdownExample); - final ValueNotifier _outputController = - ValueNotifier(const Markdown.empty()); + final TextEditingController _inputController = TextEditingController( + text: _markdownExample, + ); + final ValueNotifier _outputController = ValueNotifier( + const Markdown.empty(), + ); @override void initState() { super.initState(); // `inlineMath` is opt-in (disabled by default); enabled here to showcase // the `$...$` LaTeX conversion. - final initialMarkdown = - Markdown.fromString(_inputController.text, inlineMath: true); + final initialMarkdown = Markdown.fromString( + _inputController.text, + inlineMath: true, + ); _outputController.value = initialMarkdown; _inputController.addListener(_onInputChanged); } @@ -203,84 +215,82 @@ class _EditorTabState extends State { @override Widget build(BuildContext context) => CustomMultiChildLayout( - delegate: _layoutDelegate, - children: [ - LayoutId( - id: 0, - child: Card( - child: Padding( - padding: const EdgeInsets.all(8.0), - child: Stack( - fit: StackFit.expand, - children: [ - Positioned.fill( - child: TextField( - controller: _inputController, - expands: true, - maxLines: null, - minLines: null, - keyboardType: TextInputType.multiline, - decoration: const InputDecoration( - border: InputBorder.none, - hintText: '____________________________________\n' - '______________________________\n' - '__________________________\n' - '______________________________\n' - '____________________________________\n' - '________________________\n' - '________________________________________\n' - '______________________________\n' - '________________________\n' - '__________________________________________\n' - '______________________________\n', - ), - ), + delegate: _layoutDelegate, + children: [ + LayoutId( + id: 0, + child: Card( + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Stack( + fit: StackFit.expand, + children: [ + Positioned.fill( + child: TextField( + controller: _inputController, + expands: true, + maxLines: null, + minLines: null, + keyboardType: TextInputType.multiline, + decoration: const InputDecoration( + border: InputBorder.none, + hintText: + '____________________________________\n' + '______________________________\n' + '__________________________\n' + '______________________________\n' + '____________________________________\n' + '________________________\n' + '________________________________________\n' + '______________________________\n' + '________________________\n' + '__________________________________________\n' + '______________________________\n', ), - Align( - alignment: Alignment.topRight, - child: Padding( - padding: const EdgeInsets.only(right: 12.0), - child: Row( - mainAxisSize: MainAxisSize.max, - mainAxisAlignment: MainAxisAlignment.end, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - IconButton.filledTonal( - icon: const Icon( - Icons.refresh, - ), - onPressed: () => - _inputController.text = _markdownExample, - ), - ], + ), + ), + Align( + alignment: Alignment.topRight, + child: Padding( + padding: const EdgeInsets.only(right: 12.0), + child: Row( + mainAxisSize: MainAxisSize.max, + mainAxisAlignment: MainAxisAlignment.end, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + IconButton.filledTonal( + icon: const Icon(Icons.refresh), + onPressed: () => + _inputController.text = _markdownExample, ), - ), + ], ), - ], + ), ), - ), + ], ), ), - LayoutId( - id: 1, - child: Align( - alignment: Alignment.topLeft, - child: Card( - child: SingleChildScrollView( - primary: false, - padding: const EdgeInsets.all(8.0), - child: ValueListenableBuilder( - valueListenable: _outputController, - builder: (context, value, child) => MarkdownWidget( - markdown: value, - ), - ), - ), + ), + ), + LayoutId( + id: 1, + child: Align( + alignment: Alignment.topLeft, + child: Card( + child: SingleChildScrollView( + primary: false, + padding: const EdgeInsets.all(8.0), + child: ValueListenableBuilder( + valueListenable: _outputController, + builder: (context, value, child) => + MarkdownWidget(markdown: value), ), ), ), - ], - ); + ), + ), + ], + ); } class _HomeScreenLayoutDelegate extends MultiChildLayoutDelegate { @@ -290,16 +300,20 @@ class _HomeScreenLayoutDelegate extends MultiChildLayoutDelegate { void performLayout(Size size) { if (size.width >= size.height) { final width = size.width / 2; - final constraints = - BoxConstraints.tightFor(width: width, height: size.height); + final constraints = BoxConstraints.tightFor( + width: width, + height: size.height, + ); layoutChild(0, constraints); layoutChild(1, constraints); positionChild(0, Offset.zero); positionChild(1, Offset(width, 0)); } else { final height = size.height / 2; - final constraints = - BoxConstraints.tightFor(width: size.width, height: height); + final constraints = BoxConstraints.tightFor( + width: size.width, + height: height, + ); layoutChild(0, constraints); layoutChild(1, constraints); positionChild(0, Offset.zero); diff --git a/example/lib/tabs/lorem_tab.dart b/example/lib/tabs/lorem_tab.dart index ab034de..2e18a88 100644 --- a/example/lib/tabs/lorem_tab.dart +++ b/example/lib/tabs/lorem_tab.dart @@ -69,10 +69,12 @@ class LoremTab extends StatefulWidget { class _LoremTabState extends State { final MarkdownSelectionGroup _group = MarkdownSelectionGroup(); - late final MarkdownSelectionController _a = - MarkdownSelectionController(group: _group); - late final MarkdownSelectionController _b = - MarkdownSelectionController(group: _group); + late final MarkdownSelectionController _a = MarkdownSelectionController( + group: _group, + ); + late final MarkdownSelectionController _b = MarkdownSelectionController( + group: _group, + ); final Markdown _docA = Markdown.fromString(_loremMdA); final Markdown _docB = Markdown.fromString(_loremMdB); @@ -84,10 +86,12 @@ class _LoremTabState extends State { @override void initState() { super.initState(); - _a.setDocuments( - [MarkdownDocumentRef(id: 'A', model: _docA)]); - _b.setDocuments( - [MarkdownDocumentRef(id: 'B', model: _docB)]); + _a.setDocuments([ + MarkdownDocumentRef(id: 'A', model: _docA), + ]); + _b.setDocuments([ + MarkdownDocumentRef(id: 'B', model: _docB), + ]); _a.addListener(_onMarkdownSelection); _b.addListener(_onMarkdownSelection); } @@ -125,55 +129,58 @@ class _LoremTabState extends State { final how = formatter == null ? 'plain text' : 'Markdown'; ScaffoldMessenger.of(context) ..clearSnackBars() - ..showSnackBar(SnackBar( - behavior: SnackBarBehavior.floating, - duration: const Duration(seconds: 2), - content: Text('Copied ${text.length} chars as $how'), - )); + ..showSnackBar( + SnackBar( + behavior: SnackBarBehavior.floating, + duration: const Duration(seconds: 2), + content: Text('Copied ${text.length} chars as $how'), + ), + ); } Widget _label(String text) => Padding( - padding: const EdgeInsets.only(bottom: 8, top: 4), - child: Text(text, style: Theme.of(context).textTheme.labelLarge), - ); + padding: const EdgeInsets.only(bottom: 8, top: 4), + child: Text(text, style: Theme.of(context).textTheme.labelLarge), + ); /// A custom [contextMenuBuilder] that appends "Copy as Markdown" and /// "Copy LOUD" actions to the default Copy / Select-all buttons. Widget _loudContextMenu( BuildContext context, MarkdownSelectionScopeState state, - ) => - AdaptiveTextSelectionToolbar.buttonItems( - anchors: state.contextMenuAnchors, - buttonItems: [ - ...state.contextMenuButtonItems, - ContextMenuButtonItem( - label: 'Copy as Markdown', - onPressed: () { - // Reconstruct structure (headings, nested lists, tables, …) - // instead of the flattened plain text the default Copy uses. - Clipboard.setData(ClipboardData( - text: state.controller.getText(_markupFormatter))); - state.hideToolbar(); - }, - ), - ContextMenuButtonItem( - label: 'Copy LOUD', - onPressed: () { - Clipboard.setData(ClipboardData( - text: state.controller.getText().toUpperCase())); - state.hideToolbar(); - }, - ), - ], - ); + ) => AdaptiveTextSelectionToolbar.buttonItems( + anchors: state.contextMenuAnchors, + buttonItems: [ + ...state.contextMenuButtonItems, + ContextMenuButtonItem( + label: 'Copy as Markdown', + onPressed: () { + // Reconstruct structure (headings, nested lists, tables, …) + // instead of the flattened plain text the default Copy uses. + Clipboard.setData( + ClipboardData(text: state.controller.getText(_markupFormatter)), + ); + state.hideToolbar(); + }, + ), + ContextMenuButtonItem( + label: 'Copy LOUD', + onPressed: () { + Clipboard.setData( + ClipboardData(text: state.controller.getText().toUpperCase()), + ); + state.hideToolbar(); + }, + ), + ], + ); /// Keyboard/gesture hint shown while nothing is selected. Widget _hint() => Text( - 'Drag to select · Ctrl/Cmd+A all · Shift+arrows extend · ' - 'right-click for the toolbar · Esc clears', - style: Theme.of(context).textTheme.bodySmall, - ); + 'Drag to select · Ctrl/Cmd+A all · Shift+arrows extend · ' + 'right-click for the toolbar · Esc clears', + style: Theme.of(context).textTheme.bodySmall, + ); /// A live, side-by-side preview of what the two Copy buttons produce for the /// current selection — the whole point of [MarkdownMarkupFormatter] is that @@ -184,10 +191,16 @@ class _LoremTabState extends State { final markdown = c?.getText(_markupFormatter) ?? ''; return LayoutBuilder( builder: (context, constraints) { - final plainPanel = - _previewPanel('Plain (default)', plain, accent: false); - final mdPanel = - _previewPanel('Copy as Markdown', markdown, accent: true); + final plainPanel = _previewPanel( + 'Plain (default)', + plain, + accent: false, + ); + final mdPanel = _previewPanel( + 'Copy as Markdown', + markdown, + accent: true, + ); if (constraints.maxWidth > 620) { return Row( crossAxisAlignment: CrossAxisAlignment.start, @@ -199,11 +212,7 @@ class _LoremTabState extends State { ); } return Column( - children: [ - plainPanel, - const SizedBox(height: 8), - mdPanel, - ], + children: [plainPanel, const SizedBox(height: 8), mdPanel], ); }, ); @@ -231,9 +240,9 @@ class _LoremTabState extends State { Text( title, style: Theme.of(context).textTheme.labelSmall?.copyWith( - fontWeight: FontWeight.bold, - color: accent ? scheme.primary : scheme.onSurfaceVariant, - ), + fontWeight: FontWeight.bold, + color: accent ? scheme.primary : scheme.onSurfaceVariant, + ), ), const SizedBox(height: 6), ConstrainedBox( @@ -257,90 +266,95 @@ class _LoremTabState extends State { @override Widget build(BuildContext context) => Column( - children: [ - Expanded( - child: SingleChildScrollView( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _label('Markdown A — custom toolbar (right-click / ' - 'long-press for "Copy as Markdown" & "Copy LOUD")'), - MarkdownSelectionScope( - controller: _a, - contextMenuBuilder: _loudContextMenu, - child: MarkdownWidget(markdown: _docA, documentId: 'A'), - ), - const Divider(height: 40), - _label('Markdown B — custom selection color ' - '(selecting one clears the other)'), - MarkdownSelectionScope( - controller: _b, - selectionColor: Colors.amber.withValues(alpha: 0.4), - child: MarkdownWidget(markdown: _docB, documentId: 'B'), - ), - const Divider(height: 40), - _label( - 'Plain SelectableText — resets with the Markdown ones'), - SelectionArea( - key: ValueKey(_plainEpoch), - onSelectionChanged: (content) { - if ((content?.plainText ?? '').isNotEmpty) { - _group.clearExternal(); - } - }, - child: const Text(_loremPlain, - style: TextStyle(fontSize: 16, height: 1.4)), - ), - ], + children: [ + Expanded( + child: SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _label( + 'Markdown A — custom toolbar (right-click / ' + 'long-press for "Copy as Markdown" & "Copy LOUD")', ), - ), + MarkdownSelectionScope( + controller: _a, + contextMenuBuilder: _loudContextMenu, + child: MarkdownWidget(markdown: _docA, documentId: 'A'), + ), + const Divider(height: 40), + _label( + 'Markdown B — custom selection color ' + '(selecting one clears the other)', + ), + MarkdownSelectionScope( + controller: _b, + selectionColor: Colors.amber.withValues(alpha: 0.4), + child: MarkdownWidget(markdown: _docB, documentId: 'B'), + ), + const Divider(height: 40), + _label('Plain SelectableText — resets with the Markdown ones'), + SelectionArea( + key: ValueKey(_plainEpoch), + onSelectionChanged: (content) { + if ((content?.plainText ?? '').isNotEmpty) { + _group.clearExternal(); + } + }, + child: const Text( + _loremPlain, + style: TextStyle(fontSize: 16, height: 1.4), + ), + ), + ], ), - Material( - elevation: 8, - child: SafeArea( - top: false, - child: Padding( - padding: const EdgeInsets.all(12), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, + ), + ), + Material( + elevation: 8, + child: SafeArea( + top: false, + child: Padding( + padding: const EdgeInsets.all(12), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + _mdActive + ? 'Live preview — "Copy as Markdown" keeps the ' + 'structure that plain copy flattens:' + : 'Select any Markdown above to preview & copy it ' + 'two ways:', + style: Theme.of(context).textTheme.bodySmall, + ), + const SizedBox(height: 10), + _mdActive ? _preview() : _hint(), + const SizedBox(height: 10), + Wrap( + alignment: WrapAlignment.end, + spacing: 8, + runSpacing: 8, children: [ - Text( - _mdActive - ? 'Live preview — "Copy as Markdown" keeps the ' - 'structure that plain copy flattens:' - : 'Select any Markdown above to preview & copy it ' - 'two ways:', - style: Theme.of(context).textTheme.bodySmall, + OutlinedButton.icon( + onPressed: _mdActive + ? () => _copy(formatter: _markupFormatter) + : null, + icon: const Icon(Icons.data_object), + label: const Text('Copy as Markdown'), ), - const SizedBox(height: 10), - _mdActive ? _preview() : _hint(), - const SizedBox(height: 10), - Wrap( - alignment: WrapAlignment.end, - spacing: 8, - runSpacing: 8, - children: [ - OutlinedButton.icon( - onPressed: _mdActive - ? () => _copy(formatter: _markupFormatter) - : null, - icon: const Icon(Icons.data_object), - label: const Text('Copy as Markdown'), - ), - FilledButton.icon( - onPressed: _mdActive ? _copy : null, - icon: const Icon(Icons.copy), - label: const Text('Copy'), - ), - ], + FilledButton.icon( + onPressed: _mdActive ? _copy : null, + icon: const Icon(Icons.copy), + label: const Text('Copy'), ), ], ), - ), + ], ), ), - ], - ); + ), + ), + ], + ); } diff --git a/lib/src/selection_scope.dart b/lib/src/selection_scope.dart index f5a2f22..ea7d578 100644 --- a/lib/src/selection_scope.dart +++ b/lib/src/selection_scope.dart @@ -1056,7 +1056,7 @@ class MarkdownSelectionScopeState extends State /// Whether the toolbar is currently visible. bool get toolbarIsVisible => _contextMenuController.isShown; - + /// Whether start/end handle leader layers are attached for the live /// selection endpoints. /// diff --git a/lib/src/theme.dart b/lib/src/theme.dart index 3965b8a..94673ef 100644 --- a/lib/src/theme.dart +++ b/lib/src/theme.dart @@ -24,7 +24,7 @@ typedef MarkdownCursorResolver = MouseCursor? Function( /// Primary system monospace face for inline/`code` and fenced blocks. /// /// Prefer a real family over the CSS generic `'monospace'`, which Flutter does -/// not map on most platforms (so spans fall back to the body proportional +/// not map on most platforms (so spans fall back to the body proportional /// font). const String kMonospaceFontFamily = 'Menlo'; diff --git a/test/selection/selection_handles_test.dart b/test/selection/selection_handles_test.dart index e1d2000..a94ee02 100644 --- a/test/selection/selection_handles_test.dart +++ b/test/selection/selection_handles_test.dart @@ -311,7 +311,7 @@ void main() { final left = firstLine.map((b) => b.left).reduce((a, b) => a < b ? a : b); final right = firstLine.map((b) => b.right).reduce((a, b) => a > b ? a : b); - // Select across the first visual line's glyphs (not empty max-width + // Select across the first visual line's glyphs (not empty max-width // gutter). await _mouseDrag( tester, diff --git a/test/selection/selection_widget_test.dart b/test/selection/selection_widget_test.dart index 4496fff..c73b592 100644 --- a/test/selection/selection_widget_test.dart +++ b/test/selection/selection_widget_test.dart @@ -741,8 +741,7 @@ void main() { await tester.pumpAndSettle(); final tl = tester.getTopLeft(find.byType(MarkdownWidget)); - await tester.tapAt(tl + const Offset(40, 8), - buttons: kSecondaryButton); + await tester.tapAt(tl + const Offset(40, 8), buttons: kSecondaryButton); await tester.pumpAndSettle(); final state = tester.state( From 52b4eb2d7fe87f989c6ad586215af1da1175ff44 Mon Sep 17 00:00:00 2001 From: Mikhail Matiunin Date: Mon, 14 Sep 2026 19:27:52 +0400 Subject: [PATCH 10/19] fix(selection): scroll-protocol-agnostic autoscroll + registry and gate fixes Review follow-up on #27. Autoscroll no longer speaks the sliver protocol. The band / host-union / arming math runs against a `MarkdownAutoscrollTarget` (`viewport`, `canScroll`, `applyScrollDelta`) in screen-space deltas; `MarkdownScrollableAutoscrollTarget` is the built-in `Scrollable` adapter and `MarkdownSelectionAutoscrollConfig.targetResolver` + `MarkdownCallbackAutoscrollTarget` let a host with its own scroll implementation (an anchored chat viewport, a RenderBox that positions children itself) be driven without a `Scrollable` in the tree. The surface is now resolved once per drag instead of walking the element subtree twice per frame. Fixes found while reviewing: - Recycling a `MarkdownWidget` element onto another `documentId` (a virtualized chat list reusing a slot) overwrote the outgoing document's registry model with the incoming body. - `putDocument` without an explicit order sorted ahead of sparse explicit orders, so a heal-registered body jumped to the front of the conversation. - Documents sharing an order could swap places on any re-sort (`List.sort` is not stable), silently reversing extracted text. - `canStartSelectionAt` refusal left the drag armed: a long press / drag on refused chrome kept extending the active selection from that point. - Glyph boxes are cached per `TextPainter` layout instead of recomputed on every hover event. - `maxVelocity: 0` past the outer band reported `scrolled` forever, spinning the frame ticker on a drag that could never scroll. Adds 69 tests across autoscroll targets, host gates, registry lifecycle, selection chrome and nested quote/alert bodies, plus the missing breaking-change notes vs 0.2.0 in the changelog. --- CHANGELOG.md | 80 +- docs/selection.md | 51 +- lib/src/render/block_painter.dart | 46 +- lib/src/render/markdown_render_object.dart | 24 +- lib/src/selection.dart | 37 +- lib/src/selection_autoscroll.dart | 426 +++++++-- lib/src/selection_scope.dart | 111 ++- lib/src/widget.dart | 12 +- .../selection_autoscroll_target_test.dart | 846 ++++++++++++++++++ test/selection/selection_chrome_test.dart | 491 ++++++++++ test/selection/selection_host_gates_test.dart | 607 +++++++++++++ .../selection_nested_blocks_test.dart | 201 +++++ test/selection/selection_registry_test.dart | 382 ++++++++ test/unit_test.dart | 12 + 14 files changed, 3231 insertions(+), 95 deletions(-) create mode 100644 test/selection/selection_autoscroll_target_test.dart create mode 100644 test/selection/selection_chrome_test.dart create mode 100644 test/selection/selection_host_gates_test.dart create mode 100644 test/selection/selection_nested_blocks_test.dart create mode 100644 test/selection/selection_registry_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 949d21f..d205439 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,40 @@ ## 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**: Inline `` `code` `` and fenced blocks resolve a real monospace + family stack (`kMonospaceFontFamily` = `Menlo`, then + `kMonospaceFontFamilyFallback`) instead of the CSS generic `'monospace'`, + which Flutter does not map on most targets (spans silently fell back to the + body proportional font). Metrics of existing code spans change. Override + `MarkdownThemeData.textStyleFor` / `codeStyle` to pin a different face. +- **CHANGED**: `MarkdownThemeData.copyWith` returns `MarkdownThemeData` instead + of `ThemeExtension` (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` / @@ -24,11 +59,18 @@ - **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. + 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 @@ -84,6 +126,19 @@ 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. ### Selection chrome (SelectionArea parity) - **ADDED**: Read-only selection chrome on the controller / @@ -117,6 +172,29 @@ (`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.copyWith`. + ### Selection engine rework - **ADDED**: Edge-zone autoscroll while dragging (body / handle / long-press) near the padded viewport — host-union hard-stop, direction arming gate, diff --git a/docs/selection.md b/docs/selection.md index 652f9aa..5fd1ab8 100644 --- a/docs/selection.md +++ b/docs/selection.md @@ -207,10 +207,59 @@ Statics: `MarkdownSelectionScope.of/maybeOf` (→ controller), `stateOf` (→ st - **Edge autoscroll:** while dragging a non-collapsed selection (mouse pan, long- press move, or handle drag) near the **visible clip** of the host union (mounted selectable bodies ∩ padded viewport), - `applyMarkdownSelectionAutoscroll` jumps the scrollable. A `Ticker` keeps + `applyMarkdownSelectionAutoscroll` moves the scroll surface. A `Ticker` keeps scrolling while the pointer stays in-band and the host-union edge is not yet flush; it pauses on hard-stop / leave-band / arming-gate. Configure via `MarkdownSelectionScope.autoscroll` (default `edgeZone: 48`). + + **The scroll surface is abstract.** Nothing in the band / gate math touches + `ScrollPosition`; it all runs against a `MarkdownAutoscrollTarget`: + + | Member | Meaning | + |---|---| + | `MarkdownAutoscrollViewport? get viewport` | visible global bounds + the inset where the bands start; `null` disables the drag | + | `bool canScroll({required bool forward})` | can a positive (`forward`) / negative delta still move content? | + | `double applyScrollDelta(double delta)` | move content by **screen-space** pixels (positive = content up, revealing what is below); return the delta actually applied (`0` hard-stops and disarms that direction) | + + `MarkdownScrollableAutoscrollTarget` is the built-in adapter for the sliver + protocol and is used when no resolver is set; it flips the sign for reverse + axes (`AxisDirection.up` / `.left`) so callers always speak screen space. + + A host with its own scroll implementation — an anchored chat viewport that + owns its pixel offset, a `RenderBox` that positions children itself, a + transform-based canvas — supplies + `MarkdownSelectionAutoscrollConfig.targetResolver` instead. The scope calls it + **once per drag** (with the scope context, the `BuildContext` of the markdown + surface under the pointer, the pointer position and the config) and caches the + result, so a resolver may walk the element tree without a per-frame cost. + `MarkdownCallbackAutoscrollTarget` assembles a target from three closures: + + ```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(top: 8, bottom: 72), // composer + ); + }, + canScrollAt: ({required forward}) => + forward ? !chat.isAtTail.value : !chat.reachedOldest, + // This host's `scrollBy` is anchor-relative: a positive value reveals + // *older* messages, the opposite of screen-space movement. + onScrollDelta: (delta) { + chat.scrollBy(-delta); + return delta; + }, + ), + ), + child: child, + ); + ``` - **Cursor:** the `MarkdownWidget` render object is a `MouseTrackerAnnotation`; it shows the click (hand) cursor over an actionable link (a span with a tap recognizer, detected on hover via `handleEvent` → `markNeedsPaint` so diff --git a/lib/src/render/block_painter.dart b/lib/src/render/block_painter.dart index 16d2f9a..a0bb99c 100644 --- a/lib/src/render/block_painter.dart +++ b/lib/src/render/block_painter.dart @@ -325,17 +325,49 @@ mixin MultiPainterSelectable implements SelectableBlockPainter { /// Whether [local] (text-painter coordinates) lies inside any glyph line box /// of [painter]'s full rendered range. bool _painterHitsGlyphBoxes(TextPainter painter, Offset local) { - final plain = painter.plainText; - if (plain.isEmpty) return false; - final boxes = painter.getBoxesForSelection( - TextSelection(baseOffset: 0, extentOffset: plain.length), - ); - for (final box in boxes) { - if (box.toRect().contains(local)) return true; + for (final rect in _glyphBoxesOf(painter)) { + if (rect.contains(local)) return true; } return false; } +/// Cached glyph boxes of one [TextPainter] layout. +final class _GlyphBoxes { + const _GlyphBoxes(this.size, this.text, this.rects); + + final Size size; + final String text; + final List rects; +} + +/// Glyph boxes are re-read on **every hover event** (I-beam / link cursor) and +/// on every glyph-tight hit test. `getBoxesForSelection` over a whole block +/// allocates one box per run per line, so a long fenced code block would +/// rebuild thousands of rects per mouse move without this cache. +/// +/// Keyed on the painter; invalidated when its laid-out size or plain text +/// changes. [TextPainter.plainText] is memoised by the painter itself, so the +/// identity check is O(1). +final Expando<_GlyphBoxes> _glyphBoxCache = Expando<_GlyphBoxes>('glyphBoxes'); + +List _glyphBoxesOf(TextPainter painter) { + final plain = painter.plainText; + if (plain.isEmpty) return const []; + final size = painter.size; + final cached = _glyphBoxCache[painter]; + if (cached != null && cached.size == size && identical(cached.text, plain)) { + return cached.rects; + } + final rects = [ + for (final box in painter.getBoxesForSelection( + TextSelection(baseOffset: 0, extentOffset: plain.length), + )) + box.toRect(), + ]; + _glyphBoxCache[painter] = _GlyphBoxes(size, plain, rects); + return rects; +} + /// Whether the span under a text-local [local] point in [painter] carries a tap /// recognizer (i.e. an actionable link). False for points outside the text box. bool _spanHasRecognizerAt(TextPainter painter, Offset local) { diff --git a/lib/src/render/markdown_render_object.dart b/lib/src/render/markdown_render_object.dart index dd211c5..e05febe 100644 --- a/lib/src/render/markdown_render_object.dart +++ b/lib/src/render/markdown_render_object.dart @@ -72,7 +72,7 @@ class MarkdownRenderObject extends RenderBox bool _disposed = false; - void _attachController() { + void _attachController({Markdown? markdown}) { final controller = _controller; final id = _documentId; if (controller == null || id == null) return; @@ -81,7 +81,11 @@ class MarkdownRenderObject extends RenderBox // Mounted surfaces must exist in the registry so [rangeFor] / ordering // can resolve them. AppMarkdown also putDocuments; this heals races where // the RO attaches before (or without) an app-level registration. - controller.putDocument(id, _painter.markdown); + // + // [markdown] is the model of the *incoming* document when a recycled + // element is rewired onto another id — [_painter] still holds the + // outgoing one until [update] runs. + controller.putDocument(id, markdown ?? _painter.markdown); controller.attachSurface(this); } } @@ -96,16 +100,26 @@ class MarkdownRenderObject extends RenderBox /// Wires (or rewires) this render object to a selection [controller] under /// [documentId]. Passing a null controller makes it non-selectable (inert). + /// + /// [markdown] is the model the *incoming* [documentId] should be healed with. + /// It matters when a list recycles this render object from one message onto + /// another: the painter still holds the outgoing model at that point, and + /// registering it under the incoming id would overwrite that document's + /// registry entry with the wrong body. + /// + /// Must run **before** [update] on a rewire so the registry write that + /// [update] performs lands on the new id. @meta.internal void updateSelection( MarkdownSelectionController? controller, - Object? documentId, - ) { + Object? documentId, { + Markdown? markdown, + }) { if (identical(controller, _controller) && documentId == _documentId) return; _detachController(); _controller = controller; _documentId = documentId; - _attachController(); + _attachController(markdown: markdown); if (attached) { markNeedsCompositingBitsUpdate(); markNeedsPaint(); diff --git a/lib/src/selection.dart b/lib/src/selection.dart index b712487..5c282af 100644 --- a/lib/src/selection.dart +++ b/lib/src/selection.dart @@ -611,10 +611,17 @@ class MarkdownHandleEndpoints { } class _DocEntry { - _DocEntry(this.id, this.model, this.order); + _DocEntry(this.id, this.model, this.order, this.seq); + final Object id; Markdown model; int order; + + /// Monotonic registration sequence. Breaks [order] ties deterministically — + /// `List.sort` is not stable, so two documents sharing an order would + /// otherwise swap places on any re-sort (a model update, a new mount) and + /// silently reverse extracted text. + final int seq; } /// The single source of truth for a Markdown selection. @@ -681,6 +688,19 @@ class MarkdownSelectionController extends ChangeNotifier { /// size and show up during drags. final Map _indexById = {}; + /// Monotonic registration counter feeding [_DocEntry.seq]. + int _nextSeq = 0; + + /// An order that sorts after every currently registered document. + int _nextAppendOrder() { + if (_docs.isEmpty) return 0; + var max = _docs.first.order; + for (final entry in _docs) { + if (entry.order > max) max = entry.order; + } + return max + 1; + } + /// Per-document attach stack. Several render objects may register under the /// same [MarkdownSelectionSurface.documentId] at once (crossfade, recycle, /// overlapping mounts). A single-slot map would let a later attach overwrite @@ -824,7 +844,7 @@ class MarkdownSelectionController extends ChangeNotifier { ..clear() ..addAll(<_DocEntry>[ for (final (i, d) in docs.indexed) - _DocEntry(d.id, d.model, d.order ?? i), + _DocEntry(d.id, d.model, d.order ?? i, _nextSeq++), ]); _sort(); _validateSelection(); @@ -838,8 +858,12 @@ class MarkdownSelectionController extends ChangeNotifier { _pendingRemoveIds.remove(id); final idx = _orderIndex(id); if (idx < 0) { - final assigned = order ?? _docs.length; - _docs.add(_DocEntry(id, model, assigned)); + // No explicit order (the render-object heal path) means "append". + // `_docs.length` would sort ahead of sparse explicit orders — chat + // hosts key `order` on the message id, so a healed body would jump to + // the front of the conversation and reverse extracted text. + final assigned = order ?? _nextAppendOrder(); + _docs.add(_DocEntry(id, model, assigned, _nextSeq++)); _sort(); notifyListeners(); return; @@ -887,7 +911,10 @@ class MarkdownSelectionController extends ChangeNotifier { // documents sharing an identical `order` have unspecified relative order — // callers should supply unique `order` values (e.g. the message index). void _sort() { - _docs.sort((a, b) => a.order.compareTo(b.order)); + _docs.sort((a, b) { + final byOrder = a.order.compareTo(b.order); + return byOrder != 0 ? byOrder : a.seq.compareTo(b.seq); + }); _reindex(); } diff --git a/lib/src/selection_autoscroll.dart b/lib/src/selection_autoscroll.dart index 715641f..a664529 100644 --- a/lib/src/selection_autoscroll.dart +++ b/lib/src/selection_autoscroll.dart @@ -2,6 +2,7 @@ import 'dart:math' as math; +import 'package:flutter/foundation.dart'; import 'package:flutter/scheduler.dart'; import 'package:flutter/widgets.dart'; @@ -23,6 +24,12 @@ import 'package:flutter/widgets.dart'; /// /// After a hard stop, [MarkdownAutoscrollSession] disarms that direction until /// the pointer leaves and re-enters the band (arming gate). +/// +/// The scroll surface itself is abstract: see [MarkdownAutoscrollTarget]. By +/// default the nearest ancestor [Scrollable] is driven, but a host with its +/// own scroll protocol (a custom anchored chat viewport, a `RenderBox` that +/// moves children itself, a nested transform) supplies [targetResolver] +/// instead. Nothing in the band / gate math touches `ScrollPosition`. final class MarkdownSelectionAutoscrollConfig { /// Creates an autoscroll config. const MarkdownSelectionAutoscrollConfig({ @@ -32,6 +39,7 @@ final class MarkdownSelectionAutoscrollConfig { this.topPad = 0, this.bottomPad = 0, this.useMediaQueryPadding = true, + this.targetResolver, }) : assert(edgeZone > 0), assert(maxVelocity >= 0), assert(topPad >= 0), @@ -58,7 +66,303 @@ final class MarkdownSelectionAutoscrollConfig { /// When true, [MediaQuery.padding] / [MediaQuery.viewPadding] are added to /// [topPad]/[bottomPad] so the edge zone starts before handles disappear /// under app chrome. + /// + /// Only consulted by the built-in [Scrollable] resolver — a custom + /// [targetResolver] reports its own [MarkdownAutoscrollViewport.padding]. final bool useMediaQueryPadding; + + /// Resolves the scroll surface a selection drag drives. + /// + /// Null (the default) uses the nearest ancestor [Scrollable] — the sliver + /// protocol. Supply a resolver to drive any other scroll implementation; + /// return null from it to leave a given drag un-scrolled. + /// + /// The resolver is called at most once per drag (the scope caches the + /// target until the drag ends or the target reports no usable viewport), so + /// it may do element-tree work without a per-frame cost. + final MarkdownAutoscrollTargetResolver? targetResolver; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is MarkdownSelectionAutoscrollConfig && + other.enabled == enabled && + other.edgeZone == edgeZone && + other.maxVelocity == maxVelocity && + other.topPad == topPad && + other.bottomPad == bottomPad && + other.useMediaQueryPadding == useMediaQueryPadding && + // A resolver closure can only be compared by identity; a host that + // builds one inline gets a new object every build, which is why the + // scope never invalidates a cached surface mid-drag. + identical(other.targetResolver, targetResolver); + + @override + int get hashCode => Object.hash( + enabled, + edgeZone, + maxVelocity, + topPad, + bottomPad, + useMediaQueryPadding, + targetResolver, + ); + + /// A copy with the given fields replaced. + MarkdownSelectionAutoscrollConfig copyWith({ + bool? enabled, + double? edgeZone, + double? maxVelocity, + double? topPad, + double? bottomPad, + bool? useMediaQueryPadding, + MarkdownAutoscrollTargetResolver? targetResolver, + }) => + MarkdownSelectionAutoscrollConfig( + enabled: enabled ?? this.enabled, + edgeZone: edgeZone ?? this.edgeZone, + maxVelocity: maxVelocity ?? this.maxVelocity, + topPad: topPad ?? this.topPad, + bottomPad: bottomPad ?? this.bottomPad, + useMediaQueryPadding: + useMediaQueryPadding ?? this.useMediaQueryPadding, + targetResolver: targetResolver ?? this.targetResolver, + ); +} + +/// Signature for resolving the [MarkdownAutoscrollTarget] of a selection drag. +/// +/// Return null to leave the drag un-scrolled. +typedef MarkdownAutoscrollTargetResolver = MarkdownAutoscrollTarget? Function( + MarkdownAutoscrollRequest request, +); + +/// What the scope knows about a drag when it asks for a scroll target. +@immutable +final class MarkdownAutoscrollRequest { + /// Creates a resolver request. + const MarkdownAutoscrollRequest({ + required this.scopeContext, + required this.globalPosition, + required this.config, + this.contentContext, + }); + + /// Context of the `MarkdownSelectionScope` that owns the drag. + /// + /// This sits **above** any scrollable the scope wraps, so + /// `Scrollable.maybeOf(scopeContext)` does *not* find it — use + /// [contentContext] (or your own handle) to reach a descendant viewport. + final BuildContext scopeContext; + + /// Context of the mounted markdown surface under [globalPosition] (or the + /// first mounted one when the pointer is outside every surface), when the + /// scope could resolve it. Null when no surface is mounted. + /// + /// This is the useful context for `Scrollable.maybeOf`: it is a *descendant* + /// of the list that scrolls the markdown bodies. + final BuildContext? contentContext; + + /// Current global pointer position of the drag. + final Offset globalPosition; + + /// The config the drag runs under. + final MarkdownSelectionAutoscrollConfig config; +} + +/// Visible geometry of a scroll surface, in global coordinates. +@immutable +final class MarkdownAutoscrollViewport { + /// Creates a viewport description. + const MarkdownAutoscrollViewport({ + required this.globalBounds, + this.padding = EdgeInsets.zero, + }); + + /// Global bounds of the visible scroll viewport. + final Rect globalBounds; + + /// Inset from [globalBounds] where the activation bands start. + /// + /// Use it for chrome that overlaps the viewport (a translucent app bar, a + /// composer, the system status / gesture bars) so the band begins before a + /// handle disappears underneath. + final EdgeInsets padding; + + /// [globalBounds] deflated by [padding] (may be empty / inverted when the + /// padding exceeds the viewport). + Rect get paddedGlobalBounds => Rect.fromLTRB( + globalBounds.left + padding.left, + globalBounds.top + padding.top, + globalBounds.right - padding.right, + globalBounds.bottom - padding.bottom, + ); + + /// Whether the padded band has any vertical room to work with. + bool get isUsable => + !globalBounds.hasNaN && + globalBounds.isFinite && + paddedGlobalBounds.height > 0; + + @override + String toString() => + 'MarkdownAutoscrollViewport($globalBounds, padding: $padding)'; +} + +/// A scroll surface that markdown selection autoscroll can drive. +/// +/// Implement it to adapt any scroll protocol — the sliver +/// [Scrollable]/[ScrollPosition] pair (see +/// [MarkdownScrollableAutoscrollTarget]), an anchored chat viewport that owns +/// its own pixel offset, a `PageView`, a transform-based canvas. +/// +/// All deltas are **screen-space content movement**, never scroll-offset +/// deltas: positive moves content up so material below the bottom edge comes +/// into view. Adapters flip the sign for reverse axes / inverted anchors. +abstract interface class MarkdownAutoscrollTarget { + /// Current visible geometry, or null when the surface cannot be driven + /// right now (detached, unmounted, zero-sized). Read once per frame. + MarkdownAutoscrollViewport? get viewport; + + /// Whether [applyScrollDelta] can still move content in this direction. + /// + /// [forward] true asks about a **positive** delta (content moves up, + /// revealing what lies below the viewport bottom); false asks about a + /// negative delta. + bool canScroll({required bool forward}); + + /// Moves content by [delta] logical pixels of screen-space movement. + /// + /// A positive [delta] moves content up (reveals what is below the bottom + /// edge); a negative [delta] moves content down. Returns the delta actually + /// applied — return `0` when already flush so the drag hard-stops and the + /// direction disarms. + double applyScrollDelta(double delta); +} + +/// A [MarkdownAutoscrollTarget] assembled from closures. +/// +/// The fastest way to adopt a custom scroll implementation without writing a +/// class: +/// +/// ```dart +/// 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.symmetric(vertical: 8), +/// ); +/// }, +/// // `scrollBy` is anchor-relative: positive reveals *older* messages, +/// // the opposite of screen-space movement. +/// onScrollDelta: (delta) { +/// chatScrollController.scrollBy(-delta); +/// return delta; +/// }, +/// canScrollAt: ({required forward}) => +/// forward ? !chatScrollController.isAtTail.value : !dataSource.reachedOldest, +/// ), +/// ) +/// ``` +final class MarkdownCallbackAutoscrollTarget implements MarkdownAutoscrollTarget { + /// Creates a closure-backed target. + /// + /// [canScrollAt] defaults to "always allowed" — [onScrollDelta] returning a + /// delta smaller than half a pixel is then what hard-stops the drag. + const MarkdownCallbackAutoscrollTarget({ + required MarkdownAutoscrollViewport? Function() viewportOf, + required double Function(double delta) onScrollDelta, + bool Function({required bool forward})? canScrollAt, + }) : _viewportOf = viewportOf, + _onScrollDelta = onScrollDelta, + _canScrollAt = canScrollAt; + + final MarkdownAutoscrollViewport? Function() _viewportOf; + final double Function(double delta) _onScrollDelta; + final bool Function({required bool forward})? _canScrollAt; + + @override + MarkdownAutoscrollViewport? get viewport => _viewportOf(); + + @override + bool canScroll({required bool forward}) => + _canScrollAt?.call(forward: forward) ?? true; + + @override + double applyScrollDelta(double delta) => _onScrollDelta(delta); +} + +/// The built-in [MarkdownAutoscrollTarget] for the sliver protocol: drives the +/// [ScrollPosition] of a [ScrollableState] with `jumpTo`. +/// +/// Handles reverse axes ([AxisDirection.up] / [AxisDirection.left]) so callers +/// always speak screen-space deltas. +final class MarkdownScrollableAutoscrollTarget + implements MarkdownAutoscrollTarget { + /// Wraps [scrollable], insetting the activation bands by [padding]. + const MarkdownScrollableAutoscrollTarget( + this.scrollable, { + this.padding = EdgeInsets.zero, + }); + + /// The scrollable being driven. + final ScrollableState scrollable; + + /// Inset applied to [MarkdownAutoscrollViewport.padding]. + final EdgeInsets padding; + + /// Whether the [ScrollPosition] is ready to be read / driven. + bool get _positionReady { + if (!scrollable.mounted) return false; + final position = scrollable.position; + return position.hasPixels && position.hasContentDimensions; + } + + /// Whether a positive screen-space delta grows `ScrollPosition.pixels`. + bool get _axisMatchesScreen => switch (scrollable.axisDirection) { + AxisDirection.down || AxisDirection.right => true, + AxisDirection.up || AxisDirection.left => false, + }; + + @override + MarkdownAutoscrollViewport? get viewport { + if (!_positionReady) return null; + final box = scrollable.context.findRenderObject(); + if (box is! RenderBox || !box.hasSize || !box.attached) return null; + return MarkdownAutoscrollViewport( + globalBounds: box.localToGlobal(Offset.zero) & box.size, + padding: padding, + ); + } + + @override + bool canScroll({required bool forward}) { + if (!_positionReady) return false; + final position = scrollable.position; + final towardMax = _axisMatchesScreen == forward; + return towardMax + ? position.pixels < position.maxScrollExtent - precisionErrorTolerance + : position.pixels > position.minScrollExtent + precisionErrorTolerance; + } + + @override + double applyScrollDelta(double delta) { + if (!_positionReady) return 0; + final position = scrollable.position; + final signed = _axisMatchesScreen ? delta : -delta; + final before = position.pixels; + final next = (before + signed).clamp( + position.minScrollExtent, + position.maxScrollExtent, + ); + final applied = next - before; + if (applied.abs() < 0.5) return 0; + position.jumpTo(next); + return _axisMatchesScreen ? applied : -applied; + } } /// Per-drag arming state for the host-union autoscroll gate. @@ -187,44 +491,23 @@ double markdownAutoscrollVelocityInViewport({ return (top, bottom); } -/// Applies one autoscroll step to the nearest ancestor [Scrollable] of -/// [context]. +/// Builds the built-in sliver target for [context]: the nearest [Scrollable] +/// (or [context]'s own scrollable), padded from config + MediaQuery + the +/// screen chrome that sits *outside* the viewport. /// -/// [hostUnionTopGlobalY] / [hostUnionBottomGlobalY] are the global Y bounds of -/// the union of mounted selectable bodies under the selection host. Bands use -/// the padded viewport; the host union only gates / hard-stops each direction. -MarkdownAutoscrollResult applyMarkdownSelectionAutoscroll({ - required Offset globalPosition, +/// Returns null when [context] is unusable or has no enclosing [Scrollable]. +MarkdownScrollableAutoscrollTarget? resolveMarkdownScrollableAutoscrollTarget({ required BuildContext? context, required MarkdownSelectionAutoscrollConfig config, - required Duration? lastTimestamp, - required void Function(Duration?) storeTimestamp, - required double hostUnionTopGlobalY, - required double hostUnionBottomGlobalY, - MarkdownAutoscrollSession? session, }) { - void clear() => storeTimestamp(null); - - if (!config.enabled || context == null || !context.mounted) { - clear(); - return MarkdownAutoscrollResult.idle; - } + if (context == null || !context.mounted) return null; final scrollable = _scrollableFor(context); - if (scrollable == null) { - clear(); - return MarkdownAutoscrollResult.idle; - } + if (scrollable == null || !scrollable.mounted) return null; final position = scrollable.position; - if (!position.hasPixels || !position.hasContentDimensions) { - clear(); - return MarkdownAutoscrollResult.idle; - } + if (!position.hasPixels || !position.hasContentDimensions) return null; - final box = scrollable.context.findRenderObject() as RenderBox?; - if (box == null || !box.hasSize) { - clear(); - return MarkdownAutoscrollResult.idle; - } + final box = scrollable.context.findRenderObject(); + if (box is! RenderBox || !box.hasSize || !box.attached) return null; final (topPad, bottomPad) = resolveMarkdownAutoscrollPads( config: config, @@ -253,19 +536,64 @@ MarkdownAutoscrollResult applyMarkdownSelectionAutoscroll({ } } - final viewportOriginY = box.localToGlobal(Offset.zero).dy; - final viewportHeight = box.size.height; - final localY = globalPosition.dy - viewportOriginY; + return MarkdownScrollableAutoscrollTarget( + scrollable, + padding: EdgeInsets.only(top: resolvedTop, bottom: resolvedBottom), + ); +} - final paddedTop = viewportOriginY + resolvedTop; - final paddedBottom = viewportOriginY + viewportHeight - resolvedBottom; - final clipHeight = paddedBottom - paddedTop; - if (clipHeight <= 0) { +/// Applies one autoscroll step to [target] (or, when [target] is null, to the +/// nearest ancestor [Scrollable] of [context]). +/// +/// [hostUnionTopGlobalY] / [hostUnionBottomGlobalY] are the global Y bounds of +/// the union of mounted selectable bodies under the selection host. Bands use +/// the padded viewport; the host union only gates / hard-stops each direction. +MarkdownAutoscrollResult applyMarkdownSelectionAutoscroll({ + required Offset globalPosition, + required MarkdownSelectionAutoscrollConfig config, + required Duration? lastTimestamp, + required void Function(Duration?) storeTimestamp, + required double hostUnionTopGlobalY, + required double hostUnionBottomGlobalY, + BuildContext? context, + MarkdownAutoscrollTarget? target, + MarkdownAutoscrollSession? session, +}) { + void clear() => storeTimestamp(null); + + if (!config.enabled || config.maxVelocity <= 0) { + // A zero max velocity can never move content; without this the + // past-the-outer-band branch below would hand back `scrolled` forever and + // keep the caller's frame ticker spinning on a drag that never scrolls. + clear(); + return MarkdownAutoscrollResult.idle; + } + + final resolved = target ?? + resolveMarkdownScrollableAutoscrollTarget( + context: context, + config: config, + ); + if (resolved == null) { + clear(); + return MarkdownAutoscrollResult.idle; + } + + final viewport = resolved.viewport; + if (viewport == null || !viewport.isUsable) { session?.reset(); clear(); return MarkdownAutoscrollResult.idle; } + final bounds = viewport.globalBounds; + final padded = viewport.paddedGlobalBounds; + final paddedTop = padded.top; + final paddedBottom = padded.bottom; + final clipHeight = padded.height; + final localY = globalPosition.dy - bounds.top; + final clipTopLocal = paddedTop - bounds.top; + // Host still has content to reveal past each pad edge (or sits entirely // off-screen past that edge — drag must be able to pull it back). final canTowardStart = hostUnionTopGlobalY < paddedTop - 0.5 || @@ -273,7 +601,6 @@ MarkdownAutoscrollResult applyMarkdownSelectionAutoscroll({ final canTowardEnd = hostUnionBottomGlobalY > paddedBottom + 0.5 || hostUnionTopGlobalY >= paddedBottom - 0.5; - final clipTopLocal = resolvedTop; var velocity = markdownAutoscrollVelocity( localY: localY, clipTopLocalY: clipTopLocal, @@ -312,12 +639,6 @@ MarkdownAutoscrollResult applyMarkdownSelectionAutoscroll({ return MarkdownAutoscrollResult.suppressed; } - // Reverse axes: positive velocity should still mean "toward maxScrollExtent". - velocity = switch (scrollable.axisDirection) { - AxisDirection.down || AxisDirection.right => velocity, - AxisDirection.up || AxisDirection.left => -velocity, - }; - final previous = lastTimestamp; final now = _autoscrollNow(previous); storeTimestamp(now); @@ -329,18 +650,19 @@ MarkdownAutoscrollResult applyMarkdownSelectionAutoscroll({ return MarkdownAutoscrollResult.scrolled; } - final before = position.pixels; - final next = (before + delta).clamp( - position.minScrollExtent, - position.maxScrollExtent, - ); - if ((next - before).abs() < 0.5) { + if (!resolved.canScroll(forward: !towardStart)) { + session?.disarm(towardStart: towardStart); + clear(); + return MarkdownAutoscrollResult.suppressed; + } + + final applied = resolved.applyScrollDelta(delta); + if (applied.abs() < 0.5) { session?.disarm(towardStart: towardStart); clear(); return MarkdownAutoscrollResult.suppressed; } - position.jumpTo(next); return MarkdownAutoscrollResult.scrolled; } diff --git a/lib/src/selection_scope.dart b/lib/src/selection_scope.dart index ea7d578..f7bbe59 100644 --- a/lib/src/selection_scope.dart +++ b/lib/src/selection_scope.dart @@ -221,6 +221,13 @@ class MarkdownSelectionScopeState extends State final MarkdownAutoscrollSession _autoscrollSession = MarkdownAutoscrollSession(); + /// Scroll surface resolved for the live drag. + /// + /// Resolving walks the element tree (to find the surface element, then the + /// enclosing [Scrollable]); the result is stable for a drag, so it is cached + /// and only re-resolved when it stops reporting a usable viewport. + MarkdownAutoscrollTarget? _autoscrollTarget; + /// When true, skip creating/showing handles (Android long-press: show on end). bool _deferHandleShow = false; @@ -288,6 +295,14 @@ class MarkdownSelectionScopeState extends State if (oldWidget.selectionColor != widget.selectionColor) { _applySelectionColor(); } + if (oldWidget.autoscroll != widget.autoscroll && _dragGlobal == null) { + // Pick up a new resolver / pads on the next drag. Never mid-drag: + // [MarkdownSelectionAutoscrollConfig] has no value equality, so a host + // that builds the config inline (a closure resolver always does) would + // otherwise invalidate the cached surface on every rebuild — and an + // autoscrolling list rebuilds every frame. + _autoscrollTarget = null; + } // Host may enable the scope after arming a subject / range. Sync handles // and restore the toolbar when chrome becomes eligible. Disabling clears // visible chrome but keeps [toolbarWanted] so a later enable can restore. @@ -576,6 +591,7 @@ class MarkdownSelectionScopeState extends State _dragMovesStartEdge = isStart; _dragGlobal = d.globalPosition; _autoscrollSession.reset(); + _autoscrollTarget = null; hideToolbar(); _selectionOverlay ?.showMagnifier(_magnifierInfo(d.globalPosition, isStart: isStart)); @@ -664,6 +680,7 @@ class MarkdownSelectionScopeState extends State void _stopAutoscroll() { _pauseAutoscrollTicker(); _autoscrollSession.reset(); + _autoscrollTarget = null; _dragGlobal = null; _dragIsHandle = false; } @@ -699,30 +716,15 @@ class MarkdownSelectionScopeState extends State return; } - // Prefer a BuildContext under a mounted surface so Scrollable.maybeOf - // walks up to the enclosing ListView (scope context is an ancestor of - // the scrollable, not a descendant). - MarkdownSelectionSurface? content; - for (final surface in controller.mountedSurfaces) { - if (surface.globalBounds.contains(position)) { - content = surface; - break; - } + final target = _resolveAutoscrollTarget(position); + if (target == null) { + _pauseAutoscrollTicker(); + return; } - content ??= switch (controller.mountedSurfaces) { - final surfaces when surfaces.isEmpty => null, - final surfaces => surfaces.first, - }; - final scrollContext = switch (content) { - final surface? => _contextForSurface(surface), - null => null, - } ?? - _scrollableContextNear(position) ?? - context; final result = applyMarkdownSelectionAutoscroll( globalPosition: position, - context: scrollContext, + target: target, config: widget.autoscroll, lastTimestamp: _lastAutoscrollTimestamp, storeTimestamp: (t) => _lastAutoscrollTimestamp = t, @@ -769,6 +771,55 @@ class MarkdownSelectionScopeState extends State _tickAutoscroll(); } + /// The scroll surface this drag drives, resolved once and cached. + /// + /// A host with its own scroll protocol supplies + /// [MarkdownSelectionAutoscrollConfig.targetResolver]; otherwise the nearest + /// ancestor [Scrollable] of a mounted markdown surface is used (the scope + /// itself usually sits *above* that scrollable, so its own context cannot + /// find it). + MarkdownAutoscrollTarget? _resolveAutoscrollTarget(Offset globalPosition) { + if (_autoscrollTarget case final cached?) { + if (cached.viewport?.isUsable ?? false) return cached; + _autoscrollTarget = null; + } + // Prefer a BuildContext under a mounted surface so Scrollable.maybeOf + // walks up to the enclosing ListView (scope context is an ancestor of + // the scrollable, not a descendant). + MarkdownSelectionSurface? content; + for (final surface in controller.mountedSurfaces) { + if (surface.globalBounds.contains(globalPosition)) { + content = surface; + break; + } + } + content ??= switch (controller.mountedSurfaces) { + final surfaces when surfaces.isEmpty => null, + final surfaces => surfaces.first, + }; + final contentContext = switch (content) { + final surface? => _contextForSurface(surface), + null => null, + }; + + if (widget.autoscroll.targetResolver case final resolver?) { + final target = resolver(MarkdownAutoscrollRequest( + scopeContext: context, + contentContext: contentContext, + globalPosition: globalPosition, + config: widget.autoscroll, + )); + return _autoscrollTarget = target; + } + + final scrollContext = + contentContext ?? _scrollableContextNear(globalPosition) ?? context; + return _autoscrollTarget = resolveMarkdownScrollableAutoscrollTarget( + context: scrollContext, + config: widget.autoscroll, + ); + } + /// Element whose [RenderObject] is [surface], if mounted under this scope. BuildContext? _contextForSurface(MarkdownSelectionSurface surface) { Element? found; @@ -1357,6 +1408,7 @@ class MarkdownSelectionScopeState extends State hideToolbar(); _dragIsHandle = false; _autoscrollSession.reset(); + _autoscrollTarget = null; if (_shiftHeld && effective <= 1) { if (controller.selection case final _?) { // Extending an existing range may clamp through gaps. @@ -1376,7 +1428,12 @@ class MarkdownSelectionScopeState extends State _dragGlobal = d.globalPosition; // Multi-tap may already have anchored on tapDown; still re-begin so a // drag that starts without a clean tapUp keeps the right granularity. - _beginSelection(d.globalPosition, d.consecutiveTapCount); + // A refusal (host gate / miss) must also un-arm the drag, otherwise + // [_handleDragUpdate] would keep extending from a point the host owns. + if (!_beginSelection(d.globalPosition, d.consecutiveTapCount)) { + _dragGlobal = null; + return; + } _driveAutoscroll(); } @@ -1651,10 +1708,17 @@ class MarkdownSelectionScopeState extends State if (!controller.hitsSelectableContent(d.globalPosition)) { return; } + // The recognizer joins the arena while a range is live (so chrome + // taps can dismiss it), so the host gate has to be re-checked here + // or a long press on a link would re-anchor the active selection. + if (widget.canStartSelectionAt?.call(d.globalPosition) == false) { + return; + } _focusNode.requestFocus(); hideToolbar(); _dragIsHandle = false; _autoscrollSession.reset(); + _autoscrollTarget = null; // Android shows handles on press-end; other platforms on start. _deferHandleShow = defaultTargetPlatform == TargetPlatform.android; // Match TextField long-press: Android vibrate (LONG_PRESS) via @@ -1663,7 +1727,10 @@ class MarkdownSelectionScopeState extends State Feedback.forLongPress(context); // Arm drag before beginSelection so overlay sync is pointer-sync. _dragGlobal = d.globalPosition; - _beginSelection(d.globalPosition, 2); + if (!_beginSelection(d.globalPosition, 2)) { + _dragGlobal = null; + return; + } // Magnifier still tracks during the press even when handles wait. _selectionOverlay?.showMagnifier( _magnifierInfo(d.globalPosition, isStart: false), diff --git a/lib/src/widget.dart b/lib/src/widget.dart index 774c570..ef6103c 100644 --- a/lib/src/widget.dart +++ b/lib/src/widget.dart @@ -68,12 +68,20 @@ class MarkdownWidget extends LeafRenderObjectWidget { BuildContext context, MarkdownRenderObject renderObject, ) { + // Rewire the selection registry FIRST: a recycled element (a virtualized + // chat list reusing a slot) changes [documentId] and [markdown] together, + // and [update]'s registry write must land on the incoming id — not + // overwrite the outgoing document's model with this one's body. renderObject + ..updateSelection( + _resolveController(context), + documentId, + markdown: markdown, + ) ..update( markdown: markdown, theme: _resolveTheme(context), cursorResolver: cursorResolver, - ) - ..updateSelection(_resolveController(context), documentId); + ); } } diff --git a/test/selection/selection_autoscroll_target_test.dart b/test/selection/selection_autoscroll_target_test.dart new file mode 100644 index 0000000..2db4507 --- /dev/null +++ b/test/selection/selection_autoscroll_target_test.dart @@ -0,0 +1,846 @@ +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_md/flutter_md.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// A scroll protocol that is **not** a [Scrollable]: an anchor offset the host +/// owns, exactly like the chat viewports that render their children from a +/// `RenderBox` instead of the sliver protocol. +/// +/// `offset` grows as content moves up, so `scrollBy` here has the same sign as +/// a screen-space autoscroll delta; anchor-relative hosts (whose positive +/// delta reveals *older* content) negate it in their resolver. +class _AnchorScrollHost extends StatefulWidget { + const _AnchorScrollHost({ + required this.viewportHeight, + required this.child, + required this.onState, + this.maxOffset = double.infinity, + }); + + final double viewportHeight; + final Widget child; + final ValueChanged<_AnchorScrollHostState> onState; + + /// Hard stop, emulating "reached the oldest message". + final double maxOffset; + + @override + State<_AnchorScrollHost> createState() => _AnchorScrollHostState(); +} + +class _AnchorScrollHostState extends State<_AnchorScrollHost> { + final GlobalKey _viewportKey = GlobalKey(); + + /// Pixels of content scrolled past the top edge. + double offset = 0; + + double get maxOffset => widget.maxOffset; + + int scrollCalls = 0; + + MarkdownAutoscrollViewport? viewportOf() { + final box = _viewportKey.currentContext?.findRenderObject(); + if (box is! RenderBox || !box.hasSize) return null; + return MarkdownAutoscrollViewport( + globalBounds: box.localToGlobal(Offset.zero) & box.size, + ); + } + + bool canScroll({required bool forward}) => + forward ? offset < maxOffset : offset > 0; + + double applyScrollDelta(double delta) { + final next = (offset + delta).clamp(0.0, maxOffset); + final applied = next - offset; + if (applied.abs() < 0.5) return 0; + scrollCalls++; + setState(() => offset = next); + return applied; + } + + MarkdownAutoscrollTarget get target => MarkdownCallbackAutoscrollTarget( + viewportOf: viewportOf, + onScrollDelta: applyScrollDelta, + canScrollAt: canScroll, + ); + + @override + void initState() { + super.initState(); + widget.onState(this); + } + + @override + Widget build(BuildContext context) => Align( + alignment: Alignment.topLeft, + child: SizedBox( + key: _viewportKey, + width: 400, + height: widget.viewportHeight, + child: ClipRect( + child: OverflowBox( + alignment: Alignment.topLeft, + maxHeight: double.infinity, + child: Transform.translate( + offset: Offset(0, -offset), + child: SizedBox(width: 400, child: widget.child), + ), + ), + ), + ), + ); +} + +class _Doc extends StatelessWidget { + const _Doc(this.id); + + final String id; + + @override + Widget build(BuildContext context) { + final controller = MarkdownSelectionScope.of(context); + final model = controller.documents.firstWhere((d) => d.id == id).model; + return MarkdownWidget(markdown: model, documentId: id); + } +} + +/// A long single-paragraph document that wraps into many lines. +Markdown _longDoc() => Markdown.fromString( + List.generate(60, (i) => 'line $i of the body').join(' '), + ); + +const _config = MarkdownSelectionAutoscrollConfig( + edgeZone: 40, + maxVelocity: 2000, + useMediaQueryPadding: false, +); + +void main() { + group('MarkdownAutoscrollViewport', () { + test('padding deflates the padded bounds', () { + const viewport = MarkdownAutoscrollViewport( + globalBounds: Rect.fromLTWH(0, 100, 400, 200), + padding: EdgeInsets.only(top: 10, bottom: 20), + ); + expect( + viewport.paddedGlobalBounds, + const Rect.fromLTRB(0, 110, 400, 280), + ); + expect(viewport.isUsable, isTrue); + }); + + test('padding taller than the viewport is not usable', () { + const viewport = MarkdownAutoscrollViewport( + globalBounds: Rect.fromLTWH(0, 0, 400, 30), + padding: EdgeInsets.symmetric(vertical: 20), + ); + expect(viewport.isUsable, isFalse); + }); + + test('a non-finite viewport is not usable', () { + const viewport = MarkdownAutoscrollViewport( + globalBounds: Rect.fromLTRB(0, 0, 400, double.infinity), + ); + expect(viewport.isUsable, isFalse); + }); + }); + + group('custom scroll protocol', () { + /// Everything below drives [applyMarkdownSelectionAutoscroll] directly with + /// a hand-built target — no widget tree, no `Scrollable`. + const bounds = Rect.fromLTWH(0, 100, 400, 200); + + ({ + MarkdownAutoscrollTarget target, + List deltas, + double Function() offset, + }) makeTarget({ + EdgeInsets padding = EdgeInsets.zero, + double min = -1000, + double max = 1000, + MarkdownAutoscrollViewport? Function()? viewportOf, + }) { + var offset = 0.0; + final deltas = []; + final target = MarkdownCallbackAutoscrollTarget( + viewportOf: viewportOf ?? + () => MarkdownAutoscrollViewport( + globalBounds: bounds, + padding: padding, + ), + canScrollAt: ({required forward}) => + forward ? offset < max : offset > min, + onScrollDelta: (delta) { + final next = (offset + delta).clamp(min, max); + final applied = next - offset; + offset = next; + if (applied != 0) deltas.add(applied); + return applied; + }, + ); + return (target: target, deltas: deltas, offset: () => offset); + } + + MarkdownAutoscrollResult run( + MarkdownAutoscrollTarget target, + double globalY, { + MarkdownAutoscrollSession? session, + double hostTop = -1000, + double hostBottom = 5000, + Duration? last, + }) => + applyMarkdownSelectionAutoscroll( + globalPosition: Offset(10, globalY), + target: target, + config: _config, + lastTimestamp: last, + storeTimestamp: (_) {}, + hostUnionTopGlobalY: hostTop, + hostUnionBottomGlobalY: hostBottom, + session: session, + ); + + test('bottom band moves content up (positive delta)', () { + final t = makeTarget(); + // 3 px above the bottom edge → inside the 40 px band. + expect( + run(t.target, bounds.bottom - 3), + MarkdownAutoscrollResult.scrolled, + ); + expect(t.deltas, isNotEmpty); + expect(t.deltas.first, greaterThan(0)); + expect(t.offset(), greaterThan(0)); + }); + + test('top band moves content down (negative delta)', () { + final t = makeTarget(); + expect(run(t.target, bounds.top + 3), MarkdownAutoscrollResult.scrolled); + expect(t.deltas, isNotEmpty); + expect(t.deltas.first, lessThan(0)); + expect(t.offset(), lessThan(0)); + }); + + test('mid-viewport pointer is idle', () { + final t = makeTarget(); + expect(run(t.target, bounds.center.dy), MarkdownAutoscrollResult.idle); + expect(t.deltas, isEmpty); + }); + + test('canScroll false suppresses and disarms that direction', () { + final t = makeTarget(max: 0); + final session = MarkdownAutoscrollSession(); + expect( + run(t.target, bounds.bottom - 3, session: session), + MarkdownAutoscrollResult.suppressed, + ); + expect(t.deltas, isEmpty); + expect(session.isDisarmedTowardEnd, isTrue); + expect(session.isDisarmedTowardStart, isFalse); + }); + + test('a flush target (zero applied delta) suppresses and disarms', () { + var asked = 0; + final target = MarkdownCallbackAutoscrollTarget( + viewportOf: () => + const MarkdownAutoscrollViewport(globalBounds: bounds), + onScrollDelta: (_) { + asked++; + return 0; + }, + ); + final session = MarkdownAutoscrollSession(); + expect( + run(target, bounds.bottom - 3, session: session), + MarkdownAutoscrollResult.suppressed, + ); + expect(asked, 1); + expect(session.isDisarmedTowardEnd, isTrue); + }); + + test('a disarmed direction stays suppressed until the band is left', () { + final t = makeTarget(); + final session = MarkdownAutoscrollSession()..disarm(towardStart: false); + expect( + run(t.target, bounds.bottom - 3, session: session), + MarkdownAutoscrollResult.suppressed, + ); + expect(t.deltas, isEmpty); + // Leaving the band re-arms. + expect( + run(t.target, bounds.center.dy, session: session), + MarkdownAutoscrollResult.idle, + ); + expect(session.isDisarmedTowardEnd, isFalse); + expect( + run(t.target, bounds.bottom - 3, session: session), + MarkdownAutoscrollResult.scrolled, + ); + }); + + test('padding moves the activation band inward', () { + final padded = makeTarget(padding: const EdgeInsets.only(bottom: 60)); + // 50 px above the raw bottom edge is *below* the padded bottom (140), + // so it is still inside the band — but 10 px past the padded bottom. + expect( + run(padded.target, bounds.bottom - 50), + MarkdownAutoscrollResult.scrolled, + ); + + final plain = makeTarget(); + // The same point is deep inside an unpadded viewport → idle. + expect( + run(plain.target, bounds.bottom - 50), + MarkdownAutoscrollResult.idle, + ); + }); + + test('a null viewport idles without touching the target', () { + var asked = 0; + final target = MarkdownCallbackAutoscrollTarget( + viewportOf: () => null, + onScrollDelta: (delta) { + asked++; + return delta; + }, + ); + expect(run(target, 0), MarkdownAutoscrollResult.idle); + expect(asked, 0); + }); + + test('host union gate still applies to a custom target', () { + final t = makeTarget(); + // Host union ends mid-viewport → nothing left to reveal downward. + expect( + run( + t.target, + bounds.bottom - 3, + hostTop: bounds.top + 10, + hostBottom: bounds.top + 100, + ), + MarkdownAutoscrollResult.suppressed, + ); + expect(t.deltas, isEmpty); + }); + + test('a disabled config never resolves a target', () { + var asked = 0; + final target = MarkdownCallbackAutoscrollTarget( + viewportOf: () { + asked++; + return const MarkdownAutoscrollViewport(globalBounds: bounds); + }, + onScrollDelta: (delta) => delta, + ); + expect( + applyMarkdownSelectionAutoscroll( + globalPosition: Offset(10, bounds.bottom - 3), + target: target, + config: MarkdownSelectionAutoscrollConfig.disabled, + lastTimestamp: null, + storeTimestamp: (_) {}, + hostUnionTopGlobalY: -1000, + hostUnionBottomGlobalY: 5000, + ), + MarkdownAutoscrollResult.idle, + ); + expect(asked, 0); + }); + }); + + group('MarkdownScrollableAutoscrollTarget', () { + testWidgets('drives a forward list toward maxScrollExtent', + (tester) async { + final controller = ScrollController(); + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Align( + alignment: Alignment.topLeft, + child: SizedBox( + width: 400, + height: 200, + child: ListView( + controller: controller, + children: const [SizedBox(height: 2000)], + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + final state = tester.state(find.byType(Scrollable)); + final target = MarkdownScrollableAutoscrollTarget(state); + + expect(target.viewport, isNotNull); + expect(target.viewport!.globalBounds.height, 200); + expect(target.canScroll(forward: true), isTrue); + expect(target.canScroll(forward: false), isFalse); + + expect(target.applyScrollDelta(50), closeTo(50, 0.01)); + expect(controller.offset, closeTo(50, 0.01)); + expect(target.canScroll(forward: false), isTrue); + + expect(target.applyScrollDelta(-50), closeTo(-50, 0.01)); + expect(controller.offset, closeTo(0, 0.01)); + + // Flush at the min extent — no movement, and the caller is told so. + expect(target.applyScrollDelta(-50), 0); + controller.dispose(); + }); + + testWidgets('a reverse list keeps the screen-space delta sign', + (tester) async { + final controller = ScrollController(); + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Align( + alignment: Alignment.topLeft, + child: SizedBox( + width: 400, + height: 200, + child: ListView( + controller: controller, + reverse: true, + children: const [SizedBox(height: 2000)], + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + final state = tester.state(find.byType(Scrollable)); + expect(state.axisDirection, AxisDirection.up); + final target = MarkdownScrollableAutoscrollTarget(state); + + // A reverse list starts pinned at the bottom: content can only move + // *down* on screen (revealing what is above), i.e. a negative delta. + expect(target.canScroll(forward: true), isFalse); + expect(target.canScroll(forward: false), isTrue); + + // Screen-space "content down" must grow `pixels` on a reversed axis. + final applied = target.applyScrollDelta(-50); + expect(applied, closeTo(-50, 0.01)); + expect(controller.offset, closeTo(50, 0.01)); + controller.dispose(); + }); + + testWidgets('an unmounted scrollable reports no viewport', (tester) async { + final controller = ScrollController(); + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: SizedBox( + width: 400, + height: 200, + child: ListView( + controller: controller, + children: const [SizedBox(height: 2000)], + ), + ), + ), + ); + await tester.pumpAndSettle(); + final state = tester.state(find.byType(Scrollable)); + final target = MarkdownScrollableAutoscrollTarget(state); + expect(target.viewport, isNotNull); + + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pumpAndSettle(); + + expect(target.viewport, isNull); + expect(target.canScroll(forward: true), isFalse); + expect(target.applyScrollDelta(50), 0); + controller.dispose(); + }); + }); + + group('MarkdownSelectionScope autoscroll target resolution', () { + testWidgets('a custom scroll protocol is driven by a selection drag', + (tester) async { + final md = _longDoc(); + final selection = MarkdownSelectionController() + ..setDocuments([ + MarkdownDocumentRef(id: 'd', model: md), + ]); + addTearDown(selection.dispose); + + late _AnchorScrollHostState host; + var resolverCalls = 0; + + await tester.pumpWidget(MaterialApp( + home: Scaffold( + body: Builder( + builder: (context) => MarkdownSelectionScope( + controller: selection, + autoscroll: MarkdownSelectionAutoscrollConfig( + edgeZone: 40, + maxVelocity: 2000, + useMediaQueryPadding: false, + targetResolver: (request) { + resolverCalls++; + expect(request.config.edgeZone, 40); + expect(request.scopeContext.mounted, isTrue); + return host.target; + }, + ), + child: _AnchorScrollHost( + viewportHeight: 200, + onState: (state) => host = state, + child: const _Doc('d'), + ), + ), + ), + ), + )); + await tester.pumpAndSettle(); + + final viewport = tester.getRect(find.byType(ClipRect).first); + expect(host.offset, 0); + + final gesture = await tester.startGesture( + viewport.topLeft + const Offset(10, 10), + kind: PointerDeviceKind.mouse, + ); + await tester.pump(const Duration(milliseconds: 20)); + // Drag into the bottom edge band and hold there. + await gesture.moveTo(Offset(viewport.center.dx, viewport.bottom - 5)); + for (var i = 0; i < 6; i++) { + await tester.pump(const Duration(milliseconds: 16)); + } + await gesture.up(); + await tester.pumpAndSettle(); + + expect(resolverCalls, greaterThan(0)); + expect(host.scrollCalls, greaterThan(0)); + expect(host.offset, greaterThan(0)); + expect(selection.selection, isNotNull); + expect(selection.getText(), isNotEmpty); + expect(tester.takeException(), isNull); + }); + + testWidgets('the resolver is not re-run on every frame of one drag', + (tester) async { + final md = _longDoc(); + final selection = MarkdownSelectionController() + ..setDocuments([ + MarkdownDocumentRef(id: 'd', model: md), + ]); + addTearDown(selection.dispose); + + late _AnchorScrollHostState host; + var resolverCalls = 0; + + await tester.pumpWidget(MaterialApp( + home: Scaffold( + body: MarkdownSelectionScope( + controller: selection, + autoscroll: MarkdownSelectionAutoscrollConfig( + edgeZone: 40, + maxVelocity: 2000, + useMediaQueryPadding: false, + targetResolver: (_) { + resolverCalls++; + return host.target; + }, + ), + child: _AnchorScrollHost( + viewportHeight: 200, + onState: (state) => host = state, + child: const _Doc('d'), + ), + ), + ), + )); + await tester.pumpAndSettle(); + + final viewport = tester.getRect(find.byType(ClipRect).first); + final gesture = await tester.startGesture( + viewport.topLeft + const Offset(10, 10), + kind: PointerDeviceKind.mouse, + ); + await tester.pump(const Duration(milliseconds: 20)); + await gesture.moveTo(Offset(viewport.center.dx, viewport.bottom - 5)); + for (var i = 0; i < 10; i++) { + await tester.pump(const Duration(milliseconds: 16)); + } + final duringDrag = resolverCalls; + await gesture.up(); + await tester.pumpAndSettle(); + + expect(duringDrag, 1, + reason: 'the target is cached for the lifetime of one drag'); + expect(host.scrollCalls, greaterThan(3)); + }); + + testWidgets('a scope rebuild during a drag does not re-resolve the target', + (tester) async { + final md = _longDoc(); + final selection = MarkdownSelectionController() + ..setDocuments([ + MarkdownDocumentRef(id: 'd', model: md), + ]); + addTearDown(selection.dispose); + + late _AnchorScrollHostState host; + late StateSetter setStateFn; + var tick = 0; + var resolverCalls = 0; + + await tester.pumpWidget(MaterialApp( + home: Scaffold( + body: StatefulBuilder(builder: (context, setState) { + setStateFn = setState; + return MarkdownSelectionScope( + controller: selection, + // A fresh config (and a fresh closure) on every rebuild — what a + // real host writes. The cached surface must survive it. + autoscroll: MarkdownSelectionAutoscrollConfig( + edgeZone: 40, + maxVelocity: 2000 + tick * 0.0, + useMediaQueryPadding: false, + targetResolver: (_) { + resolverCalls++; + return host.target; + }, + ), + child: _AnchorScrollHost( + viewportHeight: 200, + onState: (state) => host = state, + child: const _Doc('d'), + ), + ); + }), + ), + )); + await tester.pumpAndSettle(); + + final viewport = tester.getRect(find.byType(ClipRect).first); + final gesture = await tester.startGesture( + viewport.topLeft + const Offset(10, 10), + kind: PointerDeviceKind.mouse, + ); + await tester.pump(const Duration(milliseconds: 20)); + await gesture.moveTo(Offset(viewport.center.dx, viewport.bottom - 5)); + for (var i = 0; i < 10; i++) { + setStateFn(() => tick++); + await tester.pump(const Duration(milliseconds: 16)); + } + final duringDrag = resolverCalls; + await gesture.up(); + await tester.pumpAndSettle(); + + expect(duringDrag, 1); + expect(host.scrollCalls, greaterThan(3)); + }); + + testWidgets('a zero max velocity never drives the host', (tester) async { + final md = _longDoc(); + final selection = MarkdownSelectionController() + ..setDocuments([ + MarkdownDocumentRef(id: 'd', model: md), + ]); + addTearDown(selection.dispose); + + late _AnchorScrollHostState host; + + await tester.pumpWidget(MaterialApp( + home: Scaffold( + body: MarkdownSelectionScope( + controller: selection, + autoscroll: MarkdownSelectionAutoscrollConfig( + edgeZone: 40, + maxVelocity: 0, + useMediaQueryPadding: false, + targetResolver: (_) => host.target, + ), + child: _AnchorScrollHost( + viewportHeight: 200, + onState: (state) => host = state, + child: const _Doc('d'), + ), + ), + ), + )); + await tester.pumpAndSettle(); + + final viewport = tester.getRect(find.byType(ClipRect).first); + final gesture = await tester.startGesture( + viewport.topLeft + const Offset(10, 10), + kind: PointerDeviceKind.mouse, + ); + await tester.pump(const Duration(milliseconds: 20)); + // Hold far past the outer band — the branch that used to report + // `scrolled` forever on a zero velocity. + await gesture.moveTo(Offset(viewport.center.dx, viewport.bottom + 200)); + for (var i = 0; i < 10; i++) { + await tester.pump(const Duration(milliseconds: 16)); + } + await gesture.up(); + await tester.pumpAndSettle(); + + expect(host.scrollCalls, 0); + expect(host.offset, 0); + }); + + testWidgets('a resolver returning null leaves the host unscrolled', + (tester) async { + final md = _longDoc(); + final selection = MarkdownSelectionController() + ..setDocuments([ + MarkdownDocumentRef(id: 'd', model: md), + ]); + addTearDown(selection.dispose); + + late _AnchorScrollHostState host; + + await tester.pumpWidget(MaterialApp( + home: Scaffold( + body: MarkdownSelectionScope( + controller: selection, + autoscroll: const MarkdownSelectionAutoscrollConfig( + edgeZone: 40, + maxVelocity: 2000, + useMediaQueryPadding: false, + targetResolver: _nullResolver, + ), + child: _AnchorScrollHost( + viewportHeight: 200, + onState: (state) => host = state, + child: const _Doc('d'), + ), + ), + ), + )); + await tester.pumpAndSettle(); + + final viewport = tester.getRect(find.byType(ClipRect).first); + final gesture = await tester.startGesture( + viewport.topLeft + const Offset(10, 10), + kind: PointerDeviceKind.mouse, + ); + await tester.pump(const Duration(milliseconds: 20)); + await gesture.moveTo(Offset(viewport.center.dx, viewport.bottom - 5)); + for (var i = 0; i < 6; i++) { + await tester.pump(const Duration(milliseconds: 16)); + } + await gesture.up(); + await tester.pumpAndSettle(); + + expect(host.scrollCalls, 0); + expect(host.offset, 0); + // Selection still works — only the scroll is host-owned. + expect(selection.selection, isNotNull); + expect(tester.takeException(), isNull); + }); + + testWidgets('a host that reports it cannot scroll is not driven', + (tester) async { + final md = _longDoc(); + final selection = MarkdownSelectionController() + ..setDocuments([ + MarkdownDocumentRef(id: 'd', model: md), + ]); + addTearDown(selection.dispose); + + late _AnchorScrollHostState host; + + await tester.pumpWidget(MaterialApp( + home: Scaffold( + body: MarkdownSelectionScope( + controller: selection, + autoscroll: MarkdownSelectionAutoscrollConfig( + edgeZone: 40, + maxVelocity: 2000, + useMediaQueryPadding: false, + targetResolver: (_) => host.target, + ), + child: _AnchorScrollHost( + viewportHeight: 200, + maxOffset: 0, + onState: (state) => host = state, + child: const _Doc('d'), + ), + ), + ), + )); + await tester.pumpAndSettle(); + + final viewport = tester.getRect(find.byType(ClipRect).first); + final gesture = await tester.startGesture( + viewport.topLeft + const Offset(10, 10), + kind: PointerDeviceKind.mouse, + ); + await tester.pump(const Duration(milliseconds: 20)); + await gesture.moveTo(Offset(viewport.center.dx, viewport.bottom - 5)); + for (var i = 0; i < 6; i++) { + await tester.pump(const Duration(milliseconds: 16)); + } + await gesture.up(); + await tester.pumpAndSettle(); + + expect(host.scrollCalls, 0); + expect(host.offset, 0); + expect(tester.takeException(), isNull); + }); + + testWidgets('without a resolver the enclosing ListView is still driven', + (tester) async { + final md = _longDoc(); + final selection = MarkdownSelectionController() + ..setDocuments([ + MarkdownDocumentRef(id: 'd', model: md), + ]); + addTearDown(selection.dispose); + final scroll = ScrollController(); + addTearDown(scroll.dispose); + + await tester.pumpWidget(MaterialApp( + home: Scaffold( + body: MarkdownSelectionScope( + controller: selection, + autoscroll: const MarkdownSelectionAutoscrollConfig( + edgeZone: 40, + maxVelocity: 2000, + useMediaQueryPadding: false, + ), + child: Align( + alignment: Alignment.topLeft, + child: SizedBox( + width: 400, + height: 200, + child: ListView( + controller: scroll, + children: const [_Doc('d'), SizedBox(height: 1200)], + ), + ), + ), + ), + ), + )); + await tester.pumpAndSettle(); + + final viewport = tester.getRect(find.byType(Scrollable)); + final gesture = await tester.startGesture( + viewport.topLeft + const Offset(10, 10), + kind: PointerDeviceKind.mouse, + ); + await tester.pump(const Duration(milliseconds: 20)); + await gesture.moveTo(Offset(viewport.center.dx, viewport.bottom - 5)); + for (var i = 0; i < 6; i++) { + await tester.pump(const Duration(milliseconds: 16)); + } + await gesture.up(); + await tester.pumpAndSettle(); + + expect(scroll.offset, greaterThan(0)); + expect(tester.takeException(), isNull); + }); + }); +} + +MarkdownAutoscrollTarget? _nullResolver(MarkdownAutoscrollRequest request) => + null; diff --git a/test/selection/selection_chrome_test.dart b/test/selection/selection_chrome_test.dart new file mode 100644 index 0000000..80737c3 --- /dev/null +++ b/test/selection/selection_chrome_test.dart @@ -0,0 +1,491 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_md/flutter_md.dart'; +import 'package:flutter_test/flutter_test.dart'; + +class _Doc extends StatelessWidget { + const _Doc(this.id); + + final String id; + + @override + Widget build(BuildContext context) { + final controller = MarkdownSelectionScope.of(context); + final model = controller.documents.firstWhere((d) => d.id == id).model; + return MarkdownWidget(markdown: model, documentId: id); + } +} + +/// Runs [body] with [platform] forced (try/finally — the framework's +/// `debugAssertAllFoundationVarsUnset` check runs before user tear-downs). +Future _withPlatform( + TargetPlatform platform, + Future Function() body, +) async { + debugDefaultTargetPlatformOverride = platform; + try { + await body(); + } finally { + debugDefaultTargetPlatformOverride = null; + } +} + +Future _mouseDrag(WidgetTester tester, Offset from, Offset to) async { + final g = await tester.startGesture(from, kind: PointerDeviceKind.mouse); + await tester.pump(const Duration(milliseconds: 100)); + await g.moveTo(to); + await tester.pump(const Duration(milliseconds: 100)); + await g.up(); + await tester.pumpAndSettle(); +} + +List _messages(int count) => [ + for (var i = 0; i < count; i++) + MarkdownDocumentRef( + id: 'm$i', + model: Markdown.fromString('Message number $i body text here'), + order: i, + ), + ]; + +void main() { + group('handle proxies', () { + testWidgets('an unmounted base edge proxies onto visible selection', + (tester) async { + await _withPlatform(TargetPlatform.android, () async { + final docs = _messages(30); + final controller = MarkdownSelectionController()..setDocuments(docs); + addTearDown(controller.dispose); + final scroll = ScrollController(); + addTearDown(scroll.dispose); + + await tester.pumpWidget(MaterialApp( + home: Scaffold( + body: MarkdownSelectionScope( + controller: controller, + child: SizedBox( + height: 200, + child: ListView.builder( + controller: scroll, + itemCount: docs.length, + itemBuilder: (context, i) => _Doc('m$i'), + ), + ), + ), + ), + )); + await tester.pumpAndSettle(); + + // Base in m0, extent far down the list. + controller.selection = const MarkdownSelection( + base: MarkdownPosition(documentId: 'm0', blockIndex: 0, offset: 0), + extent: + MarkdownPosition(documentId: 'm12', blockIndex: 0, offset: 10), + ); + await tester.pumpAndSettle(); + + // Scroll the base out of the built range; the extent stays visible. + scroll.jumpTo(600); + await tester.pumpAndSettle(); + + expect( + controller.selectionEndpointGlobalRect(base: true), + isNull, + reason: 'the true base caret is unmounted', + ); + final endpoints = controller.selectionHandleEndpoints(); + expect(endpoints, isNotNull, + reason: 'handles must still be placeable via a proxy'); + expect(endpoints!.startGlobal.height, greaterThan(0)); + expect(endpoints.endGlobal.height, greaterThan(0)); + expect(tester.takeException(), isNull); + }); + }); + + testWidgets('both endpoints unmounted still keeps the range', + (tester) async { + final docs = _messages(40); + final controller = MarkdownSelectionController()..setDocuments(docs); + addTearDown(controller.dispose); + final scroll = ScrollController(); + addTearDown(scroll.dispose); + + await tester.pumpWidget(MaterialApp( + home: Scaffold( + body: MarkdownSelectionScope( + controller: controller, + child: SizedBox( + height: 200, + child: ListView.builder( + controller: scroll, + itemCount: docs.length, + itemBuilder: (context, i) => _Doc('m$i'), + ), + ), + ), + ), + )); + await tester.pumpAndSettle(); + + controller.selection = const MarkdownSelection( + base: MarkdownPosition(documentId: 'm0', blockIndex: 0, offset: 0), + extent: MarkdownPosition(documentId: 'm2', blockIndex: 0, offset: 5), + ); + await tester.pumpAndSettle(); + final text = controller.getText(); + expect(text, isNotEmpty); + + scroll.jumpTo(2000); + await tester.pumpAndSettle(); + + expect(controller.getText(), text); + expect(controller.selectionHandleEndpoints(), isNull, + reason: 'nothing visible paints the selection any more'); + expect(tester.takeException(), isNull); + }); + }); + + group('desktop context menu', () { + testWidgets('right-click does not mutate the selection', (tester) async { + await _withPlatform(TargetPlatform.macOS, () async { + final controller = MarkdownSelectionController() + ..setDocuments([ + MarkdownDocumentRef( + id: 'd', + model: Markdown.fromString('Hello selectable world'), + ), + ]); + addTearDown(controller.dispose); + + await tester.pumpWidget(MaterialApp( + theme: ThemeData(platform: TargetPlatform.macOS), + home: Scaffold( + body: MarkdownSelectionScope( + controller: controller, + child: const Align( + alignment: Alignment.topLeft, + child: SizedBox(width: 400, child: _Doc('d')), + ), + ), + ), + )); + await tester.pumpAndSettle(); + + final box = tester.getRect(find.byType(MarkdownWidget)); + await _mouseDrag( + tester, + box.topLeft + const Offset(2, 4), + box.topLeft + const Offset(40, 4), + ); + final before = controller.selection; + expect(before, isNotNull); + + final g = await tester.startGesture( + box.centerRight - const Offset(10, 0), + kind: PointerDeviceKind.mouse, + buttons: kSecondaryMouseButton, + ); + await g.up(); + await tester.pumpAndSettle(); + + expect(controller.selection, before, + reason: 'secondary click must not select a word or collapse'); + expect(find.text('Copy'), findsOneWidget); + }); + }); + + testWidgets('a desktop mouse drag does not pop the toolbar', + (tester) async { + await _withPlatform(TargetPlatform.linux, () async { + final controller = MarkdownSelectionController() + ..setDocuments([ + MarkdownDocumentRef( + id: 'd', + model: Markdown.fromString('Hello selectable world'), + ), + ]); + addTearDown(controller.dispose); + + late MarkdownSelectionScopeState state; + await tester.pumpWidget(MaterialApp( + theme: ThemeData(platform: TargetPlatform.linux), + home: Scaffold( + body: MarkdownSelectionScope( + controller: controller, + child: Align( + alignment: Alignment.topLeft, + child: SizedBox( + width: 400, + child: Builder(builder: (context) { + state = MarkdownSelectionScope.stateOf(context)!; + return const _Doc('d'); + }), + ), + ), + ), + ), + )); + await tester.pumpAndSettle(); + + final box = tester.getRect(find.byType(MarkdownWidget)); + await _mouseDrag( + tester, + box.topLeft + const Offset(2, 4), + box.centerRight - const Offset(2, 0), + ); + + expect(controller.getText(), isNotEmpty); + expect(state.toolbarIsVisible, isFalse); + expect(controller.toolbarWanted, isFalse); + }); + }); + + testWidgets('an ancestor scroll dismisses the desktop toolbar', + (tester) async { + await _withPlatform(TargetPlatform.linux, () async { + final docs = _messages(30); + final controller = MarkdownSelectionController()..setDocuments(docs); + addTearDown(controller.dispose); + final scroll = ScrollController(); + addTearDown(scroll.dispose); + + late MarkdownSelectionScopeState state; + await tester.pumpWidget(MaterialApp( + theme: ThemeData(platform: TargetPlatform.linux), + home: Scaffold( + body: MarkdownSelectionScope( + controller: controller, + child: SizedBox( + height: 200, + child: Builder(builder: (context) { + state = MarkdownSelectionScope.stateOf(context)!; + return ListView.builder( + controller: scroll, + itemCount: docs.length, + itemBuilder: (context, i) => _Doc('m$i'), + ); + }), + ), + ), + ), + )); + await tester.pumpAndSettle(); + + final first = tester.getRect(find.byType(MarkdownWidget).first); + await _mouseDrag( + tester, + first.topLeft + const Offset(2, 4), + first.topLeft + const Offset(60, 4), + ); + state.showToolbar(first.center); + await tester.pumpAndSettle(); + expect(state.toolbarIsVisible, isTrue); + + scroll.jumpTo(120); + await tester.pumpAndSettle(); + + expect(state.toolbarIsVisible, isFalse); + }); + }); + }); + + group('enabled flag', () { + testWidgets('disabling hides chrome and enabling restores it', + (tester) async { + await _withPlatform(TargetPlatform.android, () async { + final controller = MarkdownSelectionController() + ..setDocuments([ + MarkdownDocumentRef( + id: 'd', + model: Markdown.fromString('Hello selectable world'), + ), + ]); + addTearDown(controller.dispose); + + var enabled = true; + late StateSetter setStateFn; + late MarkdownSelectionScopeState state; + + await tester.pumpWidget(MaterialApp( + theme: ThemeData(platform: TargetPlatform.android), + home: Scaffold( + body: StatefulBuilder(builder: (context, setState) { + setStateFn = setState; + return MarkdownSelectionScope( + controller: controller, + enabled: enabled, + child: Align( + alignment: Alignment.topLeft, + child: SizedBox( + width: 400, + child: Builder(builder: (context) { + state = MarkdownSelectionScope.stateOf(context)!; + return const _Doc('d'); + }), + ), + ), + ); + }), + ), + )); + await tester.pumpAndSettle(); + + controller.selectAll(); + await tester.pumpAndSettle(); + expect(controller.toolbarWanted, isTrue); + expect(state.selectionHandleLeadersAttached, isTrue); + + setStateFn(() => enabled = false); + await tester.pumpAndSettle(); + expect(state.toolbarIsVisible, isFalse); + expect(state.selectionHandleLeadersAttached, isFalse); + expect(controller.toolbarWanted, isTrue, + reason: 'the restore intent survives a disable'); + + setStateFn(() => enabled = true); + await tester.pumpAndSettle(); + expect(state.selectionHandleLeadersAttached, isTrue); + }); + }); + + testWidgets('a disabled scope does not select on drag', (tester) async { + final controller = MarkdownSelectionController() + ..setDocuments([ + MarkdownDocumentRef( + id: 'd', + model: Markdown.fromString('Hello selectable world'), + ), + ]); + addTearDown(controller.dispose); + + await tester.pumpWidget(MaterialApp( + home: Scaffold( + body: MarkdownSelectionScope( + controller: controller, + enabled: false, + child: const Align( + alignment: Alignment.topLeft, + child: SizedBox(width: 400, child: _Doc('d')), + ), + ), + ), + )); + await tester.pumpAndSettle(); + + final box = tester.getRect(find.byType(MarkdownWidget)); + await _mouseDrag( + tester, + box.topLeft + const Offset(2, 4), + box.centerRight - const Offset(2, 0), + ); + + expect(controller.selection, isNull); + }); + }); + + group('autoscroll through virtualization', () { + testWidgets('dragging to the edge builds and selects new messages', + (tester) async { + final docs = _messages(60); + final controller = MarkdownSelectionController()..setDocuments(docs); + addTearDown(controller.dispose); + final scroll = ScrollController(); + addTearDown(scroll.dispose); + + await tester.pumpWidget(MaterialApp( + home: Scaffold( + body: MarkdownSelectionScope( + controller: controller, + autoscroll: const MarkdownSelectionAutoscrollConfig( + edgeZone: 40, + maxVelocity: 3000, + useMediaQueryPadding: false, + ), + child: SizedBox( + height: 200, + child: ListView.builder( + controller: scroll, + itemCount: docs.length, + itemBuilder: (context, i) => _Doc('m$i'), + ), + ), + ), + ), + )); + await tester.pumpAndSettle(); + + final viewport = tester.getRect(find.byType(Scrollable)); + final gesture = await tester.startGesture( + viewport.topLeft + const Offset(4, 6), + kind: PointerDeviceKind.mouse, + ); + await tester.pump(const Duration(milliseconds: 20)); + await gesture.moveTo(Offset(viewport.center.dx, viewport.bottom - 4)); + // Hold at the edge across many frames: items attach and detach + // underneath the pointer while the selection keeps extending. + for (var i = 0; i < 40; i++) { + await tester.pump(const Duration(milliseconds: 16)); + } + await gesture.up(); + await tester.pumpAndSettle(); + + expect(scroll.offset, greaterThan(0)); + expect(controller.selection, isNotNull); + expect(controller.selection!.base.documentId, 'm0'); + expect(controller.selection!.extent.documentId, isNot('m0'), + reason: 'the drag must have extended across messages'); + expect(tester.takeException(), isNull); + }); + + testWidgets('the ticker stops when the drag ends', (tester) async { + final docs = _messages(40); + final controller = MarkdownSelectionController()..setDocuments(docs); + addTearDown(controller.dispose); + final scroll = ScrollController(); + addTearDown(scroll.dispose); + + await tester.pumpWidget(MaterialApp( + home: Scaffold( + body: MarkdownSelectionScope( + controller: controller, + autoscroll: const MarkdownSelectionAutoscrollConfig( + edgeZone: 40, + maxVelocity: 3000, + useMediaQueryPadding: false, + ), + child: SizedBox( + height: 200, + child: ListView.builder( + controller: scroll, + itemCount: docs.length, + itemBuilder: (context, i) => _Doc('m$i'), + ), + ), + ), + ), + )); + await tester.pumpAndSettle(); + + final viewport = tester.getRect(find.byType(Scrollable)); + final gesture = await tester.startGesture( + viewport.topLeft + const Offset(4, 6), + kind: PointerDeviceKind.mouse, + ); + await tester.pump(const Duration(milliseconds: 20)); + await gesture.moveTo(Offset(viewport.center.dx, viewport.bottom - 4)); + for (var i = 0; i < 10; i++) { + await tester.pump(const Duration(milliseconds: 16)); + } + await gesture.up(); + await tester.pumpAndSettle(); + + final settled = scroll.offset; + for (var i = 0; i < 10; i++) { + await tester.pump(const Duration(milliseconds: 16)); + } + expect(scroll.offset, settled, + reason: 'the frame ticker must not outlive the drag'); + }); + }); +} diff --git a/test/selection/selection_host_gates_test.dart b/test/selection/selection_host_gates_test.dart new file mode 100644 index 0000000..00b1a34 --- /dev/null +++ b/test/selection/selection_host_gates_test.dart @@ -0,0 +1,607 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_md/flutter_md.dart'; +import 'package:flutter_test/flutter_test.dart'; + +class _Doc extends StatelessWidget { + const _Doc(this.id); + + final String id; + + @override + Widget build(BuildContext context) { + final controller = MarkdownSelectionScope.of(context); + final model = controller.documents.firstWhere((d) => d.id == id).model; + return MarkdownWidget(markdown: model, documentId: id); + } +} + +MarkdownSelectionController _controllerWith(Map docs) => + MarkdownSelectionController() + ..setDocuments([ + for (final entry in docs.entries) + MarkdownDocumentRef( + id: entry.key, + model: Markdown.fromString(entry.value), + ), + ]); + +Future _mouseDrag(WidgetTester tester, Offset from, Offset to) async { + final g = await tester.startGesture(from, kind: PointerDeviceKind.mouse); + await tester.pump(const Duration(milliseconds: 100)); + await g.moveTo(to); + await tester.pump(const Duration(milliseconds: 100)); + await g.up(); + await tester.pumpAndSettle(); +} + +Future _longPressDrag( + WidgetTester tester, + Offset from, + Offset to, +) async { + final g = await tester.startGesture(from); + await tester.pump(const Duration(milliseconds: 700)); + await g.moveTo(to); + await tester.pump(const Duration(milliseconds: 100)); + await g.up(); + await tester.pumpAndSettle(); +} + +/// Runs [body] with [platform] forced. +/// +/// NOTE: reset via try/finally rather than `addTearDown` — the framework's +/// `debugAssertAllFoundationVarsUnset` check runs before user tear-downs, so a +/// tear-down reset arrives too late and fails the test. +Future _withPlatform( + TargetPlatform platform, + Future Function() body, +) async { + debugDefaultTargetPlatformOverride = platform; + try { + await body(); + } finally { + debugDefaultTargetPlatformOverride = null; + } +} + +Widget _scope({ + required MarkdownSelectionController controller, + required Widget child, + bool Function(Offset globalPosition)? canStartSelectionAt, + bool enableTouchGestures = true, + bool enableTouchConsecutiveTaps = true, + FocusNode? focusNode, +}) => + MaterialApp( + home: Scaffold( + body: MarkdownSelectionScope( + controller: controller, + canStartSelectionAt: canStartSelectionAt, + enableTouchGestures: enableTouchGestures, + enableTouchConsecutiveTaps: enableTouchConsecutiveTaps, + focusNode: focusNode, + child: Align( + alignment: Alignment.topLeft, + child: SizedBox(width: 400, child: child), + ), + ), + ), + ); + +void main() { + group('canStartSelectionAt', () { + testWidgets('a refused mouse drag never starts a selection', + (tester) async { + final controller = _controllerWith({'d': 'Hello selectable world'}); + addTearDown(controller.dispose); + + await tester.pumpWidget(_scope( + controller: controller, + canStartSelectionAt: (_) => false, + child: const _Doc('d'), + )); + await tester.pumpAndSettle(); + + final box = tester.getRect(find.byType(MarkdownWidget)); + await _mouseDrag( + tester, + box.topLeft + const Offset(2, 4), + box.centerRight - const Offset(2, 0), + ); + + expect(controller.selection, isNull); + expect(controller.getText(), isEmpty); + }); + + testWidgets('a refused long press never starts a selection', + (tester) async { + await _withPlatform(TargetPlatform.android, () async { + final controller = _controllerWith({'d': 'Hello selectable world'}); + addTearDown(controller.dispose); + + await tester.pumpWidget(_scope( + controller: controller, + canStartSelectionAt: (_) => false, + child: const _Doc('d'), + )); + await tester.pumpAndSettle(); + + final box = tester.getRect(find.byType(MarkdownWidget)); + await _longPressDrag( + tester, + box.topLeft + const Offset(2, 4), + box.centerRight - const Offset(2, 0), + ); + + expect(controller.selection, isNull); + expect(controller.getText(), isEmpty); + }); + }); + + testWidgets( + 'a long press on refused chrome does not re-anchor an active range', + (tester) async { + await _withPlatform(TargetPlatform.android, () async { + final controller = _controllerWith({'d': 'Hello selectable world'}); + addTearDown(controller.dispose); + + // Refuse the right half of the widget (pretend it is a link). + var split = double.infinity; + await tester.pumpWidget(_scope( + controller: controller, + canStartSelectionAt: (global) => global.dx < split, + child: const _Doc('d'), + )); + await tester.pumpAndSettle(); + final box = tester.getRect(find.byType(MarkdownWidget)); + split = box.center.dx; + + // Arm a real selection on the allowed half first. + await _longPressDrag( + tester, + box.topLeft + const Offset(2, 4), + box.topLeft + const Offset(40, 4), + ); + final armed = controller.selection; + expect(armed, isNotNull); + expect(armed!.isCollapsed, isFalse); + + // Long-pressing refused chrome must not re-anchor the range there. + await _longPressDrag( + tester, + box.centerRight - const Offset(6, 0), + box.centerRight - const Offset(6, 0), + ); + + expect( + controller.selection, + anyOf(isNull, armed), + reason: 'refused chrome may dismiss, but must not select on it', + ); + }); + }); + + testWidgets('a refused mouse drag does not extend from the refused point', + (tester) async { + final controller = _controllerWith({'d': 'Hello selectable world'}); + addTearDown(controller.dispose); + + var split = double.infinity; + await tester.pumpWidget(_scope( + controller: controller, + canStartSelectionAt: (global) => global.dx > split, + child: const _Doc('d'), + )); + await tester.pumpAndSettle(); + final box = tester.getRect(find.byType(MarkdownWidget)); + split = box.center.dx; + + // Start inside the refused left half and drag right into allowed space. + await _mouseDrag( + tester, + box.topLeft + const Offset(2, 4), + box.centerRight - const Offset(2, 0), + ); + + expect(controller.selection, isNull); + }); + + testWidgets('an allowed point still selects normally', (tester) async { + final controller = _controllerWith({'d': 'Hello selectable world'}); + addTearDown(controller.dispose); + + await tester.pumpWidget(_scope( + controller: controller, + canStartSelectionAt: (_) => true, + child: const _Doc('d'), + )); + await tester.pumpAndSettle(); + + final box = tester.getRect(find.byType(MarkdownWidget)); + await _mouseDrag( + tester, + box.topLeft + const Offset(2, 4), + box.centerRight - const Offset(2, 0), + ); + + expect(controller.getText(), 'Hello selectable world'); + }); + }); + + group('enableTouchGestures', () { + testWidgets('false leaves touch long-press to the host', (tester) async { + await _withPlatform(TargetPlatform.android, () async { + final controller = _controllerWith({'d': 'Hello selectable world'}); + addTearDown(controller.dispose); + + await tester.pumpWidget(_scope( + controller: controller, + enableTouchGestures: false, + child: const _Doc('d'), + )); + await tester.pumpAndSettle(); + + final box = tester.getRect(find.byType(MarkdownWidget)); + await _longPressDrag( + tester, + box.topLeft + const Offset(2, 4), + box.centerRight - const Offset(2, 0), + ); + + expect(controller.selection, isNull); + }); + }); + + testWidgets('false keeps mouse drag selection alive', (tester) async { + await _withPlatform(TargetPlatform.android, () async { + final controller = _controllerWith({'d': 'Hello selectable world'}); + addTearDown(controller.dispose); + + await tester.pumpWidget(_scope( + controller: controller, + enableTouchGestures: false, + child: const _Doc('d'), + )); + await tester.pumpAndSettle(); + + final box = tester.getRect(find.byType(MarkdownWidget)); + await _mouseDrag( + tester, + box.topLeft + const Offset(2, 4), + box.centerRight - const Offset(2, 0), + ); + + expect(controller.getText(), 'Hello selectable world'); + }); + }); + + testWidgets('false keeps a programmatic selection through focus loss', + (tester) async { + final controller = _controllerWith({'d': 'Hello selectable world'}); + addTearDown(controller.dispose); + final focus = FocusNode(); + addTearDown(focus.dispose); + final other = FocusNode(); + addTearDown(other.dispose); + + await tester.pumpWidget(MaterialApp( + home: Scaffold( + body: Column( + children: [ + MarkdownSelectionScope( + controller: controller, + focusNode: focus, + enableTouchGestures: false, + child: const SizedBox(width: 400, child: _Doc('d')), + ), + Focus(focusNode: other, child: const SizedBox(height: 10)), + ], + ), + ), + )); + await tester.pumpAndSettle(); + + focus.requestFocus(); + await tester.pumpAndSettle(); + controller.selectAll(); + await tester.pumpAndSettle(); + expect(controller.selection, isNotNull); + + other.requestFocus(); + await tester.pumpAndSettle(); + + expect(controller.selection, isNotNull, + reason: 'host-owned touch entry must not clear on focus loss'); + }); + }); + + group('enableTouchConsecutiveTaps', () { + testWidgets('false keeps the long-press word selection working', + (tester) async { + await _withPlatform(TargetPlatform.android, () async { + final controller = _controllerWith({'d': 'Hello selectable world'}); + addTearDown(controller.dispose); + + await tester.pumpWidget(_scope( + controller: controller, + enableTouchConsecutiveTaps: false, + child: const _Doc('d'), + )); + await tester.pumpAndSettle(); + + final box = tester.getRect(find.byType(MarkdownWidget)); + await _longPressDrag( + tester, + box.topLeft + const Offset(2, 4), + box.topLeft + const Offset(2, 4), + ); + + expect(controller.getText(), 'Hello'); + }); + }); + + testWidgets('false leaves a plain touch tap to the host', (tester) async { + await _withPlatform(TargetPlatform.android, () async { + final controller = _controllerWith({'d': 'Hello selectable world'}); + addTearDown(controller.dispose); + + await tester.pumpWidget(_scope( + controller: controller, + enableTouchConsecutiveTaps: false, + child: const _Doc('d'), + )); + await tester.pumpAndSettle(); + + final box = tester.getRect(find.byType(MarkdownWidget)); + await _longPressDrag( + tester, + box.topLeft + const Offset(2, 4), + box.topLeft + const Offset(2, 4), + ); + expect(controller.getText(), 'Hello'); + + // A bare tap is host territory now — the range survives it. + await tester.tapAt(box.center); + await tester.pumpAndSettle(); + expect(controller.getText(), 'Hello'); + }); + }); + + testWidgets('true dismisses an active range on a plain touch tap', + (tester) async { + await _withPlatform(TargetPlatform.android, () async { + final controller = _controllerWith({'d': 'Hello selectable world'}); + addTearDown(controller.dispose); + + await tester.pumpWidget(_scope( + controller: controller, + child: const _Doc('d'), + )); + await tester.pumpAndSettle(); + + final box = tester.getRect(find.byType(MarkdownWidget)); + await _longPressDrag( + tester, + box.topLeft + const Offset(2, 4), + box.topLeft + const Offset(2, 4), + ); + expect(controller.getText(), 'Hello'); + + await tester.tapAt(box.center); + await tester.pumpAndSettle(); + expect(controller.selection, isNull); + }); + }); + }); + + group('ownsSelectionChrome', () { + testWidgets('a non-owning sibling does not strip the owner handles', + (tester) async { + await _withPlatform(TargetPlatform.android, () async { + final controller = _controllerWith({ + 'a': 'First message body text', + 'b': 'Second message body text', + }); + addTearDown(controller.dispose); + + late MarkdownSelectionScopeState ownerState; + + await tester.pumpWidget(MaterialApp( + home: Scaffold( + body: Align( + alignment: Alignment.topLeft, + child: SizedBox( + width: 400, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + MarkdownSelectionScope( + controller: controller, + ownsSelectionChrome: (id) => id == 'a', + child: Builder(builder: (context) { + ownerState = MarkdownSelectionScope.stateOf(context)!; + return const _Doc('a'); + }), + ), + MarkdownSelectionScope( + controller: controller, + ownsSelectionChrome: (id) => id == 'b', + child: const _Doc('b'), + ), + ], + ), + ), + ), + ), + )); + await tester.pumpAndSettle(); + + final first = tester.getRect(find.byType(MarkdownWidget).first); + await _longPressDrag( + tester, + first.topLeft + const Offset(2, 4), + first.topLeft + const Offset(60, 4), + ); + + expect(controller.selection, isNotNull); + expect(controller.selection!.isCollapsed, isFalse); + expect(controller.selection!.base.documentId, 'a'); + expect(ownerState.selectionHandleLeadersAttached, isTrue); + + // Force every scope to re-sync (what a scroll / rebuild does). + controller.notifyListeners(); + await tester.pumpAndSettle(); + + expect(ownerState.selectionHandleLeadersAttached, isTrue, + reason: 'the non-owning sibling must not clear foreign leaders'); + }); + }); + + testWidgets('a disabled sibling does not strip the owner handles', + (tester) async { + await _withPlatform(TargetPlatform.android, () async { + final controller = _controllerWith({ + 'a': 'First message body text', + 'b': 'Second message body text', + }); + addTearDown(controller.dispose); + + late MarkdownSelectionScopeState ownerState; + + await tester.pumpWidget(MaterialApp( + home: Scaffold( + body: Align( + alignment: Alignment.topLeft, + child: SizedBox( + width: 400, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + MarkdownSelectionScope( + controller: controller, + child: Builder(builder: (context) { + ownerState = MarkdownSelectionScope.stateOf(context)!; + return const _Doc('a'); + }), + ), + MarkdownSelectionScope( + controller: controller, + enabled: false, + child: const _Doc('b'), + ), + ], + ), + ), + ), + ), + )); + await tester.pumpAndSettle(); + + final first = tester.getRect(find.byType(MarkdownWidget).first); + await _longPressDrag( + tester, + first.topLeft + const Offset(2, 4), + first.topLeft + const Offset(60, 4), + ); + + expect(ownerState.selectionHandleLeadersAttached, isTrue); + controller.notifyListeners(); + await tester.pumpAndSettle(); + expect(ownerState.selectionHandleLeadersAttached, isTrue); + }); + }); + }); + + group('glyph-tight hit testing', () { + testWidgets('empty gutter beside a short line is not selectable ink', + (tester) async { + final controller = _controllerWith({'d': 'Hi'}); + addTearDown(controller.dispose); + + await tester.pumpWidget(_scope( + controller: controller, + child: const _Doc('d'), + )); + await tester.pumpAndSettle(); + + final box = tester.getRect(find.byType(MarkdownWidget)); + final onInk = box.topLeft + const Offset(3, 6); + final onGutter = Offset(box.right - 4, box.top + 6); + + expect(controller.hitsSelectableGlyphs(onInk), isTrue); + expect(controller.hitsSelectableGlyphs(onGutter), isFalse); + // Gesture starts stay bubble-wide (tdesktop parity). + expect(controller.hitsSelectableContent(onGutter), isTrue); + }); + + testWidgets('empty chrome outside every surface does not clamp in', + (tester) async { + final controller = _controllerWith({'d': 'Hello selectable world'}); + addTearDown(controller.dispose); + + await tester.pumpWidget(_scope( + controller: controller, + child: const _Doc('d'), + )); + await tester.pumpAndSettle(); + + final box = tester.getRect(find.byType(MarkdownWidget)); + final below = Offset(box.center.dx, box.bottom + 120); + + expect(controller.hitsSelectableContent(below), isFalse); + expect( + controller.positionForGlobal(below, requireContainment: true), + isNull, + ); + // Extending across a gap still clamps onto the nearest surface. + expect(controller.positionForGlobal(below), isNotNull); + + controller.startAtGlobal(below); + expect(controller.selection, isNull); + }); + + testWidgets('a mouse click that misses clears an active range', + (tester) async { + final controller = _controllerWith({'d': 'Hello selectable world'}); + addTearDown(controller.dispose); + + await tester.pumpWidget(MaterialApp( + home: Scaffold( + body: MarkdownSelectionScope( + controller: controller, + child: const Align( + alignment: Alignment.topLeft, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox(width: 400, child: _Doc('d')), + SizedBox(width: 400, height: 200), + ], + ), + ), + ), + ), + )); + await tester.pumpAndSettle(); + + final box = tester.getRect(find.byType(MarkdownWidget)); + await _mouseDrag( + tester, + box.topLeft + const Offset(2, 4), + box.centerRight - const Offset(2, 0), + ); + expect(controller.selection, isNotNull); + + final g = await tester.startGesture( + Offset(box.center.dx, box.bottom + 100), + kind: PointerDeviceKind.mouse, + ); + await g.up(); + await tester.pumpAndSettle(); + + expect(controller.selection, isNull); + }); + }); +} diff --git a/test/selection/selection_nested_blocks_test.dart b/test/selection/selection_nested_blocks_test.dart new file mode 100644 index 0000000..f0ab4e0 --- /dev/null +++ b/test/selection/selection_nested_blocks_test.dart @@ -0,0 +1,201 @@ +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_md/flutter_md.dart'; +import 'package:flutter_test/flutter_test.dart'; + +class _Doc extends StatelessWidget { + const _Doc(this.id); + + final String id; + + @override + Widget build(BuildContext context) { + final controller = MarkdownSelectionScope.of(context); + final model = controller.documents.firstWhere((d) => d.id == id).model; + return MarkdownWidget(markdown: model, documentId: id); + } +} + +Future _pump( + WidgetTester tester, + String source, { + double width = 400, +}) async { + final controller = MarkdownSelectionController() + ..setDocuments([ + MarkdownDocumentRef(id: 'd', model: Markdown.fromString(source)), + ]); + addTearDown(controller.dispose); + + await tester.pumpWidget(MaterialApp( + home: Scaffold( + body: MarkdownSelectionScope( + controller: controller, + child: Align( + alignment: Alignment.topLeft, + child: SizedBox(width: width, child: const _Doc('d')), + ), + ), + ), + )); + await tester.pumpAndSettle(); + return controller; +} + +Future _mouseDrag(WidgetTester tester, Offset from, Offset to) async { + final g = await tester.startGesture(from, kind: PointerDeviceKind.mouse); + await tester.pump(const Duration(milliseconds: 100)); + await g.moveTo(to); + await tester.pump(const Duration(milliseconds: 100)); + await g.up(); + await tester.pumpAndSettle(); +} + +const _quoteWithFence = ''' +> Intro line before the fence +> ```dart +> void main() {} +> ``` +> Outro line after the fence +'''; + +const _alertWithFence = ''' +> [!NOTE] +> Take care here +> ```sh +> echo hi +> ``` +'''; + +void main() { + group('quote / alert with nested fenced code', () { + test('a quote body with a fence parses into nested blocks', () { + final md = Markdown.fromString(_quoteWithFence); + expect(md.blocks, hasLength(1)); + final quote = md.blocks.single as MD$Quote; + expect(quote.spans, isEmpty); + expect(quote.blocks.whereType(), hasLength(1)); + expect( + quote.blocks.whereType().single.language, + 'dart', + ); + }); + + test('an alert body with a fence parses into nested blocks', () { + final md = Markdown.fromString(_alertWithFence); + final alert = md.blocks.single as MD$Alert; + expect(alert.alert, MD$AlertType.note); + expect(alert.spans, isEmpty); + expect(alert.blocks.whereType(), hasLength(1)); + }); + + test('a plain quote keeps the leaf inline shape', () { + final md = Markdown.fromString('> just prose\n> more prose'); + final quote = md.blocks.single as MD$Quote; + expect(quote.blocks, isEmpty); + expect(quote.spans, isNotEmpty); + }); + + test('markdownBlockRenderedText walks nested children', () { + final quote = Markdown.fromString(_quoteWithFence).blocks.single; + final text = markdownBlockRenderedText(quote); + expect(text, contains('Intro line before the fence')); + expect(text, contains('void main() {}')); + expect(text, contains('Outro line after the fence')); + }); + + testWidgets( + 'the painter fragments agree with markdownBlockRenderedText', + (tester) async { + // The whole selection layer indexes a block by + // `markdownBlockRenderedText`; the painter must expose exactly that + // string, or highlight geometry and copied text drift apart. + for (final source in [_quoteWithFence, _alertWithFence]) { + final controller = await _pump(tester, source); + final block = controller.documents.single.model.blocks.single; + final expected = markdownBlockRenderedText(block); + + // Selecting every character must reproduce the block text verbatim. + controller.selection = MarkdownSelection( + base: const MarkdownPosition( + documentId: 'd', + blockIndex: 0, + offset: 0, + ), + extent: MarkdownPosition( + documentId: 'd', + blockIndex: 0, + offset: expected.length, + ), + ); + await tester.pumpAndSettle(); + expect(controller.getText(), expected); + } + }); + + testWidgets('selecting the whole nested quote yields every child line', + (tester) async { + final controller = await _pump(tester, _quoteWithFence); + + final box = tester.getRect(find.byType(MarkdownWidget)); + await _mouseDrag( + tester, + box.topLeft + const Offset(2, 3), + box.bottomRight - const Offset(2, 3), + ); + + final text = controller.getText(); + expect(text, contains('Intro line before the fence')); + expect(text, contains('void main() {}')); + expect(text, contains('Outro line after the fence')); + }); + + testWidgets('a highlight range inside the nested fence has geometry', + (tester) async { + final controller = await _pump(tester, _quoteWithFence); + final block = controller.documents.single.model.blocks.single; + final text = markdownBlockRenderedText(block); + final start = text.indexOf('void main'); + expect(start, greaterThan(-1)); + + final surface = controller.mountedSurfaces.single; + final boxes = surface.localBoxesForRange(0, start, start + 9); + expect(boxes, isNotEmpty); + for (final box in boxes) { + expect(box.width, greaterThan(0)); + expect(box.height, greaterThan(0)); + } + }); + + testWidgets('a nested quote paints without exceptions', (tester) async { + await _pump(tester, '> outer\n> > inner\n> ```\n> code\n> ```'); + expect(tester.takeException(), isNull); + }); + + testWidgets('an unterminated fence inside a quote does not hang', + (tester) async { + await _pump(tester, '> before\n> ```\n> never closed'); + expect(tester.takeException(), isNull); + }); + + test('a quote whose body is only a fence still renders text', () { + final md = Markdown.fromString('> ```\n> x\n> ```'); + final quote = md.blocks.single as MD$Quote; + expect(markdownBlockRenderedText(quote), contains('x')); + }); + + test('the markup formatter round-trips a nested fence', () { + final md = Markdown.fromString(_quoteWithFence); + final controller = MarkdownSelectionController() + ..formatter = const MarkdownMarkupFormatter() + ..setDocuments( + [MarkdownDocumentRef(id: 'd', model: md)]); + addTearDown(controller.dispose); + controller.selectAll(); + + final out = controller.getText(); + expect(out, startsWith('> ')); + expect(out, contains('void main() {}')); + }); + }); +} diff --git a/test/selection/selection_registry_test.dart b/test/selection/selection_registry_test.dart new file mode 100644 index 0000000..69378f0 --- /dev/null +++ b/test/selection/selection_registry_test.dart @@ -0,0 +1,382 @@ +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_md/flutter_md.dart'; +import 'package:flutter_test/flutter_test.dart'; + +class _Doc extends StatelessWidget { + const _Doc(this.id, {this.markdown, super.key}); + + final String id; + final Markdown? markdown; + + @override + Widget build(BuildContext context) { + final controller = MarkdownSelectionScope.of(context); + final model = markdown ?? + controller.documents.firstWhere((d) => d.id == id).model; + return MarkdownWidget(markdown: model, documentId: id); + } +} + +Future _mouseDrag(WidgetTester tester, Offset from, Offset to) async { + final g = await tester.startGesture(from, kind: PointerDeviceKind.mouse); + await tester.pump(const Duration(milliseconds: 100)); + await g.moveTo(to); + await tester.pump(const Duration(milliseconds: 100)); + await g.up(); + await tester.pumpAndSettle(); +} + +void main() { + group('registry ordering', () { + test('sparse explicit orders keep their reading order', () { + final controller = MarkdownSelectionController() + ..setDocuments([ + MarkdownDocumentRef( + id: 'a', + model: Markdown.fromString('Alpha'), + order: 10, + ), + MarkdownDocumentRef( + id: 'b', + model: Markdown.fromString('Bravo'), + order: 20, + ), + ]); + addTearDown(controller.dispose); + + expect(controller.documents.map((d) => d.id), ['a', 'b']); + }); + + test('an order-less putDocument appends after sparse explicit orders', () { + final controller = MarkdownSelectionController() + ..setDocuments([ + MarkdownDocumentRef( + id: 'a', + model: Markdown.fromString('Alpha'), + order: 10, + ), + MarkdownDocumentRef( + id: 'b', + model: Markdown.fromString('Bravo'), + order: 20, + ), + ]) + // The heal-register path on RenderObject.attach passes no order. + ..putDocument('c', Markdown.fromString('Charlie')); + addTearDown(controller.dispose); + + expect( + controller.documents.map((d) => d.id), + ['a', 'b', 'c'], + reason: 'a document with no order must not jump ahead of ordered ones', + ); + }); + + test('colliding orders keep a stable, registration-based reading order', + () { + final controller = MarkdownSelectionController() + ..setDocuments([ + MarkdownDocumentRef( + id: 'a', + model: Markdown.fromString('Alpha'), + order: 1, + ), + MarkdownDocumentRef( + id: 'b', + model: Markdown.fromString('Bravo'), + order: 1, + ), + MarkdownDocumentRef( + id: 'c', + model: Markdown.fromString('Charlie'), + order: 1, + ), + ]); + addTearDown(controller.dispose); + + final first = controller.documents.map((d) => d.id).toList(); + // Any re-sort (a model update, a new registration) must not reshuffle + // documents that share an order. + controller + ..putDocument('b', Markdown.fromString('Bravo two'), order: 1) + ..putDocument('d', Markdown.fromString('Delta'), order: 1); + final second = controller.documents + .map((d) => d.id) + .where((id) => id != 'd') + .toList(); + + expect(second, first); + }); + + test('rangeFor returns null for an unregistered document', () { + final controller = MarkdownSelectionController() + ..setDocuments([ + MarkdownDocumentRef(id: 'a', model: Markdown.fromString('Alpha')), + ]); + addTearDown(controller.dispose); + + controller.selection = const MarkdownSelection( + base: MarkdownPosition(documentId: 'a', blockIndex: 0, offset: 0), + extent: MarkdownPosition(documentId: 'a', blockIndex: 0, offset: 5), + ); + + expect(controller.rangeFor('a', 0), isNotNull); + expect(controller.rangeFor('ghost', 0), isNull); + }); + + test('an unregistered endpoint does not paint every registered body', () { + final controller = MarkdownSelectionController() + ..setDocuments([ + MarkdownDocumentRef(id: 'a', model: Markdown.fromString('Alpha')), + MarkdownDocumentRef(id: 'b', model: Markdown.fromString('Bravo')), + MarkdownDocumentRef(id: 'c', model: Markdown.fromString('Charlie')), + ]); + addTearDown(controller.dispose); + + controller.selection = const MarkdownSelection( + base: MarkdownPosition(documentId: 'ghost', blockIndex: 0, offset: 0), + extent: MarkdownPosition(documentId: 'c', blockIndex: 0, offset: 3), + ); + + expect(controller.rangeFor('a', 0), isNull); + expect(controller.rangeFor('b', 0), isNull); + expect(controller.rangeFor('c', 0), isNull); + }); + }); + + group('surface lifecycle', () { + testWidgets('two surfaces on one id keep the first hittable', + (tester) async { + final md = Markdown.fromString('Shared document body'); + final controller = MarkdownSelectionController() + ..setDocuments( + [MarkdownDocumentRef(id: 'd', model: md)]); + addTearDown(controller.dispose); + + var showSecond = true; + late StateSetter setStateFn; + + await tester.pumpWidget(MaterialApp( + home: Scaffold( + body: MarkdownSelectionScope( + controller: controller, + child: StatefulBuilder(builder: (context, setState) { + setStateFn = setState; + return Align( + alignment: Alignment.topLeft, + child: SizedBox( + width: 400, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const _Doc('d', key: ValueKey('first')), + if (showSecond) + const _Doc('d', key: ValueKey('second')), + ], + ), + ), + ); + }), + ), + ), + )); + await tester.pumpAndSettle(); + + expect(find.byType(MarkdownWidget), findsNWidgets(2)); + expect(controller.mountedSurfaces.length, 1, + reason: 'one primary surface per document id'); + + final firstRect = tester.getRect(find.byKey(const ValueKey('first'))); + final hit = firstRect.topLeft + const Offset(2, 4); + expect(controller.hitsSelectableContent(hit), isTrue); + + // Dropping the later (overlapping) mount must not unregister the first. + setStateFn(() => showSecond = false); + await tester.pumpAndSettle(); + + expect(controller.mountedSurfaces.length, 1); + expect(controller.hitsSelectableContent(hit), isTrue); + }); + + testWidgets('a mounted surface heals a missing registry entry', + (tester) async { + final controller = MarkdownSelectionController(); + addTearDown(controller.dispose); + final md = Markdown.fromString('Never registered by the app'); + + await tester.pumpWidget(MaterialApp( + home: Scaffold( + body: MarkdownSelectionScope( + controller: controller, + child: Align( + alignment: Alignment.topLeft, + child: SizedBox( + width: 400, + child: _Doc('orphan', markdown: md), + ), + ), + ), + ), + )); + await tester.pumpAndSettle(); + + expect(controller.documents.map((d) => d.id), ['orphan']); + + final box = tester.getRect(find.byType(MarkdownWidget)); + await _mouseDrag( + tester, + box.topLeft + const Offset(2, 4), + box.centerRight - const Offset(2, 0), + ); + expect(controller.getText(), 'Never registered by the app'); + }); + + testWidgets( + 'recycling a widget onto another id keeps both models intact', + (tester) async { + final a = Markdown.fromString('Alpha body'); + final b = Markdown.fromString('Bravo body'); + final controller = MarkdownSelectionController() + ..setDocuments([ + MarkdownDocumentRef(id: 'a', model: a, order: 0), + MarkdownDocumentRef(id: 'b', model: b, order: 1), + ]); + addTearDown(controller.dispose); + + var showA = true; + late StateSetter setStateFn; + + await tester.pumpWidget(MaterialApp( + home: Scaffold( + body: MarkdownSelectionScope( + controller: controller, + child: StatefulBuilder(builder: (context, setState) { + setStateFn = setState; + // No key: the element is recycled onto the other document, + // exactly like ListView.builder reusing a slot while scrolling. + return Align( + alignment: Alignment.topLeft, + child: SizedBox( + width: 400, + child: MarkdownWidget( + markdown: showA ? a : b, + documentId: showA ? 'a' : 'b', + ), + ), + ); + }), + ), + ), + )); + await tester.pumpAndSettle(); + + setStateFn(() => showA = false); + await tester.pumpAndSettle(); + + expect( + controller.documents.firstWhere((d) => d.id == 'a').model.text, + a.text, + reason: 'the outgoing document must keep its own model', + ); + expect( + controller.documents.firstWhere((d) => d.id == 'b').model.text, + b.text, + ); + expect(controller.mountedSurfaces.single.documentId, 'b'); + }); + + testWidgets('removeDocument during dispose flushes on detach', + (tester) async { + final md = Markdown.fromString('Disappearing body'); + final controller = MarkdownSelectionController() + ..setDocuments( + [MarkdownDocumentRef(id: 'd', model: md)]); + addTearDown(controller.dispose); + + await tester.pumpWidget(MaterialApp( + home: Scaffold( + body: MarkdownSelectionScope( + controller: controller, + child: const Align( + alignment: Alignment.topLeft, + child: SizedBox(width: 400, child: _Doc('d')), + ), + ), + ), + )); + await tester.pumpAndSettle(); + + // Parent State.dispose can run before the child render object detaches. + controller.removeDocument('d'); + expect(controller.documents, hasLength(1), + reason: 'deferred while the surface is still mounted'); + + await tester.pumpWidget(const MaterialApp(home: SizedBox.shrink())); + await tester.pumpAndSettle(); + + expect(controller.documents, isEmpty); + expect(controller.mountedSurfaces, isEmpty); + }); + + testWidgets('a virtualized endpoint keeps the highlight while off-screen', + (tester) async { + final docs = [ + for (var i = 0; i < 20; i++) + MarkdownDocumentRef( + id: 'm$i', + model: Markdown.fromString('Message number $i body text'), + order: i, + ), + ]; + final controller = MarkdownSelectionController()..setDocuments(docs); + addTearDown(controller.dispose); + final scroll = ScrollController(); + addTearDown(scroll.dispose); + + await tester.pumpWidget(MaterialApp( + home: Scaffold( + body: MarkdownSelectionScope( + controller: controller, + child: SizedBox( + height: 200, + child: ListView.builder( + controller: scroll, + itemCount: docs.length, + itemBuilder: (context, i) => _Doc('m$i'), + ), + ), + ), + ), + )); + await tester.pumpAndSettle(); + + // Select from the first message into the third. + final first = tester.getRect(find.byType(MarkdownWidget).first); + await _mouseDrag( + tester, + first.topLeft + const Offset(2, 4), + first.bottomRight + const Offset(-2, 40), + ); + final selected = controller.getText(); + expect(selected, isNotEmpty); + expect(controller.selection!.base.documentId, 'm0'); + + // Scroll the base endpoint out of the cache extent and back. + scroll.jumpTo(1500); + await tester.pumpAndSettle(); + expect(controller.selection, isNotNull, + reason: 'virtualization must not drop the range'); + expect(controller.getText(), selected); + // No mounted body outside the selected span may paint a highlight. + for (var i = 10; i < 20; i++) { + expect(controller.rangeFor('m$i', 0), isNull); + } + + scroll.jumpTo(0); + await tester.pumpAndSettle(); + expect(controller.getText(), selected); + expect(tester.takeException(), isNull); + }); + }); +} diff --git a/test/unit_test.dart b/test/unit_test.dart index 7a035b5..d66106a 100644 --- a/test/unit_test.dart +++ b/test/unit_test.dart @@ -13,7 +13,14 @@ import 'parser/parser_test.dart' as parser_test; import 'parser/regression_test.dart' as regression_test; import 'parser/streaming_test.dart' as streaming_test; import 'selection/markup_formatter_test.dart' as markup_formatter_test; +import 'selection/selection_autoscroll_target_test.dart' + as selection_autoscroll_target_test; import 'selection/selection_autoscroll_test.dart' as selection_autoscroll_test; +import 'selection/selection_chrome_test.dart' as selection_chrome_test; +import 'selection/selection_host_gates_test.dart' as selection_host_gates_test; +import 'selection/selection_nested_blocks_test.dart' + as selection_nested_blocks_test; +import 'selection/selection_registry_test.dart' as selection_registry_test; import 'selection/selection_handle_endpoints_test.dart' as selection_handle_endpoints_test; import 'selection/selection_handles_test.dart' as selection_handles_test; @@ -44,6 +51,11 @@ void main() => group('Unit', () { selection_handles_test.main(); selection_handle_endpoints_test.main(); selection_autoscroll_test.main(); + selection_autoscroll_target_test.main(); + selection_host_gates_test.main(); + selection_registry_test.main(); + selection_chrome_test.main(); + selection_nested_blocks_test.main(); line_clamp_test.main(); render_test.main(); widget_test.main(); From b44fde7c442d5c93127e22765496b465935b1d9a Mon Sep 17 00:00:00 2001 From: Mikhail Matiunin Date: Mon, 14 Sep 2026 19:30:59 +0400 Subject: [PATCH 11/19] fix(selection): never call onSelectionChanged from inside build or layout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The controller notifies from the build/layout phase on two ordinary chat paths: a streaming `putDocument` runs from `MarkdownWidget.updateRenderObject` and can reconcile the range away, and a deferred `removeDocument` flushes from `RenderObject.detach`. Hosts routinely `setState` from `onSelectionChanged`, so both threw "setState() or markNeedsBuild() called during build". Delivery is coalesced to the end of the frame while in `persistentCallbacks` / `midFrameMicrotasks`, and reports the settled selection. Also renames the getter behind `alwaysNeedsCompositing` off the `debug` prefix — it is production logic, not a debug aid. --- CHANGELOG.md | 6 ++ lib/src/render/markdown_render_object.dart | 14 ++- lib/src/selection_scope.dart | 31 ++++++- test/selection/selection_registry_test.dart | 97 +++++++++++++++++++++ 4 files changed, 143 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d205439..df067ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -139,6 +139,12 @@ extension points or reach into a default block painter's fields. Ordinary - **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 / diff --git a/lib/src/render/markdown_render_object.dart b/lib/src/render/markdown_render_object.dart index e05febe..077babd 100644 --- a/lib/src/render/markdown_render_object.dart +++ b/lib/src/render/markdown_render_object.dart @@ -60,12 +60,18 @@ class MarkdownRenderObject extends RenderBox final LayerHandle _startHandleLayer = LayerHandle(); final LayerHandle _endHandleLayer = LayerHandle(); - /// Whether this surface currently owns a start and/or end handle leader. - @visibleForTesting - bool get debugHasSelectionHandleLeaders => + /// Whether [paint] will push a start and/or end [LeaderLayer] this frame. + /// + /// Drives [alwaysNeedsCompositing]; keep it in lockstep with the conditions + /// in [paint] or a leader can be pushed without a composited layer for it. + bool get _pushesHandleLeaderLayers => (_startHandleLink != null && _startHandleLocal != null) || (_endHandleLink != null && _endHandleLocal != null); + /// Whether this surface currently owns a start and/or end handle leader. + @visibleForTesting + bool get debugHasSelectionHandleLeaders => _pushesHandleLeaderLayers; + void _onSelectionChange() { if (!_disposed) markNeedsPaint(); } @@ -337,7 +343,7 @@ class MarkdownRenderObject extends RenderBox bool get isRepaintBoundary => _controller != null; @override - bool get alwaysNeedsCompositing => debugHasSelectionHandleLeaders; + bool get alwaysNeedsCompositing => _pushesHandleLeaderLayers; @override bool get sizedByParent => false; diff --git a/lib/src/selection_scope.dart b/lib/src/selection_scope.dart index f7bbe59..2cc106c 100644 --- a/lib/src/selection_scope.dart +++ b/lib/src/selection_scope.dart @@ -240,6 +240,10 @@ class MarkdownSelectionScopeState extends State /// Coalesces post-frame handle-overlay syncs deferred off layout/build. bool _overlaySyncScheduled = false; + /// Coalesces post-frame [MarkdownSelectionScope.onSelectionChanged] delivery + /// for controller notifications that land during build / layout. + bool _selectionChangedNotifyScheduled = false; + /// Ancestor observer (e.g. [Scaffold]) so the toolbar follows parent scrolls /// when this scope is a scrollable *child*, not an ancestor. ScrollNotificationObserverState? _scrollNotificationObserver; @@ -353,6 +357,31 @@ class MarkdownSelectionScopeState extends State } } + /// Delivers [sel] to [MarkdownSelectionScope.onSelectionChanged]. + /// + /// The controller can notify from inside build / layout: a streaming + /// `putDocument` runs from `MarkdownWidget.updateRenderObject` and may + /// reconcile the range away, and a deferred `removeDocument` flushes from + /// `RenderObject.detach`. Hosts routinely `setState` from this callback, so + /// a synchronous call there throws "setState() called during build". + /// Coalesce to the end of the frame and deliver the settled selection. + void _notifySelectionChanged(MarkdownSelection? sel) { + if (widget.onSelectionChanged == null) return; + final phase = SchedulerBinding.instance.schedulerPhase; + if (phase != SchedulerPhase.persistentCallbacks && + phase != SchedulerPhase.midFrameMicrotasks) { + widget.onSelectionChanged?.call(sel); + return; + } + if (_selectionChangedNotifyScheduled) return; + _selectionChangedNotifyScheduled = true; + SchedulerBinding.instance.addPostFrameCallback((_) { + _selectionChangedNotifyScheduled = false; + if (!mounted) return; + widget.onSelectionChanged?.call(controller.selection); + }); + } + void _applySelectionColor() { controller.selectionColor = widget.selectionColor ?? DefaultSelectionStyle.of(context).selectionColor; @@ -362,7 +391,7 @@ class MarkdownSelectionScopeState extends State final sel = controller.selection; if (sel != _lastSelection) { _lastSelection = sel; - widget.onSelectionChanged?.call(sel); + _notifySelectionChanged(sel); // Selection moved — on mobile, drop a frozen right-click anchor so // the toolbar tracks the live selection bounds instead. On desktop, // preserve the user's secondary-click anchor. diff --git a/test/selection/selection_registry_test.dart b/test/selection/selection_registry_test.dart index 69378f0..265f3c4 100644 --- a/test/selection/selection_registry_test.dart +++ b/test/selection/selection_registry_test.dart @@ -379,4 +379,101 @@ void main() { expect(tester.takeException(), isNull); }); }); + + group('host notification safety', () { + testWidgets( + 'removeDocument flush during unmount does not crash a host that ' + 'setStates from onSelectionChanged', (tester) async { + final controller = MarkdownSelectionController() + ..setDocuments([ + MarkdownDocumentRef(id: 'd', model: Markdown.fromString('Body text')), + ]); + addTearDown(controller.dispose); + + var show = true; + var changes = 0; + late StateSetter set; + + await tester.pumpWidget(MaterialApp( + home: Scaffold( + body: StatefulBuilder(builder: (context, setState) { + set = setState; + return MarkdownSelectionScope( + controller: controller, + onSelectionChanged: (_) => setState(() => changes++), + child: show + ? const Align( + alignment: Alignment.topLeft, + child: SizedBox(width: 400, child: _Doc('d')), + ) + : const SizedBox.shrink(), + ); + }), + ), + )); + await tester.pumpAndSettle(); + + controller.selectAll(); + await tester.pumpAndSettle(); + expect(controller.selection, isNotNull); + + // App drops the document while the surface is still mounted, then the + // widget unmounts in the same frame — the deferred remove flushes from + // RenderObject.detach, inside layout. + controller.removeDocument('d'); + set(() => show = false); + await tester.pumpAndSettle(); + + expect(controller.selection, isNull); + expect(changes, greaterThan(0)); + expect(tester.takeException(), isNull); + }); + + testWidgets('a streaming model update that reconciles the selection does ' + 'not crash a host that setStates from onSelectionChanged', + (tester) async { + var model = Markdown.fromString('Hello streaming world'); + final controller = MarkdownSelectionController( + reconciliation: const MarkdownReconciliationPolicy.clearOnChange(), + )..setDocuments( + [MarkdownDocumentRef(id: 'd', model: model)]); + addTearDown(controller.dispose); + + var changes = 0; + late StateSetter set; + + await tester.pumpWidget(MaterialApp( + home: Scaffold( + body: StatefulBuilder(builder: (context, setState) { + set = setState; + return MarkdownSelectionScope( + controller: controller, + onSelectionChanged: (_) => setState(() => changes++), + child: Align( + alignment: Alignment.topLeft, + child: SizedBox( + width: 400, + child: MarkdownWidget(markdown: model, documentId: 'd'), + ), + ), + ); + }), + ), + )); + await tester.pumpAndSettle(); + + controller.selectAll(); + await tester.pumpAndSettle(); + expect(controller.selection, isNotNull); + changes = 0; + + // A streaming token arrives: MarkdownWidget.updateRenderObject pushes the + // new model into the registry during build, reconciliation drops the + // range, and the host is notified mid-build. + set(() => model = Markdown.fromString('Hello streaming world!')); + await tester.pumpAndSettle(); + + expect(tester.takeException(), isNull); + }); + }); } From 51dc4309ff00862be5f602872e4b977f5fe22b41 Mon Sep 17 00:00:00 2001 From: Mikhail Matiunin Date: Mon, 14 Sep 2026 19:32:54 +0400 Subject: [PATCH 12/19] feat(selection): opt out of the host-union autoscroll gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The gate assumes the mounted bodies extend past the viewport whenever there is more to reveal — true for a ListView with a cache extent, false for a chat viewport that builds only what is visible. There the union is barely larger than the viewport, so the gate vetoes a drag that should keep paging through history. `useHostUnionGate: false` hands the decision to the scroll target's `canScroll` and the delta it reports applying. Default stays on. Also documents the host gates and the autoscroll target in the README. --- CHANGELOG.md | 8 ++- README.md | 44 ++++++++++++ docs/selection.md | 7 ++ lib/src/selection_autoscroll.dart | 29 +++++++- .../selection_autoscroll_target_test.dart | 68 +++++++++++++++++++ 5 files changed, 152 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index df067ad..47fdd3d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -199,7 +199,13 @@ extension points or reach into a default block painter's fields. Ordinary - **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.copyWith`. +- **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) diff --git a/README.md b/README.md index 809ac35..b376452 100644 --- a/README.md +++ b/README.md @@ -276,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. @@ -299,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) diff --git a/docs/selection.md b/docs/selection.md index 5fd1ab8..af9af2e 100644 --- a/docs/selection.md +++ b/docs/selection.md @@ -232,6 +232,13 @@ Statics: `MarkdownSelectionScope.of/maybeOf` (→ controller), `stateOf` (→ st **once per drag** (with the scope context, the `BuildContext` of the markdown surface under the pointer, the pointer position and the config) and caches the result, so a resolver may walk the element tree without a per-frame cost. + The **host-union gate** (`useHostUnionGate`, default on) suits markdown that is + an island inside a larger scrollable: a body that fits inside the padded + viewport never drives the far page chrome. Turn it off when the markdown + bodies *are* the scrolling content and the host builds only what is visible — + that union is barely larger than the viewport and would veto paging through + history; the target's `canScroll` then decides alone. + `MarkdownCallbackAutoscrollTarget` assembles a target from three closures: ```dart diff --git a/lib/src/selection_autoscroll.dart b/lib/src/selection_autoscroll.dart index a664529..d997dfd 100644 --- a/lib/src/selection_autoscroll.dart +++ b/lib/src/selection_autoscroll.dart @@ -39,6 +39,7 @@ final class MarkdownSelectionAutoscrollConfig { this.topPad = 0, this.bottomPad = 0, this.useMediaQueryPadding = true, + this.useHostUnionGate = true, this.targetResolver, }) : assert(edgeZone > 0), assert(maxVelocity >= 0), @@ -71,6 +72,21 @@ final class MarkdownSelectionAutoscrollConfig { /// [targetResolver] reports its own [MarkdownAutoscrollViewport.padding]. final bool useMediaQueryPadding; + /// Whether the union of mounted selectable bodies gates each direction. + /// + /// True (the default) suits markdown that is an *island* inside a larger + /// scrollable: a body that fits inside the padded viewport never drives the + /// far page chrome, and a direction hard-stops once the union is flush with + /// that edge. + /// + /// Set it false when the markdown bodies **are** the scrolling content and + /// the host only builds what is visible — a chat viewport that mounts no + /// cache extent has a union barely larger than the viewport, so the gate + /// would veto a drag that should keep paging through history. The scroll + /// surface then decides on its own, through + /// [MarkdownAutoscrollTarget.canScroll] and the delta it reports applying. + final bool useHostUnionGate; + /// Resolves the scroll surface a selection drag drives. /// /// Null (the default) uses the nearest ancestor [Scrollable] — the sliver @@ -92,6 +108,7 @@ final class MarkdownSelectionAutoscrollConfig { other.topPad == topPad && other.bottomPad == bottomPad && other.useMediaQueryPadding == useMediaQueryPadding && + other.useHostUnionGate == useHostUnionGate && // A resolver closure can only be compared by identity; a host that // builds one inline gets a new object every build, which is why the // scope never invalidates a cached surface mid-drag. @@ -105,6 +122,7 @@ final class MarkdownSelectionAutoscrollConfig { topPad, bottomPad, useMediaQueryPadding, + useHostUnionGate, targetResolver, ); @@ -116,6 +134,7 @@ final class MarkdownSelectionAutoscrollConfig { double? topPad, double? bottomPad, bool? useMediaQueryPadding, + bool? useHostUnionGate, MarkdownAutoscrollTargetResolver? targetResolver, }) => MarkdownSelectionAutoscrollConfig( @@ -126,6 +145,7 @@ final class MarkdownSelectionAutoscrollConfig { bottomPad: bottomPad ?? this.bottomPad, useMediaQueryPadding: useMediaQueryPadding ?? this.useMediaQueryPadding, + useHostUnionGate: useHostUnionGate ?? this.useHostUnionGate, targetResolver: targetResolver ?? this.targetResolver, ); } @@ -595,10 +615,13 @@ MarkdownAutoscrollResult applyMarkdownSelectionAutoscroll({ final clipTopLocal = paddedTop - bounds.top; // Host still has content to reveal past each pad edge (or sits entirely - // off-screen past that edge — drag must be able to pull it back). - final canTowardStart = hostUnionTopGlobalY < paddedTop - 0.5 || + // off-screen past that edge — drag must be able to pull it back). Hosts whose + // bodies *are* the scrolling content opt out and let the target decide. + final canTowardStart = !config.useHostUnionGate || + hostUnionTopGlobalY < paddedTop - 0.5 || hostUnionBottomGlobalY <= paddedTop + 0.5; - final canTowardEnd = hostUnionBottomGlobalY > paddedBottom + 0.5 || + final canTowardEnd = !config.useHostUnionGate || + hostUnionBottomGlobalY > paddedBottom + 0.5 || hostUnionTopGlobalY >= paddedBottom - 0.5; var velocity = markdownAutoscrollVelocity( diff --git a/test/selection/selection_autoscroll_target_test.dart b/test/selection/selection_autoscroll_target_test.dart index 2db4507..c4f0dd1 100644 --- a/test/selection/selection_autoscroll_target_test.dart +++ b/test/selection/selection_autoscroll_target_test.dart @@ -323,6 +323,74 @@ void main() { expect(t.deltas, isEmpty); }); + test('useHostUnionGate false lets the target decide instead', () { + final gated = makeTarget(); + // Host union ends mid-viewport: with the gate on, nothing to reveal. + expect( + run( + gated.target, + bounds.bottom - 3, + hostTop: bounds.top + 10, + hostBottom: bounds.top + 100, + ), + MarkdownAutoscrollResult.suppressed, + ); + + // A chat viewport that builds only what is visible opts out: its own + // `canScroll` is the authority. + var offset = 0.0; + final ungated = MarkdownCallbackAutoscrollTarget( + viewportOf: () => + const MarkdownAutoscrollViewport(globalBounds: bounds), + onScrollDelta: (delta) { + offset += delta; + return delta; + }, + ); + expect( + applyMarkdownSelectionAutoscroll( + globalPosition: Offset(10, bounds.bottom - 3), + target: ungated, + config: _config.copyWith(useHostUnionGate: false), + lastTimestamp: null, + storeTimestamp: (_) {}, + hostUnionTopGlobalY: bounds.top + 10, + hostUnionBottomGlobalY: bounds.top + 100, + ), + MarkdownAutoscrollResult.scrolled, + ); + expect(offset, greaterThan(0)); + }); + + test('useHostUnionGate false still stops on a flush target', () { + var offset = 0.0; + final target = MarkdownCallbackAutoscrollTarget( + viewportOf: () => + const MarkdownAutoscrollViewport(globalBounds: bounds), + canScrollAt: ({required forward}) => false, + onScrollDelta: (delta) { + offset += delta; + return delta; + }, + ); + final session = MarkdownAutoscrollSession(); + expect( + applyMarkdownSelectionAutoscroll( + globalPosition: Offset(10, bounds.bottom - 3), + target: target, + config: _config.copyWith(useHostUnionGate: false), + lastTimestamp: null, + storeTimestamp: (_) {}, + hostUnionTopGlobalY: -1000, + hostUnionBottomGlobalY: 5000, + session: session, + ), + MarkdownAutoscrollResult.suppressed, + ); + expect(offset, 0); + expect(session.isDisarmedTowardEnd, isTrue); + }); + test('a disabled config never resolves a target', () { var asked = 0; final target = MarkdownCallbackAutoscrollTarget( From bbd9d29c910938dea2260842ee49e9aa9309a69d Mon Sep 17 00:00:00 2001 From: Mikhail Matiunin Date: Mon, 14 Sep 2026 19:34:18 +0400 Subject: [PATCH 13/19] test(selection): cover line-clamp and controller-lifecycle corner cases Table rows, a quote with a nested fence, a negative line budget and a zero measuring width for clampMarkdownToLines; controller dispose after unmount, group exclusivity, moveSelectionEdgeToGlobal with nothing mounted, and selectAll over an empty leading document. --- test/line_clamp/line_clamp_test.dart | 35 ++++++++ test/selection/selection_registry_test.dart | 91 +++++++++++++++++++++ 2 files changed, 126 insertions(+) diff --git a/test/line_clamp/line_clamp_test.dart b/test/line_clamp/line_clamp_test.dart index d525d88..26575fd 100644 --- a/test/line_clamp/line_clamp_test.dart +++ b/test/line_clamp/line_clamp_test.dart @@ -77,4 +77,39 @@ void main() => group('clampMarkdownToLines', () { expect(result.height, 0); expect(result.overflows, isFalse); }); + + test('a negative budget keeps nothing', () { + final result = clamp('one\ntwo', -5); + + expect(result.clampedHeight, 0); + expect(result.overflows, isTrue); + }); + + test('table rows count as one line each', () { + const source = '| a | b |\n|---|---|\n| 1 | 2 |\n| 3 | 4 |'; + final all = clamp(source, 100); + final two = clamp(source, 2); + + expect(all.overflows, isFalse); + expect(two.overflows, isTrue); + expect(two.clampedHeight, greaterThan(0)); + expect(two.clampedHeight, lessThan(all.height)); + }); + + test('a quote with a nested fence counts its child lines', () { + const source = '> intro\n> ```\n> one\n> two\n> ```\n> outro'; + final all = clamp(source, 100); + final two = clamp(source, 2); + + expect(all.height, greaterThan(0)); + expect(two.clampedHeight, greaterThan(0)); + expect(two.clampedHeight, lessThan(all.height)); + }); + + test('a zero measuring width does not throw', () { + final result = clamp('hello world', 2, width: 0); + + expect(result.height, greaterThanOrEqualTo(0)); + expect(result.clampedHeight, greaterThanOrEqualTo(0)); + }); }); diff --git a/test/selection/selection_registry_test.dart b/test/selection/selection_registry_test.dart index 265f3c4..42cff35 100644 --- a/test/selection/selection_registry_test.dart +++ b/test/selection/selection_registry_test.dart @@ -380,6 +380,97 @@ void main() { }); }); + group('controller lifecycle', () { + testWidgets('disposing the controller after the surfaces unmount is safe', + (tester) async { + final controller = MarkdownSelectionController() + ..setDocuments([ + MarkdownDocumentRef( + id: 'd', + model: Markdown.fromString('Body text'), + ), + ]); + + await tester.pumpWidget(MaterialApp( + home: Scaffold( + body: MarkdownSelectionScope( + controller: controller, + child: const Align( + alignment: Alignment.topLeft, + child: SizedBox(width: 400, child: _Doc('d')), + ), + ), + ), + )); + await tester.pumpAndSettle(); + + await tester.pumpWidget(const MaterialApp(home: SizedBox.shrink())); + await tester.pumpAndSettle(); + controller.dispose(); + + expect(tester.takeException(), isNull); + }); + + test('a group keeps one selection across controllers', () { + final group = MarkdownSelectionGroup(); + final a = MarkdownSelectionController(group: group) + ..setDocuments([ + MarkdownDocumentRef( + id: 'a', + model: Markdown.fromString('Alpha body'), + ), + ]); + final b = MarkdownSelectionController(group: group) + ..setDocuments([ + MarkdownDocumentRef( + id: 'b', + model: Markdown.fromString('Bravo body'), + ), + ]); + addTearDown(a.dispose); + addTearDown(b.dispose); + + a.selectAll(); + expect(a.selection, isNotNull); + b.selectAll(); + expect(b.selection, isNotNull); + expect(a.selection, isNull, reason: 'the group claims exclusivity'); + }); + + test('moveSelectionEdgeToGlobal with no mounted surface is a no-op', () { + final controller = MarkdownSelectionController() + ..setDocuments([ + MarkdownDocumentRef( + id: 'd', + model: Markdown.fromString('Body text'), + ), + ]); + addTearDown(controller.dispose); + controller.selectAll(); + final before = controller.selection; + + controller.moveSelectionEdgeToGlobal( + const Offset(10, 10), + isStart: true, + ); + + expect(controller.selection, before); + }); + + test('selectAll over an empty leading document does not throw', () { + final controller = MarkdownSelectionController() + ..setDocuments([ + MarkdownDocumentRef(id: 'e', model: Markdown.fromString('')), + MarkdownDocumentRef(id: 'd', model: Markdown.fromString('Body')), + ]); + addTearDown(controller.dispose); + + controller.selectAll(); + + expect(controller.getText(), isNotNull); + }); + }); + group('host notification safety', () { testWidgets( 'removeDocument flush during unmount does not crash a host that ' From 21389760d51d0cf778edab40fb8fde3c0130a97d Mon Sep 17 00:00:00 2001 From: Mikhail Matiunin Date: Mon, 14 Sep 2026 19:35:13 +0400 Subject: [PATCH 14/19] fix(render): route nested quote/alert children through the theme builder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A host that replaces a block painter through MarkdownThemeData.builder was silently getting the default painter for blocks nested in a quote or alert — so a custom code fence (copy button, line numbers) disappeared inside `> …`. --- CHANGELOG.md | 3 ++ lib/src/render/markdown_painter.dart | 15 ++++++-- .../selection_nested_blocks_test.dart | 36 +++++++++++++++++++ 3 files changed, 52 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 47fdd3d..adfbcd4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -45,6 +45,9 @@ extension points or reach into a default block painter's fields. Ordinary 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 diff --git a/lib/src/render/markdown_painter.dart b/lib/src/render/markdown_painter.dart index 6cb44da..a10a17c 100644 --- a/lib/src/render/markdown_painter.dart +++ b/lib/src/render/markdown_painter.dart @@ -65,6 +65,17 @@ class MarkdownPainter { /// index whenever a `blockFilter` drops blocks). List _sourceIndices = const []; + /// Builds a block nested inside a quote / alert body. + /// + /// Routes through [MarkdownThemeData.builder] like a top-level block does — + /// a host that replaces the code painter (a fence with a copy button, say) + /// expects the same painter inside `> …` as outside it. + static BlockPainter _nestedBlockBuilder( + MD$Block block, + MarkdownThemeData theme, + ) => + theme.builder?.call(block, theme) ?? _defaultBlockBuilder(block, theme); + static BlockPainter _defaultBlockBuilder( MD$Block block, MarkdownThemeData theme, @@ -85,7 +96,7 @@ class MarkdownPainter { theme: theme, children: [ for (final child in q.blocks) - _defaultBlockBuilder( + _nestedBlockBuilder( child, BlockPainter$Quote.inheritFrom(theme, child), ), @@ -114,7 +125,7 @@ class MarkdownPainter { spans: a.spans, theme: theme, children: [ - for (final child in a.blocks) _defaultBlockBuilder(child, theme), + for (final child in a.blocks) _nestedBlockBuilder(child, theme), ], ), spacer: (s) => BlockPainter$Spacer( diff --git a/test/selection/selection_nested_blocks_test.dart b/test/selection/selection_nested_blocks_test.dart index f0ab4e0..fdd6a52 100644 --- a/test/selection/selection_nested_blocks_test.dart +++ b/test/selection/selection_nested_blocks_test.dart @@ -184,6 +184,42 @@ void main() { expect(markdownBlockRenderedText(quote), contains('x')); }); + testWidgets('a custom theme builder also builds nested children', + (tester) async { + final seen = []; + final theme = MarkdownThemeData( + textStyle: const TextStyle(fontSize: 10, height: 2), + builder: (block, theme) { + seen.add(block.type); + return null; // fall back to the default painter + }, + ); + + await tester.pumpWidget(MaterialApp( + home: Scaffold( + body: Align( + alignment: Alignment.topLeft, + child: SizedBox( + width: 400, + child: MarkdownWidget( + markdown: Markdown.fromString(_quoteWithFence), + theme: theme, + ), + ), + ), + ), + )); + await tester.pumpAndSettle(); + + expect(seen, contains('quote')); + expect( + seen, + contains('code'), + reason: 'a host that swaps the code painter expects it inside > too', + ); + expect(tester.takeException(), isNull); + }); + test('the markup formatter round-trips a nested fence', () { final md = Markdown.fromString(_quoteWithFence); final controller = MarkdownSelectionController() From e78a5913a28fea0ddf8fb2393ac2c751c1452918 Mon Sep 17 00:00:00 2001 From: Mikhail Matiunin Date: Mon, 14 Sep 2026 20:03:44 +0400 Subject: [PATCH 15/19] fix(theme): resolve the monospace family per platform, not Apple-first MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #27 replaced the CSS generic 'monospace' with a hardcoded Menlo-first stack for every target, inside a commit about hit-testing and with nothing in the PR description or changelog. The premise is right on two platforms and wrong on the rest: CoreText (iOS / macOS) and DirectWrite (Windows) do not know 'monospace', so inline code silently rendered in the proportional body face there — but Android's font config and fontconfig on Linux resolve it natively, and Menlo/Monaco/Consolas exist on none of them. Substitute a real face on exactly the two platforms that need it (Menlo, Consolas) and keep 'monospace' everywhere it already worked, web included: its renderer cannot reach arbitrary system fonts, so naming one could only be worse than the generic the browser can answer. Rendering on Android, Fuchsia, Linux and web is byte-identical to 0.2.0. --- CHANGELOG.md | 19 +++++--- lib/src/theme.dart | 54 +++++++++++++++++----- test/theme/theme_test.dart | 91 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 147 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index adfbcd4..3f2c112 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,18 +23,25 @@ extension points or reach into a default block painter's fields. Ordinary `hitsSelectableGlyphs`, `isLinkAtGlobal`, `caretRectFor`, `localBoxesForRange`, `hasSelectionHandleLeaders` and `clearSelectionHandleLayersIfLinked`. Custom surfaces must implement them. -- **CHANGED**: Inline `` `code` `` and fenced blocks resolve a real monospace - family stack (`kMonospaceFontFamily` = `Menlo`, then - `kMonospaceFontFamilyFallback`) instead of the CSS generic `'monospace'`, - which Flutter does not map on most targets (spans silently fell back to the - body proportional font). Metrics of existing code spans change. Override - `MarkdownThemeData.textStyleFor` / `codeStyle` to pin a different face. - **CHANGED**: `MarkdownThemeData.copyWith` returns `MarkdownThemeData` instead of `ThemeExtension` (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). +### Monospace font resolution +- **FIXED**: Inline `` `code` `` and fenced blocks pick a monospace family that + the host platform can actually resolve (`kMonospaceFontFamily`, then + `kMonospaceFontFamilyFallback`). CoreText (iOS / macOS) and DirectWrite + (Windows) do not know the CSS generic `'monospace'`, so code silently + rendered in the proportional body face there; those two now get `Menlo` / + `Consolas`. **Android, Fuchsia, Linux and web keep `'monospace'`** — they + resolve it natively, so rendering there is unchanged from 0.2.0. +- **ADDED**: `kMonospaceFontFamily` (platform-resolved) and + `kMonospaceFontFamilyFallback`. There is still no theme-level knob for the + monospace face; a host that needs a bundled font has to go through + `MarkdownThemeData.builder` / `SyntaxHighlighter.baseStyleFor`. + ### 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` / diff --git a/lib/src/theme.dart b/lib/src/theme.dart index 94673ef..38a82f5 100644 --- a/lib/src/theme.dart +++ b/lib/src/theme.dart @@ -1,5 +1,7 @@ import 'dart:collection'; +import 'package:flutter/foundation.dart' + show defaultTargetPlatform, kIsWeb; import 'package:flutter/material.dart'; import '../flutter_md.dart'; @@ -21,19 +23,48 @@ typedef MarkdownCursorResolver = MouseCursor? Function( MD$Block? block, ); -/// Primary system monospace face for inline/`code` and fenced blocks. +/// Primary monospace face for inline `` `code` `` and fenced blocks, chosen for +/// the host platform. /// -/// Prefer a real family over the CSS generic `'monospace'`, which Flutter does -/// not map on most platforms (so spans fall back to the body proportional -/// font). -const String kMonospaceFontFamily = 'Menlo'; +/// Flutter passes the family straight to the platform font manager, and only +/// some of them know the CSS generic `'monospace'`: Android's font config and +/// fontconfig on Linux resolve it, CoreText (iOS / macOS) and DirectWrite +/// (Windows) do not — there a `'monospace'` span silently falls back to the +/// proportional body face, so inline code stops looking like code. +/// +/// So a real system family is substituted on exactly those two platforms, and +/// every other target keeps the generic it already resolved. Web keeps it too: +/// its renderer has no access to arbitrary system fonts, so naming one could +/// not help and the generic is what the browser (or the Noto fallback) is able +/// to answer. +/// +/// [kMonospaceFontFamilyFallback] follows, ending in `'monospace'` again, so a +/// platform whose primary is missing still lands on a real monospace face. +String get kMonospaceFontFamily { + if (kIsWeb) return 'monospace'; + return switch (defaultTargetPlatform) { + // Ships with every iOS / macOS release; SF Mono is not user-installable. + TargetPlatform.iOS || TargetPlatform.macOS => 'Menlo', + // Ships with Windows since Vista. + TargetPlatform.windows => 'Consolas', + TargetPlatform.android || + TargetPlatform.fuchsia || + TargetPlatform.linux => + 'monospace', + }; +} -/// Fallbacks after [kMonospaceFontFamily] (Android maps `'monospace'`). +/// Families tried after [kMonospaceFontFamily], for a platform whose primary +/// face is missing (a trimmed Linux image, a custom Android ROM). +/// +/// Ends in the CSS generic so anything that resolves it still gets a real +/// monospace face rather than the proportional default. const List kMonospaceFontFamilyFallback = [ - 'Monaco', - 'Consolas', + 'Menlo', // Apple + 'Consolas', // Windows + 'DejaVu Sans Mono', // Linux + 'Roboto Mono', // Android 'Courier New', - 'Courier', 'monospace', ]; @@ -310,8 +341,9 @@ class MarkdownThemeData implements ThemeExtension { TextDecoration.lineThrough, _ => null, }, - // CSS generic `'monospace'` does not resolve on most Flutter - // targets (notably Apple); use a real system stack instead. + // The CSS generic `'monospace'` does not resolve on Apple or + // Windows; [kMonospaceFontFamily] substitutes a real face there + // and keeps the generic everywhere it already worked. fontFamily: style.contains(MD$Style.monospace) ? kMonospaceFontFamily : null, diff --git a/test/theme/theme_test.dart b/test/theme/theme_test.dart index 6d01dce..6226d1a 100644 --- a/test/theme/theme_test.dart +++ b/test/theme/theme_test.dart @@ -1,3 +1,4 @@ +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/src/services/mouse_cursor.dart'; import 'package:flutter_md/flutter_md.dart'; @@ -288,4 +289,94 @@ void main() => group('MarkdownThemeData', () { expect(a.updateShouldNotify(different), isTrue); }); }); + + group('Monospace family', () { + /// Reads the resolved inline-code family under [platform]. + /// + /// Reset via try/finally rather than `addTearDown` — the framework's + /// `debugAssertAllFoundationVarsUnset` check runs first. The theme is + /// rebuilt inside the override because `textStyleFor` memoises. + (String?, List?) familyOn(TargetPlatform platform) { + debugDefaultTargetPlatformOverride = platform; + try { + final style = base().textStyleFor(MD$Style.monospace); + return (style.fontFamily, style.fontFamilyFallback); + } finally { + debugDefaultTargetPlatformOverride = null; + } + } + + test('keeps the CSS generic where the platform resolves it', () { + // Android's font config and fontconfig on Linux both map + // `monospace`; substituting a named face there could only make the + // result worse than 0.2.x. + for (final platform in const [ + TargetPlatform.android, + TargetPlatform.fuchsia, + TargetPlatform.linux, + ]) { + expect(familyOn(platform).$1, 'monospace', reason: '$platform'); + } + }); + + test('substitutes a real face where the generic does not resolve', () { + // CoreText and DirectWrite do not know `monospace`, so inline code + // silently fell back to the proportional body face. + expect(familyOn(TargetPlatform.iOS).$1, 'Menlo'); + expect(familyOn(TargetPlatform.macOS).$1, 'Menlo'); + expect(familyOn(TargetPlatform.windows).$1, 'Consolas'); + }); + + test('every platform falls back through the generic last', () { + for (final platform in TargetPlatform.values) { + final (family, fallback) = familyOn(platform); + expect(fallback, isNotNull, reason: '$platform'); + expect(fallback!.last, 'monospace', reason: '$platform'); + expect( + fallback.contains(family) || family == 'monospace', + isTrue, + reason: '$platform primary must also appear in the chain', + ); + } + }); + + test('non-monospace spans keep the body face', () { + for (final platform in TargetPlatform.values) { + debugDefaultTargetPlatformOverride = platform; + try { + final style = base().textStyleFor(MD$Style.bold); + expect(style.fontFamily, isNull, reason: '$platform'); + expect(style.fontFamilyFallback, isNull, reason: '$platform'); + } finally { + debugDefaultTargetPlatformOverride = null; + } + } + }); + + testWidgets('a fenced block renders with the same family', + (tester) async { + debugDefaultTargetPlatformOverride = TargetPlatform.macOS; + try { + expect(kMonospaceFontFamily, 'Menlo'); + await tester.pumpWidget(MaterialApp( + home: Align( + alignment: Alignment.topLeft, + child: SizedBox( + width: 400, + child: MarkdownWidget( + markdown: Markdown.fromString( + '```dart\nvoid main() {}\n```', + ), + theme: base(), + ), + ), + ), + )); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + } finally { + debugDefaultTargetPlatformOverride = null; + } + }); + }); }); From 3a65b05abb55b2935f39b595e2cbc145be325b75 Mon Sep 17 00:00:00 2001 From: Mikhail Matiunin Date: Mon, 14 Sep 2026 20:07:42 +0400 Subject: [PATCH 16/19] feat(theme): make the monospace face replaceable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit kMonospaceFontFamily picked a sane per-platform default but there was no supported way to override it. Inline code went through MarkdownThemeData.textStyleFor, so subclassing the theme moved that half; fenced blocks did not — BlockPainter$Code built its base style in a static method reading the global directly, so the only ways out were abusing SyntaxHighlighter.baseStyleFor to set a font family or reimplementing the whole fence painter through theme.builder. The two halves could not even be changed by the same mechanism. MarkdownThemeData.monospaceFontFamily / monospaceFontFamilyFallback (with effectiveMonospace… getters) now feed both paths. Null keeps the platform-resolved default; an empty fallback list means "no fallbacks" rather than "unset". --- CHANGELOG.md | 11 ++++-- README.md | 6 ++++ docs/rendering.md | 5 ++- lib/src/render/blocks/code.dart | 4 +-- lib/src/theme.dart | 48 +++++++++++++++++++++++-- test/theme/theme_test.dart | 63 +++++++++++++++++++++++++++++++++ 6 files changed, 128 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3f2c112..13dd75b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,9 +38,14 @@ extension points or reach into a default block painter's fields. Ordinary `Consolas`. **Android, Fuchsia, Linux and web keep `'monospace'`** — they resolve it natively, so rendering there is unchanged from 0.2.0. - **ADDED**: `kMonospaceFontFamily` (platform-resolved) and - `kMonospaceFontFamilyFallback`. There is still no theme-level knob for the - monospace face; a host that needs a bundled font has to go through - `MarkdownThemeData.builder` / `SyntaxHighlighter.baseStyleFor`. + `kMonospaceFontFamilyFallback` — the defaults behind the theme fields below. +- **ADDED**: `MarkdownThemeData.monospaceFontFamily` / + `monospaceFontFamilyFallback` (+ the `effectiveMonospace…` getters) to pin a + bundled face. One knob covers both inline `` `code` `` and fenced blocks; + before this there was no supported way to change the fenced-block face at all + — `BlockPainter$Code` built its style from a static global, so overriding + `textStyleFor` moved inline code only and left fences behind. An empty + fallback list is honoured (it means "no fallbacks"), not treated as unset. ### Quote / alert nested fenced code - **FIXED**: Fenced code (` ```lang ` / `~~~`) inside a blockquote or GitHub diff --git a/README.md b/README.md index b376452..b2204fc 100644 --- a/README.md +++ b/README.md @@ -488,6 +488,12 @@ MarkdownTheme( linkStyle: const TextStyle( decoration: TextDecoration.underline, ), + // Monospace face for inline `code` AND fenced blocks. Leave it unset to + // get a family the host platform can actually resolve: `monospace` on + // Android / Linux / web, Menlo on iOS + macOS, Consolas on Windows + // (CoreText and DirectWrite do not know the CSS generic, so code would + // otherwise render in the proportional body face there). + monospaceFontFamily: 'JetBrains Mono', // Per-type accent colors for GitHub alert blocks alertColors: const { MD$AlertType.warning: Color(0xFF9A6700), diff --git a/docs/rendering.md b/docs/rendering.md index a8e5f17..78fe48b 100644 --- a/docs/rendering.md +++ b/docs/rendering.md @@ -199,7 +199,10 @@ Styling: `textStyle`, per-level `h1Style..h6Style` (+ cached `headingStyleFor`), `textStyleFor(MD$Style)` (cached mapping of the bitmask → bold/italic/underline/ strikethrough/monospace + highlight/monospace backgrounds + link color), `linkColor`/`linkStyle`, `surfaceColor` (code/table/quote backgrounds), -`highlightBackgroundColor`, `monospaceBackgroundColor`, `dividerColor`, +`highlightBackgroundColor`, `monospaceBackgroundColor`, +`monospaceFontFamily`/`monospaceFontFamilyFallback` (null → the +platform-resolved `kMonospaceFontFamily`; one knob for both inline `` `code` `` +and fenced blocks), `dividerColor`, `alertColors` (+ built-in GitHub palette fallback via `alertColorFor`), `textDirection`, `textScaler`, and `onLinkTap`. diff --git a/lib/src/render/blocks/code.dart b/lib/src/render/blocks/code.dart index f35fd3d..9b13dd1 100644 --- a/lib/src/render/blocks/code.dart +++ b/lib/src/render/blocks/code.dart @@ -46,8 +46,8 @@ class BlockPainter$Code with SelectableTextBlock implements BlockPainter { MarkdownThemeData theme, ) { final baseStyle = theme.textStyle.copyWith( - fontFamily: kMonospaceFontFamily, - fontFamilyFallback: kMonospaceFontFamilyFallback, + fontFamily: theme.effectiveMonospaceFontFamily, + fontFamilyFallback: theme.effectiveMonospaceFontFamilyFallback, fontSize: theme.textStyle.fontSize ?? kDefaultFontSize, ); final highlighter = theme.highlighter; diff --git a/lib/src/theme.dart b/lib/src/theme.dart index 38a82f5..5526ff8 100644 --- a/lib/src/theme.dart +++ b/lib/src/theme.dart @@ -90,6 +90,8 @@ class MarkdownThemeData implements ThemeExtension { this.surfaceColor = const Color.fromARGB(255, 235, 235, 235), this.highlightBackgroundColor = const Color(0x40FF5722), this.monospaceBackgroundColor = const Color(0x409E9E9E), + this.monospaceFontFamily, + this.monospaceFontFamilyFallback, this.dividerColor, this.alertColors, this.blockFilter, @@ -119,6 +121,8 @@ class MarkdownThemeData implements ThemeExtension { Color? surfaceColor, Color? highlightBackgroundColor, Color? monospaceBackgroundColor, + String? monospaceFontFamily, + List? monospaceFontFamilyFallback, Color? dividerColor, Map? alertColors, bool Function(MD$Block block)? blockFilter, @@ -151,6 +155,8 @@ class MarkdownThemeData implements ThemeExtension { highlightBackgroundColor ?? theme.colorScheme.errorContainer, monospaceBackgroundColor: monospaceBackgroundColor ?? theme.colorScheme.surfaceContainerHigh, + monospaceFontFamily: monospaceFontFamily, + monospaceFontFamilyFallback: monospaceFontFamilyFallback, dividerColor: dividerColor ?? theme.dividerColor.withValues(alpha: 0.12), alertColors: alertColors, blockFilter: blockFilter, @@ -211,6 +217,30 @@ class MarkdownThemeData implements ThemeExtension { /// The color to use for the background of monospace text. final Color? monospaceBackgroundColor; + /// Font family for inline `` `code` `` and fenced blocks. + /// + /// Null (the default) resolves [kMonospaceFontFamily] for the host platform. + /// Set it to pin a bundled face (`JetBrains Mono`, `Fira Code`, …) — + /// [effectiveMonospaceFontFamily] is what both the inline span styles and + /// [BlockPainter$Code] actually read. + final String? monospaceFontFamily; + + /// Families tried after [monospaceFontFamily]. + /// + /// Null (the default) uses [kMonospaceFontFamilyFallback]. Pass an empty + /// list to disable fallbacks entirely (a bundled face with full coverage). + final List? monospaceFontFamilyFallback; + + /// The monospace family in effect: [monospaceFontFamily], else the + /// platform-resolved [kMonospaceFontFamily]. + String get effectiveMonospaceFontFamily => + monospaceFontFamily ?? kMonospaceFontFamily; + + /// The monospace fallbacks in effect: [monospaceFontFamilyFallback], else + /// [kMonospaceFontFamilyFallback]. + List get effectiveMonospaceFontFamilyFallback => + monospaceFontFamilyFallback ?? kMonospaceFontFamilyFallback; + /// The color to use for the divider. final Color? dividerColor; @@ -343,12 +373,13 @@ class MarkdownThemeData implements ThemeExtension { }, // The CSS generic `'monospace'` does not resolve on Apple or // Windows; [kMonospaceFontFamily] substitutes a real face there - // and keeps the generic everywhere it already worked. + // and keeps the generic everywhere it already worked. A host that + // bundles its own face sets [monospaceFontFamily]. fontFamily: style.contains(MD$Style.monospace) - ? kMonospaceFontFamily + ? effectiveMonospaceFontFamily : null, fontFamilyFallback: style.contains(MD$Style.monospace) - ? kMonospaceFontFamilyFallback + ? effectiveMonospaceFontFamilyFallback : null, color: switch (style) { var s when s.contains(MD$Style.link) => linkColor, @@ -387,6 +418,8 @@ class MarkdownThemeData implements ThemeExtension { Color? surfaceColor, Color? highlightBackgroundColor, Color? monospaceBackgroundColor, + String? monospaceFontFamily, + List? monospaceFontFamilyFallback, Color? dividerColor, Map? alertColors, bool Function(MD$Block block)? blockFilter, @@ -414,6 +447,9 @@ class MarkdownThemeData implements ThemeExtension { highlightBackgroundColor ?? this.highlightBackgroundColor, monospaceBackgroundColor: monospaceBackgroundColor ?? this.monospaceBackgroundColor, + monospaceFontFamily: monospaceFontFamily ?? this.monospaceFontFamily, + monospaceFontFamilyFallback: + monospaceFontFamilyFallback ?? this.monospaceFontFamilyFallback, dividerColor: dividerColor ?? this.dividerColor, alertColors: alertColors ?? this.alertColors, blockFilter: blockFilter ?? this.blockFilter, @@ -451,6 +487,12 @@ class MarkdownThemeData implements ThemeExtension { highlightBackgroundColor, other?.highlightBackgroundColor, t), monospaceBackgroundColor: Color.lerp( monospaceBackgroundColor, other?.monospaceBackgroundColor, t), + // A font family cannot be interpolated — snap at the midpoint. + monospaceFontFamily: + t < 0.5 ? monospaceFontFamily : other?.monospaceFontFamily, + monospaceFontFamilyFallback: t < 0.5 + ? monospaceFontFamilyFallback + : other?.monospaceFontFamilyFallback, dividerColor: Color.lerp(dividerColor, other?.dividerColor, t), alertColors: t < 0.5 ? alertColors : other?.alertColors, blockFilter: t < 0.5 ? blockFilter : other?.blockFilter, diff --git a/test/theme/theme_test.dart b/test/theme/theme_test.dart index 6226d1a..c8738a8 100644 --- a/test/theme/theme_test.dart +++ b/test/theme/theme_test.dart @@ -353,6 +353,69 @@ void main() => group('MarkdownThemeData', () { } }); + test('a host face overrides the platform default everywhere', () { + // Both the inline span styles and BlockPainter$Code read the + // effective getters, so one knob covers `code` and ``` fences. + final theme = MarkdownThemeData( + textStyle: const TextStyle(fontSize: 14), + monospaceFontFamily: 'JetBrains Mono', + monospaceFontFamilyFallback: const ['Fira Code'], + ); + + expect(theme.effectiveMonospaceFontFamily, 'JetBrains Mono'); + expect( + theme.effectiveMonospaceFontFamilyFallback, + const ['Fira Code'], + ); + + final inline = theme.textStyleFor(MD$Style.monospace); + expect(inline.fontFamily, 'JetBrains Mono'); + expect(inline.fontFamilyFallback, const ['Fira Code']); + }); + + test('an override survives on every platform', () { + for (final platform in TargetPlatform.values) { + debugDefaultTargetPlatformOverride = platform; + try { + final theme = MarkdownThemeData( + textStyle: const TextStyle(fontSize: 14), + monospaceFontFamily: 'JetBrains Mono', + ); + expect( + theme.textStyleFor(MD$Style.monospace).fontFamily, + 'JetBrains Mono', + reason: '$platform', + ); + // Fallbacks stay on the shared chain unless overridden too. + expect( + theme.effectiveMonospaceFontFamilyFallback, + kMonospaceFontFamilyFallback, + reason: '$platform', + ); + } finally { + debugDefaultTargetPlatformOverride = null; + } + } + }); + + test('an empty fallback list is honored, not treated as unset', () { + final theme = MarkdownThemeData( + textStyle: const TextStyle(fontSize: 14), + monospaceFontFamilyFallback: const [], + ); + expect(theme.effectiveMonospaceFontFamilyFallback, isEmpty); + expect( + theme.textStyleFor(MD$Style.monospace).fontFamilyFallback, + isEmpty, + ); + }); + + test('copyWith carries the override', () { + final theme = MarkdownThemeData(textStyle: const TextStyle()) + .copyWith(monospaceFontFamily: 'Fira Code'); + expect(theme.effectiveMonospaceFontFamily, 'Fira Code'); + }); + testWidgets('a fenced block renders with the same family', (tester) async { debugDefaultTargetPlatformOverride = TargetPlatform.macOS; From 879bd1c668adc99511a536df6688e3f4178dc971 Mon Sep 17 00:00:00 2001 From: Mikhail Matiunin Date: Mon, 14 Sep 2026 20:14:42 +0400 Subject: [PATCH 17/19] test(parser): stream fenced code inside quotes and alerts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The corpus had 'quote', 'quote-multi' and two alerts but no body containing a fence — the one construct that makes the decoder re-enter itself. The chat streams straight into it, so every mid-fence prefix now has to match a batch parse across all six chunkings (whole, per-line, per-word, fixed sizes, char-by-char, seeded-random): closed, unclosed, tilde, empty body, a nested quote beside the fence, and the alert form. --- test/parser/streaming_test.dart | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/test/parser/streaming_test.dart b/test/parser/streaming_test.dart index bafc430..26d7877 100644 --- a/test/parser/streaming_test.dart +++ b/test/parser/streaming_test.dart @@ -41,6 +41,17 @@ void main() => group('StreamingMarkdownParser', () { 'quote-multi': '> a\n> b\n> c', 'alert-note': '> [!NOTE]\n> Body of the note.\n\nafter', 'alert-warning': '> [!WARNING]\n> Careful now.\n> Second line.', + // Fenced code inside a quote / alert re-enters the decoder to parse + // the body as nested blocks. The chat streams into exactly this, so + // every mid-fence prefix has to agree with a batch parse. + 'quote-fence': + '> intro\n> ```dart\n> void main() {}\n> ```\n> outro\n\nafter', + 'quote-fence-unclosed': '> intro\n> ```\n> still going', + 'quote-fence-tilde': '> a\n> ~~~\n> x\n> ~~~\n> b', + 'quote-fence-empty': '> ```\n> ```\n\nafter', + 'quote-fence-nested-quote': '> > inner\n> ```\n> code\n> ```', + 'alert-fence': + '> [!NOTE]\n> body\n> ```sh\n> echo hi\n> ```\n\nafter', 'code-closed': '```dart\nvoid main() {}\n```\n\nafter code', 'code-tilde': '~~~\nplain\n~~~\n\nafter', 'code-unclosed': '```dart\nline 1\nline 2\nstill going', From 2868f77acde70d6cb54a23f8fe0e96d308ee69f2 Mon Sep 17 00:00:00 2001 From: Mikhail Matiunin Date: Mon, 14 Sep 2026 20:23:36 +0400 Subject: [PATCH 18/19] chore: split the line clamp and the monospace font out of this PR MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Neither has anything to do with selection chrome, and nothing inside the package calls either of them. - clampMarkdownToLines / MarkdownLineClamp / MarkdownPainter.textLineBottoms → its own branch. Zero callers in lib/ and example/, so the only thing that ever exercised the line-counting heuristic was its own unit tests. - The monospace family → its own branch. It changes rendering for every user of the package, it arrived here inside a commit about hit-testing, and font resolution is not observable in flutter_test at all, so it needs a real device pass that this PR cannot give it. --- CHANGELOG.md | 24 ----- README.md | 6 -- docs/rendering.md | 5 +- lib/flutter_md.dart | 1 - lib/src/line_clamp.dart | 61 ----------- lib/src/render/blocks/code.dart | 3 +- lib/src/render/markdown_painter.dart | 53 ---------- lib/src/theme.dart | 99 +---------------- test/line_clamp/line_clamp_test.dart | 115 -------------------- test/theme/theme_test.dart | 153 --------------------------- test/unit_test.dart | 2 - 11 files changed, 3 insertions(+), 519 deletions(-) delete mode 100644 lib/src/line_clamp.dart delete mode 100644 test/line_clamp/line_clamp_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 13dd75b..f1a9ae5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,24 +29,6 @@ extension points or reach into a default block painter's fields. Ordinary `markdown:`. `@meta.internal`, but `MarkdownWidget` subclasses that call it must now invoke it **before** `update` (see the registry fix below). -### Monospace font resolution -- **FIXED**: Inline `` `code` `` and fenced blocks pick a monospace family that - the host platform can actually resolve (`kMonospaceFontFamily`, then - `kMonospaceFontFamilyFallback`). CoreText (iOS / macOS) and DirectWrite - (Windows) do not know the CSS generic `'monospace'`, so code silently - rendered in the proportional body face there; those two now get `Menlo` / - `Consolas`. **Android, Fuchsia, Linux and web keep `'monospace'`** — they - resolve it natively, so rendering there is unchanged from 0.2.0. -- **ADDED**: `kMonospaceFontFamily` (platform-resolved) and - `kMonospaceFontFamilyFallback` — the defaults behind the theme fields below. -- **ADDED**: `MarkdownThemeData.monospaceFontFamily` / - `monospaceFontFamilyFallback` (+ the `effectiveMonospace…` getters) to pin a - bundled face. One knob covers both inline `` `code` `` and fenced blocks; - before this there was no supported way to change the fenced-block face at all - — `BlockPainter$Code` built its style from a static global, so overriding - `textStyleFor` moved inline code only and left fences behind. An empty - fallback list is honoured (it means "no fallbacks"), not treated as unset. - ### 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` / @@ -123,12 +105,6 @@ extension points or reach into a default block painter's fields. Ordinary bounding boxes for an arbitrary character range within a block, directly from cached block painters without re-layout. -### Line clamp -- **ADDED**: `clampMarkdownToLines` / `MarkdownLineClamp` — painter-measured - height for the first N visual text lines at a given width (line-boundary cut; - spacers/dividers add height without spending the line budget). Callers size a - clipped box; the renderer itself has no line budget. - ### 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 diff --git a/README.md b/README.md index b2204fc..b376452 100644 --- a/README.md +++ b/README.md @@ -488,12 +488,6 @@ MarkdownTheme( linkStyle: const TextStyle( decoration: TextDecoration.underline, ), - // Monospace face for inline `code` AND fenced blocks. Leave it unset to - // get a family the host platform can actually resolve: `monospace` on - // Android / Linux / web, Menlo on iOS + macOS, Consolas on Windows - // (CoreText and DirectWrite do not know the CSS generic, so code would - // otherwise render in the proportional body face there). - monospaceFontFamily: 'JetBrains Mono', // Per-type accent colors for GitHub alert blocks alertColors: const { MD$AlertType.warning: Color(0xFF9A6700), diff --git a/docs/rendering.md b/docs/rendering.md index 78fe48b..a8e5f17 100644 --- a/docs/rendering.md +++ b/docs/rendering.md @@ -199,10 +199,7 @@ Styling: `textStyle`, per-level `h1Style..h6Style` (+ cached `headingStyleFor`), `textStyleFor(MD$Style)` (cached mapping of the bitmask → bold/italic/underline/ strikethrough/monospace + highlight/monospace backgrounds + link color), `linkColor`/`linkStyle`, `surfaceColor` (code/table/quote backgrounds), -`highlightBackgroundColor`, `monospaceBackgroundColor`, -`monospaceFontFamily`/`monospaceFontFamilyFallback` (null → the -platform-resolved `kMonospaceFontFamily`; one knob for both inline `` `code` `` -and fenced blocks), `dividerColor`, +`highlightBackgroundColor`, `monospaceBackgroundColor`, `dividerColor`, `alertColors` (+ built-in GitHub palette fallback via `alertColorFor`), `textDirection`, `textScaler`, and `onLinkTap`. diff --git a/lib/flutter_md.dart b/lib/flutter_md.dart index ba3ba53..ab6ecf7 100644 --- a/lib/flutter_md.dart +++ b/lib/flutter_md.dart @@ -1,6 +1,5 @@ library; -export 'src/line_clamp.dart'; export 'src/markdown.dart'; export 'src/nodes.dart'; export 'src/parser.dart'; diff --git a/lib/src/line_clamp.dart b/lib/src/line_clamp.dart deleted file mode 100644 index ea6ba99..0000000 --- a/lib/src/line_clamp.dart +++ /dev/null @@ -1,61 +0,0 @@ -import 'markdown.dart'; -import 'render/markdown_painter.dart'; -import 'theme.dart'; - -/// Where a markdown document has to be cut to show a limited number of lines. -final class MarkdownLineClamp { - /// Creates a clamp result. - const MarkdownLineClamp({ - required this.height, - required this.clampedHeight, - }); - - /// Full content height at the measured width. - final double height; - - /// Height holding at most the requested lines, ending on a line boundary. - /// Equals [height] when the whole document fits. - final double clampedHeight; - - /// Whether the line budget leaves content out. - bool get overflows => clampedHeight < height; - - @override - String toString() => - 'MarkdownLineClamp(height: $height, clampedHeight: $clampedHeight)'; -} - -/// Measures [markdown] at [maxWidth] and reports where its first [maxLines] -/// text lines end. -/// -/// Lays the document out on its own so a caller can size a clipped box before -/// the renderer runs — the renderer has no line budget of its own. Cutting on -/// a line boundary is what keeps a clipped view from showing half a line. -/// -/// Line counting follows the rules of [MarkdownPainter.textLineBottoms]: blank -/// lines and dividers add height without spending the budget, so a clamp never -/// ends on trailing blank space. -MarkdownLineClamp clampMarkdownToLines({ - required Markdown markdown, - required MarkdownThemeData theme, - required double maxWidth, - required int maxLines, -}) { - final painter = MarkdownPainter(markdown: markdown, theme: theme); - try { - final height = painter.layout(maxWidth: maxWidth).height; - if (maxLines <= 0) { - return MarkdownLineClamp(height: height, clampedHeight: 0); - } - final lines = painter.textLineBottoms(); - if (lines.length <= maxLines) { - return MarkdownLineClamp(height: height, clampedHeight: height); - } - return MarkdownLineClamp( - height: height, - clampedHeight: lines[maxLines - 1], - ); - } finally { - painter.dispose(); - } -} diff --git a/lib/src/render/blocks/code.dart b/lib/src/render/blocks/code.dart index 9b13dd1..6920395 100644 --- a/lib/src/render/blocks/code.dart +++ b/lib/src/render/blocks/code.dart @@ -46,8 +46,7 @@ class BlockPainter$Code with SelectableTextBlock implements BlockPainter { MarkdownThemeData theme, ) { final baseStyle = theme.textStyle.copyWith( - fontFamily: theme.effectiveMonospaceFontFamily, - fontFamilyFallback: theme.effectiveMonospaceFontFamilyFallback, + fontFamily: 'monospace', fontSize: theme.textStyle.fontSize ?? kDefaultFontSize, ); final highlighter = theme.highlighter; diff --git a/lib/src/render/markdown_painter.dart b/lib/src/render/markdown_painter.dart index a10a17c..9925c0e 100644 --- a/lib/src/render/markdown_painter.dart +++ b/lib/src/render/markdown_painter.dart @@ -412,59 +412,6 @@ class MarkdownPainter { return _size = Size(width, height); } - /// Vertical positions where each visual text line ends, top-down. - /// - /// Positions are absolute in the laid-out content. Runs sharing a bottom - /// edge — cells of a table row, a list bullet and its first line — count as - /// one line. Blocks without text (spacer, divider) contribute no line of - /// their own; their height still shows in the positions after them. - /// - /// Only valid after [layout]. - List textLineBottoms() { - if (_isEmpty) return const []; - final blocks = _blockPainters; - if (_blockOffsets.length != blocks.length) return const []; - final bottoms = []; - for (var i = 0; i < blocks.length; i++) { - final top = _blockOffsets[i]; - for (final (painter, offset) in _textFragments(blocks[i])) { - var bottom = top + offset.dy; - for (final line in painter.computeLineMetrics()) { - bottom += line.height; - bottoms.add(bottom); - } - } - } - bottoms.sort(); - final lines = []; - for (final bottom in bottoms) { - // Runs of one line round to slightly different bottoms; a cut may only - // land where every run on that line has ended. - if (lines.isNotEmpty && bottom - lines.last < 1) { - lines[lines.length - 1] = bottom; - continue; - } - lines.add(bottom); - } - return lines; - } - - /// Selectable text runs of [block], as `(painter, block-local origin)`. - static Iterable<(TextPainter, Offset)> _textFragments(BlockPainter block) { - if (block is SelectableTextBlock) { - return <(TextPainter, Offset)>[ - ( - block.selectionPainter, - block.selectionOrigin, - ) - ]; - } - if (block is MultiPainterSelectable) { - return block.fragments.map((f) => (f.painter, f.origin)); - } - return const <(TextPainter, Offset)>[]; - } - /// Routes a tap-down / tap-up to the block under the pointer, re-basing the /// event's position into that block's local space (only taps are handled; the /// block painters use it to fire link recognizers). diff --git a/lib/src/theme.dart b/lib/src/theme.dart index 5526ff8..99c7b94 100644 --- a/lib/src/theme.dart +++ b/lib/src/theme.dart @@ -1,7 +1,5 @@ import 'dart:collection'; -import 'package:flutter/foundation.dart' - show defaultTargetPlatform, kIsWeb; import 'package:flutter/material.dart'; import '../flutter_md.dart'; @@ -23,51 +21,6 @@ typedef MarkdownCursorResolver = MouseCursor? Function( MD$Block? block, ); -/// Primary monospace face for inline `` `code` `` and fenced blocks, chosen for -/// the host platform. -/// -/// Flutter passes the family straight to the platform font manager, and only -/// some of them know the CSS generic `'monospace'`: Android's font config and -/// fontconfig on Linux resolve it, CoreText (iOS / macOS) and DirectWrite -/// (Windows) do not — there a `'monospace'` span silently falls back to the -/// proportional body face, so inline code stops looking like code. -/// -/// So a real system family is substituted on exactly those two platforms, and -/// every other target keeps the generic it already resolved. Web keeps it too: -/// its renderer has no access to arbitrary system fonts, so naming one could -/// not help and the generic is what the browser (or the Noto fallback) is able -/// to answer. -/// -/// [kMonospaceFontFamilyFallback] follows, ending in `'monospace'` again, so a -/// platform whose primary is missing still lands on a real monospace face. -String get kMonospaceFontFamily { - if (kIsWeb) return 'monospace'; - return switch (defaultTargetPlatform) { - // Ships with every iOS / macOS release; SF Mono is not user-installable. - TargetPlatform.iOS || TargetPlatform.macOS => 'Menlo', - // Ships with Windows since Vista. - TargetPlatform.windows => 'Consolas', - TargetPlatform.android || - TargetPlatform.fuchsia || - TargetPlatform.linux => - 'monospace', - }; -} - -/// Families tried after [kMonospaceFontFamily], for a platform whose primary -/// face is missing (a trimmed Linux image, a custom Android ROM). -/// -/// Ends in the CSS generic so anything that resolves it still gets a real -/// monospace face rather than the proportional default. -const List kMonospaceFontFamilyFallback = [ - 'Menlo', // Apple - 'Consolas', // Windows - 'DejaVu Sans Mono', // Linux - 'Roboto Mono', // Android - 'Courier New', - 'monospace', -]; - /// {@template markdown_theme_data} /// Theme data for Markdown widgets. /// {@endtemplate} @@ -90,8 +43,6 @@ class MarkdownThemeData implements ThemeExtension { this.surfaceColor = const Color.fromARGB(255, 235, 235, 235), this.highlightBackgroundColor = const Color(0x40FF5722), this.monospaceBackgroundColor = const Color(0x409E9E9E), - this.monospaceFontFamily, - this.monospaceFontFamilyFallback, this.dividerColor, this.alertColors, this.blockFilter, @@ -121,8 +72,6 @@ class MarkdownThemeData implements ThemeExtension { Color? surfaceColor, Color? highlightBackgroundColor, Color? monospaceBackgroundColor, - String? monospaceFontFamily, - List? monospaceFontFamilyFallback, Color? dividerColor, Map? alertColors, bool Function(MD$Block block)? blockFilter, @@ -155,8 +104,6 @@ class MarkdownThemeData implements ThemeExtension { highlightBackgroundColor ?? theme.colorScheme.errorContainer, monospaceBackgroundColor: monospaceBackgroundColor ?? theme.colorScheme.surfaceContainerHigh, - monospaceFontFamily: monospaceFontFamily, - monospaceFontFamilyFallback: monospaceFontFamilyFallback, dividerColor: dividerColor ?? theme.dividerColor.withValues(alpha: 0.12), alertColors: alertColors, blockFilter: blockFilter, @@ -217,30 +164,6 @@ class MarkdownThemeData implements ThemeExtension { /// The color to use for the background of monospace text. final Color? monospaceBackgroundColor; - /// Font family for inline `` `code` `` and fenced blocks. - /// - /// Null (the default) resolves [kMonospaceFontFamily] for the host platform. - /// Set it to pin a bundled face (`JetBrains Mono`, `Fira Code`, …) — - /// [effectiveMonospaceFontFamily] is what both the inline span styles and - /// [BlockPainter$Code] actually read. - final String? monospaceFontFamily; - - /// Families tried after [monospaceFontFamily]. - /// - /// Null (the default) uses [kMonospaceFontFamilyFallback]. Pass an empty - /// list to disable fallbacks entirely (a bundled face with full coverage). - final List? monospaceFontFamilyFallback; - - /// The monospace family in effect: [monospaceFontFamily], else the - /// platform-resolved [kMonospaceFontFamily]. - String get effectiveMonospaceFontFamily => - monospaceFontFamily ?? kMonospaceFontFamily; - - /// The monospace fallbacks in effect: [monospaceFontFamilyFallback], else - /// [kMonospaceFontFamilyFallback]. - List get effectiveMonospaceFontFamilyFallback => - monospaceFontFamilyFallback ?? kMonospaceFontFamilyFallback; - /// The color to use for the divider. final Color? dividerColor; @@ -371,16 +294,7 @@ class MarkdownThemeData implements ThemeExtension { TextDecoration.lineThrough, _ => null, }, - // The CSS generic `'monospace'` does not resolve on Apple or - // Windows; [kMonospaceFontFamily] substitutes a real face there - // and keeps the generic everywhere it already worked. A host that - // bundles its own face sets [monospaceFontFamily]. - fontFamily: style.contains(MD$Style.monospace) - ? effectiveMonospaceFontFamily - : null, - fontFamilyFallback: style.contains(MD$Style.monospace) - ? effectiveMonospaceFontFamilyFallback - : null, + fontFamily: style.contains(MD$Style.monospace) ? 'monospace' : null, color: switch (style) { var s when s.contains(MD$Style.link) => linkColor, _ => null, @@ -418,8 +332,6 @@ class MarkdownThemeData implements ThemeExtension { Color? surfaceColor, Color? highlightBackgroundColor, Color? monospaceBackgroundColor, - String? monospaceFontFamily, - List? monospaceFontFamilyFallback, Color? dividerColor, Map? alertColors, bool Function(MD$Block block)? blockFilter, @@ -447,9 +359,6 @@ class MarkdownThemeData implements ThemeExtension { highlightBackgroundColor ?? this.highlightBackgroundColor, monospaceBackgroundColor: monospaceBackgroundColor ?? this.monospaceBackgroundColor, - monospaceFontFamily: monospaceFontFamily ?? this.monospaceFontFamily, - monospaceFontFamilyFallback: - monospaceFontFamilyFallback ?? this.monospaceFontFamilyFallback, dividerColor: dividerColor ?? this.dividerColor, alertColors: alertColors ?? this.alertColors, blockFilter: blockFilter ?? this.blockFilter, @@ -487,12 +396,6 @@ class MarkdownThemeData implements ThemeExtension { highlightBackgroundColor, other?.highlightBackgroundColor, t), monospaceBackgroundColor: Color.lerp( monospaceBackgroundColor, other?.monospaceBackgroundColor, t), - // A font family cannot be interpolated — snap at the midpoint. - monospaceFontFamily: - t < 0.5 ? monospaceFontFamily : other?.monospaceFontFamily, - monospaceFontFamilyFallback: t < 0.5 - ? monospaceFontFamilyFallback - : other?.monospaceFontFamilyFallback, dividerColor: Color.lerp(dividerColor, other?.dividerColor, t), alertColors: t < 0.5 ? alertColors : other?.alertColors, blockFilter: t < 0.5 ? blockFilter : other?.blockFilter, diff --git a/test/line_clamp/line_clamp_test.dart b/test/line_clamp/line_clamp_test.dart deleted file mode 100644 index 26575fd..0000000 --- a/test/line_clamp/line_clamp_test.dart +++ /dev/null @@ -1,115 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_md/flutter_md.dart'; -import 'package:flutter_test/flutter_test.dart'; - -void main() => group('clampMarkdownToLines', () { - // Height 2.0 keeps every line exactly 20px tall, so expectations read as - // line counts. - final theme = MarkdownThemeData( - textStyle: const TextStyle(fontSize: 10, height: 2), - ); - - MarkdownLineClamp clamp(String source, int maxLines, - {double width = 300}) => - clampMarkdownToLines( - markdown: Markdown.fromString(source), - theme: theme, - maxWidth: width, - maxLines: maxLines, - ); - - test('content within the budget is not clamped', () { - final result = clamp('one\n\ntwo', 6); - - expect(result.overflows, isFalse); - expect(result.clampedHeight, result.height); - }); - - test('cuts on the boundary of the last allowed line', () { - final result = clamp('one\ntwo\nthree\nfour', 2); - - expect(result.overflows, isTrue); - expect(result.clampedHeight, 40); - expect(result.height, 80); - }); - - test('counts wrapped lines of a single paragraph', () { - // 10px glyphs at width 100 wrap roughly every 10 characters. - final result = clamp('word ' * 40, 3, width: 100); - - expect(result.overflows, isTrue); - expect(result.clampedHeight, 60); - }); - - test('counts list items as lines', () { - final result = clamp('- one\n- two\n- three', 2); - - expect(result.overflows, isTrue); - expect(result.clampedHeight, 40); - }); - - test('blank lines add height without spending the budget', () { - final withBlank = clamp('one\n\ntwo\nthree', 2); - final withoutBlank = clamp('one\ntwo\nthree', 2); - - expect(withBlank.overflows, isTrue); - // Two text lines plus the blank line between them. - expect( - withBlank.clampedHeight, greaterThan(withoutBlank.clampedHeight)); - }); - - test('dividers add height without spending the budget', () { - final withDivider = clamp('one\n\n---\n\ntwo\nthree', 2); - final withoutDivider = clamp('one\ntwo\nthree', 2); - - expect(withDivider.overflows, isTrue); - expect(withDivider.clampedHeight, - greaterThan(withoutDivider.clampedHeight)); - }); - - test('a clamp of zero lines keeps nothing', () { - expect(clamp('one\ntwo', 0).clampedHeight, 0); - }); - - test('empty markdown has no height', () { - final result = clamp('', 6); - - expect(result.height, 0); - expect(result.overflows, isFalse); - }); - - test('a negative budget keeps nothing', () { - final result = clamp('one\ntwo', -5); - - expect(result.clampedHeight, 0); - expect(result.overflows, isTrue); - }); - - test('table rows count as one line each', () { - const source = '| a | b |\n|---|---|\n| 1 | 2 |\n| 3 | 4 |'; - final all = clamp(source, 100); - final two = clamp(source, 2); - - expect(all.overflows, isFalse); - expect(two.overflows, isTrue); - expect(two.clampedHeight, greaterThan(0)); - expect(two.clampedHeight, lessThan(all.height)); - }); - - test('a quote with a nested fence counts its child lines', () { - const source = '> intro\n> ```\n> one\n> two\n> ```\n> outro'; - final all = clamp(source, 100); - final two = clamp(source, 2); - - expect(all.height, greaterThan(0)); - expect(two.clampedHeight, greaterThan(0)); - expect(two.clampedHeight, lessThan(all.height)); - }); - - test('a zero measuring width does not throw', () { - final result = clamp('hello world', 2, width: 0); - - expect(result.height, greaterThanOrEqualTo(0)); - expect(result.clampedHeight, greaterThanOrEqualTo(0)); - }); - }); diff --git a/test/theme/theme_test.dart b/test/theme/theme_test.dart index c8738a8..a66ffd1 100644 --- a/test/theme/theme_test.dart +++ b/test/theme/theme_test.dart @@ -1,4 +1,3 @@ -import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/src/services/mouse_cursor.dart'; import 'package:flutter_md/flutter_md.dart'; @@ -290,156 +289,4 @@ void main() => group('MarkdownThemeData', () { }); }); - group('Monospace family', () { - /// Reads the resolved inline-code family under [platform]. - /// - /// Reset via try/finally rather than `addTearDown` — the framework's - /// `debugAssertAllFoundationVarsUnset` check runs first. The theme is - /// rebuilt inside the override because `textStyleFor` memoises. - (String?, List?) familyOn(TargetPlatform platform) { - debugDefaultTargetPlatformOverride = platform; - try { - final style = base().textStyleFor(MD$Style.monospace); - return (style.fontFamily, style.fontFamilyFallback); - } finally { - debugDefaultTargetPlatformOverride = null; - } - } - - test('keeps the CSS generic where the platform resolves it', () { - // Android's font config and fontconfig on Linux both map - // `monospace`; substituting a named face there could only make the - // result worse than 0.2.x. - for (final platform in const [ - TargetPlatform.android, - TargetPlatform.fuchsia, - TargetPlatform.linux, - ]) { - expect(familyOn(platform).$1, 'monospace', reason: '$platform'); - } - }); - - test('substitutes a real face where the generic does not resolve', () { - // CoreText and DirectWrite do not know `monospace`, so inline code - // silently fell back to the proportional body face. - expect(familyOn(TargetPlatform.iOS).$1, 'Menlo'); - expect(familyOn(TargetPlatform.macOS).$1, 'Menlo'); - expect(familyOn(TargetPlatform.windows).$1, 'Consolas'); - }); - - test('every platform falls back through the generic last', () { - for (final platform in TargetPlatform.values) { - final (family, fallback) = familyOn(platform); - expect(fallback, isNotNull, reason: '$platform'); - expect(fallback!.last, 'monospace', reason: '$platform'); - expect( - fallback.contains(family) || family == 'monospace', - isTrue, - reason: '$platform primary must also appear in the chain', - ); - } - }); - - test('non-monospace spans keep the body face', () { - for (final platform in TargetPlatform.values) { - debugDefaultTargetPlatformOverride = platform; - try { - final style = base().textStyleFor(MD$Style.bold); - expect(style.fontFamily, isNull, reason: '$platform'); - expect(style.fontFamilyFallback, isNull, reason: '$platform'); - } finally { - debugDefaultTargetPlatformOverride = null; - } - } - }); - - test('a host face overrides the platform default everywhere', () { - // Both the inline span styles and BlockPainter$Code read the - // effective getters, so one knob covers `code` and ``` fences. - final theme = MarkdownThemeData( - textStyle: const TextStyle(fontSize: 14), - monospaceFontFamily: 'JetBrains Mono', - monospaceFontFamilyFallback: const ['Fira Code'], - ); - - expect(theme.effectiveMonospaceFontFamily, 'JetBrains Mono'); - expect( - theme.effectiveMonospaceFontFamilyFallback, - const ['Fira Code'], - ); - - final inline = theme.textStyleFor(MD$Style.monospace); - expect(inline.fontFamily, 'JetBrains Mono'); - expect(inline.fontFamilyFallback, const ['Fira Code']); - }); - - test('an override survives on every platform', () { - for (final platform in TargetPlatform.values) { - debugDefaultTargetPlatformOverride = platform; - try { - final theme = MarkdownThemeData( - textStyle: const TextStyle(fontSize: 14), - monospaceFontFamily: 'JetBrains Mono', - ); - expect( - theme.textStyleFor(MD$Style.monospace).fontFamily, - 'JetBrains Mono', - reason: '$platform', - ); - // Fallbacks stay on the shared chain unless overridden too. - expect( - theme.effectiveMonospaceFontFamilyFallback, - kMonospaceFontFamilyFallback, - reason: '$platform', - ); - } finally { - debugDefaultTargetPlatformOverride = null; - } - } - }); - - test('an empty fallback list is honored, not treated as unset', () { - final theme = MarkdownThemeData( - textStyle: const TextStyle(fontSize: 14), - monospaceFontFamilyFallback: const [], - ); - expect(theme.effectiveMonospaceFontFamilyFallback, isEmpty); - expect( - theme.textStyleFor(MD$Style.monospace).fontFamilyFallback, - isEmpty, - ); - }); - - test('copyWith carries the override', () { - final theme = MarkdownThemeData(textStyle: const TextStyle()) - .copyWith(monospaceFontFamily: 'Fira Code'); - expect(theme.effectiveMonospaceFontFamily, 'Fira Code'); - }); - - testWidgets('a fenced block renders with the same family', - (tester) async { - debugDefaultTargetPlatformOverride = TargetPlatform.macOS; - try { - expect(kMonospaceFontFamily, 'Menlo'); - await tester.pumpWidget(MaterialApp( - home: Align( - alignment: Alignment.topLeft, - child: SizedBox( - width: 400, - child: MarkdownWidget( - markdown: Markdown.fromString( - '```dart\nvoid main() {}\n```', - ), - theme: base(), - ), - ), - ), - )); - await tester.pumpAndSettle(); - expect(tester.takeException(), isNull); - } finally { - debugDefaultTargetPlatformOverride = null; - } - }); - }); }); diff --git a/test/unit_test.dart b/test/unit_test.dart index d66106a..71a6b7a 100644 --- a/test/unit_test.dart +++ b/test/unit_test.dart @@ -1,7 +1,6 @@ import 'package:flutter_test/flutter_test.dart'; import 'highlight/highlight_test.dart' as highlight_test; -import 'line_clamp/line_clamp_test.dart' as line_clamp_test; import 'nodes/nodes_test.dart' as nodes_test; import 'parser/block_test.dart' as block_test; import 'parser/edge_cases_test.dart' as edge_cases_test; @@ -56,7 +55,6 @@ void main() => group('Unit', () { selection_registry_test.main(); selection_chrome_test.main(); selection_nested_blocks_test.main(); - line_clamp_test.main(); render_test.main(); widget_test.main(); }); From 241761bdef1a804173d75e2027f0ae13e4d2585e Mon Sep 17 00:00:00 2001 From: Mikhail Matiunin Date: Mon, 14 Sep 2026 20:40:55 +0400 Subject: [PATCH 19/19] style: dart format --- lib/src/selection_autoscroll.dart | 6 +++--- test/parser/streaming_test.dart | 3 +-- .../selection_autoscroll_target_test.dart | 3 +-- test/selection/selection_nested_blocks_test.dart | 3 +-- test/selection/selection_registry_test.dart | 15 +++++++-------- test/theme/theme_test.dart | 1 - 6 files changed, 13 insertions(+), 18 deletions(-) diff --git a/lib/src/selection_autoscroll.dart b/lib/src/selection_autoscroll.dart index d997dfd..81890a5 100644 --- a/lib/src/selection_autoscroll.dart +++ b/lib/src/selection_autoscroll.dart @@ -143,8 +143,7 @@ final class MarkdownSelectionAutoscrollConfig { maxVelocity: maxVelocity ?? this.maxVelocity, topPad: topPad ?? this.topPad, bottomPad: bottomPad ?? this.bottomPad, - useMediaQueryPadding: - useMediaQueryPadding ?? this.useMediaQueryPadding, + useMediaQueryPadding: useMediaQueryPadding ?? this.useMediaQueryPadding, useHostUnionGate: useHostUnionGate ?? this.useHostUnionGate, targetResolver: targetResolver ?? this.targetResolver, ); @@ -287,7 +286,8 @@ abstract interface class MarkdownAutoscrollTarget { /// ), /// ) /// ``` -final class MarkdownCallbackAutoscrollTarget implements MarkdownAutoscrollTarget { +final class MarkdownCallbackAutoscrollTarget + implements MarkdownAutoscrollTarget { /// Creates a closure-backed target. /// /// [canScrollAt] defaults to "always allowed" — [onScrollDelta] returning a diff --git a/test/parser/streaming_test.dart b/test/parser/streaming_test.dart index 26d7877..87e2006 100644 --- a/test/parser/streaming_test.dart +++ b/test/parser/streaming_test.dart @@ -50,8 +50,7 @@ void main() => group('StreamingMarkdownParser', () { 'quote-fence-tilde': '> a\n> ~~~\n> x\n> ~~~\n> b', 'quote-fence-empty': '> ```\n> ```\n\nafter', 'quote-fence-nested-quote': '> > inner\n> ```\n> code\n> ```', - 'alert-fence': - '> [!NOTE]\n> body\n> ```sh\n> echo hi\n> ```\n\nafter', + 'alert-fence': '> [!NOTE]\n> body\n> ```sh\n> echo hi\n> ```\n\nafter', 'code-closed': '```dart\nvoid main() {}\n```\n\nafter code', 'code-tilde': '~~~\nplain\n~~~\n\nafter', 'code-unclosed': '```dart\nline 1\nline 2\nstill going', diff --git a/test/selection/selection_autoscroll_target_test.dart b/test/selection/selection_autoscroll_target_test.dart index c4f0dd1..cdfbbda 100644 --- a/test/selection/selection_autoscroll_target_test.dart +++ b/test/selection/selection_autoscroll_target_test.dart @@ -417,8 +417,7 @@ void main() { }); group('MarkdownScrollableAutoscrollTarget', () { - testWidgets('drives a forward list toward maxScrollExtent', - (tester) async { + testWidgets('drives a forward list toward maxScrollExtent', (tester) async { final controller = ScrollController(); await tester.pumpWidget( Directionality( diff --git a/test/selection/selection_nested_blocks_test.dart b/test/selection/selection_nested_blocks_test.dart index fdd6a52..abfbf99 100644 --- a/test/selection/selection_nested_blocks_test.dart +++ b/test/selection/selection_nested_blocks_test.dart @@ -104,8 +104,7 @@ void main() { expect(text, contains('Outro line after the fence')); }); - testWidgets( - 'the painter fragments agree with markdownBlockRenderedText', + testWidgets('the painter fragments agree with markdownBlockRenderedText', (tester) async { // The whole selection layer indexes a block by // `markdownBlockRenderedText`; the painter must expose exactly that diff --git a/test/selection/selection_registry_test.dart b/test/selection/selection_registry_test.dart index 42cff35..6cd9ae2 100644 --- a/test/selection/selection_registry_test.dart +++ b/test/selection/selection_registry_test.dart @@ -12,8 +12,8 @@ class _Doc extends StatelessWidget { @override Widget build(BuildContext context) { final controller = MarkdownSelectionScope.of(context); - final model = markdown ?? - controller.documents.firstWhere((d) => d.id == id).model; + final model = + markdown ?? controller.documents.firstWhere((d) => d.id == id).model; return MarkdownWidget(markdown: model, documentId: id); } } @@ -171,8 +171,7 @@ void main() { crossAxisAlignment: CrossAxisAlignment.start, children: [ const _Doc('d', key: ValueKey('first')), - if (showSecond) - const _Doc('d', key: ValueKey('second')), + if (showSecond) const _Doc('d', key: ValueKey('second')), ], ), ), @@ -232,8 +231,7 @@ void main() { expect(controller.getText(), 'Never registered by the app'); }); - testWidgets( - 'recycling a widget onto another id keeps both models intact', + testWidgets('recycling a widget onto another id keeps both models intact', (tester) async { final a = Markdown.fromString('Alpha body'); final b = Markdown.fromString('Bravo body'); @@ -520,14 +518,15 @@ void main() { expect(tester.takeException(), isNull); }); - testWidgets('a streaming model update that reconciles the selection does ' + testWidgets( + 'a streaming model update that reconciles the selection does ' 'not crash a host that setStates from onSelectionChanged', (tester) async { var model = Markdown.fromString('Hello streaming world'); final controller = MarkdownSelectionController( reconciliation: const MarkdownReconciliationPolicy.clearOnChange(), )..setDocuments( - [MarkdownDocumentRef(id: 'd', model: model)]); + [MarkdownDocumentRef(id: 'd', model: model)]); addTearDown(controller.dispose); var changes = 0; diff --git a/test/theme/theme_test.dart b/test/theme/theme_test.dart index a66ffd1..6d01dce 100644 --- a/test/theme/theme_test.dart +++ b/test/theme/theme_test.dart @@ -288,5 +288,4 @@ void main() => group('MarkdownThemeData', () { expect(a.updateShouldNotify(different), isTrue); }); }); - });