From 5cf8261e62748968eec95097450ca5edcea23f8e Mon Sep 17 00:00:00 2001 From: Emil Date: Fri, 11 Sep 2026 20:12:46 +0500 Subject: [PATCH 01/11] 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/11] 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/11] 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/11] 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/11] 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/11] 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/11] 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/11] 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/11] 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 6284c9b7633f455b2388d674f222a9973418440f Mon Sep 17 00:00:00 2001 From: Emil Date: Mon, 14 Sep 2026 20:32:17 +0500 Subject: [PATCH 10/11] test(example): add smoke widget coverage for showcase tabs Lock in boot, theme toggle, tab switching, editor refresh, and chat stream/stop so example regressions fail in CI-less local runs. --- example/test/smoke_test.dart | 110 +++++++++++++++++++++++++++++++++++ 1 file changed, 110 insertions(+) create mode 100644 example/test/smoke_test.dart diff --git a/example/test/smoke_test.dart b/example/test/smoke_test.dart new file mode 100644 index 0000000..9f8a9b3 --- /dev/null +++ b/example/test/smoke_test.dart @@ -0,0 +1,110 @@ +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/chat_tab.dart'; +import 'package:md_example/tabs/highlight_tab.dart'; +import 'package:md_example/tabs/lorem_tab.dart'; + +void main() { + Widget wrapApp() => ThemeModel( + notifier: ValueNotifier(ThemeMode.dark), + child: const App(), + ); + + Future pumpApp(WidgetTester tester) async { + // Tall enough for TabBarView + content; wide for the Editor split pane. + await tester.binding.setSurfaceSize(const Size(800, 1200)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + await tester.pumpWidget(wrapApp()); + await tester.pumpAndSettle(); + } + + Future openTab(WidgetTester tester, String label) async { + await tester.tap(find.text(label)); + await tester.pumpAndSettle(); + } + + testWidgets('App boots on the Editor tab without exceptions', (tester) async { + await pumpApp(tester); + + expect(tester.takeException(), isNull); + expect(find.text('Markdown'), findsOneWidget); + expect(find.byType(EditorTab), findsOneWidget); + expect(find.byType(TextField), findsOneWidget); + expect(find.byType(MarkdownWidget), findsWidgets); + }); + + testWidgets('theme switch toggles ThemeMode without exceptions', ( + tester, + ) async { + final mode = ValueNotifier(ThemeMode.dark); + await tester.binding.setSurfaceSize(const Size(800, 1200)); + addTearDown(() { + mode.dispose(); + return tester.binding.setSurfaceSize(null); + }); + await tester.pumpWidget(ThemeModel(notifier: mode, child: const App())); + await tester.pumpAndSettle(); + + expect(mode.value, ThemeMode.dark); + await tester.tap(find.byType(Switch)); + await tester.pumpAndSettle(); + expect(mode.value, ThemeMode.light); + expect(tester.takeException(), isNull); + }); + + testWidgets('every showcase tab builds without exceptions', (tester) async { + await pumpApp(tester); + + await openTab(tester, 'Selection'); + expect(find.byType(LoremTab), findsOneWidget); + expect(tester.takeException(), isNull); + + await openTab(tester, 'Chat'); + expect(find.byType(ChatTab), findsOneWidget); + expect(find.text('Stream'), findsOneWidget); + expect(find.text('Copy'), findsOneWidget); + expect(tester.takeException(), isNull); + + await openTab(tester, 'Highlight'); + expect(find.byType(HighlightTab), findsOneWidget); + expect(find.byType(MarkdownWidget), findsWidgets); + expect(tester.takeException(), isNull); + + await openTab(tester, 'Editor'); + expect(find.byType(EditorTab), findsOneWidget); + expect(tester.takeException(), isNull); + }); + + testWidgets('Editor refresh restores the sample markdown', (tester) async { + await pumpApp(tester); + + final field = find.byType(TextField); + await tester.enterText(field, '# scratch'); + await tester.pump(); + expect(find.text('# scratch'), findsOneWidget); + + await tester.tap(find.byIcon(Icons.refresh)); + await tester.pump(); + expect(find.text('# scratch'), findsNothing); + expect(tester.takeException(), isNull); + }); + + testWidgets('Chat Stream starts and Stop cancels without exceptions', ( + tester, + ) async { + await pumpApp(tester); + await openTab(tester, 'Chat'); + + await tester.tap(find.text('Stream')); + await tester.pump(); + expect(find.text('Stop'), findsOneWidget); + expect(tester.takeException(), isNull); + + await tester.tap(find.text('Stop')); + await tester.pump(); + expect(find.text('Stream'), findsOneWidget); + expect(tester.takeException(), isNull); + }); +} From d66568478b2b713d60279cdb8d253bc9e3408a6f Mon Sep 17 00:00:00 2001 From: Emil Date: Wed, 16 Sep 2026 18:24:37 +0500 Subject: [PATCH 11/11] docs: drop tdesktop/Telegram wording from changelog and selection docs Describe surface-bounds gesture starts and empty-click dismiss in plain terms so the package history stays host-agnostic. --- CHANGELOG.md | 8 ++++---- docs/selection.md | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 949d21f..b7c6124 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,11 +16,11 @@ 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. + bounds**. A press inside the document or bubble arms text selection. + 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. + non-collapsed range is active clears the selection, instead of leaving + the old range. - **ADDED**: `SelectableBlockPainter.hitsRenderedTextAt`, `MarkdownSelectionSurface.hitsSelectableGlyphs`, and `MarkdownSelectionController.hitsSelectableGlyphs`. diff --git a/docs/selection.md b/docs/selection.md index 652f9aa..65ad047 100644 --- a/docs/selection.md +++ b/docs/selection.md @@ -174,8 +174,8 @@ Statics: `MarkdownSelectionScope.of/maybeOf` (→ controller), `stateOf` (→ st 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 + inside a mounted surface’s bounds (`hitsSelectableContent`: press inside the + document or bubble, 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,