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..f1a9ae5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,225 @@ +## Unreleased + +### Breaking vs 0.2.0 + +Source-breaking only for hosts that implement the render / selection +extension points or reach into a default block painter's fields. Ordinary +`MarkdownWidget` / `MarkdownSelectionScope` users are unaffected. + +- **REMOVED**: `BlockPainter$Quote.painter` and `BlockPainter$Alert.bodyPainter`. + Both painters now stack nested children, so a single body `TextPainter` no + longer exists. Read `renderedText` / `fragments` (from + `MultiPainterSelectable`) instead. +- **CHANGED**: `BlockPainter$Quote` and `BlockPainter$Alert` mix in + `MultiPainterSelectable` instead of `SelectableTextBlock`. Subclasses that + overrode `selectionPainter` / `selectionOrigin` must move to `fragments`. +- **CHANGED**: `SelectableBlockPainter` gained `positionAndAffinityForLocal`, + `caretRectFor`, `hitsRenderedTextAt` and + `selectionHighlightAboveCachedContent`. Custom painters built on the + `SelectableTextBlock` / `MultiPainterSelectable` mixins inherit working + implementations; painters that `implements SelectableBlockPainter` directly + must add all four. +- **CHANGED**: `MarkdownSelectionSurface` gained `hitForGlobal`, + `hitsSelectableGlyphs`, `isLinkAtGlobal`, `caretRectFor`, + `localBoxesForRange`, `hasSelectionHandleLeaders` and + `clearSelectionHandleLayersIfLinked`. Custom surfaces must implement them. +- **CHANGED**: `MarkdownThemeData.copyWith` returns `MarkdownThemeData` instead + of `ThemeExtension` (callers gain, overriders must narrow). +- **CHANGED**: `MarkdownRenderObject.updateSelection` takes an optional + `markdown:`. `@meta.internal`, but `MarkdownWidget` subclasses that call it + must now invoke it **before** `update` (see the registry fix below). + +### Quote / alert nested fenced code +- **FIXED**: Fenced code (` ```lang ` / `~~~`) inside a blockquote or GitHub + alert is re-parsed as nested `MD$Code` in `MD$Quote.blocks` / + `MD$Alert.blocks`, instead of being consumed as inline monospace by backtick + pairing (` `` ` + monospace + ` `` `). +- **ADDED**: Optional `blocks` on `MD$Quote` / `MD$Alert` (empty for leaf + inline bodies). Render and `markdownBlockRenderedText` walk nested children + when present. +- **FIXED**: Prose nested inside a quote (paragraphs/lists/headings beside a + fence) uses `quoteStyle` again; code/table chrome keeps the document theme. +- **FIXED**: Nested children route through `MarkdownThemeData.builder` like + top-level blocks. A host that replaces the code painter (a fence with a copy + button, say) was silently getting the default one inside `> …`. + +### Glyph-tight hit testing +- **CHANGED**: Hover I-beam and link hit-testing use rendered **line/glyph + boxes**, not the full max-width layout of a paragraph. Empty horizontal + gutter beside a short line is no longer I-beam / click-to-open. +- **CHANGED**: Gesture *starts* (`hitsSelectableContent`) stay **surface- + bounds** (press inside the document/bubble arms text selection — tdesktop + `PointState::Inside` parity). Drag-extend may still clamp outside glyphs. +- **CHANGED**: Mouse single-click that misses selectable content while a + non-collapsed range is active clears the selection (tdesktop empty + Selecting / dismiss parity), instead of leaving the old range. +- **ADDED**: `SelectableBlockPainter.hitsRenderedTextAt`, + `MarkdownSelectionSurface.hitsSelectableGlyphs`, and + `MarkdownSelectionController.hitsSelectableGlyphs`. +- **CHANGED**: Glyph boxes are cached per `TextPainter` layout. They are read on + every hover event (I-beam / link cursor); recomputing + `getBoxesForSelection` over a whole block allocated one box per run per line + on every mouse move (thousands of rects over a long fenced code block). + +### Selection host gates +- **ADDED**: `MarkdownSelectionScope.canStartSelectionAt` — optional host gate + so enclosing UIs can refuse selection starts on chrome (links, code headers) + without the scope claiming the pointer. A refusal also un-arms the drag: the + recognizers still join the arena while a range is live (so a chrome tap can + dismiss it), and without this a long press / drag on refused chrome kept + extending the active selection from that point. +- **ADDED**: `MarkdownSelectionScope.enableTouchGestures` — when false, touch / + stylus / trackpad selection recognizers stay off; mouse multi-click, handles, + toolbar, and keyboard remain. Focus loss also does not clear the range (host + viewport may steal focus). +- **ADDED**: `MarkdownSelectionScope.enableTouchConsecutiveTaps` — when false + (with `enableTouchGestures` true), touch long-press → word → drag-extend + still arms, but touch multi-tap / horizontal-drag selection does not. Chat + hosts use this so taps stay with the viewport while continuous text entry + works. Focus loss also does not clear the range in that mode. +- **ADDED**: `MarkdownSelectionScope.ownsSelectionChrome` — optional host gate + so only the scope that owns the selection’s document paints handles/toolbar + when several scopes share one controller (chat per-body mounts). +- **FIXED**: Disabled / non-owning sibling scopes no longer clear handle + leaders on every shared-controller surface. Clearing is scoped to leaders + that reference that scope’s own `LayerLink`s, so chat dual mounts keep + handles after non-collapsed range commits (including repeated handle-drag + settles). +- **ADDED**: `MarkdownSelectionSurface.hasSelectionHandleLeaders`, + `clearSelectionHandleLayersIfLinked`, and + `MarkdownSelectionScopeState.selectionHandleLeadersAttached` for observing + coherent handle-leader attachment across multi-scope hosts. +- **CHANGED**: Flipping `enabled` from false → true with an existing + non-collapsed range restores handles; when `toolbarWanted` is set, restores + the toolbar. While disabled, the scope stays inert for chrome (no toolbar / + handles) but keeps `toolbarWanted` for that restore path. + +### Dynamic cursor resolution & span bounding boxes +- **ADDED**: `MarkdownThemeData.cursorResolver` and + `MarkdownWidget.cursorResolver` (`MarkdownCursorResolver`) — unopinionated + hook to dynamically resolve hover mouse cursors per local offset, block + index, and block model, falling back to default link/text/defer cursors when + returning null. +- **ADDED**: `MarkdownSelectionSurface.localBoxesForRange` and + `MarkdownPainter.localBoxesForRange` — fast query returning content-local + bounding boxes for an arbitrary character range within a block, directly from + cached block painters without re-layout. + +### Registry / multi-body selection +- **FIXED**: `removeDocument` defers while a surface for that id is mounted + and flushes on `detachSurface`; a later `putDocument` cancels the pending + remove. Parent `State.dispose` can run before child detach — eager remove + left hittable surfaces with no registry entry (`rangeFor` / ordering broken). +- **FIXED**: `rangeFor` and endpoint ordering ignore unregistered document ids + instead of treating them as index `-1` (which painted every body from the + start of the registry through the other endpoint). +- **CHANGED**: `MarkdownRenderObject` heals with `putDocument` on attach / + when the controller is wired while already attached, so a mounted selectable + surface is never missing from the registry. Prefer explicit app registration + for unmounted docs and unique reading-order `order` values. +- **FIXED**: Recycling a `MarkdownWidget` element onto a different `documentId` + (a virtualized chat list reusing a slot) overwrote the **outgoing** document's + registry model with the incoming body. `MarkdownWidget` now rewires the + selection registry before pushing the new model, and + `MarkdownRenderObject.updateSelection` takes the incoming `markdown:` so the + heal lands on the right id. +- **FIXED**: `putDocument` without an explicit `order` appended at `_docs.length` + which sorted **ahead** of sparse explicit orders (chat hosts key `order` on + the message id), so a healed body jumped to the front of the conversation and + reversed extracted text. It now sorts after every registered document. +- **FIXED**: Documents sharing an `order` could swap places on any re-sort + (`List.sort` is not stable), silently reversing extracted text. Ties now break + on registration sequence. +- **FIXED**: `MarkdownSelectionScope.onSelectionChanged` fired synchronously + from inside build / layout — a streaming `putDocument` reconciling the range + away (from `MarkdownWidget.updateRenderObject`) or a deferred + `removeDocument` flushing from `RenderObject.detach` threw + "setState() called during build" in any host that rebuilds from the callback. + Delivery is now coalesced to the end of the frame in those phases. + +### Selection chrome (SelectionArea parity) +- **ADDED**: Read-only selection chrome on the controller / + `MarkdownSelectionScope` path brought up to Flutter `SelectableRegion` / + `SelectionArea` quality without remounting SelectionArea: + - Content-gated touch `TapAndHorizontalDragGestureRecognizer` + long-press + (consecutive taps, no `DoubleTapGestureRecognizer` arena delay); mouse + `TapAndPanGestureRecognizer`. + - Gesture **starts** require a hit on mounted selectable markdown (chrome / + empty space do not nearest-neighbor clamp); clamp remains for **extend** + across gaps. + - Soft-wrap affinity on handles; directed base/extent edges with reverse + handle types; coincident-caret separation; handle proxies when an endpoint + surface unmounts (virtualization). + - Native `SelectionOverlay` handles + magnifier on touch; LeaderLayer follow + across scroll / multi-widget hosts. + - Keyboard Copy / Select-all / Shift-extend / Esc; adaptive Copy / Select-all + toolbar with live re-anchoring. + - Word / block granular multi-tap and long-press; link hand cursor / I-beam on + selectable content. +- **CHANGED**: Desktop selection toolbar parity with stock Flutter: + - On desktop (`macOS`, `Linux`, `Windows`), mouse drags, double/triple clicks, + keyboard shortcuts (`Cmd/Ctrl+A`), and programmatic selection updates + do not pop up the toolbar. + - Right-click on desktop shows the context toolbar at the click coordinates + without mutating the active selection (no select-word / caret collapse). + - Desktop context menu preserves its right-click anchor when triggering + actions such as "Select all", rather than jumping to selection endpoints. + - Desktop context menu dismisses immediately upon scroll. + - On mobile (`Android`, `iOS`), touch gestures and programmatic selection + (`selectAll`, `selection = ...`) continue to present the adaptive toolbar + and handles with action items. + +### Autoscroll is scroll-protocol agnostic +- **ADDED**: `MarkdownAutoscrollTarget` — the scroll surface autoscroll drives, + behind three members (`viewport`, `canScroll`, `applyScrollDelta`). Deltas are + **screen-space content movement** (positive moves content up), never scroll + offsets, so reverse axes and inverted anchors are the adapter's problem. +- **ADDED**: `MarkdownScrollableAutoscrollTarget` — the built-in sliver adapter + (`ScrollableState` / `ScrollPosition`), used when no resolver is configured. + Handles `AxisDirection.up` / `.left` sign flipping. +- **ADDED**: `MarkdownSelectionAutoscrollConfig.targetResolver` + (`MarkdownAutoscrollTargetResolver` + `MarkdownAutoscrollRequest`) so a host + with its own scroll implementation — an anchored chat viewport, a `RenderBox` + that positions children itself, a transform canvas — can be driven without a + `Scrollable` anywhere in the tree. Plus `MarkdownCallbackAutoscrollTarget` and + `MarkdownAutoscrollViewport` (global bounds + band inset) for closure hosts. +- **CHANGED**: `applyMarkdownSelectionAutoscroll` takes an optional `target:`; + `context:` is now optional and only used to resolve the built-in sliver + target. Band, host-union gate and arming logic no longer reference + `ScrollPosition` at all. +- **FIXED**: The scroll surface is resolved **once per drag** and cached. It + used to walk the whole element subtree twice on every autoscroll frame (to + find the surface's element, then the nearest `Scrollable`). +- **ADDED**: `MarkdownSelectionAutoscrollConfig.useHostUnionGate` (default + `true`, the existing behaviour). Turn it off when the markdown bodies **are** + the scrolling content and the host builds only what is visible: such a host's + mounted union is barely larger than the viewport, so the gate would veto a + drag that should keep paging through history. The target's `canScroll` and + the delta it reports applying are then the only stops. +- **ADDED**: `MarkdownSelectionAutoscrollConfig.copyWith`, `==` / `hashCode`. + +### Selection engine rework +- **ADDED**: Edge-zone autoscroll while dragging (body / handle / long-press) + near the padded viewport — host-union hard-stop, direction arming gate, + past-viewport max velocity while the union still allows that direction + (`MarkdownSelectionScope.autoscroll`, default `edgeZone: 48`). +- **CHANGED**: Toolbar hides while expanding (body or handle drag) and may + re-show on drag end; anchors recompute on selection change, host/ancestor + scroll, and mounted-surface layout change. +- **CHANGED**: Toolbar placement — both endpoints in clip use stock + above-preferring anchors; **bottom-only** endpoint prefers below that caret; + neither endpoint in clip (tall mid-viewport) top-pins so the below-fallback + cannot sink to the host bottom; empty intersection hides the overlay while + `toolbarWanted` restores on scroll-back / remount. +- **FIXED**: Selection highlight stays outside the glyph `Picture` cache — + under glyphs by default (sharp text), with a second pass **above** opaque + chrome (`selectionHighlightAboveCachedContent`) for code fences, table zebra + rows, and inline monospace / highlight backgrounds + (`markdownSpansPaintOpaqueBackground`). +- **FIXED**: Focus loss does not clear selection while a pointer drag is active + (list rebuilds during autoscroll); geometry walks defer off `performLayout` + (`sizeAccessAllowed`). + ## 0.2.0 > **Upgrading from 0.0.x?** See the diff --git a/README.md b/README.md index 1ede646..b376452 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. @@ -273,6 +276,18 @@ final MarkdownSelectedContent structured = controller.selectedContent(); public `MarkdownSelectionScopeState` exposes `copySelection` / `selectAll` / `clearSelection` / `showToolbar` / `contextMenuButtonItems` / `contextMenuAnchors` for a fully custom menu. +- **Host gates for embedded mounts.** A chat that owns its own taps and swipes + can hand back the pieces it needs: `enableTouchGestures: false` leaves every + touch entry to the host (mouse, handles, toolbar and keyboard stay live), + `enableTouchConsecutiveTaps: false` keeps only long-press → word → drag, + `canStartSelectionAt` refuses selection starts on chrome (links, code + headers) without claiming the pointer, and `ownsSelectionChrome` decides + which scope paints handles and toolbar when several share one controller. +- **Edge autoscroll, any scroll protocol.** Dragging into the viewport edge + scrolls the host (`autoscroll:`, default `edgeZone: 48`). It drives the + nearest ancestor `Scrollable` out of the box, and anything else through + `MarkdownSelectionAutoscrollConfig.targetResolver` — an anchored chat + viewport, a `RenderBox` that positions children itself, a transform canvas. - **Opt-in & compatible.** A `MarkdownWidget` with no `documentId`/controller is inert — existing usage is unchanged. @@ -296,6 +311,38 @@ MarkdownSelectionScope( ); ``` +Driving a custom scroll implementation — the whole surface is three members +(`viewport`, `canScroll`, `applyScrollDelta`), and deltas are screen-space +content movement, so a reverse axis or an inverted anchor is the adapter's +problem, not the library's: + +```dart +MarkdownSelectionScope( + controller: controller, + autoscroll: MarkdownSelectionAutoscrollConfig( + targetResolver: (request) => MarkdownCallbackAutoscrollTarget( + viewportOf: () { + final box = viewportKey.currentContext?.findRenderObject(); + if (box is! RenderBox || !box.hasSize) return null; + return MarkdownAutoscrollViewport( + globalBounds: box.localToGlobal(Offset.zero) & box.size, + padding: const EdgeInsets.only(bottom: 72), // composer overlaps + ); + }, + canScrollAt: ({required forward}) => + forward ? !chat.isAtTail.value : !chat.reachedOldest, + // This host's scrollBy is anchor-relative: positive reveals *older* + // messages, the opposite of screen-space movement. + onScrollDelta: (delta) { + chat.scrollBy(-delta); + return delta; + }, + ), + ), + child: /* ... */, +); +``` + See the runnable **Selection** and **Chat** tabs in `example/`. ## 🌊 Streaming (LLM output) @@ -510,7 +557,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/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/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/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/rendering.md b/docs/rendering.md index 9a3869b..a8e5f17 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: @@ -189,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/ @@ -211,12 +216,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..af9af2e 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 @@ -62,14 +68,29 @@ 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 +- `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 `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) @@ -111,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. @@ -125,23 +148,125 @@ 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; 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), +`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 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 + 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` moves the scroll surface. A `Ticker` keeps + scrolling while the pointer stays in-band and the host-union edge is not yet + flush; it pauses on hard-stop / leave-band / arming-gate. Configure via + `MarkdownSelectionScope.autoscroll` (default `edgeZone: 48`). + + **The scroll surface is abstract.** Nothing in the band / gate math touches + `ScrollPosition`; it all runs against a `MarkdownAutoscrollTarget`: + + | Member | Meaning | + |---|---| + | `MarkdownAutoscrollViewport? get viewport` | visible global bounds + the inset where the bands start; `null` disables the drag | + | `bool canScroll({required bool forward})` | can a positive (`forward`) / negative delta still move content? | + | `double applyScrollDelta(double delta)` | move content by **screen-space** pixels (positive = content up, revealing what is below); return the delta actually applied (`0` hard-stops and disarms that direction) | + + `MarkdownScrollableAutoscrollTarget` is the built-in adapter for the sliver + protocol and is used when no resolver is set; it flips the sign for reverse + axes (`AxisDirection.up` / `.left`) so callers always speak screen space. + + A host with its own scroll implementation — an anchored chat viewport that + owns its pixel offset, a `RenderBox` that positions children itself, a + transform-based canvas — supplies + `MarkdownSelectionAutoscrollConfig.targetResolver` instead. The scope calls it + **once per drag** (with the scope context, the `BuildContext` of the markdown + surface under the pointer, the pointer position and the config) and caches the + result, so a resolver may walk the element tree without a per-frame cost. + The **host-union gate** (`useHostUnionGate`, default on) suits markdown that is + an island inside a larger scrollable: a body that fits inside the padded + viewport never drives the far page chrome. Turn it off when the markdown + bodies *are* the scrolling content and the host builds only what is visible — + that union is barely larger than the viewport and would veto paging through + history; the target's `canScroll` then decides alone. + + `MarkdownCallbackAutoscrollTarget` assembles a target from three closures: + + ```dart + MarkdownSelectionScope( + controller: controller, + autoscroll: MarkdownSelectionAutoscrollConfig( + targetResolver: (request) => MarkdownCallbackAutoscrollTarget( + viewportOf: () { + final box = viewportKey.currentContext?.findRenderObject(); + if (box is! RenderBox || !box.hasSize) return null; + return MarkdownAutoscrollViewport( + globalBounds: box.localToGlobal(Offset.zero) & box.size, + padding: const EdgeInsets.only(top: 8, bottom: 72), // composer + ); + }, + canScrollAt: ({required forward}) => + forward ? !chat.isAtTail.value : !chat.reachedOldest, + // This host's `scrollBy` is anchor-relative: a positive value reveals + // *older* messages, the opposite of screen-space movement. + onScrollDelta: (delta) { + chat.scrollBy(-delta); + return delta; + }, + ), + ), + child: child, + ); + ``` - **Cursor:** the `MarkdownWidget` render object is a `MouseTrackerAnnotation`; it shows the click (hand) cursor over an actionable link (a span with a tap recognizer, detected on hover via `handleEvent` → `markNeedsPaint` so @@ -152,13 +277,59 @@ 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 — 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 others. `clearExternal()` clears all (call when a non-Markdown `SelectableText`/ @@ -168,10 +339,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 +356,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 +380,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/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/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/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/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/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); - }); -} 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/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/block_painter.dart b/lib/src/render/block_painter.dart index c261e09..a0bb99c 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,20 @@ 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); + + /// 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 + /// 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 +106,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 @@ -105,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 @@ -146,12 +201,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 @@ -188,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; @@ -212,6 +322,52 @@ 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) { + for (final rect in _glyphBoxesOf(painter)) { + if (rect.contains(local)) return true; + } + return false; +} + +/// Cached glyph boxes of one [TextPainter] layout. +final class _GlyphBoxes { + const _GlyphBoxes(this.size, this.text, this.rects); + + final Size size; + final String text; + final List rects; +} + +/// Glyph boxes are re-read on **every hover event** (I-beam / link cursor) and +/// on every glyph-tight hit test. `getBoxesForSelection` over a whole block +/// allocates one box per run per line, so a long fenced code block would +/// rebuild thousands of rects per mouse move without this cache. +/// +/// Keyed on the painter; invalidated when its laid-out size or plain text +/// changes. [TextPainter.plainText] is memoised by the painter itself, so the +/// identity check is O(1). +final Expando<_GlyphBoxes> _glyphBoxCache = Expando<_GlyphBoxes>('glyphBoxes'); + +List _glyphBoxesOf(TextPainter painter) { + final plain = painter.plainText; + if (plain.isEmpty) return const []; + final size = painter.size; + final cached = _glyphBoxCache[painter]; + if (cached != null && cached.size == size && identical(cached.text, plain)) { + return cached.rects; + } + final rects = [ + for (final box in painter.getBoxesForSelection( + TextSelection(baseOffset: 0, extentOffset: plain.length), + )) + box.toRect(), + ]; + _glyphBoxCache[painter] = _GlyphBoxes(size, plain, rects); + return rects; +} + /// Whether the span under a text-local [local] point in [painter] carries a tap /// recognizer (i.e. an actionable link). False for points outside the text box. bool _spanHasRecognizerAt(TextPainter painter, Offset local) { diff --git a/lib/src/render/blocks/alert.dart b/lib/src/render/blocks/alert.dart index e543755..309c463 100644 --- a/lib/src/render/blocks/alert.dart +++ b/lib/src/render/blocks/alert.dart @@ -15,20 +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), + 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, @@ -41,12 +46,27 @@ 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]. + @override + bool get selectionHighlightAboveCachedContent => + _selectionHighlightAboveCachedContent; + final bool _selectionHighlightAboveCachedContent; /// The kind of the alert being painted. final MD$AlertType alert; @@ -59,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; @@ -81,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 @@ -112,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; } @@ -122,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; @@ -161,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/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..08d0e35 100644 --- a/lib/src/render/blocks/quote.dart +++ b/lib/src/render/blocks/quote.dart @@ -14,30 +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, - }) : 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. @@ -45,18 +56,50 @@ class BlockPainter$Quote ..strokeWidth = 4.0 ..style = PaintingStyle.fill; + /// 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; + final bool _selectionHighlightAboveCachedContent; + /// 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; @@ -64,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 @@ -106,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, @@ -118,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/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..9925c0e 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; @@ -62,6 +65,17 @@ class MarkdownPainter { /// index whenever a `blockFilter` drops blocks). List _sourceIndices = const []; + /// Builds a block nested inside a quote / alert body. + /// + /// Routes through [MarkdownThemeData.builder] like a top-level block does — + /// a host that replaces the code painter (a fence with a copy button, say) + /// expects the same painter inside `> …` as outside it. + static BlockPainter _nestedBlockBuilder( + MD$Block block, + MarkdownThemeData theme, + ) => + theme.builder?.call(block, theme) ?? _defaultBlockBuilder(block, theme); + static BlockPainter _defaultBlockBuilder( MD$Block block, MarkdownThemeData theme, @@ -80,6 +94,13 @@ class MarkdownPainter { spans: q.spans, indent: q.indent, theme: theme, + children: [ + for (final child in q.blocks) + _nestedBlockBuilder( + child, + BlockPainter$Quote.inheritFrom(theme, child), + ), + ], ), code: (c) => BlockPainter$Code( language: c.language, @@ -103,6 +124,9 @@ class MarkdownPainter { alert: a.alert, spans: a.spans, theme: theme, + children: [ + for (final child in a.blocks) _nestedBlockBuilder(child, theme), + ], ), spacer: (s) => BlockPainter$Spacer( count: s.count, @@ -154,14 +178,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 @@ -194,16 +239,60 @@ 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 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 || + 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; + 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] /// 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]; @@ -231,6 +320,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 7f4cad2..077babd 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 @@ -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; @@ -42,10 +51,26 @@ 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 [paint] will push a start and/or end [LeaderLayer] this frame. + /// + /// Drives [alwaysNeedsCompositing]; keep it in lockstep with the conditions + /// in [paint] or a leader can be pushed without a composited layer for it. + bool get _pushesHandleLeaderLayers => + (_startHandleLink != null && _startHandleLocal != null) || + (_endHandleLink != null && _endHandleLocal != null); + + /// Whether this surface currently owns a start and/or end handle leader. + @visibleForTesting + bool get debugHasSelectionHandleLeaders => _pushesHandleLeaderLayers; void _onSelectionChange() { if (!_disposed) markNeedsPaint(); @@ -53,11 +78,22 @@ class MarkdownRenderObject extends RenderBox bool _disposed = false; - void _attachController() { + void _attachController({Markdown? markdown}) { 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. + // + // [markdown] is the model of the *incoming* document when a recycled + // element is rewired onto another id — [_painter] still holds the + // outgoing one until [update] runs. + controller.putDocument(id, markdown ?? _painter.markdown); + controller.attachSurface(this); + } } void _detachController() { @@ -65,20 +101,31 @@ 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 /// [documentId]. Passing a null controller makes it non-selectable (inert). + /// + /// [markdown] is the model the *incoming* [documentId] should be healed with. + /// It matters when a list recycles this render object from one message onto + /// another: the painter still holds the outgoing model at that point, and + /// registering it under the incoming id would overwrite that document's + /// registry entry with the wrong body. + /// + /// Must run **before** [update] on a rewire so the registry write that + /// [update] performs lands on the new id. @meta.internal void updateSelection( MarkdownSelectionController? controller, - Object? documentId, - ) { + Object? documentId, { + Markdown? markdown, + }) { if (identical(controller, _controller) && documentId == _documentId) return; _detachController(); _controller = controller; _documentId = documentId; - _attachController(); + _attachController(markdown: markdown); if (attached) { markNeedsCompositingBitsUpdate(); markNeedsPaint(); @@ -95,18 +142,51 @@ 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 + 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; + 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; @@ -131,6 +211,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, @@ -150,7 +238,38 @@ class MarkdownRenderObject extends RenderBox _endHandleLocal = endLocal; changed = true; } - if (changed && !_disposed && attached) markNeedsPaint(); + if (!changed || _disposed || !attached) return; + markNeedsCompositingBitsUpdate(); + 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; + _endHandleLink = null; + _endHandleLocal = null; + _startHandleLayer.layer = null; + _endHandleLayer.layer = null; } @override @@ -158,26 +277,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; @@ -191,7 +343,7 @@ class MarkdownRenderObject extends RenderBox bool get isRepaintBoundary => _controller != null; @override - bool get alwaysNeedsCompositing => false; + bool get alwaysNeedsCompositing => _pushesHandleLeaderLayers; @override bool get sizedByParent => false; @@ -248,15 +400,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); } @@ -273,7 +422,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. @@ -282,7 +436,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, @@ -290,6 +450,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 +463,10 @@ 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(); + _clearHoverCursor(); super.detach(); } @@ -306,6 +475,7 @@ class MarkdownRenderObject extends RenderBox void dispose() { _disposed = true; _controller?.removeListener(_onSelectionChange); + _clearSelectionHandleLayers(); super.dispose(); _painter.dispose(); } @@ -321,21 +491,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 +524,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..5c282af 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; @@ -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 @@ -17,8 +25,12 @@ import 'nodes.dart'; 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 = []; @@ -33,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); @@ -316,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, @@ -475,6 +502,21 @@ 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); + + /// 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); + /// 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. @@ -491,6 +533,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. @@ -501,11 +556,27 @@ 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(); } +/// 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 +591,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 +600,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]). @@ -540,10 +611,17 @@ class MarkdownHandleEndpoints { } class _DocEntry { - _DocEntry(this.id, this.model, this.order); + _DocEntry(this.id, this.model, this.order, this.seq); + final Object id; Markdown model; int order; + + /// Monotonic registration sequence. Breaks [order] ties deterministically — + /// `List.sort` is not stable, so two documents sharing an order would + /// otherwise swap places on any re-sort (a model update, a new mount) and + /// silently reverse extracted text. + final int seq; } /// The single source of truth for a Markdown selection. @@ -595,8 +673,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(); + } } } @@ -608,17 +688,130 @@ class MarkdownSelectionController extends ChangeNotifier { /// size and show up during drags. final Map _indexById = {}; - final Map _surfaces = - {}; + /// Monotonic registration counter feeding [_DocEntry.seq]. + int _nextSeq = 0; + + /// An order that sorts after every currently registered document. + int _nextAppendOrder() { + if (_docs.isEmpty) return 0; + var max = _docs.first.order; + for (final entry in _docs) { + if (entry.order > max) max = entry.order; + } + return max + 1; + } + + /// Per-document attach stack. Several render objects may register under the + /// same [MarkdownSelectionSurface.documentId] at once (crossfade, recycle, + /// overlapping mounts). A single-slot map would let a later attach overwrite + /// 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]. + /// + /// 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 = _surfaceFor(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. + /// + /// 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; _selection = value; - if (value != null && !value.isCollapsed) _group?._claim(this); + if (value == null || value.isCollapsed) { + toolbarWanted = false; + _baseAffinity = TextAffinity.downstream; + _extentAffinity = TextAffinity.downstream; + } else { + if (!_isDesktopPlatform) { + 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,11 +839,12 @@ 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>[ for (final (i, d) in docs.indexed) - _DocEntry(d.id, d.model, d.order ?? i), + _DocEntry(d.id, d.model, d.order ?? i, _nextSeq++), ]); _sort(); _validateSelection(); @@ -660,9 +854,16 @@ 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)); + // No explicit order (the render-object heal path) means "append". + // `_docs.length` would sort ahead of sparse explicit orders — chat + // hosts key `order` on the message id, so a healed body would jump to + // the front of the conversation and reverse extracted text. + final assigned = order ?? _nextAppendOrder(); + _docs.add(_DocEntry(id, model, assigned, _nextSeq++)); _sort(); notifyListeners(); return; @@ -683,7 +884,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 (_surfaceStacks.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; @@ -698,7 +911,10 @@ class MarkdownSelectionController extends ChangeNotifier { // documents sharing an identical `order` have unspecified relative order — // callers should supply unique `order` values (e.g. the message index). void _sort() { - _docs.sort((a, b) => a.order.compareTo(b.order)); + _docs.sort((a, b) { + final byOrder = a.order.compareTo(b.order); + return byOrder != 0 ? byOrder : a.seq.compareTo(b.seq); + }); _reindex(); } @@ -743,43 +959,125 @@ 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. } /// 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; + 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. /// /// 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; + 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* — + /// 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 mountedSurfaces) { + final bounds = surface.globalBounds; + if (!bounds.isEmpty && bounds.contains(globalPosition)) return true; + } + return false; + } + + /// Whether [globalPosition] lies over rendered glyph ink (tight line boxes). + bool hitsSelectableGlyphs(Offset globalPosition) { + for (final surface in mountedSurfaces) { + 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 mountedSurfaces) { + if (surface.isLinkAtGlobal(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) { + 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. 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 +1088,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 +1109,81 @@ 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. + /// + /// 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; - 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, + if (!_isDesktopPlatform) { + 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 +1244,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 +1282,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 +1304,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 +1360,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 +1440,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, @@ -1072,52 +1528,159 @@ 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()); } 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 = _surfaceFor(sel.base.documentId); + MarkdownSelectionSurface? endSurface = _surfaceFor(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; } - endSurface = surface; - endLocal = local.last; - endGlobal = global.last; } - if (startSurface == null || endSurface == null) return null; + if (endSurface == null || endLocal == null) { + final proxy = _proxyHandleOnVisibleSelection(forStartEdge: false); + if (proxy != null) { + endSurface = proxy.$1; + endLocal = proxy.$2; + } + } + + 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 = _surfaceFor(_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 = _surfaceFor(_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 +1692,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 +1768,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..81890a5 --- /dev/null +++ b/lib/src/selection_autoscroll.dart @@ -0,0 +1,724 @@ +// ignore_for_file: lines_longer_than_80_chars + +import 'dart:math' as math; + +import 'package:flutter/foundation.dart'; +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). +/// +/// The scroll surface itself is abstract: see [MarkdownAutoscrollTarget]. By +/// default the nearest ancestor [Scrollable] is driven, but a host with its +/// own scroll protocol (a custom anchored chat viewport, a `RenderBox` that +/// moves children itself, a nested transform) supplies [targetResolver] +/// instead. Nothing in the band / gate math touches `ScrollPosition`. +final class MarkdownSelectionAutoscrollConfig { + /// Creates an autoscroll config. + const MarkdownSelectionAutoscrollConfig({ + this.enabled = true, + this.edgeZone = 48, + this.maxVelocity = 600, + this.topPad = 0, + this.bottomPad = 0, + this.useMediaQueryPadding = true, + this.useHostUnionGate = true, + this.targetResolver, + }) : 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. + /// + /// Only consulted by the built-in [Scrollable] resolver — a custom + /// [targetResolver] reports its own [MarkdownAutoscrollViewport.padding]. + final bool useMediaQueryPadding; + + /// Whether the union of mounted selectable bodies gates each direction. + /// + /// True (the default) suits markdown that is an *island* inside a larger + /// scrollable: a body that fits inside the padded viewport never drives the + /// far page chrome, and a direction hard-stops once the union is flush with + /// that edge. + /// + /// Set it false when the markdown bodies **are** the scrolling content and + /// the host only builds what is visible — a chat viewport that mounts no + /// cache extent has a union barely larger than the viewport, so the gate + /// would veto a drag that should keep paging through history. The scroll + /// surface then decides on its own, through + /// [MarkdownAutoscrollTarget.canScroll] and the delta it reports applying. + final bool useHostUnionGate; + + /// Resolves the scroll surface a selection drag drives. + /// + /// Null (the default) uses the nearest ancestor [Scrollable] — the sliver + /// protocol. Supply a resolver to drive any other scroll implementation; + /// return null from it to leave a given drag un-scrolled. + /// + /// The resolver is called at most once per drag (the scope caches the + /// target until the drag ends or the target reports no usable viewport), so + /// it may do element-tree work without a per-frame cost. + final MarkdownAutoscrollTargetResolver? targetResolver; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is MarkdownSelectionAutoscrollConfig && + other.enabled == enabled && + other.edgeZone == edgeZone && + other.maxVelocity == maxVelocity && + other.topPad == topPad && + other.bottomPad == bottomPad && + other.useMediaQueryPadding == useMediaQueryPadding && + other.useHostUnionGate == useHostUnionGate && + // A resolver closure can only be compared by identity; a host that + // builds one inline gets a new object every build, which is why the + // scope never invalidates a cached surface mid-drag. + identical(other.targetResolver, targetResolver); + + @override + int get hashCode => Object.hash( + enabled, + edgeZone, + maxVelocity, + topPad, + bottomPad, + useMediaQueryPadding, + useHostUnionGate, + targetResolver, + ); + + /// A copy with the given fields replaced. + MarkdownSelectionAutoscrollConfig copyWith({ + bool? enabled, + double? edgeZone, + double? maxVelocity, + double? topPad, + double? bottomPad, + bool? useMediaQueryPadding, + bool? useHostUnionGate, + MarkdownAutoscrollTargetResolver? targetResolver, + }) => + MarkdownSelectionAutoscrollConfig( + enabled: enabled ?? this.enabled, + edgeZone: edgeZone ?? this.edgeZone, + maxVelocity: maxVelocity ?? this.maxVelocity, + topPad: topPad ?? this.topPad, + bottomPad: bottomPad ?? this.bottomPad, + useMediaQueryPadding: useMediaQueryPadding ?? this.useMediaQueryPadding, + useHostUnionGate: useHostUnionGate ?? this.useHostUnionGate, + targetResolver: targetResolver ?? this.targetResolver, + ); +} + +/// Signature for resolving the [MarkdownAutoscrollTarget] of a selection drag. +/// +/// Return null to leave the drag un-scrolled. +typedef MarkdownAutoscrollTargetResolver = MarkdownAutoscrollTarget? Function( + MarkdownAutoscrollRequest request, +); + +/// What the scope knows about a drag when it asks for a scroll target. +@immutable +final class MarkdownAutoscrollRequest { + /// Creates a resolver request. + const MarkdownAutoscrollRequest({ + required this.scopeContext, + required this.globalPosition, + required this.config, + this.contentContext, + }); + + /// Context of the `MarkdownSelectionScope` that owns the drag. + /// + /// This sits **above** any scrollable the scope wraps, so + /// `Scrollable.maybeOf(scopeContext)` does *not* find it — use + /// [contentContext] (or your own handle) to reach a descendant viewport. + final BuildContext scopeContext; + + /// Context of the mounted markdown surface under [globalPosition] (or the + /// first mounted one when the pointer is outside every surface), when the + /// scope could resolve it. Null when no surface is mounted. + /// + /// This is the useful context for `Scrollable.maybeOf`: it is a *descendant* + /// of the list that scrolls the markdown bodies. + final BuildContext? contentContext; + + /// Current global pointer position of the drag. + final Offset globalPosition; + + /// The config the drag runs under. + final MarkdownSelectionAutoscrollConfig config; +} + +/// Visible geometry of a scroll surface, in global coordinates. +@immutable +final class MarkdownAutoscrollViewport { + /// Creates a viewport description. + const MarkdownAutoscrollViewport({ + required this.globalBounds, + this.padding = EdgeInsets.zero, + }); + + /// Global bounds of the visible scroll viewport. + final Rect globalBounds; + + /// Inset from [globalBounds] where the activation bands start. + /// + /// Use it for chrome that overlaps the viewport (a translucent app bar, a + /// composer, the system status / gesture bars) so the band begins before a + /// handle disappears underneath. + final EdgeInsets padding; + + /// [globalBounds] deflated by [padding] (may be empty / inverted when the + /// padding exceeds the viewport). + Rect get paddedGlobalBounds => Rect.fromLTRB( + globalBounds.left + padding.left, + globalBounds.top + padding.top, + globalBounds.right - padding.right, + globalBounds.bottom - padding.bottom, + ); + + /// Whether the padded band has any vertical room to work with. + bool get isUsable => + !globalBounds.hasNaN && + globalBounds.isFinite && + paddedGlobalBounds.height > 0; + + @override + String toString() => + 'MarkdownAutoscrollViewport($globalBounds, padding: $padding)'; +} + +/// A scroll surface that markdown selection autoscroll can drive. +/// +/// Implement it to adapt any scroll protocol — the sliver +/// [Scrollable]/[ScrollPosition] pair (see +/// [MarkdownScrollableAutoscrollTarget]), an anchored chat viewport that owns +/// its own pixel offset, a `PageView`, a transform-based canvas. +/// +/// All deltas are **screen-space content movement**, never scroll-offset +/// deltas: positive moves content up so material below the bottom edge comes +/// into view. Adapters flip the sign for reverse axes / inverted anchors. +abstract interface class MarkdownAutoscrollTarget { + /// Current visible geometry, or null when the surface cannot be driven + /// right now (detached, unmounted, zero-sized). Read once per frame. + MarkdownAutoscrollViewport? get viewport; + + /// Whether [applyScrollDelta] can still move content in this direction. + /// + /// [forward] true asks about a **positive** delta (content moves up, + /// revealing what lies below the viewport bottom); false asks about a + /// negative delta. + bool canScroll({required bool forward}); + + /// Moves content by [delta] logical pixels of screen-space movement. + /// + /// A positive [delta] moves content up (reveals what is below the bottom + /// edge); a negative [delta] moves content down. Returns the delta actually + /// applied — return `0` when already flush so the drag hard-stops and the + /// direction disarms. + double applyScrollDelta(double delta); +} + +/// A [MarkdownAutoscrollTarget] assembled from closures. +/// +/// The fastest way to adopt a custom scroll implementation without writing a +/// class: +/// +/// ```dart +/// MarkdownSelectionAutoscrollConfig( +/// targetResolver: (request) => MarkdownCallbackAutoscrollTarget( +/// viewportOf: () { +/// final box = viewportKey.currentContext?.findRenderObject(); +/// if (box is! RenderBox || !box.hasSize) return null; +/// return MarkdownAutoscrollViewport( +/// globalBounds: box.localToGlobal(Offset.zero) & box.size, +/// padding: const EdgeInsets.symmetric(vertical: 8), +/// ); +/// }, +/// // `scrollBy` is anchor-relative: positive reveals *older* messages, +/// // the opposite of screen-space movement. +/// onScrollDelta: (delta) { +/// chatScrollController.scrollBy(-delta); +/// return delta; +/// }, +/// canScrollAt: ({required forward}) => +/// forward ? !chatScrollController.isAtTail.value : !dataSource.reachedOldest, +/// ), +/// ) +/// ``` +final class MarkdownCallbackAutoscrollTarget + implements MarkdownAutoscrollTarget { + /// Creates a closure-backed target. + /// + /// [canScrollAt] defaults to "always allowed" — [onScrollDelta] returning a + /// delta smaller than half a pixel is then what hard-stops the drag. + const MarkdownCallbackAutoscrollTarget({ + required MarkdownAutoscrollViewport? Function() viewportOf, + required double Function(double delta) onScrollDelta, + bool Function({required bool forward})? canScrollAt, + }) : _viewportOf = viewportOf, + _onScrollDelta = onScrollDelta, + _canScrollAt = canScrollAt; + + final MarkdownAutoscrollViewport? Function() _viewportOf; + final double Function(double delta) _onScrollDelta; + final bool Function({required bool forward})? _canScrollAt; + + @override + MarkdownAutoscrollViewport? get viewport => _viewportOf(); + + @override + bool canScroll({required bool forward}) => + _canScrollAt?.call(forward: forward) ?? true; + + @override + double applyScrollDelta(double delta) => _onScrollDelta(delta); +} + +/// The built-in [MarkdownAutoscrollTarget] for the sliver protocol: drives the +/// [ScrollPosition] of a [ScrollableState] with `jumpTo`. +/// +/// Handles reverse axes ([AxisDirection.up] / [AxisDirection.left]) so callers +/// always speak screen-space deltas. +final class MarkdownScrollableAutoscrollTarget + implements MarkdownAutoscrollTarget { + /// Wraps [scrollable], insetting the activation bands by [padding]. + const MarkdownScrollableAutoscrollTarget( + this.scrollable, { + this.padding = EdgeInsets.zero, + }); + + /// The scrollable being driven. + final ScrollableState scrollable; + + /// Inset applied to [MarkdownAutoscrollViewport.padding]. + final EdgeInsets padding; + + /// Whether the [ScrollPosition] is ready to be read / driven. + bool get _positionReady { + if (!scrollable.mounted) return false; + final position = scrollable.position; + return position.hasPixels && position.hasContentDimensions; + } + + /// Whether a positive screen-space delta grows `ScrollPosition.pixels`. + bool get _axisMatchesScreen => switch (scrollable.axisDirection) { + AxisDirection.down || AxisDirection.right => true, + AxisDirection.up || AxisDirection.left => false, + }; + + @override + MarkdownAutoscrollViewport? get viewport { + if (!_positionReady) return null; + final box = scrollable.context.findRenderObject(); + if (box is! RenderBox || !box.hasSize || !box.attached) return null; + return MarkdownAutoscrollViewport( + globalBounds: box.localToGlobal(Offset.zero) & box.size, + padding: padding, + ); + } + + @override + bool canScroll({required bool forward}) { + if (!_positionReady) return false; + final position = scrollable.position; + final towardMax = _axisMatchesScreen == forward; + return towardMax + ? position.pixels < position.maxScrollExtent - precisionErrorTolerance + : position.pixels > position.minScrollExtent + precisionErrorTolerance; + } + + @override + double applyScrollDelta(double delta) { + if (!_positionReady) return 0; + final position = scrollable.position; + final signed = _axisMatchesScreen ? delta : -delta; + final before = position.pixels; + final next = (before + signed).clamp( + position.minScrollExtent, + position.maxScrollExtent, + ); + final applied = next - before; + if (applied.abs() < 0.5) return 0; + position.jumpTo(next); + return _axisMatchesScreen ? applied : -applied; + } +} + +/// Per-drag arming state for the host-union autoscroll gate. +/// +/// 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); +} + +/// Builds the built-in sliver target for [context]: the nearest [Scrollable] +/// (or [context]'s own scrollable), padded from config + MediaQuery + the +/// screen chrome that sits *outside* the viewport. +/// +/// Returns null when [context] is unusable or has no enclosing [Scrollable]. +MarkdownScrollableAutoscrollTarget? resolveMarkdownScrollableAutoscrollTarget({ + required BuildContext? context, + required MarkdownSelectionAutoscrollConfig config, +}) { + if (context == null || !context.mounted) return null; + final scrollable = _scrollableFor(context); + if (scrollable == null || !scrollable.mounted) return null; + final position = scrollable.position; + if (!position.hasPixels || !position.hasContentDimensions) return null; + + final box = scrollable.context.findRenderObject(); + if (box is! RenderBox || !box.hasSize || !box.attached) return null; + + 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); + } + } + + return MarkdownScrollableAutoscrollTarget( + scrollable, + padding: EdgeInsets.only(top: resolvedTop, bottom: resolvedBottom), + ); +} + +/// Applies one autoscroll step to [target] (or, when [target] is null, to the +/// nearest ancestor [Scrollable] of [context]). +/// +/// [hostUnionTopGlobalY] / [hostUnionBottomGlobalY] are the global Y bounds of +/// the union of mounted selectable bodies under the selection host. Bands use +/// the padded viewport; the host union only gates / hard-stops each direction. +MarkdownAutoscrollResult applyMarkdownSelectionAutoscroll({ + required Offset globalPosition, + required MarkdownSelectionAutoscrollConfig config, + required Duration? lastTimestamp, + required void Function(Duration?) storeTimestamp, + required double hostUnionTopGlobalY, + required double hostUnionBottomGlobalY, + BuildContext? context, + MarkdownAutoscrollTarget? target, + MarkdownAutoscrollSession? session, +}) { + void clear() => storeTimestamp(null); + + if (!config.enabled || config.maxVelocity <= 0) { + // A zero max velocity can never move content; without this the + // past-the-outer-band branch below would hand back `scrolled` forever and + // keep the caller's frame ticker spinning on a drag that never scrolls. + clear(); + return MarkdownAutoscrollResult.idle; + } + + final resolved = target ?? + resolveMarkdownScrollableAutoscrollTarget( + context: context, + config: config, + ); + if (resolved == null) { + clear(); + return MarkdownAutoscrollResult.idle; + } + + final viewport = resolved.viewport; + if (viewport == null || !viewport.isUsable) { + session?.reset(); + clear(); + return MarkdownAutoscrollResult.idle; + } + + final bounds = viewport.globalBounds; + final padded = viewport.paddedGlobalBounds; + final paddedTop = padded.top; + final paddedBottom = padded.bottom; + final clipHeight = padded.height; + final localY = globalPosition.dy - bounds.top; + final clipTopLocal = paddedTop - bounds.top; + + // Host still has content to reveal past each pad edge (or sits entirely + // off-screen past that edge — drag must be able to pull it back). Hosts whose + // bodies *are* the scrolling content opt out and let the target decide. + final canTowardStart = !config.useHostUnionGate || + hostUnionTopGlobalY < paddedTop - 0.5 || + hostUnionBottomGlobalY <= paddedTop + 0.5; + final canTowardEnd = !config.useHostUnionGate || + hostUnionBottomGlobalY > paddedBottom + 0.5 || + hostUnionTopGlobalY >= paddedBottom - 0.5; + + 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; + } + + 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; + } + + if (!resolved.canScroll(forward: !towardStart)) { + session?.disarm(towardStart: towardStart); + clear(); + return MarkdownAutoscrollResult.suppressed; + } + + final applied = resolved.applyScrollDelta(delta); + if (applied.abs() < 0.5) { + session?.disarm(towardStart: towardStart); + clear(); + return MarkdownAutoscrollResult.suppressed; + } + + 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..2cc106c 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`. @@ -62,11 +67,16 @@ class MarkdownSelectionScope extends StatefulWidget { required this.child, this.focusNode, this.enabled = true, + this.enableTouchGestures = true, + this.enableTouchConsecutiveTaps = true, + this.canStartSelectionAt, + this.ownsSelectionChrome, this.selectionColor, this.contextMenuBuilder = defaultContextMenuBuilder, this.magnifierConfiguration, this.selectionControls, this.onSelectionChanged, + this.autoscroll = const MarkdownSelectionAutoscrollConfig(), super.key, }); @@ -80,15 +90,61 @@ 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 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], 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 /// `DefaultSelectionStyle`/`TextSelectionTheme` color. 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 @@ -101,6 +157,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 +191,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 +200,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 +211,45 @@ 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(); + + /// Scroll surface resolved for the live drag. + /// + /// Resolving walks the element tree (to find the surface element, then the + /// enclosing [Scrollable]); the result is stable for a drag, so it is cached + /// and only re-resolved when it stops reporting a usable viewport. + MarkdownAutoscrollTarget? _autoscrollTarget; + + /// When true, skip creating/showing handles (Android long-press: show on end). + bool _deferHandleShow = false; + + /// 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; + + /// Coalesces post-frame [MarkdownSelectionScope.onSelectionChanged] delivery + /// for controller notifications that land during build / layout. + bool _selectionChangedNotifyScheduled = false; + + /// Ancestor observer (e.g. [Scaffold]) so the toolbar follows parent scrolls + /// when this scope is a scrollable *child*, not an ancestor. + ScrollNotificationObserverState? _scrollNotificationObserver; + + PointerDeviceKind? _lastPointerDeviceKind; + FocusNode get _focusNode => widget.focusNode ?? (_internalFocusNode ??= FocusNode(debugLabel: 'MarkdownSelectionScope')); @@ -160,17 +262,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); @@ -182,10 +299,36 @@ class MarkdownSelectionScopeState extends State { if (oldWidget.selectionColor != widget.selectionColor) { _applySelectionColor(); } + if (oldWidget.autoscroll != widget.autoscroll && _dragGlobal == null) { + // Pick up a new resolver / pads on the next drag. Never mid-drag: + // [MarkdownSelectionAutoscrollConfig] has no value equality, so a host + // that builds the config inline (a closure resolver always does) would + // otherwise invalidate the cached surface on every rebuild — and an + // autoscrolling list rebuilds every frame. + _autoscrollTarget = null; + } + // Host may enable the scope after arming a subject / range. Sync handles + // and restore the toolbar when chrome becomes eligible. Disabling clears + // visible chrome but keeps [toolbarWanted] so a later enable can restore. + if (oldWidget.enabled != widget.enabled) { + if (widget.enabled) { + _syncOverlay(); + if (controller.toolbarWanted) { + _refreshToolbarForVisibleGeometry(); + } + } else { + _clearHandles(); + _contextMenuController.remove(); + } + } } @override void dispose() { + _stopAutoscroll(); + _scrollNotificationObserver?.removeListener(_onAncestorScrollNotification); + _scrollNotificationObserver = null; + _focusNode.removeListener(_handleFocusChanged); widget.controller.removeListener(_onControllerChanged); _contextMenuController.remove(); _selectionOverlay?.dispose(); @@ -194,6 +337,51 @@ class MarkdownSelectionScopeState extends State { super.dispose(); } + void _handleFocusChanged() { + // 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. + 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(); + } + } + + /// Delivers [sel] to [MarkdownSelectionScope.onSelectionChanged]. + /// + /// The controller can notify from inside build / layout: a streaming + /// `putDocument` runs from `MarkdownWidget.updateRenderObject` and may + /// reconcile the range away, and a deferred `removeDocument` flushes from + /// `RenderObject.detach`. Hosts routinely `setState` from this callback, so + /// a synchronous call there throws "setState() called during build". + /// Coalesce to the end of the frame and deliver the settled selection. + void _notifySelectionChanged(MarkdownSelection? sel) { + if (widget.onSelectionChanged == null) return; + final phase = SchedulerBinding.instance.schedulerPhase; + if (phase != SchedulerPhase.persistentCallbacks && + phase != SchedulerPhase.midFrameMicrotasks) { + widget.onSelectionChanged?.call(sel); + return; + } + if (_selectionChangedNotifyScheduled) return; + _selectionChangedNotifyScheduled = true; + SchedulerBinding.instance.addPostFrameCallback((_) { + _selectionChangedNotifyScheduled = false; + if (!mounted) return; + widget.onSelectionChanged?.call(controller.selection); + }); + } + void _applySelectionColor() { controller.selectionColor = widget.selectionColor ?? DefaultSelectionStyle.of(context).selectionColor; @@ -203,20 +391,59 @@ class MarkdownSelectionScopeState extends State { final sel = controller.selection; if (sel != _lastSelection) { _lastSelection = sel; - widget.onSelectionChanged?.call(sel); + _notifySelectionChanged(sel); + // Selection moved — on mobile, drop a frozen right-click anchor so + // the toolbar tracks the live selection bounds instead. On desktop, + // preserve the user's secondary-click anchor. + if (_contextMenuController.isShown && !_isDesktopPlatform) { + _lastSecondaryTapDown = null; + } + } + + if (sel case final current? when !current.isCollapsed) { + if (_dragGlobal == null) { + // Outside a drag: restore when [toolbarWanted] (scroll remount / + // geometry change). Skip while disabled — chrome comes back on + // the enabled flip in [didUpdateWidget]. + if (widget.enabled && controller.toolbarWanted) { + _refreshToolbarForVisibleGeometry(); + } + } + } else { + hideToolbar(); } - if (sel == null || sel.isCollapsed) hideToolbar(); + // Mid-drag: keep the menu hidden until drag-end [showToolbar]. _syncOverlay(); } // --- native handles + magnifier ------------------------------------------ - bool get _handlesEnabled => - widget.enabled && - switch (Theme.of(context).platform) { - TargetPlatform.android || - TargetPlatform.iOS || - TargetPlatform.fuchsia => + 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 || + TargetPlatform.macOS || + TargetPlatform.windows => true, _ => false, }; @@ -238,20 +465,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,61 +506,82 @@ 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(); + } + } } } /// 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 = - e == null ? null : Offset(e.startLocal.left, e.startLocal.bottom); - final endLocal = - e == null ? null : Offset(e.endLocal.right, e.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); @@ -330,43 +594,88 @@ 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; } void _onHandleDragStart(DragStartDetails d, {required bool isStart}) { + _dragIsHandle = true; + _dragMovesStartEdge = isStart; + _dragGlobal = d.globalPosition; + _autoscrollSession.reset(); + _autoscrollTarget = null; + 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 (_isDesktopPlatform || _isPrecisePointer(_lastPointerDeviceKind)) { + return; + } + 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 +684,215 @@ 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(); + _autoscrollTarget = null; + _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; + } + + final target = _resolveAutoscrollTarget(position); + if (target == null) { + _pauseAutoscrollTicker(); + return; + } + + final result = applyMarkdownSelectionAutoscroll( + globalPosition: position, + target: target, + 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(); + } + + /// The scroll surface this drag drives, resolved once and cached. + /// + /// A host with its own scroll protocol supplies + /// [MarkdownSelectionAutoscrollConfig.targetResolver]; otherwise the nearest + /// ancestor [Scrollable] of a mounted markdown surface is used (the scope + /// itself usually sits *above* that scrollable, so its own context cannot + /// find it). + MarkdownAutoscrollTarget? _resolveAutoscrollTarget(Offset globalPosition) { + if (_autoscrollTarget case final cached?) { + if (cached.viewport?.isUsable ?? false) return cached; + _autoscrollTarget = null; + } + // Prefer a BuildContext under a mounted surface so Scrollable.maybeOf + // walks up to the enclosing ListView (scope context is an ancestor of + // the scrollable, not a descendant). + MarkdownSelectionSurface? content; + for (final surface in controller.mountedSurfaces) { + if (surface.globalBounds.contains(globalPosition)) { + content = surface; + break; + } + } + content ??= switch (controller.mountedSurfaces) { + final surfaces when surfaces.isEmpty => null, + final surfaces => surfaces.first, + }; + final contentContext = switch (content) { + final surface? => _contextForSurface(surface), + null => null, + }; + + if (widget.autoscroll.targetResolver case final resolver?) { + final target = resolver(MarkdownAutoscrollRequest( + scopeContext: context, + contentContext: contentContext, + globalPosition: globalPosition, + config: widget.autoscroll, + )); + return _autoscrollTarget = target; + } + + final scrollContext = + contentContext ?? _scrollableContextNear(globalPosition) ?? context; + return _autoscrollTarget = resolveMarkdownScrollableAutoscrollTarget( + context: scrollContext, + config: widget.autoscroll, + ); + } + + /// Element whose [RenderObject] is [surface], if mounted under this scope. + BuildContext? _contextForSurface(MarkdownSelectionSurface surface) { + Element? found; + 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 +922,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 +940,330 @@ 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?) { + if (_isDesktopPlatform) { + return TextSelectionToolbarAnchors( + primaryAnchor: visible.bottomLeft, + secondaryAnchor: visible.bottomLeft, + ); + } + 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 + : (_isDesktopPlatform ? host.topLeft : 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; + /// 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. + /// + /// 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; + 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(); + 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, bool showToolbar = true}) { + _syncOverlay(); + _updateHandlesAndOverlay(); + if (showToolbar) { + if (controller.selection case final sel? when !sel.isCollapsed) { + this.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; + } + if (widget.canStartSelectionAt?.call(global) == false) { + return false; + } + final effective = _effectiveConsecutiveTapCount(tapCount); + _granularity = switch (effective) { + <= 1 => 1, + 2 => 2, + _ => 3, + }; switch (_granularity) { case 2: _granularAnchor = controller.selectWordAtGlobal(global); @@ -490,127 +1273,521 @@ 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 (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; + } + 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) { + 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. + // 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; + } + if (!_beginSelection(d.globalPosition, d.consecutiveTapCount)) { + if (controller.selection case final sel? when !sel.isCollapsed) { + hideToolbar(); + clearSelection(); + } + } + return; + } + + // 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; } - _beginSelection(d.globalPosition, d.consecutiveTapCount); + final showToolbarOnTap = !_isDesktopPlatform && !_isPrecisePointer(d.kind); + _refreshSelectionChrome(showToolbar: showToolbarOnTap); } 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(); + _autoscrollTarget = null; + 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; } - _beginSelection(d.globalPosition, d.consecutiveTapCount); - } - - 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. - TapAndPanGestureRecognizer: - GestureRecognizerFactoryWithHandlers( - () => TapAndPanGestureRecognizer( - supportedDevices: const { - PointerDeviceKind.mouse, - PointerDeviceKind.stylus, - PointerDeviceKind.invertedStylus, - PointerDeviceKind.trackpad, - }, - ), - (recognizer) => recognizer - ..dragStartBehavior = DragStartBehavior.down - ..onTapDown = _handleTapDown - ..onTapUp = _handleTapUp - ..onDragStart = _handleDragStart - ..onDragUpdate = ((d) => _updateSelection(d.globalPosition)), - ), - // 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 - }, - ), - (recognizer) => recognizer - ..onDoubleTapDown = ((d) => _lastDoubleTapDown = d.globalPosition) - ..onDoubleTap = (() { - final pos = _lastDoubleTapDown; - if (pos == null) return; - _focusNode.requestFocus(); - controller.selectWordAtGlobal(pos); - showToolbar(); - }), - ), - // 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(), - (recognizer) => recognizer - ..onLongPressStart = ((d) { - _focusNode.requestFocus(); - hideToolbar(); - _beginSelection(d.globalPosition, 2); - }) - ..onLongPressMoveUpdate = - ((d) => _updateSelection(d.globalPosition)) - ..onLongPressEnd = ((_) => showToolbar()), + // 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. + // A refusal (host gate / miss) must also un-arm the drag, otherwise + // [_handleDragUpdate] would keep extending from a point the host owns. + if (!_beginSelection(d.globalPosition, d.consecutiveTapCount)) { + _dragGlobal = null; + return; + } + _driveAutoscroll(); + } + + 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() { + 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. + _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 (!widget.enabled || !controller.toolbarWanted) return; + if (!_ownsChromeForCurrentSelection) { + _contextMenuController.remove(); + return; + } + final phase = SchedulerBinding.instance.schedulerPhase; + if (phase == SchedulerPhase.persistentCallbacks || + phase == SchedulerPhase.midFrameMicrotasks) { + _scheduleToolbarGeometryRefresh(); + return; + } + if (!_isDesktopPlatform) { + _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; + + // 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: + 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: + case TargetPlatform.iOS: + if (!onSelection) { + controller.selectWordAtGlobal(d.globalPosition); + } + showToolbar(d.globalPosition); + } + _previousSecondaryTapAnchor = d.globalPosition; + } + + 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. Content- + // gated so host chrome (links / code headers) can win the arena. + _ContentGatedTapAndPanGestureRecognizer: + GestureRecognizerFactoryWithHandlers< + _ContentGatedTapAndPanGestureRecognizer>( + () => _ContentGatedTapAndPanGestureRecognizer( + shouldArm: _shouldArmMouseSelectionGesture, + supportedDevices: const { + PointerDeviceKind.mouse, + }, ), - // 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); - }), + (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. 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; + + // 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. + // 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< + _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; + } + // The recognizer joins the arena while a range is live (so chrome + // taps can dismiss it), so the host gate has to be re-checked here + // or a long press on a link would re-anchor the active selection. + if (widget.canStartSelectionAt?.call(d.globalPosition) == false) { + return; + } + _focusNode.requestFocus(); + hideToolbar(); + _dragIsHandle = false; + _autoscrollSession.reset(); + _autoscrollTarget = null; + // Android shows handles on press-end; other platforms on start. + _deferHandleShow = defaultTargetPlatform == TargetPlatform.android; + // Match TextField long-press: Android vibrate (LONG_PRESS) via + // 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; + if (!_beginSelection(d.globalPosition, 2)) { + _dragGlobal = null; + return; + } + // Magnifier still tracks during the press even when handles wait. + _selectionOverlay?.showMagnifier( + _magnifierInfo(d.globalPosition, isStart: false), + ); + _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( @@ -694,13 +1871,98 @@ 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(); +} + +/// 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 +/// 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..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. @@ -289,7 +316,7 @@ class MarkdownThemeData implements ThemeExtension { ); @override - ThemeExtension copyWith({ + MarkdownThemeData copyWith({ TextDirection? textDirection, TextScaler? textScaler, TextStyle? textStyle, @@ -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..ef6103c 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 @@ -60,8 +68,20 @@ class MarkdownWidget extends LeafRenderObjectWidget { BuildContext context, MarkdownRenderObject renderObject, ) { + // Rewire the selection registry FIRST: a recycled element (a virtualized + // chat list reusing a slot) changes [documentId] and [markdown] together, + // and [update]'s registry write must land on the incoming id — not + // overwrite the outgoing document's model with this one's body. renderObject - ..update(markdown: markdown, theme: _resolveTheme(context)) - ..updateSelection(_resolveController(context), documentId); + ..updateSelection( + _resolveController(context), + documentId, + markdown: markdown, + ) + ..update( + markdown: markdown, + theme: _resolveTheme(context), + cursorResolver: cursorResolver, + ); } } 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..87e2006 100644 --- a/test/parser/streaming_test.dart +++ b/test/parser/streaming_test.dart @@ -41,6 +41,16 @@ void main() => group('StreamingMarkdownParser', () { 'quote-multi': '> a\n> b\n> c', 'alert-note': '> [!NOTE]\n> Body of the note.\n\nafter', 'alert-warning': '> [!WARNING]\n> Careful now.\n> Second line.', + // Fenced code inside a quote / alert re-enters the decoder to parse + // the body as nested blocks. The chat streams into exactly this, so + // every mid-fence prefix has to agree with a batch parse. + 'quote-fence': + '> intro\n> ```dart\n> void main() {}\n> ```\n> outro\n\nafter', + 'quote-fence-unclosed': '> intro\n> ```\n> still going', + 'quote-fence-tilde': '> a\n> ~~~\n> x\n> ~~~\n> b', + 'quote-fence-empty': '> ```\n> ```\n\nafter', + 'quote-fence-nested-quote': '> > inner\n> ```\n> code\n> ```', + 'alert-fence': '> [!NOTE]\n> body\n> ```sh\n> echo hi\n> ```\n\nafter', 'code-closed': '```dart\nvoid main() {}\n```\n\nafter code', 'code-tilde': '~~~\nplain\n~~~\n\nafter', 'code-unclosed': '```dart\nline 1\nline 2\nstill going', @@ -307,8 +317,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_autoscroll_target_test.dart b/test/selection/selection_autoscroll_target_test.dart new file mode 100644 index 0000000..cdfbbda --- /dev/null +++ b/test/selection/selection_autoscroll_target_test.dart @@ -0,0 +1,913 @@ +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_md/flutter_md.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// A scroll protocol that is **not** a [Scrollable]: an anchor offset the host +/// owns, exactly like the chat viewports that render their children from a +/// `RenderBox` instead of the sliver protocol. +/// +/// `offset` grows as content moves up, so `scrollBy` here has the same sign as +/// a screen-space autoscroll delta; anchor-relative hosts (whose positive +/// delta reveals *older* content) negate it in their resolver. +class _AnchorScrollHost extends StatefulWidget { + const _AnchorScrollHost({ + required this.viewportHeight, + required this.child, + required this.onState, + this.maxOffset = double.infinity, + }); + + final double viewportHeight; + final Widget child; + final ValueChanged<_AnchorScrollHostState> onState; + + /// Hard stop, emulating "reached the oldest message". + final double maxOffset; + + @override + State<_AnchorScrollHost> createState() => _AnchorScrollHostState(); +} + +class _AnchorScrollHostState extends State<_AnchorScrollHost> { + final GlobalKey _viewportKey = GlobalKey(); + + /// Pixels of content scrolled past the top edge. + double offset = 0; + + double get maxOffset => widget.maxOffset; + + int scrollCalls = 0; + + MarkdownAutoscrollViewport? viewportOf() { + final box = _viewportKey.currentContext?.findRenderObject(); + if (box is! RenderBox || !box.hasSize) return null; + return MarkdownAutoscrollViewport( + globalBounds: box.localToGlobal(Offset.zero) & box.size, + ); + } + + bool canScroll({required bool forward}) => + forward ? offset < maxOffset : offset > 0; + + double applyScrollDelta(double delta) { + final next = (offset + delta).clamp(0.0, maxOffset); + final applied = next - offset; + if (applied.abs() < 0.5) return 0; + scrollCalls++; + setState(() => offset = next); + return applied; + } + + MarkdownAutoscrollTarget get target => MarkdownCallbackAutoscrollTarget( + viewportOf: viewportOf, + onScrollDelta: applyScrollDelta, + canScrollAt: canScroll, + ); + + @override + void initState() { + super.initState(); + widget.onState(this); + } + + @override + Widget build(BuildContext context) => Align( + alignment: Alignment.topLeft, + child: SizedBox( + key: _viewportKey, + width: 400, + height: widget.viewportHeight, + child: ClipRect( + child: OverflowBox( + alignment: Alignment.topLeft, + maxHeight: double.infinity, + child: Transform.translate( + offset: Offset(0, -offset), + child: SizedBox(width: 400, child: widget.child), + ), + ), + ), + ), + ); +} + +class _Doc extends StatelessWidget { + const _Doc(this.id); + + final String id; + + @override + Widget build(BuildContext context) { + final controller = MarkdownSelectionScope.of(context); + final model = controller.documents.firstWhere((d) => d.id == id).model; + return MarkdownWidget(markdown: model, documentId: id); + } +} + +/// A long single-paragraph document that wraps into many lines. +Markdown _longDoc() => Markdown.fromString( + List.generate(60, (i) => 'line $i of the body').join(' '), + ); + +const _config = MarkdownSelectionAutoscrollConfig( + edgeZone: 40, + maxVelocity: 2000, + useMediaQueryPadding: false, +); + +void main() { + group('MarkdownAutoscrollViewport', () { + test('padding deflates the padded bounds', () { + const viewport = MarkdownAutoscrollViewport( + globalBounds: Rect.fromLTWH(0, 100, 400, 200), + padding: EdgeInsets.only(top: 10, bottom: 20), + ); + expect( + viewport.paddedGlobalBounds, + const Rect.fromLTRB(0, 110, 400, 280), + ); + expect(viewport.isUsable, isTrue); + }); + + test('padding taller than the viewport is not usable', () { + const viewport = MarkdownAutoscrollViewport( + globalBounds: Rect.fromLTWH(0, 0, 400, 30), + padding: EdgeInsets.symmetric(vertical: 20), + ); + expect(viewport.isUsable, isFalse); + }); + + test('a non-finite viewport is not usable', () { + const viewport = MarkdownAutoscrollViewport( + globalBounds: Rect.fromLTRB(0, 0, 400, double.infinity), + ); + expect(viewport.isUsable, isFalse); + }); + }); + + group('custom scroll protocol', () { + /// Everything below drives [applyMarkdownSelectionAutoscroll] directly with + /// a hand-built target — no widget tree, no `Scrollable`. + const bounds = Rect.fromLTWH(0, 100, 400, 200); + + ({ + MarkdownAutoscrollTarget target, + List deltas, + double Function() offset, + }) makeTarget({ + EdgeInsets padding = EdgeInsets.zero, + double min = -1000, + double max = 1000, + MarkdownAutoscrollViewport? Function()? viewportOf, + }) { + var offset = 0.0; + final deltas = []; + final target = MarkdownCallbackAutoscrollTarget( + viewportOf: viewportOf ?? + () => MarkdownAutoscrollViewport( + globalBounds: bounds, + padding: padding, + ), + canScrollAt: ({required forward}) => + forward ? offset < max : offset > min, + onScrollDelta: (delta) { + final next = (offset + delta).clamp(min, max); + final applied = next - offset; + offset = next; + if (applied != 0) deltas.add(applied); + return applied; + }, + ); + return (target: target, deltas: deltas, offset: () => offset); + } + + MarkdownAutoscrollResult run( + MarkdownAutoscrollTarget target, + double globalY, { + MarkdownAutoscrollSession? session, + double hostTop = -1000, + double hostBottom = 5000, + Duration? last, + }) => + applyMarkdownSelectionAutoscroll( + globalPosition: Offset(10, globalY), + target: target, + config: _config, + lastTimestamp: last, + storeTimestamp: (_) {}, + hostUnionTopGlobalY: hostTop, + hostUnionBottomGlobalY: hostBottom, + session: session, + ); + + test('bottom band moves content up (positive delta)', () { + final t = makeTarget(); + // 3 px above the bottom edge → inside the 40 px band. + expect( + run(t.target, bounds.bottom - 3), + MarkdownAutoscrollResult.scrolled, + ); + expect(t.deltas, isNotEmpty); + expect(t.deltas.first, greaterThan(0)); + expect(t.offset(), greaterThan(0)); + }); + + test('top band moves content down (negative delta)', () { + final t = makeTarget(); + expect(run(t.target, bounds.top + 3), MarkdownAutoscrollResult.scrolled); + expect(t.deltas, isNotEmpty); + expect(t.deltas.first, lessThan(0)); + expect(t.offset(), lessThan(0)); + }); + + test('mid-viewport pointer is idle', () { + final t = makeTarget(); + expect(run(t.target, bounds.center.dy), MarkdownAutoscrollResult.idle); + expect(t.deltas, isEmpty); + }); + + test('canScroll false suppresses and disarms that direction', () { + final t = makeTarget(max: 0); + final session = MarkdownAutoscrollSession(); + expect( + run(t.target, bounds.bottom - 3, session: session), + MarkdownAutoscrollResult.suppressed, + ); + expect(t.deltas, isEmpty); + expect(session.isDisarmedTowardEnd, isTrue); + expect(session.isDisarmedTowardStart, isFalse); + }); + + test('a flush target (zero applied delta) suppresses and disarms', () { + var asked = 0; + final target = MarkdownCallbackAutoscrollTarget( + viewportOf: () => + const MarkdownAutoscrollViewport(globalBounds: bounds), + onScrollDelta: (_) { + asked++; + return 0; + }, + ); + final session = MarkdownAutoscrollSession(); + expect( + run(target, bounds.bottom - 3, session: session), + MarkdownAutoscrollResult.suppressed, + ); + expect(asked, 1); + expect(session.isDisarmedTowardEnd, isTrue); + }); + + test('a disarmed direction stays suppressed until the band is left', () { + final t = makeTarget(); + final session = MarkdownAutoscrollSession()..disarm(towardStart: false); + expect( + run(t.target, bounds.bottom - 3, session: session), + MarkdownAutoscrollResult.suppressed, + ); + expect(t.deltas, isEmpty); + // Leaving the band re-arms. + expect( + run(t.target, bounds.center.dy, session: session), + MarkdownAutoscrollResult.idle, + ); + expect(session.isDisarmedTowardEnd, isFalse); + expect( + run(t.target, bounds.bottom - 3, session: session), + MarkdownAutoscrollResult.scrolled, + ); + }); + + test('padding moves the activation band inward', () { + final padded = makeTarget(padding: const EdgeInsets.only(bottom: 60)); + // 50 px above the raw bottom edge is *below* the padded bottom (140), + // so it is still inside the band — but 10 px past the padded bottom. + expect( + run(padded.target, bounds.bottom - 50), + MarkdownAutoscrollResult.scrolled, + ); + + final plain = makeTarget(); + // The same point is deep inside an unpadded viewport → idle. + expect( + run(plain.target, bounds.bottom - 50), + MarkdownAutoscrollResult.idle, + ); + }); + + test('a null viewport idles without touching the target', () { + var asked = 0; + final target = MarkdownCallbackAutoscrollTarget( + viewportOf: () => null, + onScrollDelta: (delta) { + asked++; + return delta; + }, + ); + expect(run(target, 0), MarkdownAutoscrollResult.idle); + expect(asked, 0); + }); + + test('host union gate still applies to a custom target', () { + final t = makeTarget(); + // Host union ends mid-viewport → nothing left to reveal downward. + expect( + run( + t.target, + bounds.bottom - 3, + hostTop: bounds.top + 10, + hostBottom: bounds.top + 100, + ), + MarkdownAutoscrollResult.suppressed, + ); + expect(t.deltas, isEmpty); + }); + + test('useHostUnionGate false lets the target decide instead', () { + final gated = makeTarget(); + // Host union ends mid-viewport: with the gate on, nothing to reveal. + expect( + run( + gated.target, + bounds.bottom - 3, + hostTop: bounds.top + 10, + hostBottom: bounds.top + 100, + ), + MarkdownAutoscrollResult.suppressed, + ); + + // A chat viewport that builds only what is visible opts out: its own + // `canScroll` is the authority. + var offset = 0.0; + final ungated = MarkdownCallbackAutoscrollTarget( + viewportOf: () => + const MarkdownAutoscrollViewport(globalBounds: bounds), + onScrollDelta: (delta) { + offset += delta; + return delta; + }, + ); + expect( + applyMarkdownSelectionAutoscroll( + globalPosition: Offset(10, bounds.bottom - 3), + target: ungated, + config: _config.copyWith(useHostUnionGate: false), + lastTimestamp: null, + storeTimestamp: (_) {}, + hostUnionTopGlobalY: bounds.top + 10, + hostUnionBottomGlobalY: bounds.top + 100, + ), + MarkdownAutoscrollResult.scrolled, + ); + expect(offset, greaterThan(0)); + }); + + test('useHostUnionGate false still stops on a flush target', () { + var offset = 0.0; + final target = MarkdownCallbackAutoscrollTarget( + viewportOf: () => + const MarkdownAutoscrollViewport(globalBounds: bounds), + canScrollAt: ({required forward}) => false, + onScrollDelta: (delta) { + offset += delta; + return delta; + }, + ); + final session = MarkdownAutoscrollSession(); + expect( + applyMarkdownSelectionAutoscroll( + globalPosition: Offset(10, bounds.bottom - 3), + target: target, + config: _config.copyWith(useHostUnionGate: false), + lastTimestamp: null, + storeTimestamp: (_) {}, + hostUnionTopGlobalY: -1000, + hostUnionBottomGlobalY: 5000, + session: session, + ), + MarkdownAutoscrollResult.suppressed, + ); + expect(offset, 0); + expect(session.isDisarmedTowardEnd, isTrue); + }); + + test('a disabled config never resolves a target', () { + var asked = 0; + final target = MarkdownCallbackAutoscrollTarget( + viewportOf: () { + asked++; + return const MarkdownAutoscrollViewport(globalBounds: bounds); + }, + onScrollDelta: (delta) => delta, + ); + expect( + applyMarkdownSelectionAutoscroll( + globalPosition: Offset(10, bounds.bottom - 3), + target: target, + config: MarkdownSelectionAutoscrollConfig.disabled, + lastTimestamp: null, + storeTimestamp: (_) {}, + hostUnionTopGlobalY: -1000, + hostUnionBottomGlobalY: 5000, + ), + MarkdownAutoscrollResult.idle, + ); + expect(asked, 0); + }); + }); + + group('MarkdownScrollableAutoscrollTarget', () { + testWidgets('drives a forward list toward maxScrollExtent', (tester) async { + final controller = ScrollController(); + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Align( + alignment: Alignment.topLeft, + child: SizedBox( + width: 400, + height: 200, + child: ListView( + controller: controller, + children: const [SizedBox(height: 2000)], + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + final state = tester.state(find.byType(Scrollable)); + final target = MarkdownScrollableAutoscrollTarget(state); + + expect(target.viewport, isNotNull); + expect(target.viewport!.globalBounds.height, 200); + expect(target.canScroll(forward: true), isTrue); + expect(target.canScroll(forward: false), isFalse); + + expect(target.applyScrollDelta(50), closeTo(50, 0.01)); + expect(controller.offset, closeTo(50, 0.01)); + expect(target.canScroll(forward: false), isTrue); + + expect(target.applyScrollDelta(-50), closeTo(-50, 0.01)); + expect(controller.offset, closeTo(0, 0.01)); + + // Flush at the min extent — no movement, and the caller is told so. + expect(target.applyScrollDelta(-50), 0); + controller.dispose(); + }); + + testWidgets('a reverse list keeps the screen-space delta sign', + (tester) async { + final controller = ScrollController(); + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Align( + alignment: Alignment.topLeft, + child: SizedBox( + width: 400, + height: 200, + child: ListView( + controller: controller, + reverse: true, + children: const [SizedBox(height: 2000)], + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + final state = tester.state(find.byType(Scrollable)); + expect(state.axisDirection, AxisDirection.up); + final target = MarkdownScrollableAutoscrollTarget(state); + + // A reverse list starts pinned at the bottom: content can only move + // *down* on screen (revealing what is above), i.e. a negative delta. + expect(target.canScroll(forward: true), isFalse); + expect(target.canScroll(forward: false), isTrue); + + // Screen-space "content down" must grow `pixels` on a reversed axis. + final applied = target.applyScrollDelta(-50); + expect(applied, closeTo(-50, 0.01)); + expect(controller.offset, closeTo(50, 0.01)); + controller.dispose(); + }); + + testWidgets('an unmounted scrollable reports no viewport', (tester) async { + final controller = ScrollController(); + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: SizedBox( + width: 400, + height: 200, + child: ListView( + controller: controller, + children: const [SizedBox(height: 2000)], + ), + ), + ), + ); + await tester.pumpAndSettle(); + final state = tester.state(find.byType(Scrollable)); + final target = MarkdownScrollableAutoscrollTarget(state); + expect(target.viewport, isNotNull); + + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pumpAndSettle(); + + expect(target.viewport, isNull); + expect(target.canScroll(forward: true), isFalse); + expect(target.applyScrollDelta(50), 0); + controller.dispose(); + }); + }); + + group('MarkdownSelectionScope autoscroll target resolution', () { + testWidgets('a custom scroll protocol is driven by a selection drag', + (tester) async { + final md = _longDoc(); + final selection = MarkdownSelectionController() + ..setDocuments([ + MarkdownDocumentRef(id: 'd', model: md), + ]); + addTearDown(selection.dispose); + + late _AnchorScrollHostState host; + var resolverCalls = 0; + + await tester.pumpWidget(MaterialApp( + home: Scaffold( + body: Builder( + builder: (context) => MarkdownSelectionScope( + controller: selection, + autoscroll: MarkdownSelectionAutoscrollConfig( + edgeZone: 40, + maxVelocity: 2000, + useMediaQueryPadding: false, + targetResolver: (request) { + resolverCalls++; + expect(request.config.edgeZone, 40); + expect(request.scopeContext.mounted, isTrue); + return host.target; + }, + ), + child: _AnchorScrollHost( + viewportHeight: 200, + onState: (state) => host = state, + child: const _Doc('d'), + ), + ), + ), + ), + )); + await tester.pumpAndSettle(); + + final viewport = tester.getRect(find.byType(ClipRect).first); + expect(host.offset, 0); + + final gesture = await tester.startGesture( + viewport.topLeft + const Offset(10, 10), + kind: PointerDeviceKind.mouse, + ); + await tester.pump(const Duration(milliseconds: 20)); + // Drag into the bottom edge band and hold there. + await gesture.moveTo(Offset(viewport.center.dx, viewport.bottom - 5)); + for (var i = 0; i < 6; i++) { + await tester.pump(const Duration(milliseconds: 16)); + } + await gesture.up(); + await tester.pumpAndSettle(); + + expect(resolverCalls, greaterThan(0)); + expect(host.scrollCalls, greaterThan(0)); + expect(host.offset, greaterThan(0)); + expect(selection.selection, isNotNull); + expect(selection.getText(), isNotEmpty); + expect(tester.takeException(), isNull); + }); + + testWidgets('the resolver is not re-run on every frame of one drag', + (tester) async { + final md = _longDoc(); + final selection = MarkdownSelectionController() + ..setDocuments([ + MarkdownDocumentRef(id: 'd', model: md), + ]); + addTearDown(selection.dispose); + + late _AnchorScrollHostState host; + var resolverCalls = 0; + + await tester.pumpWidget(MaterialApp( + home: Scaffold( + body: MarkdownSelectionScope( + controller: selection, + autoscroll: MarkdownSelectionAutoscrollConfig( + edgeZone: 40, + maxVelocity: 2000, + useMediaQueryPadding: false, + targetResolver: (_) { + resolverCalls++; + return host.target; + }, + ), + child: _AnchorScrollHost( + viewportHeight: 200, + onState: (state) => host = state, + child: const _Doc('d'), + ), + ), + ), + )); + await tester.pumpAndSettle(); + + final viewport = tester.getRect(find.byType(ClipRect).first); + final gesture = await tester.startGesture( + viewport.topLeft + const Offset(10, 10), + kind: PointerDeviceKind.mouse, + ); + await tester.pump(const Duration(milliseconds: 20)); + await gesture.moveTo(Offset(viewport.center.dx, viewport.bottom - 5)); + for (var i = 0; i < 10; i++) { + await tester.pump(const Duration(milliseconds: 16)); + } + final duringDrag = resolverCalls; + await gesture.up(); + await tester.pumpAndSettle(); + + expect(duringDrag, 1, + reason: 'the target is cached for the lifetime of one drag'); + expect(host.scrollCalls, greaterThan(3)); + }); + + testWidgets('a scope rebuild during a drag does not re-resolve the target', + (tester) async { + final md = _longDoc(); + final selection = MarkdownSelectionController() + ..setDocuments([ + MarkdownDocumentRef(id: 'd', model: md), + ]); + addTearDown(selection.dispose); + + late _AnchorScrollHostState host; + late StateSetter setStateFn; + var tick = 0; + var resolverCalls = 0; + + await tester.pumpWidget(MaterialApp( + home: Scaffold( + body: StatefulBuilder(builder: (context, setState) { + setStateFn = setState; + return MarkdownSelectionScope( + controller: selection, + // A fresh config (and a fresh closure) on every rebuild — what a + // real host writes. The cached surface must survive it. + autoscroll: MarkdownSelectionAutoscrollConfig( + edgeZone: 40, + maxVelocity: 2000 + tick * 0.0, + useMediaQueryPadding: false, + targetResolver: (_) { + resolverCalls++; + return host.target; + }, + ), + child: _AnchorScrollHost( + viewportHeight: 200, + onState: (state) => host = state, + child: const _Doc('d'), + ), + ); + }), + ), + )); + await tester.pumpAndSettle(); + + final viewport = tester.getRect(find.byType(ClipRect).first); + final gesture = await tester.startGesture( + viewport.topLeft + const Offset(10, 10), + kind: PointerDeviceKind.mouse, + ); + await tester.pump(const Duration(milliseconds: 20)); + await gesture.moveTo(Offset(viewport.center.dx, viewport.bottom - 5)); + for (var i = 0; i < 10; i++) { + setStateFn(() => tick++); + await tester.pump(const Duration(milliseconds: 16)); + } + final duringDrag = resolverCalls; + await gesture.up(); + await tester.pumpAndSettle(); + + expect(duringDrag, 1); + expect(host.scrollCalls, greaterThan(3)); + }); + + testWidgets('a zero max velocity never drives the host', (tester) async { + final md = _longDoc(); + final selection = MarkdownSelectionController() + ..setDocuments([ + MarkdownDocumentRef(id: 'd', model: md), + ]); + addTearDown(selection.dispose); + + late _AnchorScrollHostState host; + + await tester.pumpWidget(MaterialApp( + home: Scaffold( + body: MarkdownSelectionScope( + controller: selection, + autoscroll: MarkdownSelectionAutoscrollConfig( + edgeZone: 40, + maxVelocity: 0, + useMediaQueryPadding: false, + targetResolver: (_) => host.target, + ), + child: _AnchorScrollHost( + viewportHeight: 200, + onState: (state) => host = state, + child: const _Doc('d'), + ), + ), + ), + )); + await tester.pumpAndSettle(); + + final viewport = tester.getRect(find.byType(ClipRect).first); + final gesture = await tester.startGesture( + viewport.topLeft + const Offset(10, 10), + kind: PointerDeviceKind.mouse, + ); + await tester.pump(const Duration(milliseconds: 20)); + // Hold far past the outer band — the branch that used to report + // `scrolled` forever on a zero velocity. + await gesture.moveTo(Offset(viewport.center.dx, viewport.bottom + 200)); + for (var i = 0; i < 10; i++) { + await tester.pump(const Duration(milliseconds: 16)); + } + await gesture.up(); + await tester.pumpAndSettle(); + + expect(host.scrollCalls, 0); + expect(host.offset, 0); + }); + + testWidgets('a resolver returning null leaves the host unscrolled', + (tester) async { + final md = _longDoc(); + final selection = MarkdownSelectionController() + ..setDocuments([ + MarkdownDocumentRef(id: 'd', model: md), + ]); + addTearDown(selection.dispose); + + late _AnchorScrollHostState host; + + await tester.pumpWidget(MaterialApp( + home: Scaffold( + body: MarkdownSelectionScope( + controller: selection, + autoscroll: const MarkdownSelectionAutoscrollConfig( + edgeZone: 40, + maxVelocity: 2000, + useMediaQueryPadding: false, + targetResolver: _nullResolver, + ), + child: _AnchorScrollHost( + viewportHeight: 200, + onState: (state) => host = state, + child: const _Doc('d'), + ), + ), + ), + )); + await tester.pumpAndSettle(); + + final viewport = tester.getRect(find.byType(ClipRect).first); + final gesture = await tester.startGesture( + viewport.topLeft + const Offset(10, 10), + kind: PointerDeviceKind.mouse, + ); + await tester.pump(const Duration(milliseconds: 20)); + await gesture.moveTo(Offset(viewport.center.dx, viewport.bottom - 5)); + for (var i = 0; i < 6; i++) { + await tester.pump(const Duration(milliseconds: 16)); + } + await gesture.up(); + await tester.pumpAndSettle(); + + expect(host.scrollCalls, 0); + expect(host.offset, 0); + // Selection still works — only the scroll is host-owned. + expect(selection.selection, isNotNull); + expect(tester.takeException(), isNull); + }); + + testWidgets('a host that reports it cannot scroll is not driven', + (tester) async { + final md = _longDoc(); + final selection = MarkdownSelectionController() + ..setDocuments([ + MarkdownDocumentRef(id: 'd', model: md), + ]); + addTearDown(selection.dispose); + + late _AnchorScrollHostState host; + + await tester.pumpWidget(MaterialApp( + home: Scaffold( + body: MarkdownSelectionScope( + controller: selection, + autoscroll: MarkdownSelectionAutoscrollConfig( + edgeZone: 40, + maxVelocity: 2000, + useMediaQueryPadding: false, + targetResolver: (_) => host.target, + ), + child: _AnchorScrollHost( + viewportHeight: 200, + maxOffset: 0, + onState: (state) => host = state, + child: const _Doc('d'), + ), + ), + ), + )); + await tester.pumpAndSettle(); + + final viewport = tester.getRect(find.byType(ClipRect).first); + final gesture = await tester.startGesture( + viewport.topLeft + const Offset(10, 10), + kind: PointerDeviceKind.mouse, + ); + await tester.pump(const Duration(milliseconds: 20)); + await gesture.moveTo(Offset(viewport.center.dx, viewport.bottom - 5)); + for (var i = 0; i < 6; i++) { + await tester.pump(const Duration(milliseconds: 16)); + } + await gesture.up(); + await tester.pumpAndSettle(); + + expect(host.scrollCalls, 0); + expect(host.offset, 0); + expect(tester.takeException(), isNull); + }); + + testWidgets('without a resolver the enclosing ListView is still driven', + (tester) async { + final md = _longDoc(); + final selection = MarkdownSelectionController() + ..setDocuments([ + MarkdownDocumentRef(id: 'd', model: md), + ]); + addTearDown(selection.dispose); + final scroll = ScrollController(); + addTearDown(scroll.dispose); + + await tester.pumpWidget(MaterialApp( + home: Scaffold( + body: MarkdownSelectionScope( + controller: selection, + autoscroll: const MarkdownSelectionAutoscrollConfig( + edgeZone: 40, + maxVelocity: 2000, + useMediaQueryPadding: false, + ), + child: Align( + alignment: Alignment.topLeft, + child: SizedBox( + width: 400, + height: 200, + child: ListView( + controller: scroll, + children: const [_Doc('d'), SizedBox(height: 1200)], + ), + ), + ), + ), + ), + )); + await tester.pumpAndSettle(); + + final viewport = tester.getRect(find.byType(Scrollable)); + final gesture = await tester.startGesture( + viewport.topLeft + const Offset(10, 10), + kind: PointerDeviceKind.mouse, + ); + await tester.pump(const Duration(milliseconds: 20)); + await gesture.moveTo(Offset(viewport.center.dx, viewport.bottom - 5)); + for (var i = 0; i < 6; i++) { + await tester.pump(const Duration(milliseconds: 16)); + } + await gesture.up(); + await tester.pumpAndSettle(); + + expect(scroll.offset, greaterThan(0)); + expect(tester.takeException(), isNull); + }); + }); +} + +MarkdownAutoscrollTarget? _nullResolver(MarkdownAutoscrollRequest request) => + null; diff --git a/test/selection/selection_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_chrome_test.dart b/test/selection/selection_chrome_test.dart new file mode 100644 index 0000000..80737c3 --- /dev/null +++ b/test/selection/selection_chrome_test.dart @@ -0,0 +1,491 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_md/flutter_md.dart'; +import 'package:flutter_test/flutter_test.dart'; + +class _Doc extends StatelessWidget { + const _Doc(this.id); + + final String id; + + @override + Widget build(BuildContext context) { + final controller = MarkdownSelectionScope.of(context); + final model = controller.documents.firstWhere((d) => d.id == id).model; + return MarkdownWidget(markdown: model, documentId: id); + } +} + +/// Runs [body] with [platform] forced (try/finally — the framework's +/// `debugAssertAllFoundationVarsUnset` check runs before user tear-downs). +Future _withPlatform( + TargetPlatform platform, + Future Function() body, +) async { + debugDefaultTargetPlatformOverride = platform; + try { + await body(); + } finally { + debugDefaultTargetPlatformOverride = null; + } +} + +Future _mouseDrag(WidgetTester tester, Offset from, Offset to) async { + final g = await tester.startGesture(from, kind: PointerDeviceKind.mouse); + await tester.pump(const Duration(milliseconds: 100)); + await g.moveTo(to); + await tester.pump(const Duration(milliseconds: 100)); + await g.up(); + await tester.pumpAndSettle(); +} + +List _messages(int count) => [ + for (var i = 0; i < count; i++) + MarkdownDocumentRef( + id: 'm$i', + model: Markdown.fromString('Message number $i body text here'), + order: i, + ), + ]; + +void main() { + group('handle proxies', () { + testWidgets('an unmounted base edge proxies onto visible selection', + (tester) async { + await _withPlatform(TargetPlatform.android, () async { + final docs = _messages(30); + final controller = MarkdownSelectionController()..setDocuments(docs); + addTearDown(controller.dispose); + final scroll = ScrollController(); + addTearDown(scroll.dispose); + + await tester.pumpWidget(MaterialApp( + home: Scaffold( + body: MarkdownSelectionScope( + controller: controller, + child: SizedBox( + height: 200, + child: ListView.builder( + controller: scroll, + itemCount: docs.length, + itemBuilder: (context, i) => _Doc('m$i'), + ), + ), + ), + ), + )); + await tester.pumpAndSettle(); + + // Base in m0, extent far down the list. + controller.selection = const MarkdownSelection( + base: MarkdownPosition(documentId: 'm0', blockIndex: 0, offset: 0), + extent: + MarkdownPosition(documentId: 'm12', blockIndex: 0, offset: 10), + ); + await tester.pumpAndSettle(); + + // Scroll the base out of the built range; the extent stays visible. + scroll.jumpTo(600); + await tester.pumpAndSettle(); + + expect( + controller.selectionEndpointGlobalRect(base: true), + isNull, + reason: 'the true base caret is unmounted', + ); + final endpoints = controller.selectionHandleEndpoints(); + expect(endpoints, isNotNull, + reason: 'handles must still be placeable via a proxy'); + expect(endpoints!.startGlobal.height, greaterThan(0)); + expect(endpoints.endGlobal.height, greaterThan(0)); + expect(tester.takeException(), isNull); + }); + }); + + testWidgets('both endpoints unmounted still keeps the range', + (tester) async { + final docs = _messages(40); + final controller = MarkdownSelectionController()..setDocuments(docs); + addTearDown(controller.dispose); + final scroll = ScrollController(); + addTearDown(scroll.dispose); + + await tester.pumpWidget(MaterialApp( + home: Scaffold( + body: MarkdownSelectionScope( + controller: controller, + child: SizedBox( + height: 200, + child: ListView.builder( + controller: scroll, + itemCount: docs.length, + itemBuilder: (context, i) => _Doc('m$i'), + ), + ), + ), + ), + )); + await tester.pumpAndSettle(); + + controller.selection = const MarkdownSelection( + base: MarkdownPosition(documentId: 'm0', blockIndex: 0, offset: 0), + extent: MarkdownPosition(documentId: 'm2', blockIndex: 0, offset: 5), + ); + await tester.pumpAndSettle(); + final text = controller.getText(); + expect(text, isNotEmpty); + + scroll.jumpTo(2000); + await tester.pumpAndSettle(); + + expect(controller.getText(), text); + expect(controller.selectionHandleEndpoints(), isNull, + reason: 'nothing visible paints the selection any more'); + expect(tester.takeException(), isNull); + }); + }); + + group('desktop context menu', () { + testWidgets('right-click does not mutate the selection', (tester) async { + await _withPlatform(TargetPlatform.macOS, () async { + final controller = MarkdownSelectionController() + ..setDocuments([ + MarkdownDocumentRef( + id: 'd', + model: Markdown.fromString('Hello selectable world'), + ), + ]); + addTearDown(controller.dispose); + + await tester.pumpWidget(MaterialApp( + theme: ThemeData(platform: TargetPlatform.macOS), + home: Scaffold( + body: MarkdownSelectionScope( + controller: controller, + child: const Align( + alignment: Alignment.topLeft, + child: SizedBox(width: 400, child: _Doc('d')), + ), + ), + ), + )); + await tester.pumpAndSettle(); + + final box = tester.getRect(find.byType(MarkdownWidget)); + await _mouseDrag( + tester, + box.topLeft + const Offset(2, 4), + box.topLeft + const Offset(40, 4), + ); + final before = controller.selection; + expect(before, isNotNull); + + final g = await tester.startGesture( + box.centerRight - const Offset(10, 0), + kind: PointerDeviceKind.mouse, + buttons: kSecondaryMouseButton, + ); + await g.up(); + await tester.pumpAndSettle(); + + expect(controller.selection, before, + reason: 'secondary click must not select a word or collapse'); + expect(find.text('Copy'), findsOneWidget); + }); + }); + + testWidgets('a desktop mouse drag does not pop the toolbar', + (tester) async { + await _withPlatform(TargetPlatform.linux, () async { + final controller = MarkdownSelectionController() + ..setDocuments([ + MarkdownDocumentRef( + id: 'd', + model: Markdown.fromString('Hello selectable world'), + ), + ]); + addTearDown(controller.dispose); + + late MarkdownSelectionScopeState state; + await tester.pumpWidget(MaterialApp( + theme: ThemeData(platform: TargetPlatform.linux), + home: Scaffold( + body: MarkdownSelectionScope( + controller: controller, + child: Align( + alignment: Alignment.topLeft, + child: SizedBox( + width: 400, + child: Builder(builder: (context) { + state = MarkdownSelectionScope.stateOf(context)!; + return const _Doc('d'); + }), + ), + ), + ), + ), + )); + await tester.pumpAndSettle(); + + final box = tester.getRect(find.byType(MarkdownWidget)); + await _mouseDrag( + tester, + box.topLeft + const Offset(2, 4), + box.centerRight - const Offset(2, 0), + ); + + expect(controller.getText(), isNotEmpty); + expect(state.toolbarIsVisible, isFalse); + expect(controller.toolbarWanted, isFalse); + }); + }); + + testWidgets('an ancestor scroll dismisses the desktop toolbar', + (tester) async { + await _withPlatform(TargetPlatform.linux, () async { + final docs = _messages(30); + final controller = MarkdownSelectionController()..setDocuments(docs); + addTearDown(controller.dispose); + final scroll = ScrollController(); + addTearDown(scroll.dispose); + + late MarkdownSelectionScopeState state; + await tester.pumpWidget(MaterialApp( + theme: ThemeData(platform: TargetPlatform.linux), + home: Scaffold( + body: MarkdownSelectionScope( + controller: controller, + child: SizedBox( + height: 200, + child: Builder(builder: (context) { + state = MarkdownSelectionScope.stateOf(context)!; + return ListView.builder( + controller: scroll, + itemCount: docs.length, + itemBuilder: (context, i) => _Doc('m$i'), + ); + }), + ), + ), + ), + )); + await tester.pumpAndSettle(); + + final first = tester.getRect(find.byType(MarkdownWidget).first); + await _mouseDrag( + tester, + first.topLeft + const Offset(2, 4), + first.topLeft + const Offset(60, 4), + ); + state.showToolbar(first.center); + await tester.pumpAndSettle(); + expect(state.toolbarIsVisible, isTrue); + + scroll.jumpTo(120); + await tester.pumpAndSettle(); + + expect(state.toolbarIsVisible, isFalse); + }); + }); + }); + + group('enabled flag', () { + testWidgets('disabling hides chrome and enabling restores it', + (tester) async { + await _withPlatform(TargetPlatform.android, () async { + final controller = MarkdownSelectionController() + ..setDocuments([ + MarkdownDocumentRef( + id: 'd', + model: Markdown.fromString('Hello selectable world'), + ), + ]); + addTearDown(controller.dispose); + + var enabled = true; + late StateSetter setStateFn; + late MarkdownSelectionScopeState state; + + await tester.pumpWidget(MaterialApp( + theme: ThemeData(platform: TargetPlatform.android), + home: Scaffold( + body: StatefulBuilder(builder: (context, setState) { + setStateFn = setState; + return MarkdownSelectionScope( + controller: controller, + enabled: enabled, + child: Align( + alignment: Alignment.topLeft, + child: SizedBox( + width: 400, + child: Builder(builder: (context) { + state = MarkdownSelectionScope.stateOf(context)!; + return const _Doc('d'); + }), + ), + ), + ); + }), + ), + )); + await tester.pumpAndSettle(); + + controller.selectAll(); + await tester.pumpAndSettle(); + expect(controller.toolbarWanted, isTrue); + expect(state.selectionHandleLeadersAttached, isTrue); + + setStateFn(() => enabled = false); + await tester.pumpAndSettle(); + expect(state.toolbarIsVisible, isFalse); + expect(state.selectionHandleLeadersAttached, isFalse); + expect(controller.toolbarWanted, isTrue, + reason: 'the restore intent survives a disable'); + + setStateFn(() => enabled = true); + await tester.pumpAndSettle(); + expect(state.selectionHandleLeadersAttached, isTrue); + }); + }); + + testWidgets('a disabled scope does not select on drag', (tester) async { + final controller = MarkdownSelectionController() + ..setDocuments([ + MarkdownDocumentRef( + id: 'd', + model: Markdown.fromString('Hello selectable world'), + ), + ]); + addTearDown(controller.dispose); + + await tester.pumpWidget(MaterialApp( + home: Scaffold( + body: MarkdownSelectionScope( + controller: controller, + enabled: false, + child: const Align( + alignment: Alignment.topLeft, + child: SizedBox(width: 400, child: _Doc('d')), + ), + ), + ), + )); + await tester.pumpAndSettle(); + + final box = tester.getRect(find.byType(MarkdownWidget)); + await _mouseDrag( + tester, + box.topLeft + const Offset(2, 4), + box.centerRight - const Offset(2, 0), + ); + + expect(controller.selection, isNull); + }); + }); + + group('autoscroll through virtualization', () { + testWidgets('dragging to the edge builds and selects new messages', + (tester) async { + final docs = _messages(60); + final controller = MarkdownSelectionController()..setDocuments(docs); + addTearDown(controller.dispose); + final scroll = ScrollController(); + addTearDown(scroll.dispose); + + await tester.pumpWidget(MaterialApp( + home: Scaffold( + body: MarkdownSelectionScope( + controller: controller, + autoscroll: const MarkdownSelectionAutoscrollConfig( + edgeZone: 40, + maxVelocity: 3000, + useMediaQueryPadding: false, + ), + child: SizedBox( + height: 200, + child: ListView.builder( + controller: scroll, + itemCount: docs.length, + itemBuilder: (context, i) => _Doc('m$i'), + ), + ), + ), + ), + )); + await tester.pumpAndSettle(); + + final viewport = tester.getRect(find.byType(Scrollable)); + final gesture = await tester.startGesture( + viewport.topLeft + const Offset(4, 6), + kind: PointerDeviceKind.mouse, + ); + await tester.pump(const Duration(milliseconds: 20)); + await gesture.moveTo(Offset(viewport.center.dx, viewport.bottom - 4)); + // Hold at the edge across many frames: items attach and detach + // underneath the pointer while the selection keeps extending. + for (var i = 0; i < 40; i++) { + await tester.pump(const Duration(milliseconds: 16)); + } + await gesture.up(); + await tester.pumpAndSettle(); + + expect(scroll.offset, greaterThan(0)); + expect(controller.selection, isNotNull); + expect(controller.selection!.base.documentId, 'm0'); + expect(controller.selection!.extent.documentId, isNot('m0'), + reason: 'the drag must have extended across messages'); + expect(tester.takeException(), isNull); + }); + + testWidgets('the ticker stops when the drag ends', (tester) async { + final docs = _messages(40); + final controller = MarkdownSelectionController()..setDocuments(docs); + addTearDown(controller.dispose); + final scroll = ScrollController(); + addTearDown(scroll.dispose); + + await tester.pumpWidget(MaterialApp( + home: Scaffold( + body: MarkdownSelectionScope( + controller: controller, + autoscroll: const MarkdownSelectionAutoscrollConfig( + edgeZone: 40, + maxVelocity: 3000, + useMediaQueryPadding: false, + ), + child: SizedBox( + height: 200, + child: ListView.builder( + controller: scroll, + itemCount: docs.length, + itemBuilder: (context, i) => _Doc('m$i'), + ), + ), + ), + ), + )); + await tester.pumpAndSettle(); + + final viewport = tester.getRect(find.byType(Scrollable)); + final gesture = await tester.startGesture( + viewport.topLeft + const Offset(4, 6), + kind: PointerDeviceKind.mouse, + ); + await tester.pump(const Duration(milliseconds: 20)); + await gesture.moveTo(Offset(viewport.center.dx, viewport.bottom - 4)); + for (var i = 0; i < 10; i++) { + await tester.pump(const Duration(milliseconds: 16)); + } + await gesture.up(); + await tester.pumpAndSettle(); + + final settled = scroll.offset; + for (var i = 0; i < 10; i++) { + await tester.pump(const Duration(milliseconds: 16)); + } + expect(scroll.offset, settled, + reason: 'the frame ticker must not outlive the drag'); + }); + }); +} diff --git a/test/selection/selection_handle_endpoints_test.dart b/test/selection/selection_handle_endpoints_test.dart new file mode 100644 index 0000000..fdf8a11 --- /dev/null +++ b/test/selection/selection_handle_endpoints_test.dart @@ -0,0 +1,216 @@ +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 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 = 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)); + 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..a94ee02 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,107 @@ 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 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, + box.localToGlobal(Offset(left + 2, lineY)), + box.localToGlobal(Offset(right - 2, lineY)), + ); + 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(box.size.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 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, + 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); + 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 +409,179 @@ 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; + } + }, + ); + + 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_host_gates_test.dart b/test/selection/selection_host_gates_test.dart new file mode 100644 index 0000000..00b1a34 --- /dev/null +++ b/test/selection/selection_host_gates_test.dart @@ -0,0 +1,607 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_md/flutter_md.dart'; +import 'package:flutter_test/flutter_test.dart'; + +class _Doc extends StatelessWidget { + const _Doc(this.id); + + final String id; + + @override + Widget build(BuildContext context) { + final controller = MarkdownSelectionScope.of(context); + final model = controller.documents.firstWhere((d) => d.id == id).model; + return MarkdownWidget(markdown: model, documentId: id); + } +} + +MarkdownSelectionController _controllerWith(Map docs) => + MarkdownSelectionController() + ..setDocuments([ + for (final entry in docs.entries) + MarkdownDocumentRef( + id: entry.key, + model: Markdown.fromString(entry.value), + ), + ]); + +Future _mouseDrag(WidgetTester tester, Offset from, Offset to) async { + final g = await tester.startGesture(from, kind: PointerDeviceKind.mouse); + await tester.pump(const Duration(milliseconds: 100)); + await g.moveTo(to); + await tester.pump(const Duration(milliseconds: 100)); + await g.up(); + await tester.pumpAndSettle(); +} + +Future _longPressDrag( + WidgetTester tester, + Offset from, + Offset to, +) async { + final g = await tester.startGesture(from); + await tester.pump(const Duration(milliseconds: 700)); + await g.moveTo(to); + await tester.pump(const Duration(milliseconds: 100)); + await g.up(); + await tester.pumpAndSettle(); +} + +/// Runs [body] with [platform] forced. +/// +/// NOTE: reset via try/finally rather than `addTearDown` — the framework's +/// `debugAssertAllFoundationVarsUnset` check runs before user tear-downs, so a +/// tear-down reset arrives too late and fails the test. +Future _withPlatform( + TargetPlatform platform, + Future Function() body, +) async { + debugDefaultTargetPlatformOverride = platform; + try { + await body(); + } finally { + debugDefaultTargetPlatformOverride = null; + } +} + +Widget _scope({ + required MarkdownSelectionController controller, + required Widget child, + bool Function(Offset globalPosition)? canStartSelectionAt, + bool enableTouchGestures = true, + bool enableTouchConsecutiveTaps = true, + FocusNode? focusNode, +}) => + MaterialApp( + home: Scaffold( + body: MarkdownSelectionScope( + controller: controller, + canStartSelectionAt: canStartSelectionAt, + enableTouchGestures: enableTouchGestures, + enableTouchConsecutiveTaps: enableTouchConsecutiveTaps, + focusNode: focusNode, + child: Align( + alignment: Alignment.topLeft, + child: SizedBox(width: 400, child: child), + ), + ), + ), + ); + +void main() { + group('canStartSelectionAt', () { + testWidgets('a refused mouse drag never starts a selection', + (tester) async { + final controller = _controllerWith({'d': 'Hello selectable world'}); + addTearDown(controller.dispose); + + await tester.pumpWidget(_scope( + controller: controller, + canStartSelectionAt: (_) => false, + child: const _Doc('d'), + )); + await tester.pumpAndSettle(); + + final box = tester.getRect(find.byType(MarkdownWidget)); + await _mouseDrag( + tester, + box.topLeft + const Offset(2, 4), + box.centerRight - const Offset(2, 0), + ); + + expect(controller.selection, isNull); + expect(controller.getText(), isEmpty); + }); + + testWidgets('a refused long press never starts a selection', + (tester) async { + await _withPlatform(TargetPlatform.android, () async { + final controller = _controllerWith({'d': 'Hello selectable world'}); + addTearDown(controller.dispose); + + await tester.pumpWidget(_scope( + controller: controller, + canStartSelectionAt: (_) => false, + child: const _Doc('d'), + )); + await tester.pumpAndSettle(); + + final box = tester.getRect(find.byType(MarkdownWidget)); + await _longPressDrag( + tester, + box.topLeft + const Offset(2, 4), + box.centerRight - const Offset(2, 0), + ); + + expect(controller.selection, isNull); + expect(controller.getText(), isEmpty); + }); + }); + + testWidgets( + 'a long press on refused chrome does not re-anchor an active range', + (tester) async { + await _withPlatform(TargetPlatform.android, () async { + final controller = _controllerWith({'d': 'Hello selectable world'}); + addTearDown(controller.dispose); + + // Refuse the right half of the widget (pretend it is a link). + var split = double.infinity; + await tester.pumpWidget(_scope( + controller: controller, + canStartSelectionAt: (global) => global.dx < split, + child: const _Doc('d'), + )); + await tester.pumpAndSettle(); + final box = tester.getRect(find.byType(MarkdownWidget)); + split = box.center.dx; + + // Arm a real selection on the allowed half first. + await _longPressDrag( + tester, + box.topLeft + const Offset(2, 4), + box.topLeft + const Offset(40, 4), + ); + final armed = controller.selection; + expect(armed, isNotNull); + expect(armed!.isCollapsed, isFalse); + + // Long-pressing refused chrome must not re-anchor the range there. + await _longPressDrag( + tester, + box.centerRight - const Offset(6, 0), + box.centerRight - const Offset(6, 0), + ); + + expect( + controller.selection, + anyOf(isNull, armed), + reason: 'refused chrome may dismiss, but must not select on it', + ); + }); + }); + + testWidgets('a refused mouse drag does not extend from the refused point', + (tester) async { + final controller = _controllerWith({'d': 'Hello selectable world'}); + addTearDown(controller.dispose); + + var split = double.infinity; + await tester.pumpWidget(_scope( + controller: controller, + canStartSelectionAt: (global) => global.dx > split, + child: const _Doc('d'), + )); + await tester.pumpAndSettle(); + final box = tester.getRect(find.byType(MarkdownWidget)); + split = box.center.dx; + + // Start inside the refused left half and drag right into allowed space. + await _mouseDrag( + tester, + box.topLeft + const Offset(2, 4), + box.centerRight - const Offset(2, 0), + ); + + expect(controller.selection, isNull); + }); + + testWidgets('an allowed point still selects normally', (tester) async { + final controller = _controllerWith({'d': 'Hello selectable world'}); + addTearDown(controller.dispose); + + await tester.pumpWidget(_scope( + controller: controller, + canStartSelectionAt: (_) => true, + child: const _Doc('d'), + )); + await tester.pumpAndSettle(); + + final box = tester.getRect(find.byType(MarkdownWidget)); + await _mouseDrag( + tester, + box.topLeft + const Offset(2, 4), + box.centerRight - const Offset(2, 0), + ); + + expect(controller.getText(), 'Hello selectable world'); + }); + }); + + group('enableTouchGestures', () { + testWidgets('false leaves touch long-press to the host', (tester) async { + await _withPlatform(TargetPlatform.android, () async { + final controller = _controllerWith({'d': 'Hello selectable world'}); + addTearDown(controller.dispose); + + await tester.pumpWidget(_scope( + controller: controller, + enableTouchGestures: false, + child: const _Doc('d'), + )); + await tester.pumpAndSettle(); + + final box = tester.getRect(find.byType(MarkdownWidget)); + await _longPressDrag( + tester, + box.topLeft + const Offset(2, 4), + box.centerRight - const Offset(2, 0), + ); + + expect(controller.selection, isNull); + }); + }); + + testWidgets('false keeps mouse drag selection alive', (tester) async { + await _withPlatform(TargetPlatform.android, () async { + final controller = _controllerWith({'d': 'Hello selectable world'}); + addTearDown(controller.dispose); + + await tester.pumpWidget(_scope( + controller: controller, + enableTouchGestures: false, + child: const _Doc('d'), + )); + await tester.pumpAndSettle(); + + final box = tester.getRect(find.byType(MarkdownWidget)); + await _mouseDrag( + tester, + box.topLeft + const Offset(2, 4), + box.centerRight - const Offset(2, 0), + ); + + expect(controller.getText(), 'Hello selectable world'); + }); + }); + + testWidgets('false keeps a programmatic selection through focus loss', + (tester) async { + final controller = _controllerWith({'d': 'Hello selectable world'}); + addTearDown(controller.dispose); + final focus = FocusNode(); + addTearDown(focus.dispose); + final other = FocusNode(); + addTearDown(other.dispose); + + await tester.pumpWidget(MaterialApp( + home: Scaffold( + body: Column( + children: [ + MarkdownSelectionScope( + controller: controller, + focusNode: focus, + enableTouchGestures: false, + child: const SizedBox(width: 400, child: _Doc('d')), + ), + Focus(focusNode: other, child: const SizedBox(height: 10)), + ], + ), + ), + )); + await tester.pumpAndSettle(); + + focus.requestFocus(); + await tester.pumpAndSettle(); + controller.selectAll(); + await tester.pumpAndSettle(); + expect(controller.selection, isNotNull); + + other.requestFocus(); + await tester.pumpAndSettle(); + + expect(controller.selection, isNotNull, + reason: 'host-owned touch entry must not clear on focus loss'); + }); + }); + + group('enableTouchConsecutiveTaps', () { + testWidgets('false keeps the long-press word selection working', + (tester) async { + await _withPlatform(TargetPlatform.android, () async { + final controller = _controllerWith({'d': 'Hello selectable world'}); + addTearDown(controller.dispose); + + await tester.pumpWidget(_scope( + controller: controller, + enableTouchConsecutiveTaps: false, + child: const _Doc('d'), + )); + await tester.pumpAndSettle(); + + final box = tester.getRect(find.byType(MarkdownWidget)); + await _longPressDrag( + tester, + box.topLeft + const Offset(2, 4), + box.topLeft + const Offset(2, 4), + ); + + expect(controller.getText(), 'Hello'); + }); + }); + + testWidgets('false leaves a plain touch tap to the host', (tester) async { + await _withPlatform(TargetPlatform.android, () async { + final controller = _controllerWith({'d': 'Hello selectable world'}); + addTearDown(controller.dispose); + + await tester.pumpWidget(_scope( + controller: controller, + enableTouchConsecutiveTaps: false, + child: const _Doc('d'), + )); + await tester.pumpAndSettle(); + + final box = tester.getRect(find.byType(MarkdownWidget)); + await _longPressDrag( + tester, + box.topLeft + const Offset(2, 4), + box.topLeft + const Offset(2, 4), + ); + expect(controller.getText(), 'Hello'); + + // A bare tap is host territory now — the range survives it. + await tester.tapAt(box.center); + await tester.pumpAndSettle(); + expect(controller.getText(), 'Hello'); + }); + }); + + testWidgets('true dismisses an active range on a plain touch tap', + (tester) async { + await _withPlatform(TargetPlatform.android, () async { + final controller = _controllerWith({'d': 'Hello selectable world'}); + addTearDown(controller.dispose); + + await tester.pumpWidget(_scope( + controller: controller, + child: const _Doc('d'), + )); + await tester.pumpAndSettle(); + + final box = tester.getRect(find.byType(MarkdownWidget)); + await _longPressDrag( + tester, + box.topLeft + const Offset(2, 4), + box.topLeft + const Offset(2, 4), + ); + expect(controller.getText(), 'Hello'); + + await tester.tapAt(box.center); + await tester.pumpAndSettle(); + expect(controller.selection, isNull); + }); + }); + }); + + group('ownsSelectionChrome', () { + testWidgets('a non-owning sibling does not strip the owner handles', + (tester) async { + await _withPlatform(TargetPlatform.android, () async { + final controller = _controllerWith({ + 'a': 'First message body text', + 'b': 'Second message body text', + }); + addTearDown(controller.dispose); + + late MarkdownSelectionScopeState ownerState; + + await tester.pumpWidget(MaterialApp( + home: Scaffold( + body: Align( + alignment: Alignment.topLeft, + child: SizedBox( + width: 400, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + MarkdownSelectionScope( + controller: controller, + ownsSelectionChrome: (id) => id == 'a', + child: Builder(builder: (context) { + ownerState = MarkdownSelectionScope.stateOf(context)!; + return const _Doc('a'); + }), + ), + MarkdownSelectionScope( + controller: controller, + ownsSelectionChrome: (id) => id == 'b', + child: const _Doc('b'), + ), + ], + ), + ), + ), + ), + )); + await tester.pumpAndSettle(); + + final first = tester.getRect(find.byType(MarkdownWidget).first); + await _longPressDrag( + tester, + first.topLeft + const Offset(2, 4), + first.topLeft + const Offset(60, 4), + ); + + expect(controller.selection, isNotNull); + expect(controller.selection!.isCollapsed, isFalse); + expect(controller.selection!.base.documentId, 'a'); + expect(ownerState.selectionHandleLeadersAttached, isTrue); + + // Force every scope to re-sync (what a scroll / rebuild does). + controller.notifyListeners(); + await tester.pumpAndSettle(); + + expect(ownerState.selectionHandleLeadersAttached, isTrue, + reason: 'the non-owning sibling must not clear foreign leaders'); + }); + }); + + testWidgets('a disabled sibling does not strip the owner handles', + (tester) async { + await _withPlatform(TargetPlatform.android, () async { + final controller = _controllerWith({ + 'a': 'First message body text', + 'b': 'Second message body text', + }); + addTearDown(controller.dispose); + + late MarkdownSelectionScopeState ownerState; + + await tester.pumpWidget(MaterialApp( + home: Scaffold( + body: Align( + alignment: Alignment.topLeft, + child: SizedBox( + width: 400, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + MarkdownSelectionScope( + controller: controller, + child: Builder(builder: (context) { + ownerState = MarkdownSelectionScope.stateOf(context)!; + return const _Doc('a'); + }), + ), + MarkdownSelectionScope( + controller: controller, + enabled: false, + child: const _Doc('b'), + ), + ], + ), + ), + ), + ), + )); + await tester.pumpAndSettle(); + + final first = tester.getRect(find.byType(MarkdownWidget).first); + await _longPressDrag( + tester, + first.topLeft + const Offset(2, 4), + first.topLeft + const Offset(60, 4), + ); + + expect(ownerState.selectionHandleLeadersAttached, isTrue); + controller.notifyListeners(); + await tester.pumpAndSettle(); + expect(ownerState.selectionHandleLeadersAttached, isTrue); + }); + }); + }); + + group('glyph-tight hit testing', () { + testWidgets('empty gutter beside a short line is not selectable ink', + (tester) async { + final controller = _controllerWith({'d': 'Hi'}); + addTearDown(controller.dispose); + + await tester.pumpWidget(_scope( + controller: controller, + child: const _Doc('d'), + )); + await tester.pumpAndSettle(); + + final box = tester.getRect(find.byType(MarkdownWidget)); + final onInk = box.topLeft + const Offset(3, 6); + final onGutter = Offset(box.right - 4, box.top + 6); + + expect(controller.hitsSelectableGlyphs(onInk), isTrue); + expect(controller.hitsSelectableGlyphs(onGutter), isFalse); + // Gesture starts stay bubble-wide (tdesktop parity). + expect(controller.hitsSelectableContent(onGutter), isTrue); + }); + + testWidgets('empty chrome outside every surface does not clamp in', + (tester) async { + final controller = _controllerWith({'d': 'Hello selectable world'}); + addTearDown(controller.dispose); + + await tester.pumpWidget(_scope( + controller: controller, + child: const _Doc('d'), + )); + await tester.pumpAndSettle(); + + final box = tester.getRect(find.byType(MarkdownWidget)); + final below = Offset(box.center.dx, box.bottom + 120); + + expect(controller.hitsSelectableContent(below), isFalse); + expect( + controller.positionForGlobal(below, requireContainment: true), + isNull, + ); + // Extending across a gap still clamps onto the nearest surface. + expect(controller.positionForGlobal(below), isNotNull); + + controller.startAtGlobal(below); + expect(controller.selection, isNull); + }); + + testWidgets('a mouse click that misses clears an active range', + (tester) async { + final controller = _controllerWith({'d': 'Hello selectable world'}); + addTearDown(controller.dispose); + + await tester.pumpWidget(MaterialApp( + home: Scaffold( + body: MarkdownSelectionScope( + controller: controller, + child: const Align( + alignment: Alignment.topLeft, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox(width: 400, child: _Doc('d')), + SizedBox(width: 400, height: 200), + ], + ), + ), + ), + ), + )); + await tester.pumpAndSettle(); + + final box = tester.getRect(find.byType(MarkdownWidget)); + await _mouseDrag( + tester, + box.topLeft + const Offset(2, 4), + box.centerRight - const Offset(2, 0), + ); + expect(controller.selection, isNotNull); + + final g = await tester.startGesture( + Offset(box.center.dx, box.bottom + 100), + kind: PointerDeviceKind.mouse, + ); + await g.up(); + await tester.pumpAndSettle(); + + expect(controller.selection, isNull); + }); + }); +} diff --git a/test/selection/selection_nested_blocks_test.dart b/test/selection/selection_nested_blocks_test.dart new file mode 100644 index 0000000..abfbf99 --- /dev/null +++ b/test/selection/selection_nested_blocks_test.dart @@ -0,0 +1,236 @@ +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_md/flutter_md.dart'; +import 'package:flutter_test/flutter_test.dart'; + +class _Doc extends StatelessWidget { + const _Doc(this.id); + + final String id; + + @override + Widget build(BuildContext context) { + final controller = MarkdownSelectionScope.of(context); + final model = controller.documents.firstWhere((d) => d.id == id).model; + return MarkdownWidget(markdown: model, documentId: id); + } +} + +Future _pump( + WidgetTester tester, + String source, { + double width = 400, +}) async { + final controller = MarkdownSelectionController() + ..setDocuments([ + MarkdownDocumentRef(id: 'd', model: Markdown.fromString(source)), + ]); + addTearDown(controller.dispose); + + await tester.pumpWidget(MaterialApp( + home: Scaffold( + body: MarkdownSelectionScope( + controller: controller, + child: Align( + alignment: Alignment.topLeft, + child: SizedBox(width: width, child: const _Doc('d')), + ), + ), + ), + )); + await tester.pumpAndSettle(); + return controller; +} + +Future _mouseDrag(WidgetTester tester, Offset from, Offset to) async { + final g = await tester.startGesture(from, kind: PointerDeviceKind.mouse); + await tester.pump(const Duration(milliseconds: 100)); + await g.moveTo(to); + await tester.pump(const Duration(milliseconds: 100)); + await g.up(); + await tester.pumpAndSettle(); +} + +const _quoteWithFence = ''' +> Intro line before the fence +> ```dart +> void main() {} +> ``` +> Outro line after the fence +'''; + +const _alertWithFence = ''' +> [!NOTE] +> Take care here +> ```sh +> echo hi +> ``` +'''; + +void main() { + group('quote / alert with nested fenced code', () { + test('a quote body with a fence parses into nested blocks', () { + final md = Markdown.fromString(_quoteWithFence); + expect(md.blocks, hasLength(1)); + final quote = md.blocks.single as MD$Quote; + expect(quote.spans, isEmpty); + expect(quote.blocks.whereType(), hasLength(1)); + expect( + quote.blocks.whereType().single.language, + 'dart', + ); + }); + + test('an alert body with a fence parses into nested blocks', () { + final md = Markdown.fromString(_alertWithFence); + final alert = md.blocks.single as MD$Alert; + expect(alert.alert, MD$AlertType.note); + expect(alert.spans, isEmpty); + expect(alert.blocks.whereType(), hasLength(1)); + }); + + test('a plain quote keeps the leaf inline shape', () { + final md = Markdown.fromString('> just prose\n> more prose'); + final quote = md.blocks.single as MD$Quote; + expect(quote.blocks, isEmpty); + expect(quote.spans, isNotEmpty); + }); + + test('markdownBlockRenderedText walks nested children', () { + final quote = Markdown.fromString(_quoteWithFence).blocks.single; + final text = markdownBlockRenderedText(quote); + expect(text, contains('Intro line before the fence')); + expect(text, contains('void main() {}')); + expect(text, contains('Outro line after the fence')); + }); + + testWidgets('the painter fragments agree with markdownBlockRenderedText', + (tester) async { + // The whole selection layer indexes a block by + // `markdownBlockRenderedText`; the painter must expose exactly that + // string, or highlight geometry and copied text drift apart. + for (final source in [_quoteWithFence, _alertWithFence]) { + final controller = await _pump(tester, source); + final block = controller.documents.single.model.blocks.single; + final expected = markdownBlockRenderedText(block); + + // Selecting every character must reproduce the block text verbatim. + controller.selection = MarkdownSelection( + base: const MarkdownPosition( + documentId: 'd', + blockIndex: 0, + offset: 0, + ), + extent: MarkdownPosition( + documentId: 'd', + blockIndex: 0, + offset: expected.length, + ), + ); + await tester.pumpAndSettle(); + expect(controller.getText(), expected); + } + }); + + testWidgets('selecting the whole nested quote yields every child line', + (tester) async { + final controller = await _pump(tester, _quoteWithFence); + + final box = tester.getRect(find.byType(MarkdownWidget)); + await _mouseDrag( + tester, + box.topLeft + const Offset(2, 3), + box.bottomRight - const Offset(2, 3), + ); + + final text = controller.getText(); + expect(text, contains('Intro line before the fence')); + expect(text, contains('void main() {}')); + expect(text, contains('Outro line after the fence')); + }); + + testWidgets('a highlight range inside the nested fence has geometry', + (tester) async { + final controller = await _pump(tester, _quoteWithFence); + final block = controller.documents.single.model.blocks.single; + final text = markdownBlockRenderedText(block); + final start = text.indexOf('void main'); + expect(start, greaterThan(-1)); + + final surface = controller.mountedSurfaces.single; + final boxes = surface.localBoxesForRange(0, start, start + 9); + expect(boxes, isNotEmpty); + for (final box in boxes) { + expect(box.width, greaterThan(0)); + expect(box.height, greaterThan(0)); + } + }); + + testWidgets('a nested quote paints without exceptions', (tester) async { + await _pump(tester, '> outer\n> > inner\n> ```\n> code\n> ```'); + expect(tester.takeException(), isNull); + }); + + testWidgets('an unterminated fence inside a quote does not hang', + (tester) async { + await _pump(tester, '> before\n> ```\n> never closed'); + expect(tester.takeException(), isNull); + }); + + test('a quote whose body is only a fence still renders text', () { + final md = Markdown.fromString('> ```\n> x\n> ```'); + final quote = md.blocks.single as MD$Quote; + expect(markdownBlockRenderedText(quote), contains('x')); + }); + + testWidgets('a custom theme builder also builds nested children', + (tester) async { + final seen = []; + final theme = MarkdownThemeData( + textStyle: const TextStyle(fontSize: 10, height: 2), + builder: (block, theme) { + seen.add(block.type); + return null; // fall back to the default painter + }, + ); + + await tester.pumpWidget(MaterialApp( + home: Scaffold( + body: Align( + alignment: Alignment.topLeft, + child: SizedBox( + width: 400, + child: MarkdownWidget( + markdown: Markdown.fromString(_quoteWithFence), + theme: theme, + ), + ), + ), + ), + )); + await tester.pumpAndSettle(); + + expect(seen, contains('quote')); + expect( + seen, + contains('code'), + reason: 'a host that swaps the code painter expects it inside > too', + ); + expect(tester.takeException(), isNull); + }); + + test('the markup formatter round-trips a nested fence', () { + final md = Markdown.fromString(_quoteWithFence); + final controller = MarkdownSelectionController() + ..formatter = const MarkdownMarkupFormatter() + ..setDocuments( + [MarkdownDocumentRef(id: 'd', model: md)]); + addTearDown(controller.dispose); + controller.selectAll(); + + final out = controller.getText(); + expect(out, startsWith('> ')); + expect(out, contains('void main() {}')); + }); + }); +} diff --git a/test/selection/selection_registry_test.dart b/test/selection/selection_registry_test.dart new file mode 100644 index 0000000..6cd9ae2 --- /dev/null +++ b/test/selection/selection_registry_test.dart @@ -0,0 +1,569 @@ +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_md/flutter_md.dart'; +import 'package:flutter_test/flutter_test.dart'; + +class _Doc extends StatelessWidget { + const _Doc(this.id, {this.markdown, super.key}); + + final String id; + final Markdown? markdown; + + @override + Widget build(BuildContext context) { + final controller = MarkdownSelectionScope.of(context); + final model = + markdown ?? controller.documents.firstWhere((d) => d.id == id).model; + return MarkdownWidget(markdown: model, documentId: id); + } +} + +Future _mouseDrag(WidgetTester tester, Offset from, Offset to) async { + final g = await tester.startGesture(from, kind: PointerDeviceKind.mouse); + await tester.pump(const Duration(milliseconds: 100)); + await g.moveTo(to); + await tester.pump(const Duration(milliseconds: 100)); + await g.up(); + await tester.pumpAndSettle(); +} + +void main() { + group('registry ordering', () { + test('sparse explicit orders keep their reading order', () { + final controller = MarkdownSelectionController() + ..setDocuments([ + MarkdownDocumentRef( + id: 'a', + model: Markdown.fromString('Alpha'), + order: 10, + ), + MarkdownDocumentRef( + id: 'b', + model: Markdown.fromString('Bravo'), + order: 20, + ), + ]); + addTearDown(controller.dispose); + + expect(controller.documents.map((d) => d.id), ['a', 'b']); + }); + + test('an order-less putDocument appends after sparse explicit orders', () { + final controller = MarkdownSelectionController() + ..setDocuments([ + MarkdownDocumentRef( + id: 'a', + model: Markdown.fromString('Alpha'), + order: 10, + ), + MarkdownDocumentRef( + id: 'b', + model: Markdown.fromString('Bravo'), + order: 20, + ), + ]) + // The heal-register path on RenderObject.attach passes no order. + ..putDocument('c', Markdown.fromString('Charlie')); + addTearDown(controller.dispose); + + expect( + controller.documents.map((d) => d.id), + ['a', 'b', 'c'], + reason: 'a document with no order must not jump ahead of ordered ones', + ); + }); + + test('colliding orders keep a stable, registration-based reading order', + () { + final controller = MarkdownSelectionController() + ..setDocuments([ + MarkdownDocumentRef( + id: 'a', + model: Markdown.fromString('Alpha'), + order: 1, + ), + MarkdownDocumentRef( + id: 'b', + model: Markdown.fromString('Bravo'), + order: 1, + ), + MarkdownDocumentRef( + id: 'c', + model: Markdown.fromString('Charlie'), + order: 1, + ), + ]); + addTearDown(controller.dispose); + + final first = controller.documents.map((d) => d.id).toList(); + // Any re-sort (a model update, a new registration) must not reshuffle + // documents that share an order. + controller + ..putDocument('b', Markdown.fromString('Bravo two'), order: 1) + ..putDocument('d', Markdown.fromString('Delta'), order: 1); + final second = controller.documents + .map((d) => d.id) + .where((id) => id != 'd') + .toList(); + + expect(second, first); + }); + + test('rangeFor returns null for an unregistered document', () { + final controller = MarkdownSelectionController() + ..setDocuments([ + MarkdownDocumentRef(id: 'a', model: Markdown.fromString('Alpha')), + ]); + addTearDown(controller.dispose); + + controller.selection = const MarkdownSelection( + base: MarkdownPosition(documentId: 'a', blockIndex: 0, offset: 0), + extent: MarkdownPosition(documentId: 'a', blockIndex: 0, offset: 5), + ); + + expect(controller.rangeFor('a', 0), isNotNull); + expect(controller.rangeFor('ghost', 0), isNull); + }); + + test('an unregistered endpoint does not paint every registered body', () { + final controller = MarkdownSelectionController() + ..setDocuments([ + MarkdownDocumentRef(id: 'a', model: Markdown.fromString('Alpha')), + MarkdownDocumentRef(id: 'b', model: Markdown.fromString('Bravo')), + MarkdownDocumentRef(id: 'c', model: Markdown.fromString('Charlie')), + ]); + addTearDown(controller.dispose); + + controller.selection = const MarkdownSelection( + base: MarkdownPosition(documentId: 'ghost', blockIndex: 0, offset: 0), + extent: MarkdownPosition(documentId: 'c', blockIndex: 0, offset: 3), + ); + + expect(controller.rangeFor('a', 0), isNull); + expect(controller.rangeFor('b', 0), isNull); + expect(controller.rangeFor('c', 0), isNull); + }); + }); + + group('surface lifecycle', () { + testWidgets('two surfaces on one id keep the first hittable', + (tester) async { + final md = Markdown.fromString('Shared document body'); + final controller = MarkdownSelectionController() + ..setDocuments( + [MarkdownDocumentRef(id: 'd', model: md)]); + addTearDown(controller.dispose); + + var showSecond = true; + late StateSetter setStateFn; + + await tester.pumpWidget(MaterialApp( + home: Scaffold( + body: MarkdownSelectionScope( + controller: controller, + child: StatefulBuilder(builder: (context, setState) { + setStateFn = setState; + return Align( + alignment: Alignment.topLeft, + child: SizedBox( + width: 400, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const _Doc('d', key: ValueKey('first')), + if (showSecond) const _Doc('d', key: ValueKey('second')), + ], + ), + ), + ); + }), + ), + ), + )); + await tester.pumpAndSettle(); + + expect(find.byType(MarkdownWidget), findsNWidgets(2)); + expect(controller.mountedSurfaces.length, 1, + reason: 'one primary surface per document id'); + + final firstRect = tester.getRect(find.byKey(const ValueKey('first'))); + final hit = firstRect.topLeft + const Offset(2, 4); + expect(controller.hitsSelectableContent(hit), isTrue); + + // Dropping the later (overlapping) mount must not unregister the first. + setStateFn(() => showSecond = false); + await tester.pumpAndSettle(); + + expect(controller.mountedSurfaces.length, 1); + expect(controller.hitsSelectableContent(hit), isTrue); + }); + + testWidgets('a mounted surface heals a missing registry entry', + (tester) async { + final controller = MarkdownSelectionController(); + addTearDown(controller.dispose); + final md = Markdown.fromString('Never registered by the app'); + + await tester.pumpWidget(MaterialApp( + home: Scaffold( + body: MarkdownSelectionScope( + controller: controller, + child: Align( + alignment: Alignment.topLeft, + child: SizedBox( + width: 400, + child: _Doc('orphan', markdown: md), + ), + ), + ), + ), + )); + await tester.pumpAndSettle(); + + expect(controller.documents.map((d) => d.id), ['orphan']); + + final box = tester.getRect(find.byType(MarkdownWidget)); + await _mouseDrag( + tester, + box.topLeft + const Offset(2, 4), + box.centerRight - const Offset(2, 0), + ); + expect(controller.getText(), 'Never registered by the app'); + }); + + testWidgets('recycling a widget onto another id keeps both models intact', + (tester) async { + final a = Markdown.fromString('Alpha body'); + final b = Markdown.fromString('Bravo body'); + final controller = MarkdownSelectionController() + ..setDocuments([ + MarkdownDocumentRef(id: 'a', model: a, order: 0), + MarkdownDocumentRef(id: 'b', model: b, order: 1), + ]); + addTearDown(controller.dispose); + + var showA = true; + late StateSetter setStateFn; + + await tester.pumpWidget(MaterialApp( + home: Scaffold( + body: MarkdownSelectionScope( + controller: controller, + child: StatefulBuilder(builder: (context, setState) { + setStateFn = setState; + // No key: the element is recycled onto the other document, + // exactly like ListView.builder reusing a slot while scrolling. + return Align( + alignment: Alignment.topLeft, + child: SizedBox( + width: 400, + child: MarkdownWidget( + markdown: showA ? a : b, + documentId: showA ? 'a' : 'b', + ), + ), + ); + }), + ), + ), + )); + await tester.pumpAndSettle(); + + setStateFn(() => showA = false); + await tester.pumpAndSettle(); + + expect( + controller.documents.firstWhere((d) => d.id == 'a').model.text, + a.text, + reason: 'the outgoing document must keep its own model', + ); + expect( + controller.documents.firstWhere((d) => d.id == 'b').model.text, + b.text, + ); + expect(controller.mountedSurfaces.single.documentId, 'b'); + }); + + testWidgets('removeDocument during dispose flushes on detach', + (tester) async { + final md = Markdown.fromString('Disappearing body'); + final controller = MarkdownSelectionController() + ..setDocuments( + [MarkdownDocumentRef(id: 'd', model: md)]); + addTearDown(controller.dispose); + + await tester.pumpWidget(MaterialApp( + home: Scaffold( + body: MarkdownSelectionScope( + controller: controller, + child: const Align( + alignment: Alignment.topLeft, + child: SizedBox(width: 400, child: _Doc('d')), + ), + ), + ), + )); + await tester.pumpAndSettle(); + + // Parent State.dispose can run before the child render object detaches. + controller.removeDocument('d'); + expect(controller.documents, hasLength(1), + reason: 'deferred while the surface is still mounted'); + + await tester.pumpWidget(const MaterialApp(home: SizedBox.shrink())); + await tester.pumpAndSettle(); + + expect(controller.documents, isEmpty); + expect(controller.mountedSurfaces, isEmpty); + }); + + testWidgets('a virtualized endpoint keeps the highlight while off-screen', + (tester) async { + final docs = [ + for (var i = 0; i < 20; i++) + MarkdownDocumentRef( + id: 'm$i', + model: Markdown.fromString('Message number $i body text'), + order: i, + ), + ]; + final controller = MarkdownSelectionController()..setDocuments(docs); + addTearDown(controller.dispose); + final scroll = ScrollController(); + addTearDown(scroll.dispose); + + await tester.pumpWidget(MaterialApp( + home: Scaffold( + body: MarkdownSelectionScope( + controller: controller, + child: SizedBox( + height: 200, + child: ListView.builder( + controller: scroll, + itemCount: docs.length, + itemBuilder: (context, i) => _Doc('m$i'), + ), + ), + ), + ), + )); + await tester.pumpAndSettle(); + + // Select from the first message into the third. + final first = tester.getRect(find.byType(MarkdownWidget).first); + await _mouseDrag( + tester, + first.topLeft + const Offset(2, 4), + first.bottomRight + const Offset(-2, 40), + ); + final selected = controller.getText(); + expect(selected, isNotEmpty); + expect(controller.selection!.base.documentId, 'm0'); + + // Scroll the base endpoint out of the cache extent and back. + scroll.jumpTo(1500); + await tester.pumpAndSettle(); + expect(controller.selection, isNotNull, + reason: 'virtualization must not drop the range'); + expect(controller.getText(), selected); + // No mounted body outside the selected span may paint a highlight. + for (var i = 10; i < 20; i++) { + expect(controller.rangeFor('m$i', 0), isNull); + } + + scroll.jumpTo(0); + await tester.pumpAndSettle(); + expect(controller.getText(), selected); + expect(tester.takeException(), isNull); + }); + }); + + group('controller lifecycle', () { + testWidgets('disposing the controller after the surfaces unmount is safe', + (tester) async { + final controller = MarkdownSelectionController() + ..setDocuments([ + MarkdownDocumentRef( + id: 'd', + model: Markdown.fromString('Body text'), + ), + ]); + + await tester.pumpWidget(MaterialApp( + home: Scaffold( + body: MarkdownSelectionScope( + controller: controller, + child: const Align( + alignment: Alignment.topLeft, + child: SizedBox(width: 400, child: _Doc('d')), + ), + ), + ), + )); + await tester.pumpAndSettle(); + + await tester.pumpWidget(const MaterialApp(home: SizedBox.shrink())); + await tester.pumpAndSettle(); + controller.dispose(); + + expect(tester.takeException(), isNull); + }); + + test('a group keeps one selection across controllers', () { + final group = MarkdownSelectionGroup(); + final a = MarkdownSelectionController(group: group) + ..setDocuments([ + MarkdownDocumentRef( + id: 'a', + model: Markdown.fromString('Alpha body'), + ), + ]); + final b = MarkdownSelectionController(group: group) + ..setDocuments([ + MarkdownDocumentRef( + id: 'b', + model: Markdown.fromString('Bravo body'), + ), + ]); + addTearDown(a.dispose); + addTearDown(b.dispose); + + a.selectAll(); + expect(a.selection, isNotNull); + b.selectAll(); + expect(b.selection, isNotNull); + expect(a.selection, isNull, reason: 'the group claims exclusivity'); + }); + + test('moveSelectionEdgeToGlobal with no mounted surface is a no-op', () { + final controller = MarkdownSelectionController() + ..setDocuments([ + MarkdownDocumentRef( + id: 'd', + model: Markdown.fromString('Body text'), + ), + ]); + addTearDown(controller.dispose); + controller.selectAll(); + final before = controller.selection; + + controller.moveSelectionEdgeToGlobal( + const Offset(10, 10), + isStart: true, + ); + + expect(controller.selection, before); + }); + + test('selectAll over an empty leading document does not throw', () { + final controller = MarkdownSelectionController() + ..setDocuments([ + MarkdownDocumentRef(id: 'e', model: Markdown.fromString('')), + MarkdownDocumentRef(id: 'd', model: Markdown.fromString('Body')), + ]); + addTearDown(controller.dispose); + + controller.selectAll(); + + expect(controller.getText(), isNotNull); + }); + }); + + group('host notification safety', () { + testWidgets( + 'removeDocument flush during unmount does not crash a host that ' + 'setStates from onSelectionChanged', (tester) async { + final controller = MarkdownSelectionController() + ..setDocuments([ + MarkdownDocumentRef(id: 'd', model: Markdown.fromString('Body text')), + ]); + addTearDown(controller.dispose); + + var show = true; + var changes = 0; + late StateSetter set; + + await tester.pumpWidget(MaterialApp( + home: Scaffold( + body: StatefulBuilder(builder: (context, setState) { + set = setState; + return MarkdownSelectionScope( + controller: controller, + onSelectionChanged: (_) => setState(() => changes++), + child: show + ? const Align( + alignment: Alignment.topLeft, + child: SizedBox(width: 400, child: _Doc('d')), + ) + : const SizedBox.shrink(), + ); + }), + ), + )); + await tester.pumpAndSettle(); + + controller.selectAll(); + await tester.pumpAndSettle(); + expect(controller.selection, isNotNull); + + // App drops the document while the surface is still mounted, then the + // widget unmounts in the same frame — the deferred remove flushes from + // RenderObject.detach, inside layout. + controller.removeDocument('d'); + set(() => show = false); + await tester.pumpAndSettle(); + + expect(controller.selection, isNull); + expect(changes, greaterThan(0)); + expect(tester.takeException(), isNull); + }); + + testWidgets( + 'a streaming model update that reconciles the selection does ' + 'not crash a host that setStates from onSelectionChanged', + (tester) async { + var model = Markdown.fromString('Hello streaming world'); + final controller = MarkdownSelectionController( + reconciliation: const MarkdownReconciliationPolicy.clearOnChange(), + )..setDocuments( + [MarkdownDocumentRef(id: 'd', model: model)]); + addTearDown(controller.dispose); + + var changes = 0; + late StateSetter set; + + await tester.pumpWidget(MaterialApp( + home: Scaffold( + body: StatefulBuilder(builder: (context, setState) { + set = setState; + return MarkdownSelectionScope( + controller: controller, + onSelectionChanged: (_) => setState(() => changes++), + child: Align( + alignment: Alignment.topLeft, + child: SizedBox( + width: 400, + child: MarkdownWidget(markdown: model, documentId: 'd'), + ), + ), + ); + }), + ), + )); + await tester.pumpAndSettle(); + + controller.selectAll(); + await tester.pumpAndSettle(); + expect(controller.selection, isNotNull); + changes = 0; + + // A streaming token arrives: MarkdownWidget.updateRenderObject pushes the + // new model into the registry during build, reconciliation drops the + // range, and the host is notified mid-build. + set(() => model = Markdown.fromString('Hello streaming world!')); + await tester.pumpAndSettle(); + + expect(tester.takeException(), isNull); + }); + }); +} diff --git a/test/selection/selection_test.dart b/test/selection/selection_test.dart index b592025..e5d4009 100644 --- a/test/selection/selection_test.dart +++ b/test/selection/selection_test.dart @@ -1,7 +1,73 @@ -import 'package:flutter/painting.dart' show TextRange; +import 'dart:ui' show Offset, Rect, TextAffinity; + +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 { + _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 + bool hitsSelectableGlyphs(Offset globalPosition) => false; + + @override + bool isLinkAtGlobal(Offset globalPosition) => false; + + @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 + List localBoxesForRange( + int blockIndex, + int startOffset, + int endOffset, + ) => + const []; + + @override + void setSelectionHandleLayers({ + LayerLink? startLink, + Offset? startLocal, + LayerLink? endLink, + Offset? endLocal, + }) {} + + @override + bool get hasSelectionHandleLeaders => false; + + @override + void clearSelectionHandleLayersIfLinked({ + required LayerLink startLink, + required LayerLink endLink, + }) {} + + @override + void repaintSelection() {} +} + class _UpperFormatter implements MarkdownSelectionFormatter { const _UpperFormatter(); @override @@ -53,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', () { @@ -149,12 +222,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; @@ -197,4 +319,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 f23d061..c73b592 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'); @@ -295,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'); @@ -320,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); @@ -346,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); @@ -402,6 +458,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() @@ -425,193 +525,1812 @@ void main() { expect(tester.takeException(), isNull); }); - testWidgets('triple-click selects the whole block', (tester) async { + 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 _clicks(tester, tl + const Offset(8, 8), 3); - expect(controller.getText(), 'Hello selectable world'); + 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('single click clears an existing selection', (tester) async { + testWidgets('expand drag hides the toolbar until drag end', (tester) async { final controller = MarkdownSelectionController() ..setDocuments([ MarkdownDocumentRef( - id: 'd', model: Markdown.fromString('Hello selectable world')), + id: 'd', + model: Markdown.fromString('Hello selectable world again')), ]); final tl = await pumpParagraph(tester, controller); + final pos = tl + const Offset(8, 8); - await _clicks(tester, tl + const Offset(8, 8), 2); - expect(controller.getText(), isNotEmpty); + // 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); - await _clicks(tester, tl + const Offset(8, 8), 1); - expect(controller.getText(), isEmpty, - reason: 'a single click collapses the selection'); + // 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('shift-click grows the selection toward the clicked point', + 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')), ]); - final tl = await pumpParagraph(tester, controller); - // Caret at the very start of the line. - await _clicks(tester, tl + const Offset(1, 8), 1); - expect(controller.getText(), isEmpty, reason: 'a single click collapses'); + 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(); - // Shift-click into the middle grows a ranged selection from the caret. - await tester.sendKeyDownEvent(LogicalKeyboardKey.shiftLeft); - await _clicks(tester, tl + const Offset(120, 8), 1); - await tester.sendKeyUpEvent(LogicalKeyboardKey.shiftLeft); - final mid = controller.getText(); - expect(mid, isNotEmpty); - expect('Hello selectable world'.startsWith(mid), isTrue, - reason: 'the selection is a prefix anchored at the start caret'); - expect(mid.length, lessThan('Hello selectable world'.length)); + controller.selectAll(); + focus.requestFocus(); + await tester.pump(); + expect(controller.getText(), isNotEmpty); + expect(focus.hasFocus, isTrue); - // Shift-click past the line end grows it to the whole line. - await tester.sendKeyDownEvent(LogicalKeyboardKey.shiftLeft); - await _clicks(tester, tl + const Offset(399, 8), 1); - await tester.sendKeyUpEvent(LogicalKeyboardKey.shiftLeft); - final grown = controller.getText(); - expect(grown, 'Hello selectable world'); - expect(grown.length, greaterThan(mid.length), - reason: 'clicking further right extends the selection'); - expect(grown.startsWith(mid), 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); }); - }); - group('selection toolbar buttons', () { - testWidgets('tapping Copy in the toolbar copies the selection', - (tester) async { - debugDefaultTargetPlatformOverride = TargetPlatform.linux; - // Reset via try/finally (not addTearDown): the framework's foundation-var - // invariant check runs before user tearDowns in this Flutter version. + testWidgets( + 'null contextMenuBuilder omits secondary recognizer ' + '(no toolbar on right-click)', (tester) async { + debugDefaultTargetPlatformOverride = TargetPlatform.macOS; try { - final md = Markdown.fromString('One two\n\nThree four'); final controller = MarkdownSelectionController() - ..setDocuments( - [MarkdownDocumentRef(id: 'd', model: md)]) - ..selectAll(); - - final data = []; - tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( - SystemChannels.platform, - (call) async { - if (call.method == 'Clipboard.setData') data.add(call); - return null; - }, + ..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')), + ), + ), + ), + ), ); - addTearDown(() => tester.binding.defaultBinaryMessenger - .setMockMethodCallHandler(SystemChannels.platform, null)); + await tester.pumpAndSettle(); - await tester.pumpWidget( - _wrap(controller, const SizedBox(width: 400, child: _Doc('d')))); + 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)); - state.showToolbar(); + 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 { + 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(); - expect(find.text('Copy'), findsOneWidget); + final selected = controller.getText(); + expect(selected, isNotEmpty); - // Tap the real toolbar button end-to-end. - await tester.tap(find.text('Copy')); + final center = tl + const Offset(40, 8); + await tester.tapAt(center, buttons: kSecondaryButton); await tester.pumpAndSettle(); - expect(data, isNotEmpty); - expect(data.first.arguments['text'], 'One two\nThree four'); + 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('tapping Select all in the toolbar selects every document', - (tester) async { - debugDefaultTargetPlatformOverride = TargetPlatform.linux; + testWidgets( + 'secondary-click on desktop without selection shows toolbar ' + 'without selecting text', (tester) async { + debugDefaultTargetPlatformOverride = TargetPlatform.macOS; try { - final md = Markdown.fromString('One two\n\nThree four'); final controller = MarkdownSelectionController() - ..setDocuments( - [MarkdownDocumentRef(id: 'd', model: md)]) - // Start with only the first paragraph selected. - ..selection = const MarkdownSelection( - base: MarkdownPosition(documentId: 'd', blockIndex: 0, offset: 0), - extent: MarkdownPosition(documentId: 'd', blockIndex: 0, offset: 7), - ); - - await tester.pumpWidget( - _wrap(controller, const SizedBox(width: 400, child: _Doc('d')))); + ..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.getText(), 'One two'); + expect(controller.selection, isNull, + reason: + 'desktop right click without selection must not select text'); final state = tester.state( find.byType(MarkdownSelectionScope)); - state.showToolbar(); + 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(); - expect(find.text('Select all'), findsOneWidget); - await tester.tap(find.text('Select all')); + 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(), 'One two\nThree four', - reason: 'select all now spans both paragraphs'); - final sel = controller.selection!; - expect(sel.base.offset, 0); - expect(sel.extent.blockIndex, 2); - expect(sel.extent.offset, 10); + 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; } }); - }); - 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. - final md = Markdown.fromString('```\ncode\n```'); + 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: md)]); + ..setDocuments([ + MarkdownDocumentRef( + id: 'd', + model: Markdown.fromString('Hello selectable world today')), + ]); + final tl = await pumpParagraph(tester, controller); - await tester.pumpWidget(MaterialApp( - home: Scaffold( - body: MarkdownSelectionScope( - controller: controller, - selectionColor: const Color(0x80FF0000), // translucent red - child: const Align( - alignment: Alignment.topLeft, - child: RepaintBoundary( - key: Key('capture'), - child: SizedBox(width: 400, child: _Doc('d')), - ), - ), - ), - ), - )); + // 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(); - // Select the whole code block. - final len = markdownBlockRenderedText(md.blocks.first).length; + 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([ + MarkdownDocumentRef( + id: 'd', model: Markdown.fromString('Hello selectable world')), + ]); + 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); + }); + + 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( + '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() + ..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( + '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 { + 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(); + + // 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([ + for (var i = 0; i < 12; i++) + MarkdownDocumentRef( + id: 'm$i', + model: Markdown.fromString('Message number $i with more text'), + order: i, + ), + ]); + final scroll = ScrollController(); + + 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); + + 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); + }); + + testWidgets('shift-click grows the selection toward the clicked point', + (tester) async { + final controller = MarkdownSelectionController() + ..setDocuments([ + MarkdownDocumentRef( + id: 'd', model: Markdown.fromString('Hello selectable world')), + ]); + final tl = await pumpParagraph(tester, controller); + + // Caret at the very start of the line. + await _clicks(tester, tl + const Offset(1, 8), 1); + expect(controller.getText(), isEmpty, reason: 'a single click collapses'); + + // Shift-click into the middle grows a ranged selection from the caret. + await tester.sendKeyDownEvent(LogicalKeyboardKey.shiftLeft); + await _clicks(tester, tl + const Offset(120, 8), 1); + await tester.sendKeyUpEvent(LogicalKeyboardKey.shiftLeft); + final mid = controller.getText(); + expect(mid, isNotEmpty); + expect('Hello selectable world'.startsWith(mid), isTrue, + reason: 'the selection is a prefix anchored at the start caret'); + expect(mid.length, lessThan('Hello selectable world'.length)); + + // Shift-click past the line end grows it to the whole line. + await tester.sendKeyDownEvent(LogicalKeyboardKey.shiftLeft); + await _clicks(tester, tl + const Offset(399, 8), 1); + await tester.sendKeyUpEvent(LogicalKeyboardKey.shiftLeft); + final grown = controller.getText(); + expect(grown, 'Hello selectable world'); + expect(grown.length, greaterThan(mid.length), + reason: 'clicking further right extends the selection'); + expect(grown.startsWith(mid), isTrue); + expect(tester.takeException(), isNull); + }); + }); + + group('selection toolbar buttons', () { + testWidgets('tapping Copy in the toolbar copies the selection', + (tester) async { + debugDefaultTargetPlatformOverride = TargetPlatform.linux; + // Reset via try/finally (not addTearDown): the framework's foundation-var + // invariant check runs before user tearDowns in this Flutter version. + try { + final md = Markdown.fromString('One two\n\nThree four'); + final controller = MarkdownSelectionController() + ..setDocuments( + [MarkdownDocumentRef(id: 'd', model: md)]) + ..selectAll(); + + final data = []; + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.platform, + (call) async { + if (call.method == 'Clipboard.setData') data.add(call); + return null; + }, + ); + addTearDown(() => tester.binding.defaultBinaryMessenger + .setMockMethodCallHandler(SystemChannels.platform, null)); + + await tester.pumpWidget( + _wrap(controller, const SizedBox(width: 400, child: _Doc('d')))); + await tester.pumpAndSettle(); + + final state = tester.state( + find.byType(MarkdownSelectionScope)); + state.showToolbar(); + await tester.pumpAndSettle(); + expect(find.text('Copy'), findsOneWidget); + + // Tap the real toolbar button end-to-end. + await tester.tap(find.text('Copy')); + await tester.pumpAndSettle(); + + expect(data, isNotEmpty); + expect(data.first.arguments['text'], 'One two\nThree four'); + } finally { + debugDefaultTargetPlatformOverride = null; + } + }); + + testWidgets('tapping Select all in the toolbar selects every document', + (tester) async { + debugDefaultTargetPlatformOverride = TargetPlatform.linux; + try { + final md = Markdown.fromString('One two\n\nThree four'); + final controller = MarkdownSelectionController() + ..setDocuments( + [MarkdownDocumentRef(id: 'd', model: md)]) + // Start with only the first paragraph selected. + ..selection = const MarkdownSelection( + base: MarkdownPosition(documentId: 'd', blockIndex: 0, offset: 0), + extent: MarkdownPosition(documentId: 'd', blockIndex: 0, offset: 7), + ); + + await tester.pumpWidget( + _wrap(controller, const SizedBox(width: 400, child: _Doc('d')))); + await tester.pumpAndSettle(); + expect(controller.getText(), 'One two'); + + final state = tester.state( + find.byType(MarkdownSelectionScope)); + state.showToolbar(); + await tester.pumpAndSettle(); + expect(find.text('Select all'), findsOneWidget); + + await tester.tap(find.text('Select all')); + await tester.pumpAndSettle(); + + expect(controller.getText(), 'One two\nThree four', + reason: 'select all now spans both paragraphs'); + final sel = controller.selection!; + expect(sel.base.offset, 0); + expect(sel.extent.blockIndex, 2); + expect(sel.extent.offset, 10); + } finally { + debugDefaultTargetPlatformOverride = null; + } + }); + + testWidgets( + 'controller.selectAll on desktop updates selection without ' + 'showing toolbar', (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, 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 on desktop does not show 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, 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; + } + }); + }); + + group('selection highlight', () { + testWidgets('paints over an opaque code-block background', (tester) async { + // 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( + [MarkdownDocumentRef(id: 'd', model: md)]); + + await tester.pumpWidget(MaterialApp( + home: Scaffold( + body: MarkdownSelectionScope( + controller: controller, + selectionColor: const Color(0x80FF0000), // translucent red + child: const Align( + alignment: Alignment.topLeft, + child: RepaintBoundary( + key: Key('capture'), + child: SizedBox(width: 400, child: _Doc('d')), + ), + ), + ), + ), + )); + await tester.pumpAndSettle(); + + // Select the whole code block. + 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(); + + // Sample a pixel over the first code glyph (block padding is 8px). + // `toByteData` drives the engine, so it must run under `runAsync`. + 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 = 12; + 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 the code background'); + 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(); - // Sample a pixel over the first code glyph (block padding is 8px). - // `toByteData` drives the engine, so it must run under `runAsync`. late final int r, g, b; await tester.runAsync(() async { final boundary = tester.renderObject( @@ -620,7 +2339,7 @@ void main() { final width = image.width; final data = await image.toByteData(); image.dispose(); - const x = 12, y = 12; + const x = 12, y = 8; final i = (y * width + x) * 4; r = data!.getUint8(i); g = data.getUint8(i + 1); @@ -628,7 +2347,7 @@ void main() { }); expect(r, greaterThan(g + 20), - reason: 'the red highlight must tint the code background'); + reason: 'the red highlight must tint inline monospace chrome'); expect(r, greaterThan(b + 20)); expect(tester.takeException(), isNull); }); @@ -664,6 +2383,64 @@ 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( @@ -726,9 +2503,447 @@ 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, + ); + }); + }); + + 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. class _Doc extends StatelessWidget { const _Doc(this.id); diff --git a/test/theme/theme_test.dart b/test/theme/theme_test.dart index aedde4f..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'; @@ -63,7 +64,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 +74,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); }); @@ -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/unit_test.dart b/test/unit_test.dart index f6dce08..71a6b7a 100644 --- a/test/unit_test.dart +++ b/test/unit_test.dart @@ -1,21 +1,31 @@ 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_target_test.dart' + as selection_autoscroll_target_test; +import 'selection/selection_autoscroll_test.dart' as selection_autoscroll_test; +import 'selection/selection_chrome_test.dart' as selection_chrome_test; +import 'selection/selection_host_gates_test.dart' as selection_host_gates_test; +import 'selection/selection_nested_blocks_test.dart' + as selection_nested_blocks_test; +import 'selection/selection_registry_test.dart' as selection_registry_test; +import 'selection/selection_handle_endpoints_test.dart' + as selection_handle_endpoints_test; import 'selection/selection_handles_test.dart' as selection_handles_test; 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 +48,13 @@ 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(); + selection_autoscroll_target_test.main(); + selection_host_gates_test.main(); + selection_registry_test.main(); + selection_chrome_test.main(); + selection_nested_blocks_test.main(); render_test.main(); widget_test.main(); }); 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, 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); + }); }); }