From da2e0da821fdd5e0bdfad3b10885d182f2c800ef Mon Sep 17 00:00:00 2001 From: Mike Matiunin Date: Thu, 30 Jul 2026 17:19:42 +0400 Subject: [PATCH 01/30] Add selection spikes, render benchmark, and locked selection core API Phase 1 de-risking spikes (benchmark/experiments/s1..s7 + FINDINGS.md) prove that stock SelectionArea cannot do cross-block/cross-widget Markdown selection (glue + disposal defects; a ListView hides items behind a private scrollable delegate), and validate a controller-anchored design: logical anchors over the immutable model make text extraction mount-independent (survives disposal), with the highlight drawn outside the cached ui.Picture (paint_hit ~160x cheaper). Adds a flutter test-driven render benchmark (benchmark/render_benchmark.dart) mirroring compare.dart's min-of-batches baseline. Locks the substrate-agnostic core in lib/src/selection.dart (exported): MarkdownSelectionController, MarkdownPosition/Selection, MarkdownDocumentRef, MarkdownSelectedContent, a public MarkdownSelectionFormatter (+ default), content-anchored MarkdownReconciliationPolicy (no model id), the shared markdownBlockRenderedText linearizer, and MarkdownSelectionSurface. 12 unit tests; full suite 383 green, dart format + analyze --fatal-infos clean. Issue #25. Co-Authored-By: Claude Opus 4.8 (1M context) --- .gitignore | 1 + benchmark/experiments/FINDINGS.md | 51 ++ .../experiments/s1_stock_baseline_test.dart | 121 ++++ .../experiments/s2_custom_delegate_test.dart | 224 ++++++ .../s3_selectable_renderobject_test.dart | 390 ++++++++++ .../s4_logical_controller_test.dart | 258 +++++++ .../s5_cross_widget_topology_test.dart | 257 +++++++ benchmark/experiments/s7_caching_test.dart | 210 ++++++ benchmark/render_benchmark.dart | 211 ++++++ example/lib/experiments/s6_platforms.dart | 367 ++++++++++ lib/flutter_md.dart | 1 + lib/src/selection.dart | 682 ++++++++++++++++++ test/selection/selection_test.dart | 150 ++++ test/unit_test.dart | 2 + 14 files changed, 2925 insertions(+) create mode 100644 benchmark/experiments/FINDINGS.md create mode 100644 benchmark/experiments/s1_stock_baseline_test.dart create mode 100644 benchmark/experiments/s2_custom_delegate_test.dart create mode 100644 benchmark/experiments/s3_selectable_renderobject_test.dart create mode 100644 benchmark/experiments/s4_logical_controller_test.dart create mode 100644 benchmark/experiments/s5_cross_widget_topology_test.dart create mode 100644 benchmark/experiments/s7_caching_test.dart create mode 100644 benchmark/render_benchmark.dart create mode 100644 example/lib/experiments/s6_platforms.dart create mode 100644 lib/src/selection.dart create mode 100644 test/selection/selection_test.dart diff --git a/.gitignore b/.gitignore index ccf9a29..e526c1f 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ pubspec.lock *.exe # Benchmark comparison baseline (machine-specific, generated by benchmark/compare.dart --save) benchmark/.baseline.txt +benchmark/.render_baseline.txt diff --git a/benchmark/experiments/FINDINGS.md b/benchmark/experiments/FINDINGS.md new file mode 100644 index 0000000..d2bf5b3 --- /dev/null +++ b/benchmark/experiments/FINDINGS.md @@ -0,0 +1,51 @@ +# Selection spikes — Phase 1 findings + +Throwaway experiments for issue #25 (cross-block + cross-widget text selection). +All headless spikes are `flutter test`-driven and pass (16 tests). S6 is an +interactive app to run on-device. Nothing here is in `lib/` or `test/`, so CI +(format / analyze / `unit_test.dart`) is untouched and still green (371 tests). + +Run: `flutter test benchmark/experiments/` · `flutter test benchmark/render_benchmark.dart` + +## What each spike established + +| Spike | Result | +|-------|--------| +| **S1** stock `SelectionArea` | GLUE confirmed — adjacent selectables concatenate with **no separator** (`AlphaBravoCharlie`). On disposal, `onSelectionChanged` does **not** re-fire → the app's selection value goes stale with no retrieval path. | +| **S2** custom delegate | Separators work **only** for selectables that register directly (a `Column`). A **`ListView` interposes its own private `_ScrollableSelectionContainerDelegate`** (scrollable.dart:1157) → a delegate above it sees one pre-glued child and is powerless. `Text` also wraps itself in a `SelectionContainer`, and a dying child yields `null`, so you must **cache content while alive**. Screen-Y snapshot keys **collide on reflow** (remove-middle → wrong text). ⇒ pure-delegate route is a dead end for chat. | +| **S3** canvas `Selectable` | A single `RenderBox` with `Selectable`+`SelectionRegistrant` maps a drag to **rendered-text** offsets across internal blocks, paints the highlight under glyphs, extracts text **with separators natively** (`Heading\nBody paragraph\nThird line`), and stays one `RenderBox`. Gotcha: guard `markNeedsPaint` against post-dispose callbacks. | +| **S4** logical controller | Selection as logical anchors `(docId, blockIndex, renderedOffset)` over the immutable model → extraction is **mount-independent** (disposal survival is free). Append-only streaming keeps the anchor via a prefix fast-path; **index-only anchors break on front/mid insert** (needs a stable id or content-anchored remap). Screen-order comparator handles vertical + horizontal + **RTL**. Non-text blocks (spacer/divider) occupy indices and must be skipped. | +| **S5** cross-widget topology | Recommended topology: `MarkdownSelectionScope(controller)` → a normal `ListView.builder` of message widgets that register as **surfaces**. Selection spans multiple widgets and **survives disposal** (`before == after` after scroll-off). **No `SelectableRegion`** → the single-child `add` assert is a non-issue. | +| **S7** caching | A 30-frame selection drag rebuilt the content `ui.Picture` **exactly once** (overlay drawn outside it). Content/size changes do rebuild. `isRepaintBoundary => true` **isolates** repaint to the changed widget (neighbour did not repaint). | +| **S6** platforms | Interactive app (`example/lib/experiments/s6_platforms.dart`) — **run on device** to judge touch handles, magnifier, native menus, keyboard, and link-tap-vs-drag arena. Headless mechanics already covered by S1–S5,S7. | + +## Render benchmark (relative, headless — `benchmark/.render_baseline.txt`) + +| tier | µs/op | note | +|------|-------|------| +| layout_large | ~6400 | full layout of a 50-block doc | +| paint_miss | ~6800 | fresh painter: layout + records Picture | +| **paint_hit** | **~41** | same painter+size → reuses Picture (**~160× cheaper**) | +| stream_append | ~5700 | `update()` + relayout | +| scroll_frame | ~2900 | wall time per drag+pump (noisy) | + +The ~160× cache payoff is why the highlight **must** be drawn outside the cached +Picture. (A `selection_drag` tier asserting the S7 zero-rebuild invariant is +added once the overlay lands in `lib/`.) + +## Decisions for Phase 2 + +1. **Substrate → keep canvas + custom `Selectable`/controller.** S3+S5+S7 confirm + it meets every requirement while preserving the Picture cache and keeping + `MarkdownWidget` a `LeafRenderObjectWidget` (so the single-RenderBox tests + survive). Stock widgets+`SelectionArea` fail glue + disposal (S1/S2). +2. **Cross-widget → scope-owned controller, not `SelectableRegion`.** (S2/S5.) +3. **Repaint → `isRepaintBoundary => true` + highlight overlay outside the + Picture; `alwaysNeedsCompositing` when handle `LeaderLayer`s are pushed.** (S7.) +4. **Model identity → OPEN.** index + append-fast-path covers streaming append + (dominant chat case) but breaks on front/mid inserts (S4). Options: (a) accept + index+clamp for v1; (b) add a stable id to `MD$Block` (`nodes.dart`); (c) + content-anchored remap. **Needs a decision.** +5. **Separator / spacer policy → OPEN.** block separator (`\n`?), document + separator (`\n\n`?), table cell separator (`\t`?), and whether spacer/divider + contribute to copied text. **Needs a decision.** diff --git a/benchmark/experiments/s1_stock_baseline_test.dart b/benchmark/experiments/s1_stock_baseline_test.dart new file mode 100644 index 0000000..c780813 --- /dev/null +++ b/benchmark/experiments/s1_stock_baseline_test.dart @@ -0,0 +1,121 @@ +// SPIKE S1 — Stock SelectionArea + one Text-per-block baseline. +// +// Question: on Flutter 3.41.6, do the two documented defects actually reproduce +// with the naive "one Text widget per Markdown block in a scroll view" approach? +// (1) GLUE: text of adjacent selectables is concatenated with NO separator. +// (2) DISPOSAL: a scrolled-off (disposed) item's selected text vanishes. +// +// This is throwaway spike code. It lives OUTSIDE lib/ and test/ so it never +// enters the CI format/analyze/test gates. +// +// Run: flutter test benchmark/experiments/s1_stock_baseline_test.dart +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('S1.1 GLUE: adjacent selectables concatenate WITHOUT separators', + (tester) async { + String? captured; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SelectionArea( + onSelectionChanged: (c) => captured = c?.plainText, + child: const Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Alpha'), + Text('Bravo'), + Text('Charlie'), + ], + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + // Mouse-drag select from the very start of "Alpha" to the very end of + // "Charlie" — i.e. everything. + final start = tester.getTopLeft(find.text('Alpha')) + const Offset(1, 3); + final end = tester.getBottomRight(find.text('Charlie')) - const Offset(1, 3); + final gesture = await tester.startGesture(start, kind: PointerDeviceKind.mouse); + await tester.pump(const Duration(milliseconds: 200)); + await gesture.moveTo(end); + await tester.pump(const Duration(milliseconds: 200)); + await gesture.up(); + await tester.pumpAndSettle(); + + debugPrint('S1.1 captured plainText = ${captured!.replaceAll('\n', r'\n')}'); + + // The defect: the three fragments are glued with no separator between them. + expect(captured, isNotNull); + expect(captured, contains('Bravo')); + expect(captured, isNot(contains('\n')), + reason: 'DEFECT CONFIRMED if this passes: no separators inserted between ' + 'selectables — "Alpha", "Bravo", "Charlie" are glued.'); + // Concretely, the whole selection is the bare concatenation. + expect(captured, 'AlphaBravoCharlie'); + }); + + testWidgets('S1.2 DISPOSAL: scrolled-off item text disappears from selection', + (tester) async { + String? captured; + const itemExtent = 120.0; + final controller = ScrollController(); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SizedBox( + height: 300, // viewport shows ~2.5 items + child: SelectionArea( + onSelectionChanged: (c) => captured = c?.plainText, + child: ListView.builder( + controller: controller, + cacheExtent: 0, // force disposal of off-screen items + itemCount: 50, + itemBuilder: (_, i) => SizedBox( + height: itemExtent, + child: Text('Item$i'), + ), + ), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + // Select across Item0 and Item1 (both on screen). + final start = tester.getTopLeft(find.text('Item0')) + const Offset(1, 3); + final end = tester.getBottomRight(find.text('Item1')) - const Offset(1, 3); + final gesture = await tester.startGesture(start, kind: PointerDeviceKind.mouse); + await tester.pump(const Duration(milliseconds: 200)); + await gesture.moveTo(end); + await tester.pump(const Duration(milliseconds: 200)); + await gesture.up(); + await tester.pumpAndSettle(); + + final beforeScroll = captured; + debugPrint('S1.2 before scroll = ${beforeScroll?.replaceAll('\n', r'\n')}'); + expect(beforeScroll, contains('Item0')); + + // Scroll far so Item0 (and Item1) are disposed. + controller.jumpTo(itemExtent * 20); + await tester.pumpAndSettle(); + expect(find.text('Item0'), findsNothing, reason: 'Item0 should be disposed'); + + debugPrint('S1.2 after scroll = ${captured?.replaceAll('\n', r'\n')}'); + // FINDING: disposing the selectable does NOT re-fire onSelectionChanged, so + // the app's only selection signal goes STALE — it still reads "Item0Item1" + // even though Item0's RenderParagraph (and its selectable) are gone. There + // is no public API to pull the fresh, now-reduced live selection. That + // staleness + the lack of a retrieval path IS the disposal defect from the + // app's perspective. (S2 proves at the delegate level that the LIVE + // getSelectedContent() actually drops the disposed text.) + expect(captured, equals(beforeScroll), + reason: 'onSelectionChanged did not re-fire on disposal → stale value.'); + }); +} diff --git a/benchmark/experiments/s2_custom_delegate_test.dart b/benchmark/experiments/s2_custom_delegate_test.dart new file mode 100644 index 0000000..384b4eb --- /dev/null +++ b/benchmark/experiments/s2_custom_delegate_test.dart @@ -0,0 +1,224 @@ +// SPIKE S2 — Custom MultiSelectable delegate: separators + snapshot-on-remove, +// and the STRUCTURAL LIMIT of the delegate approach for scrollables. +// +// Findings this file establishes: +// S2.1 A StaticSelectionContainerDelegate subclass CAN insert block +// separators between the selectables that register directly into it +// (fixing the S1 glue defect) — in a NON-scrolling subtree. +// S2.2 A stock Scrollable (ListView) interposes its OWN private +// `_ScrollableSelectionContainerDelegate` (scrollable.dart:1157), so a +// custom delegate placed ABOVE the ListView sees a single, already-glued +// child and is powerless over per-item separators / ordering / disposal. +// => the pure-delegate route is a dead end for the chat/lazy-list case. +// S2.3 The snapshot-on-remove mechanism DOES retain a disposed child's text +// (where the child registers directly into our delegate), but relies on +// screen-Y keys that are not scroll-invariant — motivating the +// controller's explicit registry order (S4). +// +// Throwaway spike; outside lib/ and test/. +// Run: flutter test benchmark/experiments/s2_custom_delegate_test.dart +import 'dart:ui' show Offset; + +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; + +class _Entry { + _Entry(this.key, this.text); + final double key; + final String text; +} + +class MdDelegate extends StaticSelectionContainerDelegate { + MdDelegate({this.separator = '\n'}); + + final String separator; + final Map _lastTop = {}; + // Proactively cache each live child's content: at remove() time a dying + // nested SelectionContainer already yields null, so we snapshot from here. + final Map _lastContent = {}; + final List<_Entry> _snaps = <_Entry>[]; + + int get liveChildCount => selectables.length; + int get snapshotCount => _snaps.length; + + double _topOf(Selectable s) => + MatrixUtils.transformPoint(s.getTransformTo(null), Offset.zero).dy; + + @override + void remove(Selectable selectable) { + final cached = _lastContent[selectable]; + if (cached != null && cached.isNotEmpty) { + _snaps.add(_Entry(_lastTop[selectable] ?? 0.0, cached)); + } + _lastTop.remove(selectable); + _lastContent.remove(selectable); + super.remove(selectable); + } + + @override + SelectedContent? getSelectedContent() { + final live = <_Entry>[]; + for (final s in selectables) { + final content = s.getSelectedContent()?.plainText; + if (content == null || content.isEmpty) continue; + final top = _topOf(s); + _lastTop[s] = top; + _lastContent[s] = content; + live.add(_Entry(top, content)); + } + final entries = <_Entry>[...live]; + const eps = 1.0; + for (final s in _snaps) { + final coveredByLive = live.any((e) => (e.key - s.key).abs() < eps); + if (!coveredByLive) entries.add(s); + } + if (entries.isEmpty) return null; + entries.sort((a, b) => a.key.compareTo(b.key)); + return SelectedContent( + plainText: entries.map((e) => e.text).join(separator), + ); + } +} + +Future _dragSelect(WidgetTester tester, Finder from, Finder to) async { + final start = tester.getTopLeft(from) + const Offset(1, 3); + final end = tester.getBottomRight(to) - const Offset(1, 3); + final g = await tester.startGesture(start, kind: PointerDeviceKind.mouse); + await tester.pump(const Duration(milliseconds: 200)); + await g.moveTo(end); + await tester.pump(const Duration(milliseconds: 200)); + await g.up(); + await tester.pumpAndSettle(); +} + +void main() { + testWidgets('S2.1 separators work in a NON-scrolling subtree', (tester) async { + final delegate = MdDelegate(); + await tester.pumpWidget(MaterialApp( + home: Scaffold( + body: SelectionArea( + child: SelectionContainer( + delegate: delegate, + child: const Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [Text('Item0'), Text('Item1'), Text('Item2')], + ), + ), + ), + ), + )); + await tester.pumpAndSettle(); + + await _dragSelect(tester, find.text('Item0'), find.text('Item2')); + final text = delegate.getSelectedContent()?.plainText; + debugPrint('S2.1 liveChildren=${delegate.liveChildCount} ' + 'text=${text?.replaceAll('\n', r'\n')}'); + expect(delegate.liveChildCount, 3, + reason: 'each Text registers directly into our delegate'); + expect(text, 'Item0\nItem1\nItem2'); // separators inserted + }); + + testWidgets('S2.2 BLOCKER: a ListView hides its items behind its own container', + (tester) async { + final delegate = MdDelegate(); + await tester.pumpWidget(MaterialApp( + home: Scaffold( + body: SizedBox( + height: 300, + child: SelectionArea( + child: SelectionContainer( + delegate: delegate, + child: ListView( + children: const [ + SizedBox(height: 120, child: Text('Item0')), + SizedBox(height: 120, child: Text('Item1')), + ], + ), + ), + ), + ), + ), + )); + await tester.pumpAndSettle(); + + await _dragSelect(tester, find.text('Item0'), find.text('Item1')); + final text = delegate.getSelectedContent()?.plainText; + debugPrint('S2.2 liveChildren=${delegate.liveChildCount} ' + 'text=${text?.replaceAll('\n', r'\n')}'); + // The Scrollable interposes ONE aggregated child; our delegate can't split. + expect(delegate.liveChildCount, 1, + reason: 'Scrollable._ScrollableSelectionContainerDelegate is the child'); + expect(text, 'Item0Item1', + reason: 'gluing happened inside the private scrollable delegate, ' + 'below us — the delegate route cannot fix the chat case'); + }); + + testWidgets('S2.3 snapshot MECHANISM works when layout does not reflow', + (tester) async { + final delegate = MdDelegate(); + final result = await _removeWhileSelected(tester, delegate, removeIndex: 2); + debugPrint('S2.3 snapshots=${delegate.snapshotCount} ' + 'after=${result?.replaceAll('\n', r'\n')}'); + // Removing the LAST item: remaining items keep their Y, snapshot key + // (>liveMax) splices cleanly. The mechanism retains the text. + expect(delegate.snapshotCount, greaterThanOrEqualTo(1)); + expect(result, 'Item0\nItem1\nItem2'); + }); + + testWidgets('S2.4 LIMIT: screen-Y snapshot key collides on reflow', + (tester) async { + final delegate = MdDelegate(); + final result = await _removeWhileSelected(tester, delegate, removeIndex: 1); + debugPrint('S2.4 snapshots=${delegate.snapshotCount} ' + 'after=${result?.replaceAll('\n', r'\n')}'); + // Removing the MIDDLE item: Item2 reflows UP into Item1's old Y, so the + // snapshot's screen-Y key collides with a live child and is dropped — + // Item1's retained text is LOST. Screen geometry is not a stable identity; + // this is precisely why the controller (S4) anchors on an explicit + // registry order over the immutable model instead. + expect(result, 'Item0\nItem2', reason: 'DEFECT of the delegate approach'); + expect(result, isNot(contains('Item1'))); + }); +} + +/// Selects all three keyed Texts, then removes [removeIndex] while selected, +/// returning the delegate's assembled text afterwards. +Future _removeWhileSelected( + WidgetTester tester, + MdDelegate delegate, { + required int removeIndex, +}) async { + var items = ['Item0', 'Item1', 'Item2']; + late StateSetter setOuter; + await tester.pumpWidget(MaterialApp( + home: Scaffold( + body: SelectionArea( + child: SelectionContainer( + delegate: delegate, + child: StatefulBuilder(builder: (_, setState) { + setOuter = setState; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + for (final s in items) Text(s, key: ValueKey(s)), + ], + ); + }), + ), + ), + ), + )); + await tester.pumpAndSettle(); + + await _dragSelect(tester, find.text('Item0'), find.text('Item2')); + expect(delegate.getSelectedContent()!.plainText, 'Item0\nItem1\nItem2'); + + final removed = items[removeIndex]; + setOuter(() => items = List.of(items)..removeAt(removeIndex)); + await tester.pumpAndSettle(); + expect(find.text(removed), findsNothing); + + return delegate.getSelectedContent()?.plainText; +} diff --git a/benchmark/experiments/s3_selectable_renderobject_test.dart b/benchmark/experiments/s3_selectable_renderobject_test.dart new file mode 100644 index 0000000..b2633c3 --- /dev/null +++ b/benchmark/experiments/s3_selectable_renderobject_test.dart @@ -0,0 +1,390 @@ +// SPIKE S3 — Custom Selectable RenderBox drawing highlight on canvas. +// +// Question: can ONE RenderBox (a LeafRenderObjectWidget, mirroring +// MarkdownRenderObject) implement Selectable + SelectionRegistrant, map a +// pointer drag to RENDERED-text offsets across multiple internal "blocks", +// paint the highlight under its glyphs, and return the selected text WITH +// block separators (natively solving the S1 glue problem, since it is a single +// selectable that controls its own getSelectedContent)? +// +// Each "block" here is a TextPainter, mirroring how MarkdownPainter holds one +// (or more) TextPainter per MD$Block. Throwaway spike; outside lib/ and test/. +// +// Run: flutter test benchmark/experiments/s3_selectable_renderobject_test.dart +import 'dart:math' as math; +import 'dart:ui' as ui; + +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// A logical position inside the render box: which block + rendered-char offset. +class _Pos implements Comparable<_Pos> { + const _Pos(this.block, this.offset); + final int block; + final int offset; + @override + int compareTo(_Pos o) => + block != o.block ? block.compareTo(o.block) : offset.compareTo(o.offset); +} + +class _Block { + _Block(this.text, TextStyle style) + : painter = TextPainter( + text: TextSpan(text: text, style: style), + textDirection: TextDirection.ltr, + ); + final String text; + final TextPainter painter; + double top = 0; + double get height => painter.height; +} + +const String _blockSeparator = '\n'; + +class MdSelectableRenderBox extends RenderBox with Selectable, SelectionRegistrant { + MdSelectableRenderBox(List blocks, TextStyle style) + : _blocks = [for (final b in blocks) _Block(b, style)]; + + final List<_Block> _blocks; + + final List _listeners = []; + _Pos? _start; + _Pos? _end; + LayerLink? _startHandle; + LayerLink? _endHandle; + bool _disposed = false; + + @override + void dispose() { + _disposed = true; + _listeners.clear(); + super.dispose(); // SelectionRegistrant.dispose -> unregister -> RenderBox + } + + void _safeMarkNeedsPaint() { + if (!_disposed) markNeedsPaint(); + } + + SelectionGeometry _geometry = + const SelectionGeometry(status: SelectionStatus.none, hasContent: true); + + // ---- ValueListenable ---- + @override + SelectionGeometry get value => _geometry; + @override + void addListener(VoidCallback l) => _listeners.add(l); + @override + void removeListener(VoidCallback l) => _listeners.remove(l); + void _notify() { + for (final l in List.of(_listeners)) l(); + } + + // ---- full-text helpers (rendered text joined with block separators) ---- + (String, List) _fullTextAndBases() { + final bases = []; + final buf = StringBuffer(); + var acc = 0; + for (var i = 0; i < _blocks.length; i++) { + if (i > 0) { + buf.write(_blockSeparator); + acc += _blockSeparator.length; + } + bases.add(acc); + buf.write(_blocks[i].text); + acc += _blocks[i].text.length; + } + return (buf.toString(), bases); + } + + int _globalOffset(_Pos p, List bases) => bases[p.block] + p.offset; + + @override + int get contentLength => _fullTextAndBases().$1.length; + + // ---- hit-testing: local offset -> logical position ---- + _Pos _positionForLocal(Offset local) { + var blockIndex = 0; + for (var i = 0; i < _blocks.length; i++) { + if (local.dy >= _blocks[i].top) blockIndex = i; + } + final b = _blocks[blockIndex]; + final tp = b.painter.getPositionForOffset(local - Offset(0, b.top)); + final off = tp.offset.clamp(0, b.text.length); + return _Pos(blockIndex, off); + } + + // ---- event handling ---- + @override + SelectionResult dispatchSelectionEvent(SelectionEvent event) { + switch (event) { + case final SelectionEdgeUpdateEvent e: + final local = globalToLocal(e.globalPosition); + final pos = _positionForLocal(local); + if (e.type == SelectionEventType.startEdgeUpdate) { + _start = pos; + } else { + _end = pos; + } + _start ??= pos; + _end ??= pos; + _recompute(); + return SelectionResult.end; + case ClearSelectionEvent(): + _start = _end = null; + _recompute(); + return SelectionResult.none; + case SelectAllSelectionEvent(): + _start = const _Pos(0, 0); + _end = _Pos(_blocks.length - 1, _blocks.last.text.length); + _recompute(); + return SelectionResult.end; + case final SelectWordSelectionEvent e: + final pos = _positionForLocal(globalToLocal(e.globalPosition)); + final range = _blocks[pos.block] + .painter + .getWordBoundary(TextPosition(offset: pos.offset)); + _start = _Pos(pos.block, range.start); + _end = _Pos(pos.block, range.end); + _recompute(); + return SelectionResult.end; + default: + return SelectionResult.none; + } + } + + (_Pos, _Pos) get _ordered => + _start!.compareTo(_end!) <= 0 ? (_start!, _end!) : (_end!, _start!); + + // Local selection range within a given block, in that block's char space. + TextRange? _rangeInBlock(int block, _Pos s, _Pos e) { + if (block < s.block || block > e.block) return null; + final start = block == s.block ? s.offset : 0; + final end = block == e.block ? e.offset : _blocks[block].text.length; + if (start == end) return null; + return TextRange(start: start, end: end); + } + + List _selectionRects() { + if (_start == null || _end == null) return const []; + final (s, e) = _ordered; + final rects = []; + for (var i = s.block; i <= e.block; i++) { + final r = _rangeInBlock(i, s, e); + if (r == null) continue; + final boxes = _blocks[i].painter.getBoxesForSelection( + TextSelection(baseOffset: r.start, extentOffset: r.end), + ); + for (final box in boxes) { + rects.add(box.toRect().shift(Offset(0, _blocks[i].top))); + } + } + return rects; + } + + SelectionPoint _pointFor(_Pos p, TextSelectionHandleType type) { + final b = _blocks[p.block]; + final caret = b.painter.getOffsetForCaret( + TextPosition(offset: p.offset), + Rect.zero, + ); + return SelectionPoint( + localPosition: caret + Offset(0, b.top + b.painter.preferredLineHeight), + lineHeight: b.painter.preferredLineHeight, + handleType: type, + ); + } + + void _recompute() { + if (_start == null || _end == null) { + _geometry = + const SelectionGeometry(status: SelectionStatus.none, hasContent: true); + } else { + final rects = _selectionRects(); + final collapsed = _start!.compareTo(_end!) == 0; + _geometry = SelectionGeometry( + startSelectionPoint: _pointFor(_start!, TextSelectionHandleType.left), + endSelectionPoint: _pointFor(_end!, TextSelectionHandleType.right), + selectionRects: rects, + status: collapsed + ? SelectionStatus.collapsed + : SelectionStatus.uncollapsed, + hasContent: true, + ); + } + _safeMarkNeedsPaint(); + _notify(); + } + + // ---- content extraction ---- + @override + SelectedContent? getSelectedContent() { + if (_start == null || _end == null) return null; + final (full, bases) = _fullTextAndBases(); + final (s, e) = _ordered; + final a = _globalOffset(s, bases); + final b = _globalOffset(e, bases); + if (a == b) return null; + return SelectedContent(plainText: full.substring(a, b)); + } + + @override + SelectedContentRange? getSelection() { + if (_start == null || _end == null) return null; + final (_, bases) = _fullTextAndBases(); + return SelectedContentRange( + startOffset: _globalOffset(_start!, bases), + endOffset: _globalOffset(_end!, bases), + ); + } + + // ---- geometry required by the delegate ---- + @override + List get boundingBoxes => + [for (final b in _blocks) Rect.fromLTWH(0, b.top, size.width, b.height)]; + + @override + void pushHandleLayers(LayerLink? startHandle, LayerLink? endHandle) { + // Real impl pushes LeaderLayers in paint(); the mouse-drag spike doesn't + // need visible handles, so we just record + repaint. (Handles are S6.) + _startHandle = startHandle; + _endHandle = endHandle; + _safeMarkNeedsPaint(); + } + + // ---- layout / paint ---- + @override + void performLayout() { + var y = 0.0, w = 0.0; + for (final b in _blocks) { + b.painter.layout(maxWidth: constraints.maxWidth); + b.top = y; + y += b.painter.height; + w = math.max(w, b.painter.width); + } + size = constraints.constrain(Size(w, y)); + } + + @override + void paint(PaintingContext context, Offset offset) { + // 1) highlight UNDER the glyphs + final rects = _geometry.selectionRects; + if (rects.isNotEmpty) { + final paint = Paint()..color = const Color(0x552196F3); + for (final r in rects) { + context.canvas.drawRect(r.shift(offset), paint); + } + } + // 2) glyphs + for (final b in _blocks) { + b.painter.paint(context.canvas, offset + Offset(0, b.top)); + } + } +} + +class MdSelectableWidget extends LeafRenderObjectWidget { + const MdSelectableWidget({required this.blocks, required this.style, super.key}); + final List blocks; + final TextStyle style; + + @override + MdSelectableRenderBox createRenderObject(BuildContext context) => + MdSelectableRenderBox(blocks, style) + ..registrar = SelectionContainer.maybeOf(context); + + @override + void updateRenderObject(BuildContext context, MdSelectableRenderBox ro) { + ro.registrar = SelectionContainer.maybeOf(context); + } +} + +void main() { + const style = TextStyle(fontSize: 20, color: Color(0xFF000000)); + + testWidgets('S3 single selectable box spans blocks with separators', + (tester) async { + String? captured; + await tester.pumpWidget(MaterialApp( + home: Scaffold( + body: SelectionArea( + onSelectionChanged: (c) => captured = c?.plainText, + child: const Align( + alignment: Alignment.topLeft, + child: SizedBox( + width: 400, + child: MdSelectableWidget( + blocks: ['Heading', 'Body paragraph', 'Third line'], + style: style, + ), + ), + ), + ), + ), + )); + await tester.pumpAndSettle(); + + final box = tester.renderObject(find.byType(MdSelectableWidget)); + // Exactly one RenderBox for the widget (leaf) — the S1/render tests invariant. + expect(box, isA()); + + // Drag-select from the very top-left to the bottom-right (everything). + final topLeft = tester.getTopLeft(find.byType(MdSelectableWidget)); + final bottomRight = + tester.getBottomRight(find.byType(MdSelectableWidget)); + final g = await tester.startGesture(topLeft + const Offset(1, 3), + kind: PointerDeviceKind.mouse); + await tester.pump(const Duration(milliseconds: 200)); + await g.moveTo(bottomRight - const Offset(1, 3)); + await tester.pump(const Duration(milliseconds: 200)); + await g.up(); + await tester.pumpAndSettle(); + + debugPrint('S3 captured = ${captured?.replaceAll('\n', r'\n')}'); + // Glue solved natively: ONE selectable inserts its own separators. + expect(captured, 'Heading\nBody paragraph\nThird line'); + }); + + testWidgets('S3 partial cross-block selection', (tester) async { + String? captured; + await tester.pumpWidget(MaterialApp( + home: Scaffold( + body: SelectionArea( + onSelectionChanged: (c) => captured = c?.plainText, + child: const Align( + alignment: Alignment.topLeft, + child: SizedBox( + width: 400, + child: MdSelectableWidget( + blocks: ['ABCDEF', 'GHIJKL'], + style: style, + ), + ), + ), + ), + ), + )); + await tester.pumpAndSettle(); + + // Start mid-first-block, end mid-second-block. + final box = tester.renderObject(find.byType(MdSelectableWidget)); + final origin = tester.getTopLeft(find.byType(MdSelectableWidget)); + final half = box.size.height / 2; + final g = await tester.startGesture( + origin + Offset(box.size.width * 0.45, half * 0.5), + kind: PointerDeviceKind.mouse); + await tester.pump(const Duration(milliseconds: 200)); + await g.moveTo(origin + Offset(box.size.width * 0.55, half * 1.5)); + await tester.pump(const Duration(milliseconds: 200)); + await g.up(); + await tester.pumpAndSettle(); + + debugPrint('S3 partial captured = ${captured?.replaceAll('\n', r'\n')}'); + // Whatever the exact chars, the block boundary must carry a separator. + expect(captured, isNotNull); + expect(captured, contains('\n'), + reason: 'cross-block selection carries the block separator'); + // And the selected text is drawn from RENDERED text (letters we laid out). + expect(captured!.replaceAll('\n', ''), matches(RegExp(r'^[A-L]+$'))); + }); +} diff --git a/benchmark/experiments/s4_logical_controller_test.dart b/benchmark/experiments/s4_logical_controller_test.dart new file mode 100644 index 0000000..a3a51c4 --- /dev/null +++ b/benchmark/experiments/s4_logical_controller_test.dart @@ -0,0 +1,258 @@ +// SPIKE S4 — Controller-anchored LOGICAL selection over the immutable model. +// +// The core idea: selection is a pair of logical anchors (docId, blockIndex, +// renderedOffset) into the immutable Markdown model. Text is derived from the +// MODEL (always retained by the app), so extraction is completely independent +// of which widgets are currently mounted. This is what makes disposal survival +// and streaming reconciliation fall out. +// +// Proves: +// T1 cross-document extraction from the model (with block/doc separators). +// T2 DISPOSAL SURVIVAL: extraction is identical before/after scrolling items +// out of a ListView (i.e. after their widgets are disposed). +// T3 STREAMING: append-only reconcile keeps the anchor; and index-only anchors +// BREAK on a front-insert — the evidence for the index-vs-stable-id call. +// T4 SCREEN ORDER: a geometry comparator (vertical, then horizontal flipped +// for RTL) orders MOUNTED docs; registry order governs unmounted ones. +// +// Uses the REAL flutter_md model. Throwaway spike; outside lib/ and test/. +// Run: flutter test benchmark/experiments/s4_logical_controller_test.dart +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_md/flutter_md.dart'; +import 'package:flutter_test/flutter_test.dart'; + +// --- shared block linearization (seed for the real MarkdownBlockText helper) -- +String renderedBlockText(MD$Block b) => b.map( + paragraph: (p) => p.spans.map((s) => s.text).join(), + heading: (h) => h.spans.map((s) => s.text).join(), + quote: (q) => q.spans.map((s) => s.text).join(), + alert: (a) => a.spans.map((s) => s.text).join(), + code: (c) => c.text, + list: (l) => + l.items.map((i) => i.spans.map((s) => s.text).join()).join('\n'), + table: (t) => [ + t.header.cells.map((c) => c.map((s) => s.text).join()).join('\t'), + for (final r in t.rows) + r.cells.map((c) => c.map((s) => s.text).join()).join('\t'), + ].join('\n'), + divider: (_) => '', // structural, no selectable text + spacer: (_) => '', // structural, no selectable text (policy: Phase 2) + ); + +// --- logical model --- +@immutable +class MdPos { + const MdPos(this.doc, this.block, this.offset); + final Object doc; + final int block; + final int offset; +} + +@immutable +class MdSel { + const MdSel(this.base, this.extent); + final MdPos base; + final MdPos extent; +} + +class MdDoc { + MdDoc(this.id, this.model); + final Object id; + Markdown model; +} + +/// Append-only fast path, else clamp. Returns null to drop an anchor. +MdPos reconcile(MdPos anchor, Markdown oldM, Markdown newM) { + final oldB = oldM.blocks, newB = newM.blocks; + bool prefixUnchanged() { + if (anchor.block >= newB.length) return false; + for (var i = 0; i < anchor.block; i++) { + if (i >= newB.length || + renderedBlockText(oldB[i]) != renderedBlockText(newB[i])) { + return false; + } + } + // anchor block itself: old rendered text must be a prefix of the new one. + final oldT = renderedBlockText(oldB[anchor.block]); + final newT = renderedBlockText(newB[anchor.block]); + return newT.startsWith(oldT) || oldT.startsWith(newT); + } + + if (anchor.block < oldB.length && prefixUnchanged()) return anchor; // keep + // clamp + final block = anchor.block.clamp(0, newB.length - 1); + final len = renderedBlockText(newB[block]).length; + return MdPos(anchor.doc, block, anchor.offset.clamp(0, len)); +} + +class MdController extends ChangeNotifier { + final List docs = []; // registry order == reading order + MdSel? selection; + + int _docIndex(Object id) => docs.indexWhere((d) => d.id == id); + Markdown _model(Object id) => docs[_docIndex(id)].model; + + void updateDocument(Object id, Markdown next) { + final d = docs[_docIndex(id)]; + final old = d.model; + d.model = next; + final sel = selection; + if (sel != null) { + selection = MdSel( + sel.base.doc == id ? reconcile(sel.base, old, next) : sel.base, + sel.extent.doc == id ? reconcile(sel.extent, old, next) : sel.extent, + ); + } + notifyListeners(); + } + + int _cmp(MdPos a, MdPos b) { + final ai = _docIndex(a.doc), bi = _docIndex(b.doc); + if (ai != bi) return ai.compareTo(bi); + if (a.block != b.block) return a.block.compareTo(b.block); + return a.offset.compareTo(b.offset); + } + + String getPlainText({String blockSep = '\n', String docSep = '\n\n'}) { + final sel = selection; + if (sel == null) return ''; + var a = sel.base, b = sel.extent; + if (_cmp(a, b) > 0) { + final t = a; + a = b; + b = t; + } + final startDoc = _docIndex(a.doc), endDoc = _docIndex(b.doc); + final docChunks = []; + for (var d = startDoc; d <= endDoc; d++) { + final blocks = docs[d].model.blocks; + final fromBlock = d == startDoc ? a.block : 0; + final toBlock = d == endDoc ? b.block : blocks.length - 1; + final blockChunks = []; + for (var bi = fromBlock; bi <= toBlock; bi++) { + final text = renderedBlockText(blocks[bi]); + if (text.isEmpty) continue; // skip structural blocks (spacer/divider) + final from = (d == startDoc && bi == a.block) ? a.offset : 0; + final to = (d == endDoc && bi == b.block) ? b.offset : text.length; + blockChunks.add(text.substring( + from.clamp(0, text.length), to.clamp(0, text.length))); + } + docChunks.add(blockChunks.join(blockSep)); + } + return docChunks.join(docSep); + } +} + +// Screen-order comparator for MOUNTED surfaces (mirrors Flutter's +// _compareScreenOrder, with an RTL horizontal flip). +int compareScreenOrder(Rect a, Rect b, TextDirection dir) { + const threshold = 4.0; + if ((a.top - b.top).abs() > threshold) return a.top.compareTo(b.top); + return dir == TextDirection.rtl + ? b.left.compareTo(a.left) + : a.left.compareTo(b.left); +} + +void main() { + final docA = Markdown.fromString('Alpha one\n\nAlpha two'); + final docB = Markdown.fromString('Bravo one\n\nBravo two'); + + MdController freshController() => MdController() + ..docs.addAll([MdDoc('a', docA), MdDoc('b', docB)]); + + // Block indices: 0 = paragraph, 1 = spacer (blank line), 2 = paragraph. + test('T1 cross-document extraction with separators', () { + final c = freshController(); + c.selection = const MdSel(MdPos('a', 0, 0), MdPos('b', 2, 9)); + expect(c.getPlainText(), + 'Alpha one\nAlpha two\n\nBravo one\nBravo two'); + + c.selection = const MdSel(MdPos('a', 2, 6), MdPos('b', 0, 5)); + expect(c.getPlainText(), 'two\n\nBravo'); // 'Alpha two'[6:]='two' + }); + + testWidgets('T2 DISPOSAL SURVIVAL: extraction unchanged after scroll-off', + (tester) async { + final c = MdController(); + for (var i = 0; i < 8; i++) { + c.docs.add(MdDoc('d$i', Markdown.fromString('Message number $i'))); + } + // Select from d0 through d7 (whole conversation). + c.selection = const MdSel(MdPos('d0', 0, 0), MdPos('d7', 0, 16)); + final before = c.getPlainText(); + expect(before, contains('Message number 0')); + expect(before, contains('Message number 7')); + + final scroll = ScrollController(); + await tester.pumpWidget(MaterialApp( + home: Scaffold( + body: SizedBox( + height: 200, + child: ListView.builder( + controller: scroll, + cacheExtent: 0, + itemCount: c.docs.length, + itemBuilder: (_, i) => SizedBox( + height: 80, + child: Text(c.docs[i].model.blocks + .map(renderedBlockText) + .join()), + ), + ), + ), + ), + )); + await tester.pumpAndSettle(); + + scroll.jumpTo(80.0 * 6); // dispose the first several messages + await tester.pumpAndSettle(); + expect(find.text('Message number 0'), findsNothing); // truly disposed + + // The controller reads the MODEL, not live widgets → identical text. + expect(c.getPlainText(), before); + expect(c.getPlainText(), contains('Message number 0')); + }); + + test('T3 STREAMING: append keeps anchor; front-insert breaks index-only', () { + // Anchor extent inside docB block 2 ("Bravo two"), offset 9 = end. + final c = freshController(); + c.selection = const MdSel(MdPos('a', 0, 0), MdPos('b', 2, 9)); + final base = c.getPlainText(); + + // (a) Append-only streaming: grow the last block + add a new block. + c.updateDocument('b', Markdown.fromString( + 'Bravo one\n\nBravo two three\n\nBravo appended')); + // block 2 grew as a prefix ("Bravo two" -> "Bravo two three"), so the fast + // path keeps the anchor; the originally-selected text is unchanged. + expect(c.selection!.extent.block, 2); + expect(c.selection!.extent.offset, 9); + expect(c.getPlainText(), base); // selection content preserved verbatim + + // (b) Front-insert: prepend a new first block. With INDEX-only anchors the + // fast path fails (block 0 changed) and clamp keeps block index 1 — which + // now points at a DIFFERENT block. The selected text changes => WRONG. + final c2 = freshController(); + c2.selection = const MdSel(MdPos('b', 0, 0), MdPos('b', 0, 9)); + final beforeInsert = c2.getPlainText(); // "Bravo one" + c2.updateDocument('b', Markdown.fromString( + 'INSERTED HEADER\n\nBravo one\n\nBravo two')); + final afterInsert = c2.getPlainText(); + expect(beforeInsert, 'Bravo one'); + expect(afterInsert, isNot('Bravo one'), + reason: 'index-only anchors mis-track a front-insert → needs stable id'); + }); + + test('T4 SCREEN ORDER: vertical, then horizontal with RTL flip', () { + final vTop = const Rect.fromLTWH(0, 0, 100, 40); + final vBot = const Rect.fromLTWH(0, 60, 100, 40); + expect(compareScreenOrder(vTop, vBot, TextDirection.ltr) < 0, isTrue); + + final left = const Rect.fromLTWH(0, 0, 100, 40); + final right = const Rect.fromLTWH(120, 1, 100, 40); // same row (±threshold) + expect(compareScreenOrder(left, right, TextDirection.ltr) < 0, isTrue, + reason: 'LTR: left comes first'); + expect(compareScreenOrder(left, right, TextDirection.rtl) > 0, isTrue, + reason: 'RTL: right comes first'); + }); +} diff --git a/benchmark/experiments/s5_cross_widget_topology_test.dart b/benchmark/experiments/s5_cross_widget_topology_test.dart new file mode 100644 index 0000000..c1da23a --- /dev/null +++ b/benchmark/experiments/s5_cross_widget_topology_test.dart @@ -0,0 +1,257 @@ +// SPIKE S5 — Cross-widget topology. +// +// Question: what topology lets ONE selection span N MarkdownWidgets in a +// ListView.builder AND survive item disposal? +// +// Given S2's finding (a stock Scrollable interposes its own private +// SelectionContainer, so stock SelectableRegion coordinates only LIVE items and +// drops disposed ones), the answer is: DON'T route cross-widget selection +// through SelectableRegion/Scrollable at all. Instead a scope-owned controller +// holds logical anchors + an app-supplied model registry (survives disposal), +// while mounted render objects register as "surfaces" that map a global point +// to a logical position. The scope's gesture layer drives the controller. +// +// This spike wires that end to end (one paragraph per message, since cross-BLOCK +// was already proven in S3 and cross-doc extraction in S4) and proves: +// * no SelectableRegion single-child assert is involved (we don't use it); +// * selection spans multiple mounted MarkdownWidgets; +// * it SURVIVES disposal — text still retrievable after scroll-off. +// +// Throwaway spike; outside lib/ and test/. +// Run: flutter test benchmark/experiments/s5_cross_widget_topology_test.dart +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; + +@immutable +class MdPos { + const MdPos(this.doc, this.offset); + final Object doc; + final int offset; +} + +abstract interface class MdSurface { + Object get docId; + Rect get globalBounds; + int offsetForGlobal(Offset global); +} + +class MdController extends ChangeNotifier { + MdController(this.docs); // app-supplied, ordered, ALL messages (mounted or not) + final List<(Object id, String text)> docs; + + final Map _surfaces = {}; + MdPos? base; + MdPos? extent; + + void registerSurface(MdSurface s) => _surfaces[s.docId] = s; + void unregisterSurface(MdSurface s) { + if (_surfaces[s.docId] == s) _surfaces.remove(s.docId); + } + + Iterable get mountedDocIds => _surfaces.keys; + + int _docIndex(Object id) => docs.indexWhere((d) => d.$1 == id); + String _text(Object id) => docs[_docIndex(id)].$2; + + /// Map a global point to a logical position by asking mounted surfaces. + MdPos? hitTest(Offset global) { + for (final s in _surfaces.values) { + if (s.globalBounds.contains(global)) { + return MdPos(s.docId, s.offsetForGlobal(global)); + } + } + return null; + } + + void startAt(Offset global) { + final p = hitTest(global); + if (p == null) return; + base = extent = p; + notifyListeners(); + } + + void extendTo(Offset global) { + final p = hitTest(global); + if (p == null) return; + extent = p; + notifyListeners(); + } + + int _cmp(MdPos a, MdPos b) { + final ai = _docIndex(a.doc), bi = _docIndex(b.doc); + return ai != bi ? ai.compareTo(bi) : a.offset.compareTo(b.offset); + } + + String getPlainText({String docSep = '\n\n'}) { + if (base == null || extent == null) return ''; + var a = base!, b = extent!; + if (_cmp(a, b) > 0) { + final t = a; + a = b; + b = t; + } + final start = _docIndex(a.doc), end = _docIndex(b.doc); + final chunks = []; + for (var d = start; d <= end; d++) { + final text = docs[d].$2; + final from = d == start ? a.offset : 0; + final to = d == end ? b.offset : text.length; + chunks.add(text.substring(from.clamp(0, text.length), to.clamp(0, text.length))); + } + return chunks.join(docSep); + } +} + +class _Scope extends InheritedWidget { + const _Scope({required this.controller, required super.child}); + final MdController controller; + static MdController of(BuildContext c) => + c.dependOnInheritedWidgetOfExactType<_Scope>()!.controller; + @override + bool updateShouldNotify(_Scope old) => controller != old.controller; +} + +class MarkdownScope extends StatelessWidget { + const MarkdownScope({required this.controller, required this.child, super.key}); + final MdController controller; + final Widget child; + + @override + Widget build(BuildContext context) => _Scope( + controller: controller, + // A real scope resolves scroll-vs-select in the gesture arena (S6); + // here the coordinator just forwards global drag points. + child: RawGestureDetector( + gestures: { + PanGestureRecognizer: + GestureRecognizerFactoryWithHandlers( + () => PanGestureRecognizer(), + (r) => r + ..onStart = ((d) => controller.startAt(d.globalPosition)) + ..onUpdate = ((d) => controller.extendTo(d.globalPosition)), + ), + }, + child: child, + ), + ); +} + +class MdMessage extends LeafRenderObjectWidget { + const MdMessage({required this.docId, required this.text, super.key}); + final Object docId; + final String text; + + @override + RenderObject createRenderObject(BuildContext context) => + _MdMessageBox(docId, text, _Scope.of(context)); + @override + void updateRenderObject(BuildContext context, _MdMessageBox ro) => + ro.controller = _Scope.of(context); +} + +class _MdMessageBox extends RenderBox implements MdSurface { + _MdMessageBox(this.docId, String text, this.controller) + : _painter = TextPainter( + text: TextSpan( + text: text, + style: const TextStyle(fontSize: 16, color: Color(0xFF000000)), + ), + textDirection: TextDirection.ltr, + ); + + @override + final Object docId; + final TextPainter _painter; + MdController controller; + + @override + void attach(PipelineOwner owner) { + super.attach(owner); + controller.registerSurface(this); + } + + @override + void detach() { + controller.unregisterSurface(this); + super.detach(); + } + + @override + Rect get globalBounds => localToGlobal(Offset.zero) & size; + + @override + int offsetForGlobal(Offset global) => + _painter.getPositionForOffset(globalToLocal(global)).offset; + + @override + void performLayout() { + _painter.layout(maxWidth: constraints.maxWidth); + size = constraints.constrain(_painter.size); + } + + @override + void paint(PaintingContext context, Offset offset) => + _painter.paint(context.canvas, offset); +} + +void main() { + testWidgets('S5 cross-widget selection survives disposal', (tester) async { + final controller = MdController(<(Object, String)>[ + for (var i = 0; i < 8; i++) ('m$i', 'Message number $i'), + ]); + final scroll = ScrollController(); + + await tester.pumpWidget(MaterialApp( + home: Scaffold( + body: SizedBox( + height: 200, + child: MarkdownScope( + controller: controller, + child: ListView.builder( + controller: scroll, + cacheExtent: 0, + itemCount: 8, + itemBuilder: (_, i) => SizedBox( + height: 80, + child: MdMessage(docId: 'm$i', text: 'Message number $i'), + ), + ), + ), + ), + ), + )); + await tester.pumpAndSettle(); + + // Only the first few messages are mounted (surfaces). Anchor the selection + // across m0..m1 by hit-testing their mounted surfaces — exactly what the + // scope gesture layer does on a real drag. + final p0 = tester.getTopLeft(find.byType(MdMessage).first) + const Offset(1, 3); + final m1 = find.byWidgetPredicate( + (w) => w is MdMessage && w.docId == 'm1'); + final p1 = tester.getBottomRight(m1) - const Offset(1, 3); + controller.startAt(p0); + controller.extendTo(p1); + + final before = controller.getPlainText(); + debugPrint('S5 before = ${before.replaceAll('\n', r'\n')}'); + expect(before, contains('Message number 0')); + expect(before, contains('Message number 1')); + + // Scroll so m0 is disposed. + scroll.jumpTo(80.0 * 6); + await tester.pumpAndSettle(); + expect(find.byWidgetPredicate((w) => w is MdMessage && w.docId == 'm0'), + findsNothing); + expect(controller.mountedDocIds, isNot(contains('m0')), + reason: 'm0 surface unregistered on disposal'); + + // Selection text is derived from the app-supplied model registry, so it is + // fully intact even though m0 is gone. + final after = controller.getPlainText(); + debugPrint('S5 after = ${after.replaceAll('\n', r'\n')}'); + expect(after, before, reason: 'cross-widget selection survived disposal'); + expect(after, contains('Message number 0')); + }); +} diff --git a/benchmark/experiments/s7_caching_test.dart b/benchmark/experiments/s7_caching_test.dart new file mode 100644 index 0000000..a9016f7 --- /dev/null +++ b/benchmark/experiments/s7_caching_test.dart @@ -0,0 +1,210 @@ +// SPIKE S7 — Caching: static content Picture vs dynamic selection overlay. +// +// Question: can the selection highlight be drawn as an overlay that changes +// every drag-frame WITHOUT rebuilding the cached content ui.Picture, and does a +// RepaintBoundary isolate one widget's selection repaint from its neighbours? +// +// Mirrors MarkdownPainter's cache (one ui.Picture keyed by size). The highlight +// is drawn OUTSIDE that Picture each paint. Instruments rebuild/paint counts. +// +// Throwaway spike; outside lib/ and test/. +// Run: flutter test benchmark/experiments/s7_caching_test.dart +import 'dart:ui' as ui; + +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; + +class CachingBox extends RenderBox { + CachingBox(String text, this._contentRevision) + : _painter = TextPainter( + text: TextSpan( + text: text, + style: const TextStyle(fontSize: 16, color: Color(0xFF000000)), + ), + textDirection: TextDirection.ltr, + ); + + TextPainter _painter; + int _contentRevision; + ui.Picture? _content; + Size? _contentSize; + int? _cachedRevision; + + Rect? _selectionRect; + int contentRebuilds = 0; + int paintCount = 0; + + @override + bool get isRepaintBoundary => true; // candidate decision (Spike 7) + + set selectionRect(Rect? r) { + if (r == _selectionRect) return; + _selectionRect = r; + markNeedsPaint(); // selection change => repaint only, no relayout + } + + void setContent(String text, int revision) { + if (revision == _contentRevision) return; + _painter = TextPainter( + text: TextSpan( + text: text, + style: const TextStyle(fontSize: 16, color: Color(0xFF000000)), + ), + textDirection: TextDirection.ltr, + ); + _contentRevision = revision; + markNeedsLayout(); + } + + @override + void performLayout() { + _painter.layout(maxWidth: constraints.maxWidth); + size = constraints.constrain(Size(_painter.width, _painter.height + 20)); + } + + @override + void paint(PaintingContext context, Offset offset) { + paintCount++; + // (Re)build the content Picture only when size or content changed. + if (_content == null || + _contentSize != size || + _cachedRevision != _contentRevision) { + final recorder = ui.PictureRecorder(); + final canvas = Canvas(recorder); + _painter.paint(canvas, Offset.zero); + _content = recorder.endRecording(); + _contentSize = size; + _cachedRevision = _contentRevision; + contentRebuilds++; + } + final canvas = context.canvas..save(); + canvas.translate(offset.dx, offset.dy); + // Dynamic overlay drawn fresh each paint, OUTSIDE the cached Picture. + if (_selectionRect != null) { + canvas.drawRect(_selectionRect!, Paint()..color = const Color(0x552196F3)); + } + canvas.drawPicture(_content!); + canvas.restore(); + } +} + +class CacheWidget extends LeafRenderObjectWidget { + const CacheWidget({ + required this.text, + required this.revision, + this.selectionRect, + super.key, + }); + final String text; + final int revision; + final Rect? selectionRect; + + @override + CachingBox createRenderObject(BuildContext context) => + CachingBox(text, revision)..selectionRect = selectionRect; + @override + void updateRenderObject(BuildContext context, CachingBox ro) { + ro + ..setContent(text, revision) + ..selectionRect = selectionRect; + } +} + +void main() { + testWidgets('S7.1 selection drag => ZERO extra content-Picture rebuilds', + (tester) async { + Rect? sel; + late StateSetter setOuter; + await tester.pumpWidget(MaterialApp( + home: Scaffold( + body: Center( + child: StatefulBuilder(builder: (_, setState) { + setOuter = setState; + return CacheWidget( + text: 'Selectable content here', revision: 0, selectionRect: sel); + }), + ), + ), + )); + await tester.pumpAndSettle(); + + final box = tester.renderObject(find.byType(CacheWidget)); + expect(box.contentRebuilds, 1); // built once + final paintsAfterFirst = box.paintCount; + + // Simulate a 30-frame selection drag: only selectionRect changes. + for (var i = 0; i < 30; i++) { + setOuter(() => sel = Rect.fromLTWH(0, 0, 4.0 * i, 18)); + await tester.pump(); + } + + debugPrint('S7.1 contentRebuilds=${box.contentRebuilds} ' + 'paints=${box.paintCount}'); + // The overlay redrew every frame (paints grew) but the Picture never rebuilt. + expect(box.contentRebuilds, 1, reason: 'content Picture reused across drag'); + expect(box.paintCount, greaterThan(paintsAfterFirst)); + }); + + testWidgets('S7.2 content or size change DOES rebuild the Picture', + (tester) async { + var text = 'first'; + var rev = 0; + late StateSetter setOuter; + await tester.pumpWidget(MaterialApp( + home: Scaffold( + body: Center( + child: StatefulBuilder(builder: (_, setState) { + setOuter = setState; + return CacheWidget(text: text, revision: rev); + }), + ), + ), + )); + await tester.pumpAndSettle(); + final box = tester.renderObject(find.byType(CacheWidget)); + expect(box.contentRebuilds, 1); + + setOuter(() { + text = 'second content that is different'; + rev = 1; + }); + await tester.pumpAndSettle(); + debugPrint('S7.2 contentRebuilds=${box.contentRebuilds}'); + expect(box.contentRebuilds, 2, reason: 'content change rebuilds Picture'); + }); + + testWidgets('S7.3 RepaintBoundary isolates per-widget selection repaint', + (tester) async { + Rect? selA; + late StateSetter setOuter; + await tester.pumpWidget(MaterialApp( + home: Scaffold( + body: StatefulBuilder(builder: (_, setState) { + setOuter = setState; + return Column( + children: [ + CacheWidget(text: 'Message A', revision: 0, selectionRect: selA), + const CacheWidget(text: 'Message B', revision: 0), + ], + ); + }), + ), + )); + await tester.pumpAndSettle(); + + final widgets = find.byType(CacheWidget); + final a = tester.renderObject(widgets.at(0)); + final b = tester.renderObject(widgets.at(1)); + final aPaints = a.paintCount, bPaints = b.paintCount; + + // Change ONLY A's selection. + setOuter(() => selA = const Rect.fromLTWH(0, 0, 40, 18)); + await tester.pump(); + + debugPrint('S7.3 A paints ${aPaints}->${a.paintCount}, ' + 'B paints ${bPaints}->${b.paintCount}'); + expect(a.paintCount, greaterThan(aPaints), reason: 'A repainted'); + expect(b.paintCount, bPaints, reason: 'B did NOT repaint (isolated)'); + }); +} diff --git a/benchmark/render_benchmark.dart b/benchmark/render_benchmark.dart new file mode 100644 index 0000000..95fb3c4 --- /dev/null +++ b/benchmark/render_benchmark.dart @@ -0,0 +1,211 @@ +// RENDER BENCHMARK — guards layout/paint cost and the content-Picture cache. +// +// Must run under the Flutter test engine (needs real dart:ui text layout + +// PictureRecorder); plain `dart run` has no text backend. +// +// Compare against the saved baseline: +// flutter test benchmark/render_benchmark.dart +// Record a new baseline (benchmark/.render_baseline.txt): +// flutter test benchmark/render_benchmark.dart --dart-define=RENDER_BASELINE=save +// +// Timings are RELATIVE (headless engine) — use the deltas for regressions, and +// confirm true frame-rate with `flutter run --profile` + DevTools timeline. +// +// Tiers: +// layout_large full layout of a large doc (performLayout cost) +// paint_miss fresh painter: layout + first paint (records new Picture) +// paint_hit same painter+size: paint again (cache hit → drawPicture) +// stream_append update(newModel) + relayout (LLM streaming rebuild) +// scroll_frame wall time per pump during a drag (widget-level) +// NOTE: a `selection_drag` tier (asserting zero content-Picture rebuilds, per +// spike S7) will be added once the selection overlay lands in lib/. +import 'dart:io'; +import 'dart:math' as math; +import 'dart:ui' as ui; + +import 'package:flutter/material.dart'; +import 'package:flutter_md/flutter_md.dart'; +import 'package:flutter_md/src/render.dart' show MarkdownPainter; +import 'package:flutter_test/flutter_test.dart'; + +const bool _save = String.fromEnvironment('RENDER_BASELINE') == 'save'; +const double _kWidth = 400; + +final Map _results = {}; + +MarkdownThemeData _theme() => MarkdownThemeData( + textStyle: const TextStyle(fontSize: 14, color: Color(0xFF000000)), + textDirection: TextDirection.ltr, + textScaler: TextScaler.noScaling, + ); + +String _largeSource({int blocks = 50}) { + final b = StringBuffer(); + for (var i = 0; i < blocks; i++) { + b.writeln('## Section $i'); + b.writeln(); + b.writeln('Paragraph $i with **bold**, _italic_, `code` and ' + '[a link](https://example.com/$i) plus some filler words here.'); + b.writeln(); + b.writeln('- item one for $i'); + b.writeln('- item two for $i'); + b.writeln(); + b.writeln('| A | B |'); + b.writeln('|---|---|'); + b.writeln('| $i | ${i * 2} |'); + b.writeln(); + } + return b.toString(); +} + +/// Min-of-batches per-op microseconds (mirrors benchmark/compare.dart). +double _bench(void Function() body, + {int warmupMs = 150, int batches = 20, int minBatchMs = 8}) { + final warm = Stopwatch()..start(); + while (warm.elapsedMilliseconds < warmupMs) body(); + + var iters = 1; + while (true) { + final sw = Stopwatch()..start(); + for (var i = 0; i < iters; i++) body(); + sw.stop(); + if (sw.elapsedMicroseconds >= minBatchMs * 1000) break; + iters = iters < 2 ? 2 : iters * 2; + if (iters > 1 << 22) break; + } + + var best = double.infinity; + for (var b = 0; b < batches; b++) { + final sw = Stopwatch()..start(); + for (var i = 0; i < iters; i++) body(); + sw.stop(); + best = math.min(best, sw.elapsedMicroseconds / iters); + } + return best; +} + +void _paintOnce(MarkdownPainter p, Size size) { + final rec = ui.PictureRecorder(); + final canvas = Canvas(rec); + p.paint(canvas, size); + rec.endRecording().dispose(); +} + +void main() { + final theme = _theme(); + final large = Markdown.fromString(_largeSource()); + final largeGrown = Markdown.fromString('${_largeSource()}\n\nAppended tail.'); + + test('micro: layout / paint-miss / paint-hit / stream', () { + // layout_large + _results['layout_large'] = _bench(() { + MarkdownPainter(markdown: large, theme: theme) + ..layout(maxWidth: _kWidth) + ..dispose(); + }); + + // paint_miss (fresh painter each time -> records a new Picture) + _results['paint_miss'] = _bench(() { + final p = MarkdownPainter(markdown: large, theme: theme); + final size = p.layout(maxWidth: _kWidth); + _paintOnce(p, size); + p.dispose(); + }); + + // paint_hit (same painter + size -> reuses the cached Picture) + final hitPainter = MarkdownPainter(markdown: large, theme: theme); + final hitSize = hitPainter.layout(maxWidth: _kWidth); + _paintOnce(hitPainter, hitSize); // prime the cache + _results['paint_hit'] = _bench(() => _paintOnce(hitPainter, hitSize)); + hitPainter.dispose(); + + // stream_append (toggle model so update() always sees a change) + final p = MarkdownPainter(markdown: large, theme: theme) + ..layout(maxWidth: _kWidth); + var flip = false; + _results['stream_append'] = _bench(() { + flip = !flip; + p + ..update(markdown: flip ? largeGrown : large, theme: theme) + ..layout(maxWidth: _kWidth); + }); + p.dispose(); + + // Sanity: the cache must make a hit dramatically cheaper than a miss. + expect(_results['paint_hit']!, lessThan(_results['paint_miss']!)); + }); + + testWidgets('widget: scroll_frame wall time', (tester) async { + final docs = [ + for (var i = 0; i < 30; i++) + Markdown.fromString('### Message $i\n\nBody of message $i with text.'), + ]; + await tester.pumpWidget(MaterialApp( + home: Scaffold( + body: ListView.builder( + itemCount: docs.length, + itemBuilder: (_, i) => Padding( + padding: const EdgeInsets.all(8), + child: MarkdownWidget(markdown: docs[i], theme: theme), + ), + ), + ), + )); + await tester.pumpAndSettle(); + + const frames = 40; + final sw = Stopwatch()..start(); + for (var i = 0; i < frames; i++) { + await tester.drag(find.byType(ListView), const Offset(0, -12)); + await tester.pump(const Duration(milliseconds: 16)); + } + sw.stop(); + _results['scroll_frame'] = sw.elapsedMicroseconds / frames; + }); + + tearDownAll(() { + final baseline = _loadBaseline(); + // ignore: avoid_print + print('\n${'tier'.padRight(16)}${'us/op'.padLeft(12)}' + '${'base'.padLeft(12)}${'delta'.padLeft(10)}'); + for (final e in _results.entries) { + final base = baseline?[e.key]; + final delta = base == null ? '-' : _delta(base, e.value); + // ignore: avoid_print + print('${e.key.padRight(16)}${e.value.toStringAsFixed(2).padLeft(12)}' + '${(base?.toStringAsFixed(2) ?? '-').padLeft(12)}${delta.padLeft(10)}'); + } + if (_save) { + _saveBaseline(_results); + // ignore: avoid_print + print('\nSaved baseline to benchmark/.render_baseline.txt'); + } + }); +} + +String _delta(double base, double now) { + final pct = (now - base) / base * 100; + return '${pct <= 0 ? '' : '+'}${pct.toStringAsFixed(1)}%'; +} + +Map? _loadBaseline() { + final file = File('benchmark/.render_baseline.txt'); + if (!file.existsSync()) return null; + final map = {}; + for (final line in file.readAsLinesSync()) { + final parts = line.split('\t'); + if (parts.length == 2) { + final v = double.tryParse(parts[1]); + if (v != null) map[parts[0]] = v; + } + } + return map; +} + +void _saveBaseline(Map results) { + final buffer = StringBuffer(); + for (final e in results.entries) { + buffer.writeln('${e.key}\t${e.value.toStringAsFixed(3)}'); + } + File('benchmark/.render_baseline.txt').writeAsStringSync(buffer.toString()); +} diff --git a/example/lib/experiments/s6_platforms.dart b/example/lib/experiments/s6_platforms.dart new file mode 100644 index 0000000..602951d --- /dev/null +++ b/example/lib/experiments/s6_platforms.dart @@ -0,0 +1,367 @@ +// SPIKE S6 — Platform interaction demo (RUN THIS ON EACH PLATFORM). +// +// This wires the recommended architecture end to end at a small scale: +// * a scope-owned controller holds the selection as logical anchors over an +// app-supplied model registry (so it survives disposal); +// * each "message" is a custom Selectable RenderBox that paints its own +// highlight under the glyphs (S3) and registers a surface (S5); +// * a scope gesture layer drives selection from a mouse/touch drag; +// * "Copy" reads controller.getPlainText() (full text WITH separators, even +// for scrolled-off messages); +// * one message is a link, to exercise the tap-vs-drag gesture arena. +// +// It is a manual, interactive spike (touch handles / magnifier / native menus +// can only be judged by a human on device). Headless mechanics are already +// proven by s1..s5,s7 under benchmark/experiments/. +// +// Run (from example/): +// flutter run -t lib/experiments/s6_platforms.dart -d chrome +// flutter run -t lib/experiments/s6_platforms.dart -d linux +// flutter run -t lib/experiments/s6_platforms.dart -d + +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; + +void main() => runApp(const _App()); + +class _App extends StatefulWidget { + const _App(); + @override + State<_App> createState() => _AppState(); +} + +class _AppState extends State<_App> { + final MdSelectionController controller = MdSelectionController([ + MdDoc('m0', 'Heading of the conversation'), + MdDoc('m1', 'This is the first message. Drag across me and the next ones.'), + MdDoc('m2', 'Second message with a bit more text to select through.'), + MdDoc('m3', 'LINK: tap me to test the tap-vs-drag arena.', isLink: true), + MdDoc('m4', 'Fourth message. Selection should span all of these blocks.'), + MdDoc('m5', 'Fifth and final message in this little transcript.'), + ]); + + @override + Widget build(BuildContext context) { + return MaterialApp( + title: 'S6 selection spike', + home: Scaffold( + appBar: AppBar(title: const Text('flutter_md selection spike (S6)')), + floatingActionButton: FloatingActionButton.extended( + icon: const Icon(Icons.copy), + label: const Text('Copy'), + onPressed: () async { + final text = controller.getPlainText(); + await Clipboard.setData(ClipboardData(text: text)); + if (!context.mounted) return; + ScaffoldMessenger.of(context) + ..clearSnackBars() + ..showSnackBar(SnackBar( + content: Text(text.isEmpty + ? '(no selection)' + : 'Copied ${text.length} chars:\n$text'), + )); + }, + ), + body: MarkdownSelectionScope( + controller: controller, + child: ListView( + padding: const EdgeInsets.all(16), + children: [ + for (final d in controller.docs) + Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: MdMessage(docId: d.id), + ), + ], + ), + ), + ), + ); + } +} + +// --------------------------------------------------------------------------- +// Controller + model registry (logical anchors over the immutable text). +// --------------------------------------------------------------------------- +class MdDoc { + MdDoc(this.id, this.text, {this.isLink = false}); + final Object id; + final String text; + final bool isLink; +} + +@immutable +class MdPos { + const MdPos(this.doc, this.offset); + final Object doc; + final int offset; +} + +class MdSelectionController extends ChangeNotifier { + MdSelectionController(this.docs); + final List docs; + final Map _surfaces = {}; + MdPos? base; + MdPos? extent; + + void registerSurface(MdSurface s) => _surfaces[s.docId] = s; + void unregisterSurface(MdSurface s) { + if (_surfaces[s.docId] == s) _surfaces.remove(s.docId); + } + + int docIndex(Object id) => docs.indexWhere((d) => d.id == id); + String _text(Object id) => docs[docIndex(id)].text; + + MdPos? hitTest(Offset global) { + for (final s in _surfaces.values) { + if (s.globalBounds.inflate(6).contains(global)) { + return MdPos(s.docId, s.offsetForGlobal(global)); + } + } + return null; + } + + void startAt(Offset g) { + final p = hitTest(g); + if (p == null) return; + base = extent = p; + notifyListeners(); + } + + void extendTo(Offset g) { + final p = hitTest(g); + if (p == null) return; + extent = p; + notifyListeners(); + } + + void clear() { + base = extent = null; + notifyListeners(); + } + + int _cmp(MdPos a, MdPos b) { + final ai = docIndex(a.doc), bi = docIndex(b.doc); + return ai != bi ? ai.compareTo(bi) : a.offset.compareTo(b.offset); + } + + /// The [start, end] selected range for [docId], or null if not selected. + (int, int)? rangeFor(Object docId) { + if (base == null || extent == null) return null; + var a = base!, b = extent!; + if (_cmp(a, b) > 0) { + final t = a; + a = b; + b = t; + } + final di = docIndex(docId); + if (di < docIndex(a.doc) || di > docIndex(b.doc)) return null; + final len = _text(docId).length; + final from = docId == a.doc ? a.offset : 0; + final to = docId == b.doc ? b.offset : len; + return (from.clamp(0, len), to.clamp(0, len)); + } + + String getPlainText({String docSep = '\n\n'}) { + if (base == null || extent == null) return ''; + var a = base!, b = extent!; + if (_cmp(a, b) > 0) { + final t = a; + a = b; + b = t; + } + final start = docIndex(a.doc), end = docIndex(b.doc); + final chunks = []; + for (var d = start; d <= end; d++) { + final text = docs[d].text; + final from = d == start ? a.offset : 0; + final to = d == end ? b.offset : text.length; + chunks.add( + text.substring(from.clamp(0, text.length), to.clamp(0, text.length))); + } + return chunks.join(docSep); + } +} + +// --------------------------------------------------------------------------- +// Scope: provides the controller + a scope-level drag coordinator. +// --------------------------------------------------------------------------- +abstract interface class MdSurface { + Object get docId; + Rect get globalBounds; + int offsetForGlobal(Offset global); +} + +class _ScopeInherited extends InheritedWidget { + const _ScopeInherited({required this.controller, required super.child}); + final MdSelectionController controller; + static MdSelectionController of(BuildContext c) => + c.dependOnInheritedWidgetOfExactType<_ScopeInherited>()!.controller; + @override + bool updateShouldNotify(_ScopeInherited old) => controller != old.controller; +} + +class MarkdownSelectionScope extends StatelessWidget { + const MarkdownSelectionScope({ + required this.controller, + required this.child, + super.key, + }); + final MdSelectionController controller; + final Widget child; + + @override + Widget build(BuildContext context) { + return _ScopeInherited( + controller: controller, + // Mouse: drag selects. Touch: long-press-then-drag selects (so a plain + // swipe still scrolls the ListView). This is the arena resolution S6 is + // meant to eyeball on each platform. + child: RawGestureDetector( + gestures: { + PanGestureRecognizer: + GestureRecognizerFactoryWithHandlers( + () => PanGestureRecognizer( + supportedDevices: {PointerDeviceKind.mouse}), + (r) => r + ..onStart = ((d) => controller.startAt(d.globalPosition)) + ..onUpdate = ((d) => controller.extendTo(d.globalPosition)), + ), + LongPressGestureRecognizer: + GestureRecognizerFactoryWithHandlers( + () => LongPressGestureRecognizer(), + (r) => r + ..onLongPressStart = ((d) => controller.startAt(d.globalPosition)) + ..onLongPressMoveUpdate = + ((d) => controller.extendTo(d.globalPosition)), + ), + }, + child: child, + ), + ); + } +} + +// --------------------------------------------------------------------------- +// A message = a custom Selectable-ish RenderBox that paints its own highlight. +// --------------------------------------------------------------------------- +class MdMessage extends LeafRenderObjectWidget { + const MdMessage({required this.docId, super.key}); + final Object docId; + + @override + RenderObject createRenderObject(BuildContext context) { + final controller = _ScopeInherited.of(context); + final doc = controller.docs[controller.docIndex(docId)]; + return _MdMessageBox(doc, controller); + } + + @override + void updateRenderObject(BuildContext context, _MdMessageBox ro) { + ro.controller = _ScopeInherited.of(context); + } +} + +class _MdMessageBox extends RenderBox implements MdSurface { + _MdMessageBox(this.doc, this._controller) + : _painter = TextPainter( + text: TextSpan( + text: doc.text, + style: TextStyle( + fontSize: 16, + color: doc.isLink + ? const Color(0xFF1565C0) + : const Color(0xFF111111), + decoration: doc.isLink ? TextDecoration.underline : null, + ), + ), + textDirection: TextDirection.ltr, + ); + + final MdDoc doc; + final TextPainter _painter; + MdSelectionController _controller; + bool _disposed = false; + TapGestureRecognizer? _tap; + + @override + Object get docId => doc.id; + + set controller(MdSelectionController c) { + if (identical(c, _controller)) return; + _controller.removeListener(_onSel); + _controller = c; + _controller.addListener(_onSel); + } + + void _onSel() { + if (!_disposed) markNeedsPaint(); + } + + @override + void attach(PipelineOwner owner) { + super.attach(owner); + _controller + ..registerSurface(this) + ..addListener(_onSel); + if (doc.isLink) { + _tap = TapGestureRecognizer() + ..onTap = () => debugPrint('LINK TAP fired for ${doc.id}'); + } + } + + @override + void detach() { + _controller + ..unregisterSurface(this) + ..removeListener(_onSel); + _tap?.dispose(); + _tap = null; + super.detach(); + } + + @override + void dispose() { + _disposed = true; + _painter.dispose(); + super.dispose(); + } + + // Tap handling for the link (drag is handled at the scope level). + @override + bool hitTestSelf(Offset position) => doc.isLink; + @override + void handleEvent(PointerEvent event, covariant HitTestEntry entry) { + if (doc.isLink && event is PointerDownEvent) _tap?.addPointer(event); + } + + @override + Rect get globalBounds => localToGlobal(Offset.zero) & size; + + @override + int offsetForGlobal(Offset global) => + _painter.getPositionForOffset(globalToLocal(global)).offset; + + @override + void performLayout() { + _painter.layout(maxWidth: constraints.maxWidth); + size = constraints.constrain(Size(constraints.maxWidth, _painter.height)); + } + + @override + void paint(PaintingContext context, Offset offset) { + final range = _controller.rangeFor(doc.id); + if (range != null && range.$1 != range.$2) { + final boxes = _painter.getBoxesForSelection( + TextSelection(baseOffset: range.$1, extentOffset: range.$2), + ); + final paint = Paint()..color = const Color(0x552196F3); + for (final b in boxes) { + context.canvas.drawRect(b.toRect().shift(offset), paint); + } + } + _painter.paint(context.canvas, offset); + } +} diff --git a/lib/flutter_md.dart b/lib/flutter_md.dart index 4d6dfbb..1db467f 100644 --- a/lib/flutter_md.dart +++ b/lib/flutter_md.dart @@ -4,5 +4,6 @@ export 'src/markdown.dart'; export 'src/nodes.dart'; export 'src/parser.dart'; export 'src/render.dart' show BlockPainter; +export 'src/selection.dart'; export 'src/theme.dart'; export 'src/widget.dart'; diff --git a/lib/src/selection.dart b/lib/src/selection.dart new file mode 100644 index 0000000..ab45ef0 --- /dev/null +++ b/lib/src/selection.dart @@ -0,0 +1,682 @@ +import 'dart:ui' show Offset, Rect, TextRange; + +import 'package:flutter/foundation.dart'; + +import 'markdown.dart'; +import 'nodes.dart'; + +/// 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 +/// and pointer hit-testing. +/// +/// Structural blocks ([MD$Divider], [MD$Spacer]) contribute no text. Lists join +/// items (and nested items, depth-first) with `\n`; tables join cells with `\t` +/// and rows with `\n`. +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), + code: (c) => c.text, + list: (l) { + final buffer = StringBuffer(); + _listItems(l.items, buffer); + return buffer.toString(); + }, + table: (t) => [ + t.header.cells.map(_spans).join('\t'), + for (final row in t.rows) row.cells.map(_spans).join('\t'), + ].join('\n'), + divider: (_) => '', + spacer: (_) => '', + ); + +String _spans(List spans) { + final buffer = StringBuffer(); + for (final span in spans) buffer.write(span.text); + return buffer.toString(); +} + +void _listItems(List items, StringBuffer buffer) { + for (final item in items) { + if (buffer.isNotEmpty) buffer.write('\n'); + buffer.write(_spans(item.spans)); + if (item.children.isNotEmpty) _listItems(item.children, buffer); + } +} + +/// A logical caret position inside a selectable Markdown document. +/// +/// Anchored on the immutable model — never on a mounted render object — so it +/// stays valid while the widget is scrolled off-screen and disposed. +@immutable +final class MarkdownPosition { + /// Creates a position in [documentId] at rendered-text [offset] of the block + /// at [blockIndex] in `Markdown.blocks`. + const MarkdownPosition({ + required this.documentId, + required this.blockIndex, + required this.offset, + }); + + /// Stable id of the document (e.g. a chat message id). Opaque to the library. + final Object documentId; + + /// Index into the source `Markdown.blocks` (not the painter list). + final int blockIndex; + + /// Offset into the block's rendered text (see [markdownBlockRenderedText]). + final int offset; + + @override + bool operator ==(Object other) => + other is MarkdownPosition && + other.documentId == documentId && + other.blockIndex == blockIndex && + other.offset == offset; + + @override + int get hashCode => Object.hash(documentId, blockIndex, offset); + + @override + String toString() => 'MarkdownPosition($documentId#$blockIndex@$offset)'; +} + +/// A directed selection between [base] (where the gesture anchored) and +/// [extent] (the moving end). Reading order is resolved by the controller. +@immutable +final class MarkdownSelection { + /// Creates a selection from [base] to [extent]. + const MarkdownSelection({required this.base, required this.extent}); + + /// A collapsed (empty) selection at [at]. + const MarkdownSelection.collapsed(MarkdownPosition at) + : base = at, + extent = at; + + /// The fixed anchor of the selection. + final MarkdownPosition base; + + /// The moving end of the selection. + final MarkdownPosition extent; + + /// Whether [base] and [extent] coincide (nothing is selected). + bool get isCollapsed => base == extent; + + @override + bool operator ==(Object other) => + other is MarkdownSelection && + other.base == base && + other.extent == extent; + + @override + int get hashCode => Object.hash(base, extent); + + @override + String toString() => 'MarkdownSelection($base -> $extent)'; +} + +/// A document registered with a controller, in reading order. +@immutable +final class MarkdownDocumentRef { + /// Creates a reference binding a stable [id] to its immutable [model]. + const MarkdownDocumentRef( + {required this.id, required this.model, this.order}); + + /// Stable id of the document (e.g. a chat message id). + final Object id; + + /// The immutable Markdown model. Retained by the app; the controller reads + /// text from it even while the widget is unmounted. + final Markdown model; + + /// Explicit reading-order key. When null, registration order is used. Supply + /// it (e.g. the message index) so unmounted documents still order correctly. + final int? order; +} + +/// One block's contribution to a selection: the guaranteed [text] slice plus +/// structured metadata for custom formatters. +@immutable +final class MarkdownSelectedBlock { + /// Creates a selected-block segment. + const MarkdownSelectedBlock({ + required this.blockIndex, + required this.type, + required this.text, + required this.renderedRange, + required this.block, + this.sourceRange, + }); + + /// Index of the block in `Markdown.blocks`. + final int blockIndex; + + /// The block's `MD$Block.type` (`'paragraph'`, `'table'`, ...). + final String type; + + /// The selected slice of the block's rendered text. Always populated. + final String text; + + /// The selected range within the block's rendered text. + final TextRange renderedRange; + + /// Best-effort range within the block's Markdown source (may be null). + final TextRange? sourceRange; + + /// The immutable block, for consumers that reconstruct richer output. + final MD$Block block; +} + +/// One document's contribution to a selection. +@immutable +final class MarkdownSelectedDocument { + /// Creates a selected-document segment. + const MarkdownSelectedDocument({ + required this.documentId, + required this.blocks, + }); + + /// The document's stable id. + final Object documentId; + + /// The selected blocks of this document, in reading order. + final List blocks; +} + +/// The structured result of a selection, spanning one or more documents. +/// +/// This is the canonical representation; render it to a string with a +/// [MarkdownSelectionFormatter] (or the default [MarkdownPlainTextFormatter]). +@immutable +final class MarkdownSelectedContent { + /// Creates structured selected content. + const MarkdownSelectedContent({required this.documents}); + + /// The selected documents, in reading order. + final List documents; + + /// Whether nothing is selected. + bool get isEmpty => documents.isEmpty; + + /// Whether something is selected. + bool get isNotEmpty => documents.isNotEmpty; + + /// Convenience: format with the default [MarkdownPlainTextFormatter]. + String toPlainText() => const MarkdownPlainTextFormatter().format(this); +} + +/// Turns [MarkdownSelectedContent] into a string. Implement this to customize +/// how a selection is copied (e.g. "Copy as Markdown"). +abstract interface class MarkdownSelectionFormatter { + /// Formats [content] into a single string. + String format(MarkdownSelectedContent content); +} + +/// The default formatter: joins block slices with [blockSeparator] and +/// documents with [documentSeparator]. Structural blocks (empty text) are +/// skipped. Table/list cell structure is already baked into each block's text. +@immutable +final class MarkdownPlainTextFormatter implements MarkdownSelectionFormatter { + /// Creates a plain-text formatter. + const MarkdownPlainTextFormatter({ + this.blockSeparator = '\n', + this.documentSeparator = '\n\n', + }); + + /// Inserted between blocks within a document. + final String blockSeparator; + + /// Inserted between documents. + final String documentSeparator; + + @override + String format(MarkdownSelectedContent content) { + final docs = []; + for (final doc in content.documents) { + final blocks = []; + for (final block in doc.blocks) { + if (block.text.isEmpty) continue; + blocks.add(block.text); + } + if (blocks.isNotEmpty) docs.add(blocks.join(blockSeparator)); + } + return docs.join(documentSeparator); + } +} + +/// Decides how a selection anchor is remapped when a document's model is +/// replaced (e.g. streaming). Returning null drops the anchor (collapsing the +/// selection). No stable block id is required — remapping is content-based. +abstract interface class MarkdownReconciliationPolicy { + /// Append-only fast path, else clamp indices/offsets into the new bounds. + /// Cheapest; correct for streaming appends, drifts on front/mid inserts. + const factory MarkdownReconciliationPolicy.appendFastPath() = + _AppendFastPathPolicy; + + /// Append fast path, then relocate by matching block rendered text, else + /// clamp. The default — robust to inserts/reorders without a model id. + const factory MarkdownReconciliationPolicy.contentAnchored() = + _ContentAnchoredPolicy; + + /// Drop the selection whenever the anchor's document changes at all. + const factory MarkdownReconciliationPolicy.clearOnChange() = _ClearPolicy; + + /// Remaps [anchor] from [oldModel] to [newModel]; null drops it. + MarkdownPosition? remap( + MarkdownPosition anchor, + Markdown oldModel, + Markdown newModel, + ); +} + +MarkdownPosition _clampInto(MarkdownPosition anchor, Markdown model) { + if (model.blocks.isEmpty) { + return MarkdownPosition( + documentId: anchor.documentId, blockIndex: 0, offset: 0); + } + final bi = anchor.blockIndex.clamp(0, model.blocks.length - 1); + final len = markdownBlockRenderedText(model.blocks[bi]).length; + return MarkdownPosition( + documentId: anchor.documentId, + blockIndex: bi, + offset: anchor.offset.clamp(0, len), + ); +} + +bool _appendPrefixKeeps(MarkdownPosition anchor, Markdown o, Markdown n) { + if (anchor.blockIndex >= o.blocks.length || + anchor.blockIndex >= n.blocks.length) { + return false; + } + for (var i = 0; i < anchor.blockIndex; i++) { + if (i >= n.blocks.length || + markdownBlockRenderedText(o.blocks[i]) != + markdownBlockRenderedText(n.blocks[i])) { + return false; + } + } + final oldText = markdownBlockRenderedText(o.blocks[anchor.blockIndex]); + final newText = markdownBlockRenderedText(n.blocks[anchor.blockIndex]); + return newText.startsWith(oldText) || oldText.startsWith(newText); +} + +@immutable +class _AppendFastPathPolicy implements MarkdownReconciliationPolicy { + const _AppendFastPathPolicy(); + @override + MarkdownPosition? remap(MarkdownPosition anchor, Markdown o, Markdown n) { + if (_appendPrefixKeeps(anchor, o, n)) { + final len = markdownBlockRenderedText(n.blocks[anchor.blockIndex]).length; + return MarkdownPosition( + documentId: anchor.documentId, + blockIndex: anchor.blockIndex, + offset: anchor.offset.clamp(0, len), + ); + } + return _clampInto(anchor, n); + } +} + +@immutable +class _ContentAnchoredPolicy implements MarkdownReconciliationPolicy { + const _ContentAnchoredPolicy(); + @override + MarkdownPosition? remap(MarkdownPosition anchor, Markdown o, Markdown n) { + if (anchor.blockIndex >= o.blocks.length) return _clampInto(anchor, n); + if (_appendPrefixKeeps(anchor, o, n)) { + final len = markdownBlockRenderedText(n.blocks[anchor.blockIndex]).length; + return MarkdownPosition( + documentId: anchor.documentId, + blockIndex: anchor.blockIndex, + offset: anchor.offset.clamp(0, len), + ); + } + // Relocate by matching the anchor block's rendered text in the new model. + final oldText = markdownBlockRenderedText(o.blocks[anchor.blockIndex]); + if (oldText.isNotEmpty) { + for (var i = 0; i < n.blocks.length; i++) { + if (markdownBlockRenderedText(n.blocks[i]) == oldText) { + return MarkdownPosition( + documentId: anchor.documentId, + blockIndex: i, + offset: anchor.offset.clamp(0, oldText.length), + ); + } + } + } + return _clampInto(anchor, n); + } +} + +@immutable +class _ClearPolicy implements MarkdownReconciliationPolicy { + const _ClearPolicy(); + @override + MarkdownPosition? remap(MarkdownPosition anchor, Markdown o, Markdown n) => + null; +} + +/// A mounted document's geometry bridge — the controller's window onto a live +/// render object. Implemented by the render layer; used for hit-testing. +abstract interface class MarkdownSelectionSurface { + /// The document id this surface renders. + Object get documentId; + + /// The surface's bounds in global (screen) coordinates. + Rect get globalBounds; + + /// Maps a global point to a logical position, or null if outside any text. + MarkdownPosition? positionForGlobal(Offset globalPosition); +} + +class _DocEntry { + _DocEntry(this.id, this.model, this.order); + final Object id; + Markdown model; + int order; +} + +/// The single source of truth for a Markdown selection. +/// +/// Holds the selection as logical anchors over an app-supplied registry of +/// immutable models, so selected text can always be extracted — even for +/// documents whose widgets are currently unmounted (e.g. scrolled out of a +/// chat list). Mounted render objects register as [MarkdownSelectionSurface]s +/// for hit-testing and listen for repaints. +class MarkdownSelectionController extends ChangeNotifier { + /// Creates a controller with an optional [reconciliation] policy (defaults to + /// [MarkdownReconciliationPolicy.contentAnchored]) and default [formatter]. + MarkdownSelectionController({ + MarkdownReconciliationPolicy? reconciliation, + MarkdownSelectionFormatter formatter = const MarkdownPlainTextFormatter(), + }) : reconciliation = reconciliation ?? + const MarkdownReconciliationPolicy.contentAnchored(), + _formatter = formatter; + + /// The anchor-remapping policy used on document updates. + final MarkdownReconciliationPolicy reconciliation; + + MarkdownSelectionFormatter _formatter; + + /// The default formatter used by [getText] when none is supplied. + MarkdownSelectionFormatter get formatter => _formatter; + set formatter(MarkdownSelectionFormatter value) { + if (identical(value, _formatter)) return; + _formatter = value; + notifyListeners(); + } + + final List<_DocEntry> _docs = <_DocEntry>[]; + final Map _surfaces = + {}; + + MarkdownSelection? _selection; + + /// The current selection, or null when nothing is selected. + MarkdownSelection? get selection => _selection; + set selection(MarkdownSelection? value) { + if (value == _selection) return; + _selection = value; + notifyListeners(); + } + + /// The registered documents, in reading order. + List get documents => [ + for (final e in _docs) + MarkdownDocumentRef(id: e.id, model: e.model, order: e.order), + ]; + + // --- registry ----------------------------------------------------------- + + /// Replaces the whole registry (initial/bulk load), preserving a still-valid + /// selection by clamping it into the new documents. + void setDocuments(Iterable docs) { + _docs + ..clear() + ..addAll(<_DocEntry>[ + for (final (i, d) in docs.indexed) + _DocEntry(d.id, d.model, d.order ?? i), + ]); + _sort(); + _validateSelection(); + notifyListeners(); + } + + /// 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}) { + final idx = _docs.indexWhere((e) => e.id == id); + if (idx < 0) { + _docs.add(_DocEntry(id, model, order ?? _docs.length)); + _sort(); + notifyListeners(); + return; + } + final entry = _docs[idx]; + final old = entry.model; + if (order != null) entry.order = order; + if (identical(old, model)) { + _sort(); + notifyListeners(); + return; + } + entry.model = model; + _sort(); + _reconcile(id, old, model); + notifyListeners(); + } + + /// Removes a document. If the selection touched it, the selection is dropped. + void removeDocument(Object id) { + _docs.removeWhere((e) => e.id == id); + final sel = _selection; + if (sel != null && + (sel.base.documentId == id || sel.extent.documentId == id)) { + _selection = null; + } + notifyListeners(); + } + + void _sort() { + _docs.sort((a, b) => a.order.compareTo(b.order)); + } + + void _reconcile(Object id, Markdown oldModel, Markdown newModel) { + final sel = _selection; + if (sel == null) return; + final base = sel.base.documentId == id + ? reconciliation.remap(sel.base, oldModel, newModel) + : sel.base; + final extent = sel.extent.documentId == id + ? reconciliation.remap(sel.extent, oldModel, newModel) + : sel.extent; + _selection = (base == null || extent == null) + ? null + : MarkdownSelection(base: base, extent: extent); + } + + void _validateSelection() { + final sel = _selection; + if (sel == null) return; + if (_orderIndex(sel.base.documentId) < 0 || + _orderIndex(sel.extent.documentId) < 0) { + _selection = null; + return; + } + _selection = MarkdownSelection( + base: _clampInto(sel.base, _modelOf(sel.base.documentId)), + extent: _clampInto(sel.extent, _modelOf(sel.extent.documentId)), + ); + } + + // --- surfaces (mounted geometry) ---------------------------------------- + + /// Registers a mounted [surface] for hit-testing. Called on RenderObject + /// attach. + void attachSurface(MarkdownSelectionSurface surface) { + _surfaces[surface.documentId] = surface; + } + + /// Unregisters a [surface]. Called on RenderObject detach/dispose. + void detachSurface(MarkdownSelectionSurface surface) { + if (identical(_surfaces[surface.documentId], surface)) { + _surfaces.remove(surface.documentId); + } + } + + /// The currently mounted surfaces. + Iterable get mountedSurfaces => _surfaces.values; + + /// Maps a global point to a logical position by asking mounted surfaces. + MarkdownPosition? positionForGlobal(Offset globalPosition) { + for (final surface in _surfaces.values) { + if (surface.globalBounds.contains(globalPosition)) { + return surface.positionForGlobal(globalPosition); + } + } + return null; + } + + // --- mutation ------------------------------------------------------------ + + /// Clears the selection. + void clear() => selection = null; + + /// Collapses the selection at [position]. + void collapseAt(MarkdownPosition position) => + selection = MarkdownSelection.collapsed(position); + + /// Extends the moving end of the selection to [position] (anchoring [base] + /// first if there is no selection yet). + void extendTo(MarkdownPosition position) { + final sel = _selection; + selection = sel == null + ? MarkdownSelection.collapsed(position) + : MarkdownSelection(base: sel.base, extent: position); + } + + /// 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); + } + + /// 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); + } + + /// Selects everything across every registered document. + 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, + ), + ); + } + + /// 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. + TextRange? rangeFor(Object documentId, int blockIndex) { + final sel = _selection; + if (sel == null) return null; + final (a, b) = _ordered(sel); + final di = _orderIndex(documentId); + if (di < 0) return null; + final startDoc = _orderIndex(a.documentId); + final endDoc = _orderIndex(b.documentId); + if (di < startDoc || di > endDoc) return null; + final model = _modelOf(documentId); + if (blockIndex < 0 || blockIndex >= model.blocks.length) return null; + final len = markdownBlockRenderedText(model.blocks[blockIndex]).length; + final startBlock = di == startDoc ? a.blockIndex : 0; + final endBlock = di == endDoc ? b.blockIndex : model.blocks.length - 1; + if (blockIndex < startBlock || blockIndex > endBlock) return null; + final from = (di == startDoc && blockIndex == a.blockIndex) ? a.offset : 0; + final to = (di == endDoc && blockIndex == b.blockIndex) ? b.offset : len; + return TextRange(start: from.clamp(0, len), end: to.clamp(0, len)); + } + + // --- extraction ---------------------------------------------------------- + + /// The structured selected content, assembled from the models in reading + /// order (works regardless of which surfaces are mounted). + MarkdownSelectedContent selectedContent() { + final sel = _selection; + if (sel == null) { + return const MarkdownSelectedContent( + documents: []); + } + final (a, b) = _ordered(sel); + final startDoc = _orderIndex(a.documentId); + final endDoc = _orderIndex(b.documentId); + if (startDoc < 0 || endDoc < 0) { + return const MarkdownSelectedContent( + documents: []); + } + final out = []; + for (var d = startDoc; d <= endDoc; d++) { + final entry = _docs[d]; + final blocks = entry.model.blocks; + final fromBlock = d == startDoc ? a.blockIndex : 0; + final toBlock = d == endDoc ? b.blockIndex : blocks.length - 1; + final segs = []; + for (var bi = fromBlock; bi <= toBlock && bi < blocks.length; bi++) { + if (bi < 0) continue; + final text = markdownBlockRenderedText(blocks[bi]); + final from = (d == startDoc && bi == a.blockIndex) + ? a.offset.clamp(0, text.length) + : 0; + final to = (d == endDoc && bi == b.blockIndex) + ? b.offset.clamp(0, text.length) + : text.length; + if (to <= from) continue; + segs.add(MarkdownSelectedBlock( + blockIndex: bi, + type: blocks[bi].type, + text: text.substring(from, to), + renderedRange: TextRange(start: from, end: to), + block: blocks[bi], + )); + } + if (segs.isNotEmpty) { + out.add(MarkdownSelectedDocument(documentId: entry.id, blocks: segs)); + } + } + return MarkdownSelectedContent(documents: out); + } + + /// The selected text, formatted with [formatter] (or the default when null). + String getText([MarkdownSelectionFormatter? formatter]) => + (formatter ?? _formatter).format(selectedContent()); + + // --- ordering helpers ---------------------------------------------------- + + int _orderIndex(Object id) => _docs.indexWhere((e) => e.id == id); + + Markdown _modelOf(Object id) => _docs[_orderIndex(id)].model; + + 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) + return a.blockIndex.compareTo(b.blockIndex); + return a.offset.compareTo(b.offset); + } + + (MarkdownPosition, MarkdownPosition) _ordered(MarkdownSelection sel) => + _compare(sel.base, sel.extent) <= 0 + ? (sel.base, sel.extent) + : (sel.extent, sel.base); +} diff --git a/test/selection/selection_test.dart b/test/selection/selection_test.dart new file mode 100644 index 0000000..9ae13ad --- /dev/null +++ b/test/selection/selection_test.dart @@ -0,0 +1,150 @@ +import 'package:flutter/painting.dart' show TextRange; +import 'package:flutter_md/flutter_md.dart'; +import 'package:flutter_test/flutter_test.dart'; + +class _UpperFormatter implements MarkdownSelectionFormatter { + const _UpperFormatter(); + @override + String format(MarkdownSelectedContent content) => content.documents + .expand((d) => d.blocks) + .map((b) => b.text.toUpperCase()) + .join(' | '); +} + +void main() { + group('selection core', () { + // Block indices: 0 = paragraph, 1 = spacer, 2 = paragraph. + final docA = Markdown.fromString('Alpha one\n\nAlpha two'); + final docB = Markdown.fromString('Bravo one\n\nBravo two'); + + MarkdownSelectionController two() => MarkdownSelectionController() + ..setDocuments([ + MarkdownDocumentRef(id: 'a', model: docA), + MarkdownDocumentRef(id: 'b', model: docB), + ]); + + const full = MarkdownSelection( + base: MarkdownPosition(documentId: 'a', blockIndex: 0, offset: 0), + extent: MarkdownPosition(documentId: 'b', blockIndex: 2, offset: 9), + ); + + test('block linearization', () { + expect(markdownBlockRenderedText(docA.blocks[0]), 'Alpha one'); + expect(markdownBlockRenderedText(docA.blocks[1]), ''); // spacer + expect(markdownBlockRenderedText(docA.blocks[2]), 'Alpha two'); + final table = Markdown.fromString('| a | b |\n|---|---|\n| 1 | 2 |') + .blocks + .firstWhere((b) => b.type == 'table'); + expect(markdownBlockRenderedText(table), 'a\tb\n1\t2'); + }); + + test('cross-document extraction with default formatter', () { + final c = two()..selection = full; + expect(c.getText(), 'Alpha one\nAlpha two\n\nBravo one\nBravo two'); + final content = c.selectedContent(); + expect(content.documents.length, 2); + expect(content.documents.first.blocks.map((b) => b.text).toList(), + ['Alpha one', 'Alpha two']); // spacer skipped + expect(content.documents.first.blocks.first.type, 'paragraph'); + }); + + test('partial slice', () { + final c = two() + ..selection = const MarkdownSelection( + base: MarkdownPosition(documentId: 'a', blockIndex: 2, offset: 6), + extent: MarkdownPosition(documentId: 'b', blockIndex: 0, offset: 5), + ); + expect(c.getText(), 'two\n\nBravo'); + }); + + test('custom formatter is used', () { + final c = two()..selection = full; + expect(c.getText(const _UpperFormatter()), + 'ALPHA ONE | ALPHA TWO | BRAVO ONE | BRAVO TWO'); + }); + + test('reconcile: append keeps the anchor verbatim', () { + final c = two()..selection = full; + final before = c.getText(); + c.putDocument( + 'b', Markdown.fromString('Bravo one\n\nBravo two three\n\nEnd')); + expect(c.selection!.extent.blockIndex, 2); + expect(c.selection!.extent.offset, 9); + expect(c.getText(), before); + }); + + test('reconcile: content-anchored survives a front-insert', () { + final c = MarkdownSelectionController() + ..setDocuments( + [MarkdownDocumentRef(id: 'b', model: docB)]) + ..selection = const MarkdownSelection( + base: MarkdownPosition(documentId: 'b', blockIndex: 2, offset: 0), + extent: MarkdownPosition(documentId: 'b', blockIndex: 2, offset: 9), + ); + expect(c.getText(), 'Bravo two'); + c.putDocument( + 'b', Markdown.fromString('HEADER\n\nBravo one\n\nBravo two')); + expect(c.getText(), 'Bravo two'); // relocated by content, not index + }); + + test('reconcile: appendFastPath drifts on a front-insert', () { + final c = MarkdownSelectionController( + reconciliation: const MarkdownReconciliationPolicy.appendFastPath(), + ) + ..setDocuments( + [MarkdownDocumentRef(id: 'b', model: docB)]) + ..selection = const MarkdownSelection( + base: MarkdownPosition(documentId: 'b', blockIndex: 0, offset: 0), + extent: MarkdownPosition(documentId: 'b', blockIndex: 0, offset: 9), + ); + expect(c.getText(), 'Bravo one'); + c.putDocument( + 'b', Markdown.fromString('HEADER LINE\n\nBravo one\n\nBravo two')); + expect(c.getText(), isNot('Bravo one')); // clamped to new block 0 + }); + + test('reconcile: clearOnChange drops the selection', () { + final c = MarkdownSelectionController( + reconciliation: const MarkdownReconciliationPolicy.clearOnChange(), + ) + ..setDocuments( + [MarkdownDocumentRef(id: 'b', model: docB)]) + ..selection = const MarkdownSelection( + base: MarkdownPosition(documentId: 'b', blockIndex: 0, offset: 0), + extent: MarkdownPosition(documentId: 'b', blockIndex: 2, offset: 9), + ); + c.putDocument('b', Markdown.fromString('Bravo one\n\nBravo two four')); + expect(c.selection, isNull); + }); + + test('selectAll and clear', () { + final c = two()..selectAll(); + expect(c.getText(), 'Alpha one\nAlpha two\n\nBravo one\nBravo two'); + c.clear(); + expect(c.selection, isNull); + expect(c.getText(), ''); + }); + + test('rangeFor', () { + final c = two()..selection = full; + expect(c.rangeFor('a', 0), const TextRange(start: 0, end: 9)); + expect(c.rangeFor('b', 0), const TextRange(start: 0, end: 9)); + expect(c.rangeFor('missing', 0), isNull); + }); + + test('removeDocument drops a touching selection', () { + final c = two()..selection = full; + c.removeDocument('a'); + expect(c.selection, isNull); + }); + + test('notifies listeners on selection change', () { + final c = two(); + var n = 0; + c.addListener(() => n++); + c.selection = full; + c.clear(); + expect(n, 2); + }); + }); +} diff --git a/test/unit_test.dart b/test/unit_test.dart index 7552576..889f3a3 100644 --- a/test/unit_test.dart +++ b/test/unit_test.dart @@ -9,6 +9,7 @@ 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 'selection/selection_test.dart' as selection_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; @@ -24,6 +25,7 @@ void main() => group('Unit', () { golden_test.main(); nodes_test.main(); theme_test.main(); + selection_test.main(); render_test.main(); widget_test.main(); }); From 16332ef161ed2f97de0e5e63d7fb6b1b72bc951e Mon Sep 17 00:00:00 2001 From: Mike Matiunin Date: Thu, 30 Jul 2026 17:35:55 +0400 Subject: [PATCH 02/30] Wire selection into the render layer: selectable MarkdownWidget + scope - render.dart: MarkdownRenderObject now implements MarkdownSelectionSurface, maps pointers to logical positions, and paints the selection highlight OUTSIDE the cached content ui.Picture (isRepaintBoundary when selectable). A SelectableBlockPainter interface + SelectableTextBlock mixin add selection to the paragraph/heading/quote/alert/code painters; MarkdownPainter tracks source block indices (for blockFilter) and exposes positionForLocal / paintHighlight. - selection.dart: MarkdownSelectionGroup coordinates multiple controllers (and external SelectableText via clearExternal) so only one selection is active; positionForGlobal falls back to the nearest surface so drags through the gaps between widgets still extend. - selection_scope.dart: MarkdownSelectionScope drives selection from a mouse/trackpad/stylus drag (DragStartBehavior.down) or touch long-press-drag. - widget.dart: MarkdownWidget gains optional documentId/controller (resolved from the ambient scope); inert and fully backward compatible when absent. 5 widget integration tests verify single-paragraph, cross-widget, disposal survival, group reset, and inert-without-documentId. Full suite 388 green, format + analyze --fatal-infos clean. Issue #25. Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/flutter_md.dart | 4 +- lib/src/render.dart | 245 ++++++++++++++++++++-- lib/src/selection.dart | 67 +++++- lib/src/selection_scope.dart | 80 +++++++ lib/src/widget.dart | 61 +++--- test/selection/selection_widget_test.dart | 225 ++++++++++++++++++++ test/unit_test.dart | 2 + 7 files changed, 637 insertions(+), 47 deletions(-) create mode 100644 lib/src/selection_scope.dart create mode 100644 test/selection/selection_widget_test.dart diff --git a/lib/flutter_md.dart b/lib/flutter_md.dart index 1db467f..14d8e59 100644 --- a/lib/flutter_md.dart +++ b/lib/flutter_md.dart @@ -3,7 +3,9 @@ library; export 'src/markdown.dart'; export 'src/nodes.dart'; export 'src/parser.dart'; -export 'src/render.dart' show BlockPainter; +export 'src/render.dart' + show BlockPainter, SelectableBlockPainter, SelectableTextBlock; export 'src/selection.dart'; +export 'src/selection_scope.dart'; export 'src/theme.dart'; export 'src/widget.dart'; diff --git a/lib/src/render.dart b/lib/src/render.dart index a8848b2..ca7e41b 100644 --- a/lib/src/render.dart +++ b/lib/src/render.dart @@ -12,10 +12,15 @@ import 'package:meta/meta.dart' as meta show internal; import 'markdown.dart'; import 'nodes.dart'; +import 'selection.dart'; import 'theme.dart'; +/// Default color used to paint the selection highlight beneath the glyphs. +const Color _kSelectionColor = Color(0x552196F3); + @meta.internal -class MarkdownRenderObject extends RenderBox { +class MarkdownRenderObject extends RenderBox + implements MarkdownSelectionSurface { MarkdownRenderObject({ required Markdown markdown, required MarkdownThemeData theme, @@ -27,13 +32,81 @@ class MarkdownRenderObject extends RenderBox { /// Painter for rendering markdown content. final MarkdownPainter _painter; + /// The selection controller this render object participates in, if any. + MarkdownSelectionController? _controller; + + /// The stable document id used to anchor selection positions. + Object? _documentId; + + static final Paint _highlightPaint = Paint()..color = _kSelectionColor; + + void _onSelectionChange() { + if (!_disposed) markNeedsPaint(); + } + + bool _disposed = false; + + void _attachController() { + final controller = _controller; + if (controller == null) return; + controller.addListener(_onSelectionChange); + if (attached) controller.attachSurface(this); + } + + void _detachController() { + final controller = _controller; + if (controller == null) return; + controller.removeListener(_onSelectionChange); + controller.detachSurface(this); + } + + /// Wires (or rewires) this render object to a selection [controller] under + /// [documentId]. Passing a null controller makes it non-selectable (inert). + @meta.internal + void updateSelection( + MarkdownSelectionController? controller, + Object? documentId, + ) { + if (identical(controller, _controller) && documentId == _documentId) return; + _detachController(); + _controller = controller; + _documentId = documentId; + _attachController(); + if (attached) { + markNeedsCompositingBitsUpdate(); + markNeedsPaint(); + } + } + + // --- MarkdownSelectionSurface --- + + @override + Object get documentId => _documentId!; + + @override + Rect get globalBounds => localToGlobal(Offset.zero) & size; + + @override + MarkdownPosition? positionForGlobal(Offset globalPosition) { + final id = _documentId; + if (id == null) return null; + final local = globalToLocal(globalPosition); + final hit = _painter.positionForLocal(local); + if (hit == null) return null; + return MarkdownPosition( + documentId: id, + blockIndex: hit.$1, + offset: hit.$2, + ); + } + /// Current size of the render box. @override Size get size => _size; Size _size = Size.zero; @override - bool get isRepaintBoundary => false; + bool get isRepaintBoundary => _controller != null; @override bool get alwaysNeedsCompositing => false; @@ -107,10 +180,10 @@ class MarkdownRenderObject extends RenderBox { } @override - // ignore: unnecessary_overrides void attach(PipelineOwner owner) { super.attach(owner); PaintingBinding.instance.systemFonts.addListener(_handleSystemFontsChange); + _controller?.attachSurface(this); } /// Updates the render object with a new values. @@ -134,12 +207,15 @@ class MarkdownRenderObject extends RenderBox { void detach() { PaintingBinding.instance.systemFonts .removeListener(_handleSystemFontsChange); + _controller?.detachSurface(this); super.detach(); } @override @protected void dispose() { + _disposed = true; + _controller?.removeListener(_onSelectionChange); super.dispose(); _painter.dispose(); } @@ -150,12 +226,23 @@ class MarkdownRenderObject extends RenderBox { if (_painter.isEmpty) return; // If the markdown is empty, do not paint anything. - // ignore: unused_local_variable final canvas = context.canvas ..save() ..translate(offset.dx, offset.dy); //..clipRect(Rect.fromLTWH(0, 0, size.width, size.height)); + // Paint the selection highlight OUTSIDE the cached content Picture, beneath + // the glyphs, so drag/streaming repaints never rebuild the glyph cache. + final controller = _controller; + final id = _documentId; + if (controller != null && id != null) { + _painter.paintHighlight( + canvas, + (source) => controller.rangeFor(id, source), + _highlightPaint, + ); + } + _painter.paint(canvas, size); canvas.restore(); @@ -196,6 +283,10 @@ class MarkdownPainter { Float32List _blockOffsets = Float32List(0); List _blockPainters = const []; + /// Source `Markdown.blocks` index for each painter (differs from the painter + /// index whenever a `blockFilter` drops blocks). + List _sourceIndices = const []; + static BlockPainter _defaultBlockBuilder( MD$Block block, MarkdownThemeData theme, @@ -250,18 +341,73 @@ class MarkdownPainter { _needsLayout = true; // Mark that layout needs to be recalculated. _size = Size.zero; // Reset size before rebuilding. final filter = _theme.blockFilter; - final filtered = - filter != null ? _markdown.blocks.where(filter) : _markdown.blocks; final builder = _theme.builder ?? _defaultBlockBuilder; - _blockPainters = filtered - .map( - (block) => - builder(block, _theme) ?? _defaultBlockBuilder(block, _theme), - ) - .toList(growable: false); + final blocks = _markdown.blocks; + final painters = []; + final sources = []; + for (var i = 0; i < blocks.length; i++) { + final block = blocks[i]; + if (filter != null && !filter(block)) continue; + painters + .add(builder(block, _theme) ?? _defaultBlockBuilder(block, _theme)); + sources.add(i); + } + _blockPainters = painters; + _sourceIndices = sources; _blockOffsets = Float32List(_blockPainters.length); } + /// Binary-searches the painter index whose vertical band contains [dy]. + int _blockIndexForDy(double dy) { + var min = 0; + var max = _blockOffsets.length; + var idx = 0; + while (min < max) { + final mid = min + ((max - min) >> 1); + final offset = _blockOffsets[mid]; + if (offset > dy) { + max = mid; + } else { + idx = mid; + if (offset == dy) break; + min = mid + 1; + } + } + return idx; + } + + /// Maps a content-local [local] offset to `(sourceBlockIndex, offset)`, or + /// null if the hit block does not support selection. + (int, int)? positionForLocal(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); + } + + /// Paints the selection highlight of every selectable block, using [rangeOf] + /// to look up the selected rendered range for a source block index. + void paintHighlight( + Canvas canvas, + TextRange? Function(int sourceIndex) rangeOf, + Paint paint, + ) { + for (var i = 0; i < _blockPainters.length; i++) { + final painter = _blockPainters[i]; + if (painter is! SelectableBlockPainter) continue; + final range = rangeOf(_sourceIndices[i]); + if (range == null || range.start >= range.end) continue; + final top = _blockOffsets[i]; + for (final rect in painter.boxesForRange(range.start, range.end)) { + canvas.drawRect(rect.shift(Offset(0, top)), paint); + } + } + } + /// Update the painter with new values. /// If the values are the same, /// no update is required and the method returns false. @@ -618,6 +764,46 @@ abstract interface class BlockPainter { void dispose(); } +/// A [BlockPainter] that supports text selection. All coordinates are local to +/// the block's top-left corner (as passed to [paint]'s `offset`). +/// +/// The [renderedText] MUST match [markdownBlockRenderedText] for the same block +/// so that hit-testing, highlighting, and extraction agree on the offset space. +abstract interface class SelectableBlockPainter implements BlockPainter { + /// The block's rendered plain text. + String get renderedText; + + /// Maps a block-local [local] offset to a rendered-text index. + int offsetForLocalPosition(Offset local); + + /// Highlight rectangles (block-local) for the rendered range `[start, end)`. + List boxesForRange(int start, int end); +} + +/// Provides [SelectableBlockPainter] for a block backed by a single +/// [TextPainter]. Subclasses supply [selectionPainter] and, when the glyphs are +/// not painted at the block origin, [selectionOrigin]. +mixin SelectableTextBlock implements SelectableBlockPainter { + /// The text painter that owns the selectable glyphs. + TextPainter get selectionPainter; + + /// Block-local origin where [selectionPainter] is painted. + Offset get selectionOrigin => Offset.zero; + + @override + String get renderedText => selectionPainter.plainText; + + @override + int offsetForLocalPosition(Offset local) => + selectionPainter.getPositionForOffset(local - selectionOrigin).offset; + + @override + List boxesForRange(int start, int end) => selectionPainter + .getBoxesForSelection(TextSelection(baseOffset: start, extentOffset: end)) + .map((box) => box.toRect().shift(selectionOrigin)) + .toList(growable: false); +} + @meta.internal mixin ParagraphGestureHandler { /// Handle tap events with a [TextPainter]. @@ -636,8 +822,11 @@ mixin ParagraphGestureHandler { /// A class for painting a paragraph block in markdown. @meta.internal class BlockPainter$Paragraph - with ParagraphGestureHandler + with ParagraphGestureHandler, SelectableTextBlock implements BlockPainter { + @override + TextPainter get selectionPainter => painter; + BlockPainter$Paragraph({ required List spans, required this.theme, @@ -710,8 +899,11 @@ class BlockPainter$Paragraph /// A class for painting a paragraph block in markdown. @meta.internal class BlockPainter$Heading - with ParagraphGestureHandler + with ParagraphGestureHandler, SelectableTextBlock implements BlockPainter { + @override + TextPainter get selectionPainter => painter; + BlockPainter$Heading({ required int level, required List spans, @@ -785,7 +977,14 @@ class BlockPainter$Heading /// A class for painting a quote block in markdown. @meta.internal -class BlockPainter$Quote with ParagraphGestureHandler implements BlockPainter { +class BlockPainter$Quote + with ParagraphGestureHandler, SelectableTextBlock + implements BlockPainter { + @override + TextPainter get selectionPainter => painter; + @override + Offset get selectionOrigin => Offset(lineIndent + indent * lineIndent, 0); + BlockPainter$Quote({ required List spans, required this.indent, @@ -893,7 +1092,14 @@ class BlockPainter$Quote with ParagraphGestureHandler implements BlockPainter { /// A class for painting a GitHub-style alert (admonition) block in markdown. @meta.internal -class BlockPainter$Alert with ParagraphGestureHandler implements BlockPainter { +class BlockPainter$Alert + with ParagraphGestureHandler, SelectableTextBlock + implements BlockPainter { + @override + TextPainter get selectionPainter => bodyPainter; + @override + Offset get selectionOrigin => _bodyOrigin; + BlockPainter$Alert({ required this.alert, required List spans, @@ -1287,7 +1493,12 @@ class BlockPainter$Divider implements BlockPainter { /// A class for painting a code block in markdown. @meta.internal -class BlockPainter$Code implements BlockPainter { +class BlockPainter$Code with SelectableTextBlock implements BlockPainter { + @override + TextPainter get selectionPainter => painter; + @override + Offset get selectionOrigin => const Offset(padding, padding); + BlockPainter$Code({ required String text, required String? language, diff --git a/lib/src/selection.dart b/lib/src/selection.dart index ab45ef0..3a28344 100644 --- a/lib/src/selection.dart +++ b/lib/src/selection.dart @@ -391,13 +391,19 @@ class MarkdownSelectionController extends ChangeNotifier { MarkdownSelectionController({ MarkdownReconciliationPolicy? reconciliation, MarkdownSelectionFormatter formatter = const MarkdownPlainTextFormatter(), + MarkdownSelectionGroup? group, }) : reconciliation = reconciliation ?? const MarkdownReconciliationPolicy.contentAnchored(), - _formatter = formatter; + _formatter = formatter, + _group = group { + group?._add(this); + } /// The anchor-remapping policy used on document updates. final MarkdownReconciliationPolicy reconciliation; + final MarkdownSelectionGroup? _group; + MarkdownSelectionFormatter _formatter; /// The default formatter used by [getText] when none is supplied. @@ -419,9 +425,16 @@ class MarkdownSelectionController extends ChangeNotifier { set selection(MarkdownSelection? value) { if (value == _selection) return; _selection = value; + if (value != null && !value.isCollapsed) _group?._claim(this); notifyListeners(); } + @override + void dispose() { + _group?._remove(this); + super.dispose(); + } + /// The registered documents, in reading order. List get documents => [ for (final e in _docs) @@ -530,13 +543,35 @@ class MarkdownSelectionController extends ChangeNotifier { Iterable get mountedSurfaces => _surfaces.values; /// 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) { + MarkdownSelectionSurface? nearest; + var bestDistance = double.infinity; for (final surface in _surfaces.values) { - if (surface.globalBounds.contains(globalPosition)) { + final bounds = surface.globalBounds; + if (bounds.contains(globalPosition)) { return surface.positionForGlobal(globalPosition); } + final dy = globalPosition.dy < bounds.top + ? bounds.top - globalPosition.dy + : (globalPosition.dy > bounds.bottom + ? globalPosition.dy - bounds.bottom + : 0.0); + if (dy < bestDistance) { + bestDistance = dy; + nearest = surface; + } } - return null; + if (nearest == null) return null; + final bounds = nearest.globalBounds; + final clamped = Offset( + globalPosition.dx.clamp(bounds.left, bounds.right - 0.01), + globalPosition.dy.clamp(bounds.top, bounds.bottom - 0.01), + ); + return nearest.positionForGlobal(clamped); } // --- mutation ------------------------------------------------------------ @@ -680,3 +715,29 @@ class MarkdownSelectionController extends ChangeNotifier { ? (sel.base, sel.extent) : (sel.extent, sel.base); } + +/// Coordinates several controllers (and external selectables) so that at most +/// one has an active selection at a time. Pass the same group +/// to each controller; when one starts a (non-collapsed) selection the others +/// are cleared. Call [clearExternal] when a non-Markdown selectable (e.g. a +/// plain `SelectableText` / `SelectionArea`) begins its own selection. +class MarkdownSelectionGroup { + final Set _members = + {}; + + void _add(MarkdownSelectionController controller) => _members.add(controller); + + void _remove(MarkdownSelectionController controller) => + _members.remove(controller); + + void _claim(MarkdownSelectionController owner) { + for (final member in _members) { + if (!identical(member, owner)) member.clear(); + } + } + + /// Clears the selection of every member controller in this group. + void clearExternal() { + for (final member in _members) member.clear(); + } +} diff --git a/lib/src/selection_scope.dart b/lib/src/selection_scope.dart new file mode 100644 index 0000000..331cfb6 --- /dev/null +++ b/lib/src/selection_scope.dart @@ -0,0 +1,80 @@ +import 'package:flutter/gestures.dart'; +import 'package:flutter/widgets.dart'; + +import 'selection.dart'; + +class _ScopeMarker extends InheritedWidget { + const _ScopeMarker({required this.controller, required super.child}); + + final MarkdownSelectionController controller; + + @override + bool updateShouldNotify(_ScopeMarker old) => + !identical(controller, old.controller); +} + +/// Owns Markdown selection gestures for its subtree and exposes the ambient +/// [MarkdownSelectionController] to descendant `MarkdownWidget`s. +/// +/// A mouse/trackpad/stylus drag selects; on touch a long-press-then-drag +/// selects (so a plain swipe still scrolls an enclosing list). Wrap a chat's +/// `ListView` (or any group of `MarkdownWidget`s sharing one controller) in a +/// single scope to get selection that spans widgets and survives disposal. +class MarkdownSelectionScope extends StatelessWidget { + /// Creates a selection scope backed by [controller]. + const MarkdownSelectionScope({ + required this.controller, + required this.child, + super.key, + }); + + /// The controller that owns the selection for this subtree. + final MarkdownSelectionController controller; + + /// The subtree in which selection gestures apply. + final Widget child; + + /// The nearest ambient controller, or null if there is no enclosing scope. + static MarkdownSelectionController? maybeOf(BuildContext context) => + context.dependOnInheritedWidgetOfExactType<_ScopeMarker>()?.controller; + + /// The nearest ambient controller. Throws if there is no enclosing scope. + static MarkdownSelectionController of(BuildContext context) => + maybeOf(context)!; + + @override + Widget build(BuildContext context) => _ScopeMarker( + controller: controller, + child: RawGestureDetector( + behavior: HitTestBehavior.translucent, + gestures: { + PanGestureRecognizer: + GestureRecognizerFactoryWithHandlers( + () => PanGestureRecognizer( + supportedDevices: const { + PointerDeviceKind.mouse, + PointerDeviceKind.stylus, + PointerDeviceKind.invertedStylus, + PointerDeviceKind.trackpad, + }, + ), + (recognizer) => recognizer + ..dragStartBehavior = DragStartBehavior.down + ..onStart = ((d) => controller.startAtGlobal(d.globalPosition)) + ..onUpdate = + ((d) => controller.extendToGlobal(d.globalPosition)), + ), + LongPressGestureRecognizer: GestureRecognizerFactoryWithHandlers< + LongPressGestureRecognizer>( + () => LongPressGestureRecognizer(), + (recognizer) => recognizer + ..onLongPressStart = + ((d) => controller.startAtGlobal(d.globalPosition)) + ..onLongPressMoveUpdate = + ((d) => controller.extendToGlobal(d.globalPosition)), + ), + }, + child: child, + ), + ); +} diff --git a/lib/src/widget.dart b/lib/src/widget.dart index 1bb6dcc..1d65e8a 100644 --- a/lib/src/widget.dart +++ b/lib/src/widget.dart @@ -2,6 +2,8 @@ import 'package:flutter/widgets.dart'; import 'markdown.dart' show Markdown; import 'render.dart' show MarkdownRenderObject; +import 'selection.dart' show MarkdownSelectionController; +import 'selection_scope.dart' show MarkdownSelectionScope; import 'theme.dart'; /// {@template markdown_widget} @@ -12,6 +14,8 @@ class MarkdownWidget extends LeafRenderObjectWidget { const MarkdownWidget({ required this.markdown, this.theme, + this.controller, + this.documentId, super.key, // ignore: unused_element }); @@ -21,38 +25,43 @@ class MarkdownWidget extends LeafRenderObjectWidget { /// Current theme for the markdown widget. final MarkdownThemeData? theme; + /// The selection controller this widget participates in. When null, the + /// nearest [MarkdownSelectionScope] controller is used, if any. + final MarkdownSelectionController? controller; + + /// The stable document id used to anchor selection positions. Selection is + /// only enabled when this is non-null AND a controller is available; the app + /// must register this document's model with the controller. + final Object? documentId; + + MarkdownThemeData _resolveTheme(BuildContext context) => + theme ?? + MarkdownTheme.maybeOf(context) ?? + MarkdownThemeData( + textStyle: DefaultTextStyle.of(context).style, + textDirection: Directionality.maybeOf(context) ?? TextDirection.ltr, + textScaler: + MediaQuery.maybeTextScalerOf(context) ?? TextScaler.noScaling, + ); + + MarkdownSelectionController? _resolveController(BuildContext context) => + documentId == null + ? null + : (controller ?? MarkdownSelectionScope.maybeOf(context)); + @override - RenderObject createRenderObject(BuildContext context) { - final theme = this.theme ?? - MarkdownTheme.maybeOf(context) ?? - MarkdownThemeData( - textStyle: DefaultTextStyle.of(context).style, - textDirection: Directionality.maybeOf(context) ?? TextDirection.ltr, - textScaler: - MediaQuery.maybeTextScalerOf(context) ?? TextScaler.noScaling, - ); - return MarkdownRenderObject( - markdown: markdown, - theme: theme, - ); - } + RenderObject createRenderObject(BuildContext context) => MarkdownRenderObject( + markdown: markdown, + theme: _resolveTheme(context), + )..updateSelection(_resolveController(context), documentId); @override void updateRenderObject( BuildContext context, MarkdownRenderObject renderObject, ) { - final theme = this.theme ?? - MarkdownTheme.maybeOf(context) ?? - MarkdownThemeData( - textStyle: DefaultTextStyle.of(context).style, - textDirection: Directionality.maybeOf(context) ?? TextDirection.ltr, - textScaler: - MediaQuery.maybeTextScalerOf(context) ?? TextScaler.noScaling, - ); - renderObject.update( - markdown: markdown, - theme: theme, - ); + renderObject + ..update(markdown: markdown, theme: _resolveTheme(context)) + ..updateSelection(_resolveController(context), documentId); } } diff --git a/test/selection/selection_widget_test.dart b/test/selection/selection_widget_test.dart new file mode 100644 index 0000000..3c233a6 --- /dev/null +++ b/test/selection/selection_widget_test.dart @@ -0,0 +1,225 @@ +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_md/flutter_md.dart'; +import 'package:flutter_test/flutter_test.dart'; + +Future _mouseDrag(WidgetTester tester, Offset from, Offset to) async { + final g = await tester.startGesture(from, kind: PointerDeviceKind.mouse); + await tester.pump(const Duration(milliseconds: 200)); + await g.moveTo(to); + await tester.pump(const Duration(milliseconds: 200)); + await g.up(); + await tester.pumpAndSettle(); +} + +Widget _wrap(MarkdownSelectionController controller, Widget child) => + MaterialApp( + home: Scaffold( + body: MarkdownSelectionScope(controller: controller, child: child), + ), + ); + +void main() { + group('selection widget integration', () { + testWidgets('drag selects a single paragraph', (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)); + final br = tester.getBottomRight(find.byType(MarkdownWidget)); + await _mouseDrag( + tester, tl + const Offset(1, 3), br - const Offset(1, 3)); + + expect(controller.getText(), 'Hello selectable world'); + expect(tester.takeException(), isNull); + }); + + testWidgets('drag spans two MarkdownWidgets with a document separator', + (tester) async { + final a = Markdown.fromString('First message body'); + final b = Markdown.fromString('Second message body'); + final controller = MarkdownSelectionController() + ..setDocuments([ + MarkdownDocumentRef(id: 'a', model: a), + MarkdownDocumentRef(id: 'b', model: b), + ]); + + await tester.pumpWidget(_wrap( + controller, + const Align( + alignment: Alignment.topLeft, + child: SizedBox( + width: 400, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [_Doc('a'), _Doc('b')], + ), + ), + ), + )); + await tester.pumpAndSettle(); + + final first = find.byType(MarkdownWidget).first; + final last = find.byType(MarkdownWidget).last; + await _mouseDrag( + tester, + tester.getTopLeft(first) + const Offset(1, 3), + tester.getBottomRight(last) - const Offset(1, 3), + ); + + expect(controller.getText(), 'First message body\n\nSecond message body'); + expect(tester.takeException(), isNull); + }); + + testWidgets('cross-widget selection survives ListView disposal', + (tester) async { + 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, + cacheExtent: 0, + itemCount: 10, + itemBuilder: (_, i) => SizedBox( + height: 80, + child: _Doc('m$i'), + ), + ), + ), + )); + await tester.pumpAndSettle(); + + final p0 = tester.getTopLeft(find.byType(MarkdownWidget).first) + + const Offset(1, 3); + final p1 = tester.getCenter(find.byType(MarkdownWidget).at(1)); + await _mouseDrag(tester, p0, p1); + final before = controller.getText(); + expect(before, contains('Message number 0')); + expect(before, contains('Message number 1')); + + scroll.jumpTo(80.0 * 7); // dispose the first messages + await tester.pumpAndSettle(); + expect( + find.byWidgetPredicate( + (w) => w is MarkdownWidget && w.documentId == 'm0'), + findsNothing); + + // Text is derived from the model registry → intact after disposal. + expect(controller.getText(), before); + expect(controller.getText(), contains('Message number 0')); + expect(tester.takeException(), isNull); + }); + + testWidgets('selecting in one controller clears the other (group)', + (tester) async { + final group = MarkdownSelectionGroup(); + final a = Markdown.fromString('Alpha body text'); + final b = Markdown.fromString('Bravo body text'); + final ca = MarkdownSelectionController(group: group) + ..setDocuments( + [MarkdownDocumentRef(id: 'a', model: a)]); + final cb = MarkdownSelectionController(group: group) + ..setDocuments( + [MarkdownDocumentRef(id: 'b', model: b)]); + + await tester.pumpWidget(MaterialApp( + home: Scaffold( + body: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + MarkdownSelectionScope( + controller: ca, + child: const SizedBox(width: 400, child: _Doc('a')), + ), + MarkdownSelectionScope( + controller: cb, + child: const SizedBox(width: 400, child: _Doc('b')), + ), + ], + ), + ), + )); + await tester.pumpAndSettle(); + + // Select in controller B first. + final bw = find + .byWidgetPredicate((w) => w is MarkdownWidget && w.documentId == 'b'); + await _mouseDrag( + tester, + tester.getTopLeft(bw) + const Offset(1, 3), + tester.getBottomRight(bw) - const Offset(1, 3), + ); + expect(cb.getText(), isNotEmpty); + + // Now select in controller A — B must be cleared. + final aw = find + .byWidgetPredicate((w) => w is MarkdownWidget && w.documentId == 'a'); + await _mouseDrag( + tester, + tester.getTopLeft(aw) + const Offset(1, 3), + tester.getBottomRight(aw) - const Offset(1, 3), + ); + expect(ca.getText(), isNotEmpty); + expect(cb.selection, isNull, + reason: 'group cleared the other controller'); + expect(tester.takeException(), isNull); + }); + + testWidgets('MarkdownWidget without documentId stays inert', + (tester) async { + final md = Markdown.fromString('Not selectable here'); + final controller = MarkdownSelectionController() + ..setDocuments( + [MarkdownDocumentRef(id: 'x', model: md)]); + await tester.pumpWidget(_wrap( + controller, + SizedBox(width: 400, child: MarkdownWidget(markdown: md)), + )); + 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.getText(), '', reason: 'no documentId => inert'); + expect(tester.takeException(), isNull); + }); + }); +} + +/// A MarkdownWidget that resolves its controller from the ambient scope. +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); + } +} diff --git a/test/unit_test.dart b/test/unit_test.dart index 889f3a3..4a320bc 100644 --- a/test/unit_test.dart +++ b/test/unit_test.dart @@ -10,6 +10,7 @@ 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 'selection/selection_test.dart' as selection_test; +import 'selection/selection_widget_test.dart' as selection_widget_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; @@ -26,6 +27,7 @@ void main() => group('Unit', () { nodes_test.main(); theme_test.main(); selection_test.main(); + selection_widget_test.main(); render_test.main(); widget_test.main(); }); From 3ee3f36a64b03296067ea2c284138f7ac91848c9 Mon Sep 17 00:00:00 2001 From: Mike Matiunin Date: Thu, 30 Jul 2026 17:41:26 +0400 Subject: [PATCH 03/30] example: tabbed selection demo (editor, lorem, chat) Restructures the example into three tabs: - Editor: the existing split-pane live editor (unchanged behaviour). - Selection: cross-block selection in a MarkdownWidget, two independent controllers that reset each other via a shared MarkdownSelectionGroup, and a plain SelectableText that participates in the reset (both directions). - Chat: a ListView.builder of message bubbles with cross-message selection through one controller (survives scroll/disposal), a Copy button, and a Stream button that grows the last message to show reconciliation. Adds example/test/smoke_test.dart verifying every tab builds and a drag-select does not throw. Issue #25. Co-Authored-By: Claude Opus 4.8 (1M context) --- example/lib/main.dart | 215 +++++++++++++++++++------------- example/lib/tabs/chat_tab.dart | 179 ++++++++++++++++++++++++++ example/lib/tabs/lorem_tab.dart | 166 ++++++++++++++++++++++++ example/test/smoke_test.dart | 40 ++++++ 4 files changed, 515 insertions(+), 85 deletions(-) create mode 100644 example/lib/tabs/chat_tab.dart create mode 100644 example/lib/tabs/lorem_tab.dart create mode 100644 example/test/smoke_test.dart diff --git a/example/lib/main.dart b/example/lib/main.dart index 6373591..cd2a527 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -5,6 +5,9 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_md/flutter_md.dart'; +import 'tabs/chat_tab.dart'; +import 'tabs/lorem_tab.dart'; + void main() => runZonedGuarded( () => runApp(ThemeModel( notifier: ValueNotifier(ThemeMode.dark), @@ -86,7 +89,7 @@ class ThemeModel extends InheritedNotifier> { } /// {@template home_screen} -/// HomeScreen widget. +/// HomeScreen widget: a tabbed showcase of the markdown renderer. /// {@endtemplate} class HomeScreen extends StatefulWidget { /// {@macro home_screen} @@ -99,7 +102,65 @@ class HomeScreen extends StatefulWidget { } /// State for widget HomeScreen. -class _HomeScreenState extends State { +class _HomeScreenState extends State + with SingleTickerProviderStateMixin { + late final TabController _tabs = TabController(length: 3, vsync: this); + + @override + void dispose() { + _tabs.dispose(); + super.dispose(); + } + + @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)), + ], + ), + ), + body: SafeArea( + child: TabBarView( + controller: _tabs, + children: const [ + EditorTab(), + LoremTab(), + ChatTab(), + ], + ), + ), + ); +} + +/// {@template editor_tab} +/// A split-pane live Markdown editor (source on one side, render on the other). +/// {@endtemplate} +class EditorTab extends StatefulWidget { + /// {@macro editor_tab} + const EditorTab({super.key}); + + @override + State createState() => _EditorTabState(); +} + +/// State for widget EditorTab. +class _EditorTabState extends State { final MultiChildLayoutDelegate _layoutDelegate = _HomeScreenLayoutDelegate(); final TextEditingController _inputController = TextEditingController(text: _markdownExample); @@ -138,100 +199,84 @@ class _HomeScreenState extends State { } @override - Widget build(BuildContext context) => Scaffold( - appBar: AppBar( - centerTitle: true, - title: const Text('Markdown'), - actions: [ - // theme switch widget - Switch.adaptive( - value: ThemeModel.of(context).value == ThemeMode.dark, - onChanged: (value) { - ThemeModel.of(context).value = - value ? ThemeMode.dark : ThemeMode.light; - }), - ]), - body: SafeArea( - child: 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', - ), - ), + 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', ), - 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, ), ), ), ), - ], + ), ), - ), + ], ); } diff --git a/example/lib/tabs/chat_tab.dart b/example/lib/tabs/chat_tab.dart new file mode 100644 index 0000000..f87ef0f --- /dev/null +++ b/example/lib/tabs/chat_tab.dart @@ -0,0 +1,179 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_md/flutter_md.dart'; + +/// A chat-like tab: messages in a `ListView.builder` with cross-message text +/// selection driven by a single [MarkdownSelectionController]. Because the +/// selection is anchored on the immutable models, it survives messages being +/// scrolled off-screen (and disposed), and the whole selection can be copied at +/// any time. The "Stream" button grows the last message to show that streaming +/// updates keep the selection anchored. +class ChatTab extends StatefulWidget { + /// Creates the chat demo tab. + const ChatTab({super.key}); + + @override + State createState() => _ChatTabState(); +} + +class _ChatTabState extends State { + final MarkdownSelectionController _controller = MarkdownSelectionController(); + late final List<_Msg> _messages = _seed(); + int _streamCount = 0; + + @override + void initState() { + super.initState(); + _controller.setDocuments([ + for (final (i, m) in _messages.indexed) + MarkdownDocumentRef(id: m.id, model: m.markdown, order: i), + ]); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + Future _copy() async { + final text = _controller.getText(); + await Clipboard.setData(ClipboardData(text: text)); + if (!mounted) return; + ScaffoldMessenger.of(context) + ..clearSnackBars() + ..showSnackBar(SnackBar( + content: Text(text.isEmpty ? 'Nothing selected' : 'Copied:\n$text'), + )); + } + + void _stream() { + final last = _messages.last; + _streamCount++; + final grown = Markdown.fromString( + '${last.markdown.markdown} …streamed token #$_streamCount'); + setState(() => _messages[_messages.length - 1] = last.withMarkdown(grown)); + // Reconciliation keeps any active selection anchored across the update. + _controller.putDocument(last.id, grown, order: _messages.length - 1); + } + + @override + Widget build(BuildContext context) => Column( + children: [ + Expanded( + child: MarkdownSelectionScope( + controller: _controller, + child: ListView.builder( + padding: const EdgeInsets.all(12), + itemCount: _messages.length, + itemBuilder: (context, i) => _Bubble(message: _messages[i]), + ), + ), + ), + _SelectionBar( + controller: _controller, onCopy: _copy, onStream: _stream), + ], + ); +} + +class _SelectionBar extends StatelessWidget { + const _SelectionBar({ + required this.controller, + required this.onCopy, + required this.onStream, + }); + + final MarkdownSelectionController controller; + final Future Function() onCopy; + final VoidCallback onStream; + + @override + Widget build(BuildContext context) => Material( + elevation: 8, + child: SafeArea( + top: false, + child: Padding( + padding: const EdgeInsets.all(8), + child: Row( + children: [ + Expanded( + child: AnimatedBuilder( + animation: controller, + builder: (context, _) { + final n = controller.getText().length; + return Text(n == 0 + ? 'Drag (mouse) / long-press-drag (touch) across messages' + : 'Selected $n characters across messages'); + }, + ), + ), + TextButton.icon( + onPressed: onStream, + icon: const Icon(Icons.bolt), + label: const Text('Stream'), + ), + const SizedBox(width: 8), + FilledButton.icon( + onPressed: onCopy, + icon: const Icon(Icons.copy), + label: const Text('Copy'), + ), + ], + ), + ), + ), + ); +} + +class _Bubble extends StatelessWidget { + const _Bubble({required this.message}); + + final _Msg message; + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + final isUser = message.isUser; + return Align( + alignment: isUser ? Alignment.centerRight : Alignment.centerLeft, + child: Container( + margin: const EdgeInsets.symmetric(vertical: 6), + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + constraints: + BoxConstraints(maxWidth: MediaQuery.sizeOf(context).width * 0.82), + decoration: BoxDecoration( + color: + isUser ? scheme.primaryContainer : scheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(14), + ), + child: + MarkdownWidget(markdown: message.markdown, documentId: message.id), + ), + ); + } +} + +class _Msg { + const _Msg(this.id, this.isUser, this.markdown); + final String id; + final bool isUser; + final Markdown markdown; + _Msg withMarkdown(Markdown m) => _Msg(id, isUser, m); +} + +List<_Msg> _seed() { + Markdown md(String s) => Markdown.fromString(s); + return <_Msg>[ + for (var i = 0; i < 14; i++) + _Msg( + 'm$i', + i.isEven, + md(i.isEven + ? 'Question **#${i ~/ 2}**: how does cross-message selection work?' + : 'Answer ${i ~/ 2}:\n\nSelection is anchored on the *immutable ' + 'model*, so it survives `ListView` disposal. Try dragging across ' + 'me and the next messages, scroll, then press **Copy**.\n\n' + '- point one for message $i\n- point two for message $i'), + ), + ]; +} diff --git a/example/lib/tabs/lorem_tab.dart b/example/lib/tabs/lorem_tab.dart new file mode 100644 index 0000000..016d7e9 --- /dev/null +++ b/example/lib/tabs/lorem_tab.dart @@ -0,0 +1,166 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_md/flutter_md.dart'; + +const String _loremMdA = ''' +# Lorem ipsum + +**Lorem ipsum** dolor sit amet, consectetur _adipiscing_ elit. Sed do eiusmod +tempor incididunt ut labore et dolore magna aliqua, drag across these blocks. + +> Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi. + +- alpha item +- beta item +- gamma item +'''; + +const String _loremMdB = ''' +## A different document + +Duis aute irure dolor in `reprehenderit` in voluptate velit esse cillum dolore +eu fugiat nulla pariatur — this block belongs to a **second** controller. +'''; + +const String _loremPlain = + 'This is a plain SelectableText (not Markdown). Selecting here clears the ' + 'Markdown selections above — and selecting Markdown clears this one.'; + +/// Demonstrates cross-block selection within a single [MarkdownWidget], several +/// independent controllers that reset one another via a shared +/// [MarkdownSelectionGroup], and coordination with a plain `SelectableText`. +class LoremTab extends StatefulWidget { + /// Creates the lorem-ipsum selection demo tab. + const LoremTab({super.key}); + + @override + State createState() => _LoremTabState(); +} + +class _LoremTabState extends State { + final MarkdownSelectionGroup _group = MarkdownSelectionGroup(); + 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); + + // Bumping this key recreates the SelectionArea, clearing its selection when a + // Markdown selection starts (the Markdown -> plain direction of the reset). + int _plainEpoch = 0; + bool _mdActive = false; + + @override + void initState() { + super.initState(); + _a.setDocuments( + [MarkdownDocumentRef(id: 'A', model: _docA)]); + _b.setDocuments( + [MarkdownDocumentRef(id: 'B', model: _docB)]); + _a.addListener(_onMarkdownSelection); + _b.addListener(_onMarkdownSelection); + } + + bool _isActive(MarkdownSelectionController c) => + c.selection != null && !c.selection!.isCollapsed; + + void _onMarkdownSelection() { + final active = _isActive(_a) || _isActive(_b); + setState(() { + if (active && !_mdActive) _plainEpoch++; // clear the plain SelectableText + _mdActive = active; + }); + } + + @override + void dispose() { + _a + ..removeListener(_onMarkdownSelection) + ..dispose(); + _b + ..removeListener(_onMarkdownSelection) + ..dispose(); + super.dispose(); + } + + Future _copy() async { + final text = + _isActive(_a) ? _a.getText() : (_isActive(_b) ? _b.getText() : ''); + if (text.isEmpty) return; + await Clipboard.setData(ClipboardData(text: text)); + if (!mounted) return; + ScaffoldMessenger.of(context) + ..clearSnackBars() + ..showSnackBar(SnackBar(content: Text('Copied:\n$text'))); + } + + Widget _label(String text) => Padding( + padding: const EdgeInsets.only(bottom: 8, top: 4), + child: Text(text, style: Theme.of(context).textTheme.labelLarge), + ); + + @override + Widget build(BuildContext context) => Column( + children: [ + Expanded( + child: SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _label('Markdown A — its own controller'), + MarkdownSelectionScope( + controller: _a, + child: MarkdownWidget(markdown: _docA, documentId: 'A'), + ), + const Divider(height: 40), + _label('Markdown B — a different controller ' + '(selecting one clears the other)'), + MarkdownSelectionScope( + controller: _b, + 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(8), + child: Row( + children: [ + Expanded( + child: Text(_mdActive + ? 'Markdown selection active' + : 'Drag across a Markdown block to select'), + ), + FilledButton.icon( + onPressed: _copy, + icon: const Icon(Icons.copy), + label: const Text('Copy'), + ), + ], + ), + ), + ), + ), + ], + ); +} diff --git a/example/test/smoke_test.dart b/example/test/smoke_test.dart new file mode 100644 index 0000000..424ae2d --- /dev/null +++ b/example/test/smoke_test.dart @@ -0,0 +1,40 @@ +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'; + +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); + + // 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); + }); +} From 4951d77cf3f978d936d10515d079f931696ecd32 Mon Sep 17 00:00:00 2001 From: Mike Matiunin Date: Thu, 30 Jul 2026 17:43:58 +0400 Subject: [PATCH 04/30] docs: document text selection; bump to 0.2.0 README gains a Text Selection section (controller, scope, group, formatter, streaming reconcile, gestures) and a Features bullet; CHANGELOG 0.2.0 lists the new public API. Issue #25. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 19 +++++++++++++++++++ README.md | 53 ++++++++++++++++++++++++++++++++++++++++++++++++++++ pubspec.yaml | 2 +- 3 files changed, 73 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d0f4b1d..9f2e5a6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,22 @@ +## 0.2.0 + +- **ADDED**: Cross-block and cross-widget text selection. A + `MarkdownSelectionController` anchors the selection on the immutable model, so + it spans multiple blocks and multiple `MarkdownWidget`s and survives list + disposal (e.g. chat scrolling). New public API: `MarkdownSelectionController`, + `MarkdownSelectionScope`, `MarkdownSelectionGroup`, `MarkdownPosition`, + `MarkdownSelection`, `MarkdownDocumentRef`, `MarkdownSelectedContent` + (+ document/block), `MarkdownSelectionFormatter` / + `MarkdownPlainTextFormatter`, `MarkdownReconciliationPolicy`, + `MarkdownSelectionSurface`, `markdownBlockRenderedText`, and + `SelectableBlockPainter` / `SelectableTextBlock`. +- **ADDED**: `MarkdownWidget` gains optional `documentId` and `controller` + parameters (resolved from the ambient scope). Backward compatible: a widget + with no `documentId` is inert. +- **CHANGED**: `MarkdownWidget`'s render object now draws the selection + highlight outside the cached content `Picture` and becomes a repaint boundary + when selectable, so selection/drag repaints do not rebuild the glyph cache. + ## 0.1.0 - **ADDED**: GitHub-style alert blocks (`> [!NOTE]`, `> [!TIP]`, `> [!IMPORTANT]`, diff --git a/README.md b/README.md index e8d5ef6..455b2cf 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,8 @@ A high-performance, lightweight Markdown parser and renderer specifically design - **🎨 Fully Customizable**: Theme-based styling with complete control over appearance - **📱 Flutter Native**: Built from the ground up for Flutter with custom render objects - **🔗 Interactive Elements**: Clickable links with customizable tap handlers +- **✂️ Text Selection**: Cross-block and cross-widget (chat) selection via a + controller that survives list disposal and streaming updates - **🌐 Cross Platform**: Works on all Flutter-supported platforms - **📝 GitHub Flavored**: Alerts (`> [!NOTE]`), task lists (`- [x]`), tables with column alignment, thematic breaks, strikethrough, and more @@ -190,6 +192,57 @@ Then run: flutter pub get ``` +## ✂️ Text Selection + +Selection is anchored on the immutable Markdown model, not on the render +objects, so it spans multiple blocks (heading → paragraph → list → table cell) +**and** multiple `MarkdownWidget`s (e.g. chat messages), and it survives widgets +being scrolled off-screen and disposed. Wrap a group of widgets in a +`MarkdownSelectionScope`, give each a stable `documentId`, and register the +models with the controller: + +```dart +final controller = MarkdownSelectionController(); + +// Register the documents in reading order (a chat feeds this from its list). +controller.setDocuments([ + for (final (i, m) in messages.indexed) + MarkdownDocumentRef(id: m.id, model: m.markdown, order: i), +]); + +MarkdownSelectionScope( + controller: controller, + child: ListView.builder( + itemCount: messages.length, + itemBuilder: (context, i) => MarkdownWidget( + documentId: messages[i].id, + markdown: messages[i].markdown, + ), + ), +); + +// Any time — even for messages scrolled off-screen: +final String text = controller.getText(); // default formatter +final MarkdownSelectedContent structured = controller.selectedContent(); +``` + +- **Get the text your way.** `getText()` uses the default + `MarkdownPlainTextFormatter` (configurable block/document separators); pass a + custom `MarkdownSelectionFormatter` for e.g. "Copy as Markdown". + `selectedContent()` returns the structured per-document / per-block result. +- **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). +- **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. +- **Gestures.** A mouse/trackpad/stylus drag selects; on touch a + long-press-then-drag selects (so a plain swipe still scrolls the list). +- **Opt-in & compatible.** A `MarkdownWidget` with no `documentId`/controller is + inert — existing usage is unchanged. + +See the runnable **Selection** and **Chat** tabs in `example/`. + ## 🎨 Customization ### Theme Configuration diff --git a/pubspec.yaml b/pubspec.yaml index fb86420..666a301 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -8,7 +8,7 @@ description: > Markdown library written in Dart. It can parse and display Markdown. -version: 0.1.0 +version: 0.2.0 homepage: https://github.com/DoctorinaAI/md repository: https://github.com/DoctorinaAI/md From 24173315832272e1f8bf786fc993db4539d46bc7 Mon Sep 17 00:00:00 2001 From: Mike Matiunin Date: Thu, 30 Jul 2026 17:59:50 +0400 Subject: [PATCH 05/30] fix: crash when dragging near a zero-size selection surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adversarial review found that positionForGlobal's nearest-surface fallback inverted num.clamp's limits (right-0.01 < left) for a zero-size surface — e.g. a MarkdownWidget(markdown: Markdown.empty()) (a streaming chat message before any tokens) or a spacer/divider-only document — throwing ArgumentError inside the drag/long-press gesture callback. Skip zero-area surfaces and clamp defensively. Two regression tests cover drag-with-only-empty-doc and drag-past-empty-doc. Also documents that a theme spanFilter dropping text-bearing spans shifts the painter offset space vs the unfiltered model (highlight stays correct, copied text may misalign) — avoid it with selection enabled. Issue #25. Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/src/render.dart | 10 +++- lib/src/selection.dart | 13 +++++- test/selection/selection_widget_test.dart | 56 +++++++++++++++++++++++ 3 files changed, 75 insertions(+), 4 deletions(-) diff --git a/lib/src/render.dart b/lib/src/render.dart index ca7e41b..5a719dc 100644 --- a/lib/src/render.dart +++ b/lib/src/render.dart @@ -767,8 +767,14 @@ abstract interface class BlockPainter { /// A [BlockPainter] that supports text selection. All coordinates are local to /// the block's top-left corner (as passed to [paint]'s `offset`). /// -/// The [renderedText] MUST match [markdownBlockRenderedText] for the same block -/// so that hit-testing, highlighting, and extraction agree on the offset space. +/// The [renderedText] should match [markdownBlockRenderedText] for the same +/// block so that hit-testing, highlighting, and model-side extraction agree on +/// the offset space. +/// +/// Caveat: a [MarkdownThemeData.spanFilter] that drops text-bearing spans +/// shifts the painter's offset space relative to the (unfiltered) model, so the +/// on-screen highlight stays correct but copied text may be misaligned. Avoid +/// dropping text-bearing spans when selection is enabled. abstract interface class SelectableBlockPainter implements BlockPainter { /// The block's rendered plain text. String get renderedText; diff --git a/lib/src/selection.dart b/lib/src/selection.dart index 3a28344..cff0c34 100644 --- a/lib/src/selection.dart +++ b/lib/src/selection.dart @@ -552,6 +552,9 @@ class MarkdownSelectionController extends ChangeNotifier { var bestDistance = double.infinity; for (final surface in _surfaces.values) { 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.positionForGlobal(globalPosition); } @@ -567,9 +570,15 @@ class MarkdownSelectionController extends ChangeNotifier { } if (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). + final maxX = bounds.right - 0.01; + final maxY = bounds.bottom - 0.01; final clamped = Offset( - globalPosition.dx.clamp(bounds.left, bounds.right - 0.01), - globalPosition.dy.clamp(bounds.top, bounds.bottom - 0.01), + globalPosition.dx + .clamp(bounds.left, maxX < bounds.left ? bounds.left : maxX), + globalPosition.dy + .clamp(bounds.top, maxY < bounds.top ? bounds.top : maxY), ); return nearest.positionForGlobal(clamped); } diff --git a/test/selection/selection_widget_test.dart b/test/selection/selection_widget_test.dart index 3c233a6..26c6998 100644 --- a/test/selection/selection_widget_test.dart +++ b/test/selection/selection_widget_test.dart @@ -208,6 +208,62 @@ void main() { expect(controller.getText(), '', reason: 'no documentId => inert'); expect(tester.takeException(), isNull); }); + + testWidgets('drag with only an empty (zero-size) document does not crash', + (tester) async { + // Regression: positionForGlobal's nearest-surface fallback used to invert + // num.clamp for a zero-area surface, throwing ArgumentError mid-drag. + final controller = MarkdownSelectionController() + ..setDocuments(const [ + MarkdownDocumentRef(id: 'e', model: Markdown.empty()), + ]); + await tester.pumpWidget(_wrap( + controller, + const SizedBox(width: 400, height: 200, child: _Doc('e')), + )); + await tester.pumpAndSettle(); + + await _mouseDrag(tester, const Offset(20, 20), const Offset(220, 160)); + expect(tester.takeException(), isNull); + expect(controller.getText(), ''); + }); + + testWidgets('drag past an empty document still selects a real one', + (tester) async { + final controller = MarkdownSelectionController() + ..setDocuments([ + const MarkdownDocumentRef( + id: 'empty', model: Markdown.empty(), order: 0), + MarkdownDocumentRef( + id: 'real', + model: Markdown.fromString('Real content here'), + order: 1), + ]); + await tester.pumpWidget(_wrap( + controller, + const Align( + alignment: Alignment.topLeft, + child: SizedBox( + width: 400, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [_Doc('empty'), _Doc('real')], + ), + ), + ), + )); + await tester.pumpAndSettle(); + + final realWidget = find.byWidgetPredicate( + (w) => w is MarkdownWidget && w.documentId == 'real'); + await _mouseDrag( + tester, + tester.getTopLeft(realWidget) + const Offset(1, 3), + tester.getBottomRight(realWidget) - const Offset(1, 3), + ); + expect(tester.takeException(), isNull); + expect(controller.getText(), 'Real content here'); + }); }); } From cd8d8ee15326534a814d03f044a10e64f1b597c5 Mon Sep 17 00:00:00 2001 From: Mike Matiunin Date: Thu, 30 Jul 2026 19:14:09 +0400 Subject: [PATCH 06/30] feat: selectable lists & tables; richer, longer example demos Library: - Add MultiPainterSelectable mixin (+ SelectableFragment) that maps pointer positions and highlight boxes across the many TextPainters of a list's items or a table's cells, and apply it to BlockPainter$List / BlockPainter$Table. Lists and tables are now interactively selectable; a drag can start or end inside an item/cell and copied text keeps the \n / \t separators that markdownBlockRenderedText produces. Fragment lists cleared on dispose. Tests: - Widget drags that select a table, a list, and a selection spanning a table. - Linearization asserts for nested and task lists. Example: - Chat tab: longer, varied conversation (tables, code, nested/task lists, quotes, GitHub alerts, math), real token-by-token streaming with a typing indicator and auto-stick-to-bottom, Select-all/Clear controls, avatars. - Selection tab: spans every block type (heading, quote, nested list, table, code, alert) across two grouped controllers plus a plain SelectableText. Docs: CHANGELOG + README note full block-type selection coverage. Verified: dart analyze --fatal-infos + format clean on lib/ test/; 393 unit tests green; example smoke green; render benchmark shows no regression (paint_hit ~41us, deterministic tiers within +/-2%). Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 8 + README.md | 3 + example/lib/tabs/chat_tab.dart | 472 +++++++++++++++++++--- example/lib/tabs/lorem_tab.dart | 39 +- lib/src/render.dart | 165 +++++++- test/selection/selection_test.dart | 10 + test/selection/selection_widget_test.dart | 77 ++++ 7 files changed, 712 insertions(+), 62 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9f2e5a6..9f501d6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,9 +13,17 @@ - **ADDED**: `MarkdownWidget` gains optional `documentId` and `controller` parameters (resolved from the ambient scope). Backward compatible: a widget with no `documentId` is inert. +- **ADDED**: Lists and tables are now interactively selectable. A new + `MultiPainterSelectable` mixin (+ `SelectableFragment`) maps pointer positions + and highlight boxes across the many `TextPainter`s of a list's items or a + table's cells, so a drag can start or end inside a list item or table cell and + the copied text keeps the `\n` / `\t` separators of `markdownBlockRenderedText`. - **CHANGED**: `MarkdownWidget`'s render object now draws the selection highlight outside the cached content `Picture` and becomes a repaint boundary when selectable, so selection/drag repaints do not rebuild the glyph cache. +- **EXAMPLE**: Reworked the demo tabs — a longer, richer chat (tables, code, + nested/task lists, alerts, math, token-by-token streaming with a typing + indicator, Select-all/Clear) and a Selection tab that spans every block type. ## 0.1.0 diff --git a/README.md b/README.md index 455b2cf..e5a5041 100644 --- a/README.md +++ b/README.md @@ -226,6 +226,9 @@ final String text = controller.getText(); // default formatter final MarkdownSelectedContent structured = controller.selectedContent(); ``` +- **Every block is selectable.** Paragraphs, headings, quotes, code, alerts, + lists and tables — a drag can start or end inside a list item or table cell, + and copied text preserves the list `\n` / table `\t` separators. - **Get the text your way.** `getText()` uses the default `MarkdownPlainTextFormatter` (configurable block/document separators); pass a custom `MarkdownSelectionFormatter` for e.g. "Copy as Markdown". diff --git a/example/lib/tabs/chat_tab.dart b/example/lib/tabs/chat_tab.dart index f87ef0f..be0482d 100644 --- a/example/lib/tabs/chat_tab.dart +++ b/example/lib/tabs/chat_tab.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_md/flutter_md.dart'; @@ -6,8 +8,13 @@ import 'package:flutter_md/flutter_md.dart'; /// selection driven by a single [MarkdownSelectionController]. Because the /// selection is anchored on the immutable models, it survives messages being /// scrolled off-screen (and disposed), and the whole selection can be copied at -/// any time. The "Stream" button grows the last message to show that streaming -/// updates keep the selection anchored. +/// any time. +/// +/// The conversation is intentionally long and varied — headings, tables, code, +/// nested and task lists, block quotes, GitHub alerts and inline math — to +/// exercise selection across every block type. The "Stream" button appends a +/// new assistant reply and grows it token-by-token to show that streaming +/// updates keep any active selection anchored (content-based reconciliation). class ChatTab extends StatefulWidget { /// Creates the chat demo tab. const ChatTab({super.key}); @@ -18,43 +25,118 @@ class ChatTab extends StatefulWidget { class _ChatTabState extends State { final MarkdownSelectionController _controller = MarkdownSelectionController(); + final ScrollController _scroll = ScrollController(); late final List<_Msg> _messages = _seed(); - int _streamCount = 0; + + Timer? _streamTimer; + List _streamTokens = const []; + int _streamCursor = 0; + String _streamBuffer = ''; + + bool get _isStreaming => _streamTimer != null; @override void initState() { super.initState(); - _controller.setDocuments([ - for (final (i, m) in _messages.indexed) - MarkdownDocumentRef(id: m.id, model: m.markdown, order: i), - ]); + _controller.setDocuments(_refs()); } @override void dispose() { + _streamTimer?.cancel(); + _scroll.dispose(); _controller.dispose(); super.dispose(); } + List _refs() => [ + for (final (i, m) in _messages.indexed) + MarkdownDocumentRef(id: m.id, model: m.markdown, order: i), + ]; + Future _copy() async { final text = _controller.getText(); + if (text.isEmpty) { + _toast('Nothing selected — drag across a few messages first.'); + return; + } await Clipboard.setData(ClipboardData(text: text)); if (!mounted) return; - ScaffoldMessenger.of(context) - ..clearSnackBars() - ..showSnackBar(SnackBar( - content: Text(text.isEmpty ? 'Nothing selected' : 'Copied:\n$text'), - )); + final preview = text.length > 160 ? '${text.substring(0, 160)}…' : text; + _toast('Copied ${text.length} characters:\n$preview'); + } + + void _toast(String message) => ScaffoldMessenger.of(context) + ..clearSnackBars() + ..showSnackBar(SnackBar(content: Text(message))); + + // --- streaming ----------------------------------------------------------- + + void _toggleStream() { + if (_isStreaming) { + _stopStream(); + return; + } + final id = 'stream-${DateTime.now().microsecondsSinceEpoch}'; + _streamTokens = _streamAnswer.split(' '); + _streamCursor = 0; + _streamBuffer = ''; + setState(() => _messages.add(_Msg(id, false, const Markdown.empty()))); + _controller.putDocument(id, const Markdown.empty(), + order: _messages.length - 1); + _scrollToBottom(); + _streamTimer = + Timer.periodic(const Duration(milliseconds: 55), (_) => _tick(id)); } - void _stream() { - final last = _messages.last; - _streamCount++; - final grown = Markdown.fromString( - '${last.markdown.markdown} …streamed token #$_streamCount'); - setState(() => _messages[_messages.length - 1] = last.withMarkdown(grown)); + void _tick(String id) { + if (_streamCursor >= _streamTokens.length) { + _stopStream(); + return; + } + final token = _streamTokens[_streamCursor++]; + _streamBuffer = _streamBuffer.isEmpty ? token : '$_streamBuffer $token'; + final grown = Markdown.fromString(_streamBuffer); + final idx = _messages.indexWhere((m) => m.id == id); + if (idx < 0) { + _stopStream(); + return; + } + setState(() => _messages[idx] = _messages[idx].withMarkdown(grown)); // Reconciliation keeps any active selection anchored across the update. - _controller.putDocument(last.id, grown, order: _messages.length - 1); + _controller.putDocument(id, grown, order: idx); + _stickToBottom(); + } + + void _stopStream() { + _streamTimer?.cancel(); + _streamTimer = null; + if (mounted) setState(() {}); + } + + void _scrollToBottom() { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!_scroll.hasClients) return; + _scroll.animateTo( + _scroll.position.maxScrollExtent, + duration: const Duration(milliseconds: 250), + curve: Curves.easeOut, + ); + }); + } + + /// Keeps the view pinned to the bottom while streaming, but only if the user + /// hasn't scrolled up to read/select earlier messages. + void _stickToBottom() { + if (!_scroll.hasClients) return; + final pos = _scroll.position; + if (pos.maxScrollExtent - pos.pixels < 120) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (_scroll.hasClients) { + _scroll.jumpTo(_scroll.position.maxScrollExtent); + } + }); + } } @override @@ -64,14 +146,22 @@ class _ChatTabState extends State { child: MarkdownSelectionScope( controller: _controller, child: ListView.builder( - padding: const EdgeInsets.all(12), + controller: _scroll, + padding: + const EdgeInsets.symmetric(horizontal: 12, vertical: 8), itemCount: _messages.length, itemBuilder: (context, i) => _Bubble(message: _messages[i]), ), ), ), _SelectionBar( - controller: _controller, onCopy: _copy, onStream: _stream), + controller: _controller, + isStreaming: _isStreaming, + onCopy: _copy, + onSelectAll: _controller.selectAll, + onClear: _controller.clear, + onStream: _toggleStream, + ), ], ); } @@ -79,12 +169,18 @@ class _ChatTabState extends State { class _SelectionBar extends StatelessWidget { const _SelectionBar({ required this.controller, + required this.isStreaming, required this.onCopy, + required this.onSelectAll, + required this.onClear, required this.onStream, }); final MarkdownSelectionController controller; + final bool isStreaming; final Future Function() onCopy; + final VoidCallback onSelectAll; + final VoidCallback onClear; final VoidCallback onStream; @override @@ -93,7 +189,7 @@ class _SelectionBar extends StatelessWidget { child: SafeArea( top: false, child: Padding( - padding: const EdgeInsets.all(8), + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6), child: Row( children: [ Expanded( @@ -101,18 +197,32 @@ class _SelectionBar extends StatelessWidget { animation: controller, builder: (context, _) { final n = controller.getText().length; - return Text(n == 0 - ? 'Drag (mouse) / long-press-drag (touch) across messages' - : 'Selected $n characters across messages'); + return Text( + n == 0 + ? 'Drag (mouse) or long-press-drag (touch) across ' + 'messages — even ones scrolled off-screen.' + : '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: const Icon(Icons.bolt), - label: const Text('Stream'), + icon: Icon(isStreaming ? Icons.stop : Icons.bolt), + label: Text(isStreaming ? 'Stop' : 'Stream'), ), - const SizedBox(width: 8), + const SizedBox(width: 4), FilledButton.icon( onPressed: onCopy, icon: const Icon(Icons.copy), @@ -134,23 +244,115 @@ class _Bubble extends StatelessWidget { Widget build(BuildContext context) { final scheme = Theme.of(context).colorScheme; final isUser = message.isUser; - return Align( - alignment: isUser ? Alignment.centerRight : Alignment.centerLeft, - child: Container( - margin: const EdgeInsets.symmetric(vertical: 6), - padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), - constraints: - BoxConstraints(maxWidth: MediaQuery.sizeOf(context).width * 0.82), - decoration: BoxDecoration( - color: - isUser ? scheme.primaryContainer : scheme.surfaceContainerHighest, - borderRadius: BorderRadius.circular(14), + return Padding( + padding: const EdgeInsets.symmetric(vertical: 5), + child: Row( + mainAxisAlignment: + isUser ? MainAxisAlignment.end : MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (!isUser) ...[ + const _Avatar(isUser: false), + const SizedBox(width: 8), + ], + Flexible( + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + constraints: BoxConstraints( + maxWidth: MediaQuery.sizeOf(context).width * 0.78), + decoration: BoxDecoration( + color: isUser + ? scheme.primaryContainer + : scheme.surfaceContainerHighest, + borderRadius: BorderRadius.only( + topLeft: const Radius.circular(16), + topRight: const Radius.circular(16), + bottomLeft: Radius.circular(isUser ? 16 : 4), + bottomRight: Radius.circular(isUser ? 4 : 16), + ), + ), + child: message.markdown.isEmpty + ? const _TypingDots() + : MarkdownWidget( + markdown: message.markdown, documentId: message.id), + ), + ), + if (isUser) ...[ + const SizedBox(width: 8), + const _Avatar(isUser: true), + ], + ], + ), + ); + } +} + +class _Avatar extends StatelessWidget { + const _Avatar({required this.isUser}); + final bool isUser; + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + return CircleAvatar( + radius: 16, + backgroundColor: isUser ? scheme.primary : scheme.secondary, + foregroundColor: isUser ? scheme.onPrimary : scheme.onSecondary, + child: Icon(isUser ? Icons.person : Icons.smart_toy_outlined, size: 18), + ); + } +} + +/// A small animated "typing" indicator shown while a streamed reply is empty. +class _TypingDots extends StatefulWidget { + const _TypingDots(); + + @override + State<_TypingDots> createState() => _TypingDotsState(); +} + +class _TypingDotsState extends State<_TypingDots> + with SingleTickerProviderStateMixin { + late final AnimationController _c = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 900), + )..repeat(); + + @override + void dispose() { + _c.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final color = Theme.of(context).colorScheme.onSurfaceVariant; + return SizedBox( + width: 40, + height: 16, + child: AnimatedBuilder( + animation: _c, + builder: (context, _) => Row( + mainAxisSize: MainAxisSize.min, + children: [ + for (var i = 0; i < 3; i++) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 2), + child: Opacity( + opacity: 0.3 + 0.7 * _phase(i), + child: CircleAvatar(radius: 3, backgroundColor: color), + ), + ), + ], ), - child: - MarkdownWidget(markdown: message.markdown, documentId: message.id), ), ); } + + double _phase(int i) { + final t = (_c.value + i / 3) % 1.0; + return t < 0.5 ? t * 2 : (1 - t) * 2; + } } class _Msg { @@ -161,19 +363,183 @@ class _Msg { _Msg withMarkdown(Markdown m) => _Msg(id, isUser, m); } +/// The answer streamed in token-by-token when the "Stream" button is pressed. +const String _streamAnswer = + 'Absolutely — here is a streamed reply. Because the selection is anchored ' + 'on the **immutable model**, it stays put while these words arrive one ' + 'at a time, and the parser re-runs on every token. Try selecting an ' + 'earlier message first, then press Stream and watch the highlight hold.'; + List<_Msg> _seed() { - Markdown md(String s) => Markdown.fromString(s); + final data = <(bool, String)>[ + ( + true, + 'Hi! Can you give me a **quick tour** of what this Markdown renderer ' + 'can display?', + ), + ( + false, + ''' +## Welcome 👋 + +`flutter_md` renders GitHub-Flavored Markdown on a **cached canvas** — no +per-glyph widgets — so long chats stay smooth. It supports: + +- Headings, paragraphs, **bold**, _italic_, `inline code`, ~~strikethrough~~ +- Ordered, unordered, **nested** and task lists +- Tables, block quotes, fenced code, thematic breaks +- GitHub alerts and opt-in inline math + +> [!NOTE] +> Every block below is selectable — drag right across the bubbles.''', + ), + (true, 'Nice. Show me a **table** comparing the block types.'), + ( + false, + ''' +Here you go: + +| Block | Example | Selectable | +| ------------ | -------------------- | :--------: | +| Heading | `# Title` | ✅ | +| Paragraph | plain text | ✅ | +| List | `- item` | ✅ | +| Table | this one | ✅ | +| Code | `fenced` | ✅ | +| Quote | `> quote` | ✅ | + +Try dragging from the header row down to the last cell.''', + ), + (true, 'How fast is it? Any `benchmarks`?'), + ( + false, + ''' +The parser got roughly **45% faster** in `0.1.0`. Rough figures on a laptop: + +| Scenario | Before | After | +| ------------------- | -----: | ----: | +| Parse 50-block doc | 1.8 ms | 1.0 ms | +| Paint (cache hit) | 41 µs | 41 µs | +| Paint (cache miss) | 6.5 ms | 6.5 ms | + +> Headless timings are relative — real FPS is confirmed with +> `flutter run --profile` and DevTools.''', + ), + (true, 'Show me how to **embed** it.'), + ( + false, + ''' +Three steps: + +1. Add the dependency to `pubspec.yaml`. +2. Parse once, cache the result. +3. Hand the model to `MarkdownWidget`. + +```dart +final md = Markdown.fromString('# Hello'); + +@override +Widget build(BuildContext context) => MarkdownWidget( + markdown: md, + documentId: 'msg-1', // opt into selection + ); +``` + +> [!TIP] +> Parse off the build method and reuse the `Markdown` instance — it is +> immutable and cheap to hold.''', + ), + (true, 'What about **math**? I write a lot of formulas.'), + ( + false, + r''' +Inline math is opt-in (`inlineMath: true`). Then `$...$` renders: +$\alpha$, $\beta$, $\pi \approx 3.14$, $x \rightarrow \infty$, with +superscripts and subscripts like $x^2$ and $H_2O$. + +The quadratic roots are $x = \frac{-b \pm \sqrt{b^2 - 4ac}}{2a}$.''', + ), + (true, 'Do **nested** lists and **task** lists work?'), + ( + false, + ''' +Both, to arbitrary depth: + +- Parsing + - Tokenize + - Build the block tree + - Inline spans + - Source offsets +- Rendering + - Layout + - Paint + +And a checklist: + +- [x] Parser +- [x] GitHub alerts +- [x] Text selection + - [x] Cross-block + - [x] Cross-widget (this chat) +- [ ] Native mobile handles''', + ), + (true, 'Any **warnings** I should know about?'), + ( + false, + ''' +> [!WARNING] +> Selection offsets are anchored on the *rendered* text, not the raw source — +> markup characters are not part of the offset space. + +> [!CAUTION] +> Dropping text-bearing spans with a `spanFilter` shifts that offset space, so +> the highlight stays correct but copied text may drift. Avoid it when +> selection is on.''', + ), + (true, 'Give me a bigger table — say, common **HTTP status codes**.'), + ( + false, + ''' +| Code | Meaning | Category | +| ---: | --------------------- | ------------ | +| 200 | OK | Success | +| 201 | Created | Success | +| 301 | Moved Permanently | Redirect | +| 400 | Bad Request | Client error | +| 401 | Unauthorized | Client error | +| 404 | Not Found | Client error | +| 418 | I'm a teapot | Client error | +| 500 | Internal Server Error | Server error | +| 503 | Service Unavailable | Server error |''', + ), + ( + true, + 'Great. And I can **select across all of this** — even the messages ' + "I've scrolled past?", + ), + ( + false, + ''' +Exactly. The selection lives in the controller as logical anchors +`(documentId, blockIndex, offset)` over the immutable models, so: + +1. It **survives disposal** when a bubble scrolls out of the lazy list. +2. `Copy` always returns the *full* text, including off-screen messages. +3. Streaming updates **reconcile** — the anchor holds as text grows. + +> Scroll to the top, start a drag, scroll back down, and finish it — then hit +> **Copy**. Press **Stream** to watch reconciliation in action.''', + ), + (true, 'Perfect, thanks! 🙏'), + ( + false, + 'Anytime. Pro tip: **Select all** grabs the entire transcript at once, ' + 'and **Clear** resets it. Happy hacking with `flutter_md`!', + ), + ]; + return <_Msg>[ - for (var i = 0; i < 14; i++) - _Msg( - 'm$i', - i.isEven, - md(i.isEven - ? 'Question **#${i ~/ 2}**: how does cross-message selection work?' - : 'Answer ${i ~/ 2}:\n\nSelection is anchored on the *immutable ' - 'model*, so it survives `ListView` disposal. Try dragging across ' - 'me and the next messages, scroll, then press **Copy**.\n\n' - '- point one for message $i\n- point two for message $i'), - ), + for (final (i, (isUser, text)) in data.indexed) + _Msg('m$i', isUser, Markdown.fromString(text, inlineMath: true)), ]; } diff --git a/example/lib/tabs/lorem_tab.dart b/example/lib/tabs/lorem_tab.dart index 016d7e9..4ad4b49 100644 --- a/example/lib/tabs/lorem_tab.dart +++ b/example/lib/tabs/lorem_tab.dart @@ -3,24 +3,49 @@ import 'package:flutter/services.dart'; import 'package:flutter_md/flutter_md.dart'; const String _loremMdA = ''' -# Lorem ipsum +# Cross-block selection -**Lorem ipsum** dolor sit amet, consectetur _adipiscing_ elit. Sed do eiusmod -tempor incididunt ut labore et dolore magna aliqua, drag across these blocks. +**Lorem ipsum** dolor sit amet, consectetur _adipiscing_ elit. Drag from this +heading straight down through every block below — headings, quotes, lists, +tables and code all join into one selection with sensible separators. -> Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi. +> Ut enim ad minim veniam, quis nostrud exercitation `ullamco` laboris nisi ut +> aliquip ex ea commodo consequat. + +A nested list: - alpha item + - alpha one + - alpha two - beta item - gamma item -'''; + +And a table — its cells are selectable too: + +| Lang | Typing | Year | +| ------ | -------- | ---: | +| Dart | static | 2011 | +| Python | dynamic | 1991 | +| Rust | static | 2010 | + +```dart +void main() => print('selectable code block'); +``` + +> [!TIP] +> Selecting Markdown clears the plain `SelectableText` below, and vice-versa.'''; const String _loremMdB = ''' ## A different document Duis aute irure dolor in `reprehenderit` in voluptate velit esse cillum dolore -eu fugiat nulla pariatur — this block belongs to a **second** controller. -'''; +eu fugiat nulla pariatur — this block belongs to a **second** controller, so +selecting here clears the selection above. + +| Column A | Column B | +| -------- | -------- | +| one | two | +| three | four |'''; const String _loremPlain = 'This is a plain SelectableText (not Markdown). Selecting here clears the ' diff --git a/lib/src/render.dart b/lib/src/render.dart index 5a719dc..c604bd4 100644 --- a/lib/src/render.dart +++ b/lib/src/render.dart @@ -810,6 +810,97 @@ mixin SelectableTextBlock implements SelectableBlockPainter { .toList(growable: false); } +/// One selectable text run inside a multi-painter block (a list item or a table +/// cell): the [painter] that owns its glyphs, the block-local [origin] where it +/// is painted, and the [textStart] index of its text within the block's +/// [markdownBlockRenderedText] linearization. +@meta.internal +class SelectableFragment { + /// Creates a fragment for [painter] painted at [origin], whose text begins at + /// [textStart] in the block's rendered text. + SelectableFragment(this.painter, this.origin, this.textStart); + + /// The text painter that owns this run's glyphs. + final TextPainter painter; + + /// Block-local top-left where [painter] is painted. + final Offset origin; + + /// Index of this run's first character in the block's rendered text. + final int textStart; + + /// Length of this run's text. + int get length => painter.plainText.length; + + /// One-past-the-last index of this run's text in the block's rendered text. + int get textEnd => textStart + length; +} + +/// Provides [SelectableBlockPainter] for a block whose text is spread across +/// several [TextPainter]s painted at different origins (lists, tables). +/// +/// [fragments] must be listed in the same order their text appears in +/// [markdownBlockRenderedText], with each fragment's `textStart` matching that +/// linearization (the gaps between fragments are the `\n`/`\t` separators, which +/// have no glyphs of their own). [renderedText] must equal that linearization. +mixin MultiPainterSelectable implements SelectableBlockPainter { + /// The selectable runs of this block, in rendered-text order. Rebuilt on each + /// [BlockPainter.layout]. + List get fragments; + + @override + int offsetForLocalPosition(Offset local) { + final frags = fragments; + if (frags.isEmpty) return 0; + SelectableFragment? best; + var bestDistance = double.infinity; + for (final fragment in frags) { + final rect = fragment.origin & fragment.painter.size; + final distance = _distanceToRect(local, rect); + if (distance < bestDistance) { + bestDistance = distance; + best = fragment; + if (distance == 0) break; + } + } + final fragment = best!; + final inner = + fragment.painter.getPositionForOffset(local - fragment.origin).offset; + return fragment.textStart + inner.clamp(0, fragment.length); + } + + @override + List boxesForRange(int start, int end) { + final out = []; + for (final fragment in fragments) { + final localStart = start.clamp(fragment.textStart, fragment.textEnd) - + fragment.textStart; + final localEnd = + end.clamp(fragment.textStart, fragment.textEnd) - fragment.textStart; + if (localEnd <= localStart) continue; + final boxes = fragment.painter.getBoxesForSelection( + TextSelection(baseOffset: localStart, extentOffset: localEnd)); + for (final box in boxes) { + out.add(box.toRect().shift(fragment.origin)); + } + } + return out; + } +} + +/// Shortest distance from [point] to [rect] (0 when the point is inside). +double _distanceToRect(Offset point, Rect rect) { + final dx = point.dx < rect.left + ? rect.left - point.dx + : (point.dx > rect.right ? point.dx - rect.right : 0.0); + final dy = point.dy < rect.top + ? rect.top - point.dy + : (point.dy > rect.bottom ? point.dy - rect.bottom : 0.0); + if (dx == 0) return dy; + if (dy == 0) return dx; + return math.sqrt(dx * dx + dy * dy); +} + @meta.internal mixin ParagraphGestureHandler { /// Handle tap events with a [TextPainter]. @@ -1272,7 +1363,9 @@ class _ListItemMetrics { /// A class for painting a list block in markdown. @meta.internal -class BlockPainter$List with ParagraphGestureHandler implements BlockPainter { +class BlockPainter$List + with ParagraphGestureHandler, MultiPainterSelectable + implements BlockPainter { BlockPainter$List({ required List items, required this.theme, @@ -1283,6 +1376,14 @@ class BlockPainter$List with ParagraphGestureHandler implements BlockPainter { final List _items; final List<_ListItemMetrics> _painters; + @override + List get fragments => _fragments; + List _fragments = const []; + + @override + String get renderedText => _renderedText; + String _renderedText = ''; + // Indentation for the entire list block. static const double _baseIndent = 8.0; @@ -1383,9 +1484,25 @@ class BlockPainter$List with ParagraphGestureHandler implements BlockPainter { } layoutItems(_items, 0); + _rebuildFragments(); return _size = Size(maxContentWidth, currentHeight); } + /// Rebuilds the selectable fragments from the laid-out item metrics. Items + /// (depth-first, matching `markdownBlockRenderedText`) are joined by `\n`. + void _rebuildFragments() { + final frags = []; + var textPos = 0; + for (final metrics in _painters) { + if (frags.isNotEmpty) textPos += 1; // the '\n' item separator + final origin = metrics.offset + Offset(metrics.bulletPainter.width, 0); + frags.add(SelectableFragment(metrics.contentPainter, origin, textPos)); + textPos += metrics.contentPainter.plainText.length; + } + _fragments = frags; + _renderedText = frags.map((f) => f.painter.plainText).join('\n'); + } + @override void paint(Canvas canvas, Size size, double offset) { for (final metrics in _painters) { @@ -1404,6 +1521,7 @@ class BlockPainter$List with ParagraphGestureHandler implements BlockPainter { metrics.dispose(); } _painters.clear(); + _fragments = const []; } } @@ -1583,7 +1701,9 @@ class BlockPainter$Code with SelectableTextBlock implements BlockPainter { /// A class for painting a table block in markdown. @meta.internal -class BlockPainter$Table with ParagraphGestureHandler implements BlockPainter { +class BlockPainter$Table + with ParagraphGestureHandler, MultiPainterSelectable + implements BlockPainter { BlockPainter$Table({ required this.header, required this.rows, @@ -1651,6 +1771,14 @@ class BlockPainter$Table with ParagraphGestureHandler implements BlockPainter { List> _cellPainters = const []; + @override + List get fragments => _fragments; + List _fragments = const []; + + @override + String get renderedText => _renderedText; + String _renderedText = ''; + /// Last span hit by the tap down event. TextSpan? _lastSpan; @@ -1840,9 +1968,41 @@ class BlockPainter$Table with ParagraphGestureHandler implements BlockPainter { } _borderPoints = points; + _rebuildFragments(allRows); + return _size = Size(totalWidth, totalHeight); } + /// Rebuilds the selectable fragments (row-major: cells joined by `\t`, rows by + /// `\n`) so they line up with `markdownBlockRenderedText`. Only the cells that + /// exist in the source row contribute text, matching the painted cells. + void _rebuildFragments(List allRows) { + final frags = []; + final text = StringBuffer(); + var rowTop = 0.0; + for (var r = 0; r < allRows.length; r++) { + if (r > 0) text.write('\n'); + final cells = allRows[r].cells; + var colLeft = 0.0; + for (var c = 0; c < columns; c++) { + if (c < cells.length) { + if (c > 0) text.write('\t'); + final painter = _cellPainters[r][c]; + final verticalPadding = (_rowHeights[r] - painter.height) / 2; + final horizontalPadding = _cellHorizontalPadding(r, c, painter.width); + final origin = + Offset(colLeft + horizontalPadding, rowTop + verticalPadding); + frags.add(SelectableFragment(painter, origin, text.length)); + text.write(painter.plainText); + } + colLeft += _columnWidths[c]; + } + rowTop += _rowHeights[r]; + } + _fragments = frags; + _renderedText = text.toString(); + } + @override void paint(Canvas canvas, Size size, double offset) { // If the width is less than required do not paint anything. @@ -1913,6 +2073,7 @@ class BlockPainter$Table with ParagraphGestureHandler implements BlockPainter { } } _cellPainters = const []; + _fragments = const []; } /// Helper function to distribute widths among columns, respecting minimums. diff --git a/test/selection/selection_test.dart b/test/selection/selection_test.dart index 9ae13ad..05e91e4 100644 --- a/test/selection/selection_test.dart +++ b/test/selection/selection_test.dart @@ -36,6 +36,16 @@ void main() { .blocks .firstWhere((b) => b.type == 'table'); expect(markdownBlockRenderedText(table), 'a\tb\n1\t2'); + + final list = Markdown.fromString('- one\n- two\n - nested\n- three') + .blocks + .firstWhere((b) => b.type == 'list'); + expect(markdownBlockRenderedText(list), 'one\ntwo\nnested\nthree'); + + final tasks = Markdown.fromString('- [x] done\n- [ ] todo') + .blocks + .firstWhere((b) => b.type == 'list'); + expect(markdownBlockRenderedText(tasks), 'done\ntodo'); }); test('cross-document extraction with default formatter', () { diff --git a/test/selection/selection_widget_test.dart b/test/selection/selection_widget_test.dart index 26c6998..b2ff0f5 100644 --- a/test/selection/selection_widget_test.dart +++ b/test/selection/selection_widget_test.dart @@ -264,6 +264,83 @@ void main() { expect(tester.takeException(), isNull); expect(controller.getText(), 'Real content here'); }); + + testWidgets('drag selects the cells of a table', (tester) async { + final md = + Markdown.fromString('| A | B |\n|---|---|\n| 1 | 2 |\n| 3 | 4 |'); + final table = md.blocks.firstWhere((b) => b.type == 'table'); + 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)); + final br = tester.getBottomRight(find.byType(MarkdownWidget)); + await _mouseDrag( + tester, tl + const Offset(2, 2), br - const Offset(2, 2)); + + expect(controller.getText(), markdownBlockRenderedText(table)); + expect(controller.getText(), 'A\tB\n1\t2\n3\t4'); + expect(tester.takeException(), isNull); + }); + + testWidgets('drag selects the items of a list', (tester) async { + final md = Markdown.fromString('- alpha\n- beta\n- gamma'); + 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)); + final br = tester.getBottomRight(find.byType(MarkdownWidget)); + await _mouseDrag( + tester, tl + const Offset(2, 2), br - const Offset(2, 2)); + + expect(controller.getText(), 'alpha\nbeta\ngamma'); + expect(tester.takeException(), isNull); + }); + + testWidgets('selection spanning a table includes its cells', + (tester) async { + final md = Markdown.fromString( + 'Intro line\n\n| A | B |\n|---|---|\n| 1 | 2 |\n\nOutro line'); + 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)); + final br = tester.getBottomRight(find.byType(MarkdownWidget)); + await _mouseDrag( + tester, tl + const Offset(2, 2), br - const Offset(2, 2)); + + expect(controller.getText(), 'Intro line\nA\tB\n1\t2\nOutro line'); + expect(tester.takeException(), isNull); + }); }); } From 26b0ad6a772d3120b8db1b857c9d50aa129d8875 Mon Sep 17 00:00:00 2001 From: Mike Matiunin Date: Thu, 30 Jul 2026 19:47:26 +0400 Subject: [PATCH 07/30] feat(selection): keyboard shortcuts, context toolbar, customization API Mirror SelectableRegion/SelectableText for the Markdown selection scope: - MarkdownSelectionScope is now a StatefulWidget with a public MarkdownSelectionScopeState. It owns a FocusNode + Actions map so that, when focused, the ambient DefaultTextEditingShortcuts drive: Ctrl/Cmd+C copy, Ctrl/Cmd+A select-all, Shift+arrows extend (character/word/line/ document + vertical-by-geometry, honoring intent.collapseSelection so bare arrows don't fire), Esc clear. - Right-click (desktop) / long-press (mobile) shows an adaptive context toolbar via ContextMenuController + AdaptiveTextSelectionToolbar. Fully customizable through contextMenuBuilder; state exposes contextMenuButtonItems / contextMenuAnchors / showToolbar / hideToolbar / copySelection / selectAll / clearSelection. - New customization params: focusNode, enabled, selectionColor, contextMenuBuilder, magnifierConfiguration, selectionControls, onSelectionChanged (magnifier/handles params are wired in the next commit). - Controller/render additions: customizable selectionColor (per-instance highlight paint), globalSelectionRects() + MarkdownSelectionSurface. globalSelectionRects(), moveSelectionEdgeToGlobal(), the extendSelectionBy* family, MarkdownPosition.copyWith, and MarkdownPainter.selectionBoxes(). Tests: 6 new keyboard/toolbar widget tests (Ctrl+A/C, Esc, Shift+Arrow, right-click toolbar, state-driven show/hide). 399 unit tests green; dart analyze --fatal-infos + format clean on lib/ test/. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 14 + lib/src/render.dart | 32 +- lib/src/selection.dart | 216 +++++++++- lib/src/selection_scope.dart | 429 ++++++++++++++++++-- test/selection/selection_keyboard_test.dart | 220 ++++++++++ test/unit_test.dart | 2 + 6 files changed, 870 insertions(+), 43 deletions(-) create mode 100644 test/selection/selection_keyboard_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 9f501d6..59055a2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,9 +18,23 @@ and highlight boxes across the many `TextPainter`s of a list's items or a table's cells, so a drag can start or end inside a list item or table cell and the copied text keeps the `\n` / `\t` separators of `markdownBlockRenderedText`. +- **ADDED**: Keyboard shortcuts and a context toolbar on `MarkdownSelectionScope`, + mirroring `SelectableRegion`/`SelectableText`. When focused: `Ctrl/Cmd+C` + copies, `Ctrl/Cmd+A` selects all, `Shift`+arrows extend by character / word / + line / document (and vertically by geometry), `Esc` clears. Right-click + (desktop) / long-press (mobile) shows an adaptive Copy / Select-all toolbar. + The scope is now a `StatefulWidget` with a public `MarkdownSelectionScopeState` + (`copySelection` / `selectAll` / `clearSelection` / `showToolbar` / + `hideToolbar` / `contextMenuButtonItems` / `contextMenuAnchors`). New + customization params: `focusNode`, `enabled`, `selectionColor`, + `contextMenuBuilder`, `magnifierConfiguration`, `selectionControls`, + `onSelectionChanged`. New controller ops: `selectionColor`, + `globalSelectionRects`, `moveSelectionEdgeToGlobal`, and the + `extendSelectionBy*` family; `MarkdownPosition.copyWith`. - **CHANGED**: `MarkdownWidget`'s render object now draws the selection highlight outside the cached content `Picture` and becomes a repaint boundary when selectable, so selection/drag repaints do not rebuild the glyph cache. + The highlight color is now customizable via the controller / scope. - **EXAMPLE**: Reworked the demo tabs — a longer, richer chat (tables, code, nested/task lists, alerts, math, token-by-token streaming with a typing indicator, Select-all/Clear) and a Selection tab that spans every block type. diff --git a/lib/src/render.dart b/lib/src/render.dart index c604bd4..8ebe4ce 100644 --- a/lib/src/render.dart +++ b/lib/src/render.dart @@ -38,7 +38,7 @@ class MarkdownRenderObject extends RenderBox /// The stable document id used to anchor selection positions. Object? _documentId; - static final Paint _highlightPaint = Paint()..color = _kSelectionColor; + final Paint _highlightPaint = Paint()..color = _kSelectionColor; void _onSelectionChange() { if (!_disposed) markNeedsPaint(); @@ -100,6 +100,17 @@ class MarkdownRenderObject extends RenderBox ); } + @override + List globalSelectionRects() { + final controller = _controller; + final id = _documentId; + if (controller == null || id == null) return const []; + final local = _painter.selectionBoxes((s) => controller.rangeFor(id, s)); + if (local.isEmpty) return const []; + final origin = localToGlobal(Offset.zero); + return [for (final rect in local) rect.shift(origin)]; + } + /// Current size of the render box. @override Size get size => _size; @@ -236,6 +247,7 @@ class MarkdownRenderObject extends RenderBox final controller = _controller; final id = _documentId; if (controller != null && id != null) { + _highlightPaint.color = controller.selectionColor ?? _kSelectionColor; _painter.paintHighlight( canvas, (source) => controller.rangeFor(id, source), @@ -408,6 +420,24 @@ class MarkdownPainter { } } + /// Content-local rectangles covering the selection described by [rangeOf], in + /// reading order. Same geometry [paintHighlight] draws, collected instead of + /// painted — used to position selection handles, the magnifier and toolbar. + List selectionBoxes(TextRange? Function(int sourceIndex) rangeOf) { + final out = []; + for (var i = 0; i < _blockPainters.length; i++) { + final painter = _blockPainters[i]; + if (painter is! SelectableBlockPainter) continue; + final range = rangeOf(_sourceIndices[i]); + if (range == null || range.start >= range.end) continue; + final top = _blockOffsets[i]; + for (final rect in painter.boxesForRange(range.start, range.end)) { + out.add(rect.shift(Offset(0, top))); + } + } + return out; + } + /// 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/selection.dart b/lib/src/selection.dart index cff0c34..bfe469b 100644 --- a/lib/src/selection.dart +++ b/lib/src/selection.dart @@ -1,4 +1,4 @@ -import 'dart:ui' show Offset, Rect, TextRange; +import 'dart:ui' show Color, Offset, Rect, TextRange; import 'package:flutter/foundation.dart'; @@ -69,6 +69,18 @@ final class MarkdownPosition { /// Offset into the block's rendered text (see [markdownBlockRenderedText]). final int offset; + /// A copy with the given fields replaced. + MarkdownPosition copyWith({ + Object? documentId, + int? blockIndex, + int? offset, + }) => + MarkdownPosition( + documentId: documentId ?? this.documentId, + blockIndex: blockIndex ?? this.blockIndex, + offset: offset ?? this.offset, + ); + @override bool operator ==(Object other) => other is MarkdownPosition && @@ -369,6 +381,12 @@ abstract interface class MarkdownSelectionSurface { /// Maps a global point to a logical position, or null if outside any text. MarkdownPosition? positionForGlobal(Offset globalPosition); + + /// Global (screen-space) rectangles covering the selected part of this + /// surface's document, in reading order. Empty when nothing here is + /// selected. Used to place selection handles, the magnifier and the toolbar + /// anchor. + List globalSelectionRects(); } class _DocEntry { @@ -414,6 +432,18 @@ class MarkdownSelectionController extends ChangeNotifier { notifyListeners(); } + Color? _selectionColor; + + /// The color of the selection highlight, or null to use the render layer's + /// default. Usually set by [MarkdownSelectionScope] from the ambient + /// `DefaultSelectionStyle` / `TextSelectionTheme`. + Color? get selectionColor => _selectionColor; + set selectionColor(Color? value) { + if (value == _selectionColor) return; + _selectionColor = value; + notifyListeners(); + } + final List<_DocEntry> _docs = <_DocEntry>[]; final Map _surfaces = {}; @@ -629,6 +659,94 @@ class MarkdownSelectionController extends ChangeNotifier { ); } + // --- keyboard extension -------------------------------------------------- + + void _extendExtent(MarkdownPosition Function(MarkdownPosition) step) { + final sel = _selection; + if (sel == null) return; + final next = step(sel.extent); + if (next == sel.extent) return; + selection = MarkdownSelection(base: sel.base, extent: next); + } + + /// Extends the moving end of the selection by one character ([forward] = + /// toward the end of the text). No-op without a current selection. + void extendSelectionByCharacter({required bool forward}) => + _extendExtent((p) => _stepCharacter(p, forward: forward)); + + /// Extends the moving end of the selection by one word. + void extendSelectionByWord({required bool forward}) => + _extendExtent((p) => _stepWord(p, forward: forward)); + + /// Extends the moving end of the selection to the start/end of its block + /// (the closest analogue of a line-break extension for Markdown blocks). + void extendSelectionToLineBreak({required bool forward}) => + _extendExtent((p) => _stepLineBreak(p, forward: forward)); + + /// Extends the moving end of the selection to the very start of the first + /// document or the very end of the last one. + void extendSelectionToDocumentBoundary({required bool forward}) { + if (_docs.isEmpty) return; + _extendExtent((_) => _documentBoundary(forward: forward)); + } + + /// Extends the moving end of the selection to the adjacent visual line, using + /// on-screen geometry (falls back to nothing when the endpoint is unmounted). + void extendSelectionToAdjacentLine({required bool forward}) { + final sel = _selection; + if (sel == null) return; + final rects = globalSelectionRects(); + if (rects.isEmpty) return; + final (a, _) = _ordered(sel); + final rect = sel.extent == a ? rects.first : rects.last; + final target = Offset( + rect.center.dx, + rect.center.dy + (forward ? rect.height : -rect.height), + ); + final moved = positionForGlobal(target); + if (moved != null) { + selection = MarkdownSelection(base: sel.base, extent: moved); + } + } + + /// 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. + 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); + selection = isStart + ? MarkdownSelection(base: b, extent: moved) + : MarkdownSelection(base: a, extent: moved); + } + + // --- geometry (mounted surfaces) ----------------------------------------- + + /// Global rects covering the current selection across every mounted surface, + /// in reading order. Unmounted documents contribute nothing (they have no + /// geometry). Empty when the selection is collapsed or entirely off-screen. + List globalSelectionRects() { + final sel = _selection; + if (sel == null || sel.isCollapsed) return const []; + final (a, b) = _ordered(sel); + final startDoc = _orderIndex(a.documentId); + final endDoc = _orderIndex(b.documentId); + if (startDoc < 0 || endDoc < 0) return const []; + final out = []; + for (var d = startDoc; d <= endDoc; d++) { + final surface = _surfaces[_docs[d].id]; + if (surface == null) continue; + out.addAll(surface.globalSelectionRects()); + } + return out; + } + /// 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. @@ -723,6 +841,102 @@ class MarkdownSelectionController extends ChangeNotifier { _compare(sel.base, sel.extent) <= 0 ? (sel.base, sel.extent) : (sel.extent, sel.base); + + // --- position stepping (keyboard) ---------------------------------------- + + String _blockTextAt(int docIndex, int blockIndex) { + final blocks = _docs[docIndex].model.blocks; + if (blockIndex < 0 || blockIndex >= blocks.length) return ''; + return markdownBlockRenderedText(blocks[blockIndex]); + } + + static bool _isSpace(String ch) => ch == ' ' || ch == '\t' || ch == '\n'; + + /// The next/previous block (scanning across documents) that has non-empty + /// rendered text, as `(docIndex, blockIndex)`, or null at the ends. + (int, int)? _adjacentBlock(int di, int bi, {required bool forward}) { + var d = di; + var b = bi; + for (var guard = 0; guard < 1000000; guard++) { + if (forward) { + b++; + while (d < _docs.length && b >= _docs[d].model.blocks.length) { + d++; + b = 0; + } + if (d >= _docs.length) return null; + } else { + b--; + while (d >= 0 && b < 0) { + d--; + if (d >= 0) b = _docs[d].model.blocks.length - 1; + } + if (d < 0) return null; + } + if (_blockTextAt(d, b).isNotEmpty) return (d, b); + } + return null; + } + + MarkdownPosition _stepCharacter(MarkdownPosition p, {required bool forward}) { + final di = _orderIndex(p.documentId); + if (di < 0) return p; + final len = _blockTextAt(di, p.blockIndex).length; + if (forward) { + if (p.offset < len) return p.copyWith(offset: p.offset + 1); + final adj = _adjacentBlock(di, p.blockIndex, forward: true); + return adj == null + ? p + : MarkdownPosition( + documentId: _docs[adj.$1].id, blockIndex: adj.$2, offset: 0); + } + if (p.offset > 0) return p.copyWith(offset: p.offset - 1); + final adj = _adjacentBlock(di, p.blockIndex, forward: false); + return adj == null + ? p + : MarkdownPosition( + documentId: _docs[adj.$1].id, + blockIndex: adj.$2, + offset: _blockTextAt(adj.$1, adj.$2).length); + } + + MarkdownPosition _stepWord(MarkdownPosition p, {required bool forward}) { + final di = _orderIndex(p.documentId); + if (di < 0) return p; + final text = _blockTextAt(di, p.blockIndex); + if (forward) { + if (p.offset >= text.length) return _stepCharacter(p, forward: true); + var i = p.offset; + while (i < text.length && _isSpace(text[i])) i++; + while (i < text.length && !_isSpace(text[i])) i++; + return p.copyWith(offset: i); + } + if (p.offset <= 0) return _stepCharacter(p, forward: false); + var i = p.offset; + while (i > 0 && _isSpace(text[i - 1])) i--; + while (i > 0 && !_isSpace(text[i - 1])) i--; + return p.copyWith(offset: i); + } + + MarkdownPosition _stepLineBreak(MarkdownPosition p, {required bool forward}) { + final di = _orderIndex(p.documentId); + if (di < 0) return p; + return p.copyWith( + offset: forward ? _blockTextAt(di, p.blockIndex).length : 0); + } + + MarkdownPosition _documentBoundary({required bool forward}) { + if (forward) { + final di = _docs.length - 1; + final bi = _docs[di].model.blocks.length - 1; + return MarkdownPosition( + documentId: _docs[di].id, + blockIndex: bi < 0 ? 0 : bi, + offset: bi < 0 ? 0 : _blockTextAt(di, bi).length); + } + return MarkdownPosition( + documentId: _docs.first.id, blockIndex: 0, offset: 0); + } } /// Coordinates several controllers (and external selectables) so that at most diff --git a/lib/src/selection_scope.dart b/lib/src/selection_scope.dart index 331cfb6..d398e80 100644 --- a/lib/src/selection_scope.dart +++ b/lib/src/selection_scope.dart @@ -1,30 +1,67 @@ import 'package:flutter/gestures.dart'; -import 'package:flutter/widgets.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'selection.dart'; +/// Signature for building the selection context menu (toolbar), mirroring +/// `SelectableRegion.contextMenuBuilder`. +/// +/// Read [MarkdownSelectionScopeState.contextMenuButtonItems] / +/// [MarkdownSelectionScopeState.contextMenuAnchors] to build an adaptive menu, +/// or call [MarkdownSelectionScopeState.copySelection] / `selectAll` / +/// `clearSelection` from a fully custom menu. +typedef MarkdownSelectionContextMenuBuilder = Widget Function( + BuildContext context, + MarkdownSelectionScopeState state, +); + class _ScopeMarker extends InheritedWidget { - const _ScopeMarker({required this.controller, required super.child}); + const _ScopeMarker({ + required this.controller, + required this.state, + required super.child, + }); final MarkdownSelectionController controller; + final MarkdownSelectionScopeState state; @override bool updateShouldNotify(_ScopeMarker old) => - !identical(controller, old.controller); + !identical(controller, old.controller) || !identical(state, old.state); } -/// Owns Markdown selection gestures for its subtree and exposes the ambient +/// Owns Markdown selection gestures, keyboard shortcuts and the selection +/// toolbar for its subtree, and exposes the ambient /// [MarkdownSelectionController] to descendant `MarkdownWidget`s. /// -/// A mouse/trackpad/stylus drag selects; on touch a long-press-then-drag -/// selects (so a plain swipe still scrolls an enclosing list). Wrap a chat's -/// `ListView` (or any group of `MarkdownWidget`s sharing one controller) in a -/// single scope to get selection that spans widgets and survives disposal. -class MarkdownSelectionScope extends StatelessWidget { +/// Modeled on `SelectionArea`/`SelectableRegion`: +/// +/// * A mouse/trackpad/stylus drag selects; on touch a long-press-then-drag +/// selects (so a plain swipe still scrolls an enclosing list). +/// * Keyboard shortcuts work when the scope is focused — `Ctrl/Cmd+C` copies, +/// `Ctrl/Cmd+A` selects all, `Shift`+arrows extend the selection (by +/// character, word, line or document per the platform bindings), and `Esc` +/// clears it. The key bindings come from the ambient +/// `DefaultTextEditingShortcuts` (installed by `WidgetsApp`/`MaterialApp`). +/// * Right-click (desktop) or long-press (mobile) shows an adaptive context +/// toolbar; customize it with [contextMenuBuilder]. +/// +/// Everything is customizable in the same spirit as `SelectableText`: +/// [selectionColor], [contextMenuBuilder], [magnifierConfiguration], +/// [selectionControls], [focusNode] and [onSelectionChanged]. +class MarkdownSelectionScope extends StatefulWidget { /// Creates a selection scope backed by [controller]. const MarkdownSelectionScope({ required this.controller, required this.child, + this.focusNode, + this.enabled = true, + this.selectionColor, + this.contextMenuBuilder = defaultContextMenuBuilder, + this.magnifierConfiguration, + this.selectionControls, + this.onSelectionChanged, super.key, }); @@ -34,6 +71,42 @@ class MarkdownSelectionScope extends StatelessWidget { /// The subtree in which selection gestures apply. final Widget child; + /// An optional external focus node. When null the scope manages its own. + final FocusNode? focusNode; + + /// Whether selection gestures, handles and shortcuts are active. When false + /// the scope is inert (but still exposes the controller to descendants). + final bool enabled; + + /// 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. + final MarkdownSelectionContextMenuBuilder? contextMenuBuilder; + + /// Magnifier configuration for touch selection/handle drags. Defaults to the + /// platform-adaptive magnifier. + final TextMagnifierConfiguration? magnifierConfiguration; + + /// Controls used to paint the selection handles. Defaults per platform. + final TextSelectionControls? selectionControls; + + /// Called whenever the selection changes. + final ValueChanged? onSelectionChanged; + + /// The default [contextMenuBuilder]: an [AdaptiveTextSelectionToolbar] built + /// from the scope's [MarkdownSelectionScopeState.contextMenuButtonItems]. + static Widget defaultContextMenuBuilder( + BuildContext context, + MarkdownSelectionScopeState state, + ) => + AdaptiveTextSelectionToolbar.buttonItems( + buttonItems: state.contextMenuButtonItems, + anchors: state.contextMenuAnchors, + ); + /// The nearest ambient controller, or null if there is no enclosing scope. static MarkdownSelectionController? maybeOf(BuildContext context) => context.dependOnInheritedWidgetOfExactType<_ScopeMarker>()?.controller; @@ -42,39 +115,313 @@ class MarkdownSelectionScope extends StatelessWidget { static MarkdownSelectionController of(BuildContext context) => maybeOf(context)!; + /// The nearest ambient scope state, or null when there is no enclosing scope. + static MarkdownSelectionScopeState? stateOf(BuildContext context) => + context.dependOnInheritedWidgetOfExactType<_ScopeMarker>()?.state; + + @override + State createState() => MarkdownSelectionScopeState(); +} + +/// State for [MarkdownSelectionScope]. Public so a custom [contextMenuBuilder] +/// can drive it (copy/select-all/clear + toolbar geometry), mirroring +/// `SelectableRegionState`. +class MarkdownSelectionScopeState extends State { + final ContextMenuController _contextMenuController = ContextMenuController(); + FocusNode? _internalFocusNode; + Offset? _lastSecondaryTapDown; + MarkdownSelection? _lastSelection; + + FocusNode get _focusNode => + widget.focusNode ?? + (_internalFocusNode ??= FocusNode(debugLabel: 'MarkdownSelectionScope')); + + /// The controller this scope drives. + MarkdownSelectionController get controller => widget.controller; + + @override + void initState() { + super.initState(); + _lastSelection = widget.controller.selection; + widget.controller.addListener(_onControllerChanged); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _applySelectionColor(); + } + + @override + void didUpdateWidget(covariant MarkdownSelectionScope oldWidget) { + super.didUpdateWidget(oldWidget); + if (!identical(oldWidget.controller, widget.controller)) { + oldWidget.controller.removeListener(_onControllerChanged); + widget.controller.addListener(_onControllerChanged); + _lastSelection = widget.controller.selection; + } + if (oldWidget.selectionColor != widget.selectionColor) { + _applySelectionColor(); + } + } + @override - Widget build(BuildContext context) => _ScopeMarker( - controller: controller, - child: RawGestureDetector( - behavior: HitTestBehavior.translucent, - gestures: { - PanGestureRecognizer: - GestureRecognizerFactoryWithHandlers( - () => PanGestureRecognizer( - supportedDevices: const { - PointerDeviceKind.mouse, - PointerDeviceKind.stylus, - PointerDeviceKind.invertedStylus, - PointerDeviceKind.trackpad, - }, - ), - (recognizer) => recognizer - ..dragStartBehavior = DragStartBehavior.down - ..onStart = ((d) => controller.startAtGlobal(d.globalPosition)) - ..onUpdate = - ((d) => controller.extendToGlobal(d.globalPosition)), - ), - LongPressGestureRecognizer: GestureRecognizerFactoryWithHandlers< - LongPressGestureRecognizer>( - () => LongPressGestureRecognizer(), - (recognizer) => recognizer - ..onLongPressStart = - ((d) => controller.startAtGlobal(d.globalPosition)) - ..onLongPressMoveUpdate = - ((d) => controller.extendToGlobal(d.globalPosition)), - ), - }, - child: child, + void dispose() { + widget.controller.removeListener(_onControllerChanged); + _contextMenuController.remove(); + _internalFocusNode?.dispose(); + super.dispose(); + } + + void _applySelectionColor() { + controller.selectionColor = widget.selectionColor ?? + DefaultSelectionStyle.of(context).selectionColor; + } + + void _onControllerChanged() { + final sel = controller.selection; + if (sel != _lastSelection) { + _lastSelection = sel; + widget.onSelectionChanged?.call(sel); + } + if (sel == null || sel.isCollapsed) hideToolbar(); + } + + // --- public selection ops ------------------------------------------------ + + /// Copies the current selection to the clipboard and hides the toolbar. + Future copySelection() async { + final text = controller.getText(); + if (text.isNotEmpty) { + await Clipboard.setData(ClipboardData(text: text)); + } + hideToolbar(); + } + + /// Selects everything across the registered documents. + void selectAll() { + _focusNode.requestFocus(); + controller.selectAll(); + } + + /// Clears the selection and hides the toolbar. + void clearSelection() { + controller.clear(); + hideToolbar(); + } + + // --- context menu -------------------------------------------------------- + + /// The default toolbar buttons for the current selection: Copy (when + /// 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) { + items.add(ContextMenuButtonItem( + type: ContextMenuButtonType.copy, + onPressed: copySelection, + )); + } + if (controller.documents.isNotEmpty) { + items.add(ContextMenuButtonItem( + type: ContextMenuButtonType.selectAll, + onPressed: () { + selectAll(); + showToolbar(); + }, + )); + } + return items; + } + + /// Where to anchor the toolbar: the last right-click point, else the top / + /// bottom center of the selection's bounding box. + TextSelectionToolbarAnchors get contextMenuAnchors { + final secondary = _lastSecondaryTapDown; + if (secondary != null) { + 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; + return TextSelectionToolbarAnchors( + primaryAnchor: bounds.topCenter, + secondaryAnchor: bounds.bottomCenter, + ); + } + var bounds = rects.first; + for (final rect in rects.skip(1)) { + bounds = bounds.expandToInclude(rect); + } + return TextSelectionToolbarAnchors( + primaryAnchor: bounds.topCenter, + secondaryAnchor: bounds.bottomCenter, + ); + } + + /// Whether the toolbar is currently visible. + bool get toolbarIsVisible => _contextMenuController.isShown; + + /// Shows the context toolbar. [location] anchors it at a point (e.g. the + /// right-click position); otherwise it anchors to the selection. + void showToolbar([Offset? location]) { + final builder = widget.contextMenuBuilder; + if (builder == null) return; + _lastSecondaryTapDown = location; + _contextMenuController.remove(); + _contextMenuController.show( + context: context, + contextMenuBuilder: (context) => builder(context, this), + ); + } + + /// Hides the context toolbar. + void hideToolbar() { + _lastSecondaryTapDown = null; + _contextMenuController.remove(); + } + + // --- gestures ------------------------------------------------------------ + + void _onDragDown(Offset globalPosition) { + _focusNode.requestFocus(); + hideToolbar(); + controller.startAtGlobal(globalPosition); + } + + Map get _gestures => + { + PanGestureRecognizer: + GestureRecognizerFactoryWithHandlers( + () => PanGestureRecognizer( + supportedDevices: const { + PointerDeviceKind.mouse, + PointerDeviceKind.stylus, + PointerDeviceKind.invertedStylus, + PointerDeviceKind.trackpad, + }, + ), + (recognizer) => recognizer + ..dragStartBehavior = DragStartBehavior.down + ..onStart = ((d) => _onDragDown(d.globalPosition)) + ..onUpdate = ((d) => controller.extendToGlobal(d.globalPosition)), ), + LongPressGestureRecognizer: + GestureRecognizerFactoryWithHandlers( + () => LongPressGestureRecognizer(), + (recognizer) => recognizer + ..onLongPressStart = ((d) => _onDragDown(d.globalPosition)) + ..onLongPressMoveUpdate = + ((d) => controller.extendToGlobal(d.globalPosition)) + ..onLongPressEnd = ((_) => showToolbar()), + ), + TapGestureRecognizer: + GestureRecognizerFactoryWithHandlers( + () => TapGestureRecognizer(), + (recognizer) => recognizer + ..onTapDown = ((_) => hideToolbar()) + ..onSecondaryTapDown = + ((d) => _lastSecondaryTapDown = d.globalPosition) + ..onSecondaryTapUp = ((d) { + _focusNode.requestFocus(); + showToolbar(d.globalPosition); + }), + ), + }; + + late final Map> _actions = >{ + CopySelectionTextIntent: CallbackAction( + onInvoke: (_) { + copySelection(); + return null; + }, + ), + SelectAllTextIntent: CallbackAction( + onInvoke: (_) { + selectAll(); + return null; + }, + ), + ExtendSelectionByCharacterIntent: + CallbackAction( + onInvoke: (intent) { + if (!intent.collapseSelection) { + controller.extendSelectionByCharacter(forward: intent.forward); + } + return null; + }, + ), + ExtendSelectionToNextWordBoundaryIntent: + CallbackAction( + onInvoke: (intent) { + if (!intent.collapseSelection) { + controller.extendSelectionByWord(forward: intent.forward); + } + return null; + }, + ), + ExtendSelectionToLineBreakIntent: + CallbackAction( + onInvoke: (intent) { + if (!intent.collapseSelection) { + controller.extendSelectionToLineBreak(forward: intent.forward); + } + return null; + }, + ), + ExtendSelectionVerticallyToAdjacentLineIntent: + CallbackAction( + onInvoke: (intent) { + if (!intent.collapseSelection) { + controller.extendSelectionToAdjacentLine(forward: intent.forward); + } + return null; + }, + ), + ExtendSelectionToDocumentBoundaryIntent: + CallbackAction( + onInvoke: (intent) { + if (!intent.collapseSelection) { + controller.extendSelectionToDocumentBoundary(forward: intent.forward); + } + return null; + }, + ), + DismissIntent: CallbackAction( + onInvoke: (_) { + clearSelection(); + return null; + }, + ), + }; + + @override + Widget build(BuildContext context) { + if (!widget.enabled) { + return _ScopeMarker( + controller: widget.controller, + state: this, + child: widget.child, ); + } + return _ScopeMarker( + controller: widget.controller, + state: this, + child: Actions( + actions: _actions, + child: Focus( + focusNode: _focusNode, + child: RawGestureDetector( + behavior: HitTestBehavior.translucent, + gestures: _gestures, + child: widget.child, + ), + ), + ), + ); + } } diff --git a/test/selection/selection_keyboard_test.dart b/test/selection/selection_keyboard_test.dart new file mode 100644 index 0000000..00551d2 --- /dev/null +++ b/test/selection/selection_keyboard_test.dart @@ -0,0 +1,220 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_md/flutter_md.dart'; +import 'package:flutter_test/flutter_test.dart'; + +Widget _wrap( + MarkdownSelectionController controller, + Widget child, { + FocusNode? focusNode, +}) => + MaterialApp( + home: Scaffold( + body: MarkdownSelectionScope( + controller: controller, + focusNode: focusNode, + 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); + } +} + +void main() { + group('keyboard shortcuts', () { + testWidgets('Ctrl+A selects all', (tester) async { + debugDefaultTargetPlatformOverride = TargetPlatform.linux; + final md = Markdown.fromString('Hello keyboard world'); + final controller = MarkdownSelectionController() + ..setDocuments( + [MarkdownDocumentRef(id: 'd', model: md)]); + final focus = FocusNode(); + addTearDown(focus.dispose); + + await tester.pumpWidget(_wrap( + controller, + const SizedBox(width: 400, child: _Doc('d')), + focusNode: focus, + )); + await tester.pumpAndSettle(); + focus.requestFocus(); + await tester.pump(); + + await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.keyA); + await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); + await tester.pump(); + + debugDefaultTargetPlatformOverride = null; + expect(controller.getText(), 'Hello keyboard world'); + }); + + testWidgets('Esc clears the selection', (tester) async { + debugDefaultTargetPlatformOverride = TargetPlatform.linux; + final md = Markdown.fromString('Hello keyboard world'); + final controller = MarkdownSelectionController() + ..setDocuments( + [MarkdownDocumentRef(id: 'd', model: md)]) + ..selectAll(); + final focus = FocusNode(); + addTearDown(focus.dispose); + + await tester.pumpWidget(_wrap( + controller, + const SizedBox(width: 400, child: _Doc('d')), + focusNode: focus, + )); + await tester.pumpAndSettle(); + focus.requestFocus(); + await tester.pump(); + expect(controller.getText(), isNotEmpty); + + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await tester.pump(); + + debugDefaultTargetPlatformOverride = null; + expect(controller.selection, isNull); + }); + + testWidgets('Shift+ArrowRight extends the selection by a character', + (tester) async { + debugDefaultTargetPlatformOverride = TargetPlatform.linux; + final md = Markdown.fromString('Hello'); + final controller = MarkdownSelectionController() + ..setDocuments( + [MarkdownDocumentRef(id: 'd', model: md)]) + ..selection = const MarkdownSelection.collapsed( + MarkdownPosition(documentId: 'd', blockIndex: 0, offset: 0)); + final focus = FocusNode(); + addTearDown(focus.dispose); + + await tester.pumpWidget(_wrap( + controller, + const SizedBox(width: 400, child: _Doc('d')), + focusNode: focus, + )); + await tester.pumpAndSettle(); + focus.requestFocus(); + await tester.pump(); + + await tester.sendKeyDownEvent(LogicalKeyboardKey.shiftLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); + await tester.sendKeyUpEvent(LogicalKeyboardKey.shiftLeft); + await tester.pump(); + + debugDefaultTargetPlatformOverride = null; + expect(controller.selection!.extent.offset, 1); + expect(controller.getText(), 'H'); + }); + + testWidgets('Ctrl+C copies the selection to the clipboard', (tester) async { + debugDefaultTargetPlatformOverride = TargetPlatform.linux; + final md = Markdown.fromString('Copy this text'); + final controller = MarkdownSelectionController() + ..setDocuments( + [MarkdownDocumentRef(id: 'd', model: md)]) + ..selectAll(); + final focus = FocusNode(); + addTearDown(focus.dispose); + + 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')), + focusNode: focus, + )); + await tester.pumpAndSettle(); + focus.requestFocus(); + await tester.pump(); + + await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.keyC); + await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); + await tester.pumpAndSettle(); + + debugDefaultTargetPlatformOverride = null; + expect(data, isNotEmpty); + expect(data.first.arguments['text'], 'Copy this text'); + }); + }); + + group('context toolbar', () { + testWidgets('right-click over a selection shows a Copy button', + (tester) async { + debugDefaultTargetPlatformOverride = TargetPlatform.linux; + final md = Markdown.fromString('Toolbar target text'); + final controller = MarkdownSelectionController() + ..setDocuments( + [MarkdownDocumentRef(id: 'd', model: md)]) + ..selectAll(); + + await tester.pumpWidget(_wrap( + controller, + const SizedBox(width: 400, child: _Doc('d')), + )); + await tester.pumpAndSettle(); + + final center = tester.getCenter(find.byType(MarkdownWidget)); + final g = await tester.startGesture(center, + kind: PointerDeviceKind.mouse, buttons: kSecondaryMouseButton); + await g.up(); + await tester.pumpAndSettle(); + + debugDefaultTargetPlatformOverride = null; + expect(find.text('Copy'), findsOneWidget); + expect(find.text('Select all'), findsOneWidget); + expect(tester.takeException(), isNull); + }); + + testWidgets('showToolbar/hideToolbar via the scope state', (tester) async { + debugDefaultTargetPlatformOverride = TargetPlatform.linux; + final md = Markdown.fromString('State driven toolbar'); + final controller = MarkdownSelectionController() + ..setDocuments( + [MarkdownDocumentRef(id: 'd', model: md)]) + ..selectAll(); + + 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); + expect(state.toolbarIsVisible, isTrue); + + state.hideToolbar(); + await tester.pumpAndSettle(); + + debugDefaultTargetPlatformOverride = null; + expect(find.text('Copy'), findsNothing); + expect(state.toolbarIsVisible, isFalse); + }); + }); +} diff --git a/test/unit_test.dart b/test/unit_test.dart index 4a320bc..72f037b 100644 --- a/test/unit_test.dart +++ b/test/unit_test.dart @@ -9,6 +9,7 @@ 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 '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 'theme/theme_test.dart' as theme_test; @@ -28,6 +29,7 @@ void main() => group('Unit', () { theme_test.main(); selection_test.main(); selection_widget_test.main(); + selection_keyboard_test.main(); render_test.main(); widget_test.main(); }); From ceabbd77961fdafae14ea63a552c29242492cd44 Mon Sep 17 00:00:00 2001 From: Mike Matiunin Date: Thu, 30 Jul 2026 19:58:41 +0400 Subject: [PATCH 08/30] feat(selection): native handles + magnifier via SelectionOverlay Add touch-platform selection handles and a magnifier, mirroring SelectableText/SelectionArea, driven from the logical-model controller: - Render objects push start/end LeaderLayers at the selection endpoints (MarkdownRenderObject.setSelectionHandleLayers + paint), so the scope's SelectionOverlay handles follow the content as it scrolls and across multiple MarkdownWidgets. New surface geometry: localSelectionRects, setSelectionHandleLayers, repaintSelection. - Controller resolves the two endpoints (owning surface + local/global caret rects) via selectionHandleEndpoints() / MarkdownHandleEndpoints. - The scope owns a SelectionOverlay + 3 LayerLinks, assigns leaders to the owning surfaces each selection change (single-pass, no needless repaints), and wires handle-drag -> moveSelectionEdgeToGlobal + magnifier show/update/ hide. Handles/magnifier use platform-default TextSelectionControls + adaptive magnifier (customizable), and are gated to touch platforms. - Overlay sync is deferred to a post-frame callback when it would run during a build/layout phase (e.g. a streaming setState). Fix: selectionColor now repaints mounted surfaces directly instead of calling notifyListeners (it is a render detail, and is applied from didChangeDependencies where a listener's setState would throw). Tests: 3 handle tests (edge-drag adjusts selection; touch shows handles; desktop shows none). 402 unit tests green; analyze --fatal-infos + format clean; example smoke green; benchmark shows no regression (paint_hit ~41us). Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 10 ++ lib/src/render.dart | 71 ++++++++- lib/src/selection.dart | 100 +++++++++++- lib/src/selection_scope.dart | 177 +++++++++++++++++++++ test/selection/selection_handles_test.dart | 124 +++++++++++++++ test/unit_test.dart | 2 + 6 files changed, 479 insertions(+), 5 deletions(-) create mode 100644 test/selection/selection_handles_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 59055a2..ab5e977 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,16 @@ `onSelectionChanged`. New controller ops: `selectionColor`, `globalSelectionRects`, `moveSelectionEdgeToGlobal`, and the `extendSelectionBy*` family; `MarkdownPosition.copyWith`. +- **ADDED**: Native selection handles and a magnifier on touch platforms, + driven by Flutter's `SelectionOverlay`. Selection endpoints push + `LeaderLayer`s from the render objects so the handles follow the content as it + scrolls (and across multiple `MarkdownWidget`s); dragging a handle adjusts the + selection and shows the platform magnifier. Handles/magnifier respect the + platform (`selectionControls`, `magnifierConfiguration`) and are absent on + desktop, matching `SelectableText`. New surface geometry: + `localSelectionRects`, `setSelectionHandleLayers`, `repaintSelection`, and + `MarkdownSelectionController.selectionHandleEndpoints` / + `MarkdownHandleEndpoints`. - **CHANGED**: `MarkdownWidget`'s render object now draws the selection highlight outside the cached content `Picture` and becomes a repaint boundary when selectable, so selection/drag repaints do not rebuild the glyph cache. diff --git a/lib/src/render.dart b/lib/src/render.dart index 8ebe4ce..11d18df 100644 --- a/lib/src/render.dart +++ b/lib/src/render.dart @@ -40,6 +40,13 @@ class MarkdownRenderObject extends RenderBox final Paint _highlightPaint = Paint()..color = _kSelectionColor; + // Selection-handle leader layers pushed during paint so native handles + // (drawn by the scope's SelectionOverlay) follow the content as it scrolls. + LayerLink? _startHandleLink; + Offset? _startHandleLocal; + LayerLink? _endHandleLink; + Offset? _endHandleLocal; + void _onSelectionChange() { if (!_disposed) markNeedsPaint(); } @@ -102,15 +109,47 @@ class MarkdownRenderObject extends RenderBox @override List globalSelectionRects() { - final controller = _controller; - final id = _documentId; - if (controller == null || id == null) return const []; - final local = _painter.selectionBoxes((s) => controller.rangeFor(id, s)); + final local = localSelectionRects(); if (local.isEmpty) return const []; final origin = localToGlobal(Offset.zero); return [for (final rect in local) rect.shift(origin)]; } + @override + List localSelectionRects() { + final controller = _controller; + final id = _documentId; + if (controller == null || id == null) return const []; + return _painter.selectionBoxes((s) => controller.rangeFor(id, s)); + } + + @override + void setSelectionHandleLayers({ + LayerLink? startLink, + Offset? startLocal, + LayerLink? endLink, + Offset? endLocal, + }) { + var changed = false; + if (!identical(startLink, _startHandleLink) || + startLocal != _startHandleLocal) { + _startHandleLink = startLink; + _startHandleLocal = startLocal; + changed = true; + } + if (!identical(endLink, _endHandleLink) || endLocal != _endHandleLocal) { + _endHandleLink = endLink; + _endHandleLocal = endLocal; + changed = true; + } + if (changed && !_disposed && attached) markNeedsPaint(); + } + + @override + void repaintSelection() { + if (!_disposed && attached) markNeedsPaint(); + } + /// Current size of the render box. @override Size get size => _size; @@ -258,9 +297,33 @@ class MarkdownRenderObject extends RenderBox _painter.paint(canvas, size); canvas.restore(); + + // Push handle leader layers (empty layers) so the scope's SelectionOverlay + // handles follow this content as it scrolls. + final startLink = _startHandleLink; + final startLocal = _startHandleLocal; + if (startLink != null && startLocal != null) { + context.pushLayer( + LeaderLayer(link: startLink, offset: offset + startLocal), + _paintNothing, + Offset.zero, + ); + } + final endLink = _endHandleLink; + final endLocal = _endHandleLocal; + if (endLink != null && endLocal != null) { + context.pushLayer( + LeaderLayer(link: endLink, offset: offset + endLocal), + _paintNothing, + Offset.zero, + ); + } } } +/// A no-op paint callback for pushing childless [LeaderLayer]s. +void _paintNothing(PaintingContext context, Offset offset) {} + /// A painter for rendering markdown content via blocks and spans. @meta.internal class MarkdownPainter { diff --git a/lib/src/selection.dart b/lib/src/selection.dart index bfe469b..7b78418 100644 --- a/lib/src/selection.dart +++ b/lib/src/selection.dart @@ -1,6 +1,7 @@ import 'dart:ui' show Color, Offset, Rect, TextRange; import 'package:flutter/foundation.dart'; +import 'package:flutter/rendering.dart' show LayerLink; import 'markdown.dart'; import 'nodes.dart'; @@ -387,6 +388,58 @@ abstract interface class MarkdownSelectionSurface { /// selected. Used to place selection handles, the magnifier and the toolbar /// anchor. List globalSelectionRects(); + + /// Content-local rectangles covering the selected part of this surface's + /// document, in reading order. The local-space twin of [globalSelectionRects] + /// used to anchor handle leader layers. + List localSelectionRects(); + + /// 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. + void setSelectionHandleLayers({ + LayerLink? startLink, + Offset? startLocal, + LayerLink? endLink, + Offset? endLocal, + }); + + /// 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(); +} + +/// The mounted geometry at the two ends of a selection, for placing handles. +@immutable +class MarkdownHandleEndpoints { + /// Creates endpoints binding each edge to its owning surface with the local + /// and global caret rects at that edge. + const MarkdownHandleEndpoints({ + required this.startSurface, + required this.startLocal, + required this.startGlobal, + required this.endSurface, + required this.endLocal, + required this.endGlobal, + }); + + /// The surface owning the reading-order start edge. + final MarkdownSelectionSurface startSurface; + + /// The local caret rect at the start edge (within [startSurface]). + final Rect startLocal; + + /// The global caret rect at the start edge. + final Rect startGlobal; + + /// The surface owning the reading-order end edge. + final MarkdownSelectionSurface endSurface; + + /// The local caret rect at the end edge (within [endSurface]). + final Rect endLocal; + + /// The global caret rect at the end edge. + final Rect endGlobal; } class _DocEntry { @@ -437,11 +490,17 @@ class MarkdownSelectionController extends ChangeNotifier { /// The color of the selection highlight, or null to use the render layer's /// default. Usually set by [MarkdownSelectionScope] from the ambient /// `DefaultSelectionStyle` / `TextSelectionTheme`. + /// + /// Changing it repaints mounted surfaces directly rather than notifying + /// listeners — it is a rendering detail, not a selection change, and is often + /// set during a build phase (from `didChangeDependencies`). Color? get selectionColor => _selectionColor; set selectionColor(Color? value) { if (value == _selectionColor) return; _selectionColor = value; - notifyListeners(); + for (final surface in _surfaces.values) { + surface.repaintSelection(); + } } final List<_DocEntry> _docs = <_DocEntry>[]; @@ -747,6 +806,45 @@ class MarkdownSelectionController extends ChangeNotifier { return out; } + /// Resolves the mounted surfaces and local/global caret rects at the two ends + /// of the current selection, for placing selection handles. Null when the + /// selection is collapsed or neither end is mounted. + 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; + } + endSurface = surface; + endLocal = local.last; + endGlobal = global.last; + } + if (startSurface == null || endSurface == null) return null; + return MarkdownHandleEndpoints( + startSurface: startSurface, + startLocal: startLocal!, + startGlobal: startGlobal!, + endSurface: endSurface, + endLocal: endLocal!, + endGlobal: endGlobal!, + ); + } + /// 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. diff --git a/lib/src/selection_scope.dart b/lib/src/selection_scope.dart index d398e80..d968fbf 100644 --- a/lib/src/selection_scope.dart +++ b/lib/src/selection_scope.dart @@ -1,5 +1,10 @@ +import 'package:flutter/cupertino.dart' + show + cupertinoTextSelectionHandleControls, + cupertinoDesktopTextSelectionHandleControls; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/scheduler.dart'; import 'package:flutter/services.dart'; import 'selection.dart'; @@ -128,6 +133,10 @@ class MarkdownSelectionScope extends StatefulWidget { /// `SelectableRegionState`. class MarkdownSelectionScopeState extends State { final ContextMenuController _contextMenuController = ContextMenuController(); + final LayerLink _startHandleLink = LayerLink(); + final LayerLink _endHandleLink = LayerLink(); + final LayerLink _toolbarLink = LayerLink(); + SelectionOverlay? _selectionOverlay; FocusNode? _internalFocusNode; Offset? _lastSecondaryTapDown; MarkdownSelection? _lastSelection; @@ -169,6 +178,8 @@ class MarkdownSelectionScopeState extends State { void dispose() { widget.controller.removeListener(_onControllerChanged); _contextMenuController.remove(); + _selectionOverlay?.dispose(); + _selectionOverlay = null; _internalFocusNode?.dispose(); super.dispose(); } @@ -185,6 +196,172 @@ class MarkdownSelectionScopeState extends State { widget.onSelectionChanged?.call(sel); } if (sel == null || sel.isCollapsed) hideToolbar(); + _syncOverlay(); + } + + // --- native handles + magnifier ------------------------------------------ + + bool get _handlesEnabled => + widget.enabled && + switch (Theme.of(context).platform) { + TargetPlatform.android || + TargetPlatform.iOS || + TargetPlatform.fuchsia => + true, + _ => false, + }; + + TextSelectionControls get _effectiveControls => + widget.selectionControls ?? + switch (Theme.of(context).platform) { + TargetPlatform.android || + TargetPlatform.fuchsia => + materialTextSelectionHandleControls, + TargetPlatform.linux || + TargetPlatform.windows => + desktopTextSelectionHandleControls, + TargetPlatform.iOS => cupertinoTextSelectionHandleControls, + TargetPlatform.macOS => cupertinoDesktopTextSelectionHandleControls, + }; + + TextMagnifierConfiguration get _effectiveMagnifier => + 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`). + void _syncOverlay() { + final phase = SchedulerBinding.instance.schedulerPhase; + if (phase == SchedulerPhase.persistentCallbacks || + phase == SchedulerPhase.midFrameMicrotasks) { + SchedulerBinding.instance.addPostFrameCallback((_) { + if (mounted) _updateHandlesAndOverlay(); + }); + } else { + _updateHandlesAndOverlay(); + } + } + + void _updateHandlesAndOverlay() { + if (!_handlesEnabled) { + _clearHandles(); + return; + } + final endpoints = controller.selectionHandleEndpoints(); + if (endpoints == null) { + _clearHandles(); + 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) { + _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]; + } + } + + /// 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); + for (final surface in controller.mountedSurfaces) { + final isStart = identical(surface, startSurface); + final isEnd = identical(surface, endSurface); + surface.setSelectionHandleLayers( + startLink: isStart ? _startHandleLink : null, + startLocal: isStart ? startLocal : null, + endLink: isEnd ? _endHandleLink : null, + endLocal: isEnd ? endLocal : null, + ); + } + } + + void _clearHandles() { + _applyHandles(null); + _selectionOverlay?.hide(); + _selectionOverlay?.dispose(); + _selectionOverlay = null; + } + + void _onHandleDragStart(DragStartDetails d, {required bool isStart}) { + _selectionOverlay + ?.showMagnifier(_magnifierInfo(d.globalPosition, isStart: isStart)); + } + + void _onHandleDragUpdate(DragUpdateDetails d, {required bool isStart}) { + final e = controller.selectionHandleEndpoints(); + final lineHeight = + e == null ? 0.0 : (isStart ? e.startLocal.height : e.endLocal.height); + controller.moveSelectionEdgeToGlobal( + d.globalPosition - Offset(0, lineHeight / 2), + isStart: isStart, + ); + _selectionOverlay + ?.updateMagnifier(_magnifierInfo(d.globalPosition, isStart: isStart)); + } + + void _onHandleDragEnd() { + _selectionOverlay?.hideMagnifier(); + 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); + return MagnifierInfo( + globalGesturePosition: gesture, + caretRect: caret, + fieldBounds: bounds, + currentLineBoundaries: bounds, + ); } // --- public selection ops ------------------------------------------------ diff --git a/test/selection/selection_handles_test.dart b/test/selection/selection_handles_test.dart new file mode 100644 index 0000000..6414c66 --- /dev/null +++ b/test/selection/selection_handles_test.dart @@ -0,0 +1,124 @@ +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'; + +Future _mouseDrag(WidgetTester tester, Offset from, Offset to) async { + final g = await tester.startGesture(from, kind: PointerDeviceKind.mouse); + await tester.pump(const Duration(milliseconds: 200)); + await g.moveTo(to); + await tester.pump(const Duration(milliseconds: 200)); + await g.up(); + await tester.pumpAndSettle(); +} + +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); + } +} + +void main() { + group('selection handles', () { + testWidgets('moveSelectionEdgeToGlobal adjusts the moving edge', + (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(); + + controller.selectAll(); + final fullLength = controller.getText().length; + expect(fullLength, 22); + + // Pull the end edge back to near the start of the line. + final tl = tester.getTopLeft(find.byType(MarkdownWidget)); + controller.moveSelectionEdgeToGlobal(tl + const Offset(40, 8), + isStart: false); + await tester.pump(); + + final text = controller.getText(); + expect(text.length, lessThan(fullLength)); + expect('Hello selectable world', startsWith(text)); + }); + + testWidgets('touch platform shows draggable handles for a selection', + (tester) async { + debugDefaultTargetPlatformOverride = TargetPlatform.android; + 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)); + final br = tester.getBottomRight(find.byType(MarkdownWidget)); + await _mouseDrag( + tester, tl + const Offset(1, 3), br - const Offset(1, 3)); + + expect(controller.getText(), isNotEmpty); + // Two handles are composited to follow the content. + expect(find.byType(CompositedTransformFollower), findsWidgets); + expect(tester.takeException(), isNull); + debugDefaultTargetPlatformOverride = null; + }); + + testWidgets('desktop platform shows no selection handles', (tester) async { + debugDefaultTargetPlatformOverride = TargetPlatform.linux; + 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)); + final br = tester.getBottomRight(find.byType(MarkdownWidget)); + await _mouseDrag( + tester, tl + const Offset(1, 3), br - const Offset(1, 3)); + + expect(controller.getText(), isNotEmpty); + expect(find.byType(CompositedTransformFollower), findsNothing); + expect(tester.takeException(), isNull); + debugDefaultTargetPlatformOverride = null; + }); + }); +} diff --git a/test/unit_test.dart b/test/unit_test.dart index 72f037b..3a6036e 100644 --- a/test/unit_test.dart +++ b/test/unit_test.dart @@ -9,6 +9,7 @@ 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 '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; @@ -30,6 +31,7 @@ void main() => group('Unit', () { selection_test.main(); selection_widget_test.main(); selection_keyboard_test.main(); + selection_handles_test.main(); render_test.main(); widget_test.main(); }); From a56adcc90fecde5d382cae19bc6242f72ecad9b4 Mon Sep 17 00:00:00 2001 From: Mike Matiunin Date: Thu, 30 Jul 2026 20:02:14 +0400 Subject: [PATCH 09/30] bench+example+docs: selection_drag tier, custom-menu demo, docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - benchmark: add the selection_drag tier (deferred since spike S7). It grows a highlight over the cached content Picture each frame and asserts the drag frame stays below paint_miss/3 — measured ~45us vs paint_hit ~41us, i.e. the highlight adds ~4us over a cache hit and never re-records the Picture. The path is already optimal, so no code change was needed. - example: the Selection tab now demonstrates customization — a custom contextMenuBuilder adding a "Copy LOUD" action on doc A, and a custom selectionColor on doc B. Chat/Selection hint text mentions the keyboard shortcuts, right-click/long-press toolbar and touch handles. - README: document native handles/magnifier/toolbar, keyboard shortcuts and the SelectableText-style customization params, with a contextMenuBuilder snippet. 402 unit tests green; analyze --fatal-infos + format clean; example smoke green. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 34 ++++++++++++++++++++++++++++ benchmark/render_benchmark.dart | 31 ++++++++++++++++++++++++-- example/lib/tabs/chat_tab.dart | 5 +++-- example/lib/tabs/lorem_tab.dart | 39 ++++++++++++++++++++++++++++----- 4 files changed, 100 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index e5a5041..29f2fb4 100644 --- a/README.md +++ b/README.md @@ -241,9 +241,43 @@ final MarkdownSelectedContent structured = controller.selectedContent(); when a plain `SelectableText`/`SelectionArea` starts its own selection. - **Gestures.** A mouse/trackpad/stylus drag selects; on touch a long-press-then-drag selects (so a plain swipe still scrolls the list). +- **Native handles, magnifier & toolbar.** On touch platforms the selection + shows draggable handles and a magnifier (they follow the content as it + scrolls, even across widgets); right-click (desktop) or long-press (mobile) + shows an adaptive Copy / Select-all toolbar. +- **Keyboard shortcuts.** When the scope is focused: `Ctrl/Cmd+C` copies, + `Ctrl/Cmd+A` selects all, `Shift`+arrows extend (character / word / line / + document, plus vertical), `Esc` clears — using the ambient + `DefaultTextEditingShortcuts`. +- **Customizable like `SelectableText`.** `MarkdownSelectionScope` takes + `selectionColor`, `contextMenuBuilder`, `magnifierConfiguration`, + `selectionControls`, `focusNode`, `enabled` and `onSelectionChanged`; its + public `MarkdownSelectionScopeState` exposes `copySelection` / `selectAll` / + `clearSelection` / `showToolbar` / `contextMenuButtonItems` / + `contextMenuAnchors` for a fully custom menu. - **Opt-in & compatible.** A `MarkdownWidget` with no `documentId`/controller is inert — existing usage is unchanged. +```dart +MarkdownSelectionScope( + controller: controller, + selectionColor: Colors.amber.withOpacity(0.3), + onSelectionChanged: (sel) => debugPrint('selection: $sel'), + contextMenuBuilder: (context, state) => AdaptiveTextSelectionToolbar.buttonItems( + anchors: state.contextMenuAnchors, + buttonItems: [ + ...state.contextMenuButtonItems, // Copy, Select all + ContextMenuButtonItem( + label: 'Copy LOUD', + onPressed: () => Clipboard.setData( + ClipboardData(text: state.controller.getText().toUpperCase())), + ), + ], + ), + child: /* ... */, +); +``` + See the runnable **Selection** and **Chat** tabs in `example/`. ## 🎨 Customization diff --git a/benchmark/render_benchmark.dart b/benchmark/render_benchmark.dart index 95fb3c4..267bd1b 100644 --- a/benchmark/render_benchmark.dart +++ b/benchmark/render_benchmark.dart @@ -17,8 +17,11 @@ // paint_hit same painter+size: paint again (cache hit → drawPicture) // stream_append update(newModel) + relayout (LLM streaming rebuild) // scroll_frame wall time per pump during a drag (widget-level) -// NOTE: a `selection_drag` tier (asserting zero content-Picture rebuilds, per -// spike S7) will be added once the selection overlay lands in lib/. +// selection_drag highlight repaint over cached Picture (must NOT rebuild it) +// +// The selection_drag tier encodes the spike-S7 invariant: a selection drag +// repaints only the highlight overlay, reusing the cached content Picture, so a +// drag frame must be far cheaper than a fresh (cache-miss) paint. import 'dart:io'; import 'dart:math' as math; import 'dart:ui' as ui; @@ -131,8 +134,32 @@ void main() { }); p.dispose(); + // selection_drag (grow a highlight over the cached content Picture each + // frame; the content Picture must be reused, not re-recorded). + final selPaint = Paint()..color = const Color(0x552196F3); + final selPainter = MarkdownPainter(markdown: large, theme: theme); + final selSize = selPainter.layout(maxWidth: _kWidth); + _paintOnce(selPainter, selSize); // prime the content cache + var off = 0; + _results['selection_drag'] = _bench(() { + off = (off + 1) % 8; + final rec = ui.PictureRecorder(); + final canvas = Canvas(rec); + selPainter.paintHighlight( + canvas, + (source) => source == 0 ? TextRange(start: 0, end: off) : null, + selPaint, + ); + selPainter.paint(canvas, selSize); // cache hit — no new Picture + rec.endRecording().dispose(); + }); + selPainter.dispose(); + // Sanity: the cache must make a hit dramatically cheaper than a miss. expect(_results['paint_hit']!, lessThan(_results['paint_miss']!)); + // A selection-drag frame must not rebuild the content Picture, so it stays + // far below a fresh paint (spike S7 invariant). + expect(_results['selection_drag']!, lessThan(_results['paint_miss']! / 3)); }); testWidgets('widget: scroll_frame wall time', (tester) async { diff --git a/example/lib/tabs/chat_tab.dart b/example/lib/tabs/chat_tab.dart index be0482d..d122152 100644 --- a/example/lib/tabs/chat_tab.dart +++ b/example/lib/tabs/chat_tab.dart @@ -199,8 +199,9 @@ class _SelectionBar extends StatelessWidget { final n = controller.getText().length; return Text( n == 0 - ? 'Drag (mouse) or long-press-drag (touch) across ' - 'messages — even ones scrolled off-screen.' + ? '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, ); diff --git a/example/lib/tabs/lorem_tab.dart b/example/lib/tabs/lorem_tab.dart index 4ad4b49..3e3f78b 100644 --- a/example/lib/tabs/lorem_tab.dart +++ b/example/lib/tabs/lorem_tab.dart @@ -125,6 +125,27 @@ class _LoremTabState extends State { child: Text(text, style: Theme.of(context).textTheme.labelLarge), ); + /// A custom [contextMenuBuilder] that appends a "Copy LOUD" action to the + /// default Copy / Select-all buttons. + Widget _loudContextMenu( + BuildContext context, + MarkdownSelectionScopeState state, + ) => + AdaptiveTextSelectionToolbar.buttonItems( + anchors: state.contextMenuAnchors, + buttonItems: [ + ...state.contextMenuButtonItems, + ContextMenuButtonItem( + label: 'Copy LOUD', + onPressed: () { + Clipboard.setData(ClipboardData( + text: state.controller.getText().toUpperCase())); + state.hideToolbar(); + }, + ), + ], + ); + @override Widget build(BuildContext context) => Column( children: [ @@ -134,16 +155,19 @@ class _LoremTabState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - _label('Markdown A — its own controller'), + _label('Markdown A — custom toolbar (right-click / ' + 'long-press for a "Copy LOUD" action)'), MarkdownSelectionScope( controller: _a, + contextMenuBuilder: _loudContextMenu, child: MarkdownWidget(markdown: _docA, documentId: 'A'), ), const Divider(height: 40), - _label('Markdown B — a different controller ' + _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), @@ -172,9 +196,14 @@ class _LoremTabState extends State { child: Row( children: [ Expanded( - child: Text(_mdActive - ? 'Markdown selection active' - : 'Drag across a Markdown block to select'), + child: Text( + _mdActive + ? 'Selection active — Ctrl/Cmd+C to copy, ' + 'right-click for the toolbar, Esc to clear' + : 'Drag to select · Ctrl/Cmd+A all · ' + 'Shift+arrows extend · right-click toolbar', + style: Theme.of(context).textTheme.bodySmall, + ), ), FilledButton.icon( onPressed: _copy, From bf97cf7965e59edcda76683914d0bf46c28f91a8 Mon Sep 17 00:00:00 2001 From: Mike Matiunin Date: Thu, 30 Jul 2026 20:02:59 +0400 Subject: [PATCH 10/30] fix(selection): guard handles/toolbar when there is no Overlay host Skip creating the SelectionOverlay and showing the context toolbar when the scope is not under an Overlay (e.g. used without a MaterialApp), avoiding a hard failure on touch platforms. Selection, highlight and keyboard still work. Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/src/selection_scope.dart | 2 ++ 1 file changed, 2 insertions(+) diff --git a/lib/src/selection_scope.dart b/lib/src/selection_scope.dart index d968fbf..b557cf2 100644 --- a/lib/src/selection_scope.dart +++ b/lib/src/selection_scope.dart @@ -267,6 +267,7 @@ class MarkdownSelectionScopeState extends State { ); final overlay = _selectionOverlay; if (overlay == null) { + if (Overlay.maybeOf(context) == null) return; // no host for handles _selectionOverlay = SelectionOverlay( context: context, startHandleType: TextSelectionHandleType.left, @@ -448,6 +449,7 @@ class MarkdownSelectionScopeState extends State { 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( From fb6b15e9b1c501f97137b199618bfcf6ed44223a Mon Sep 17 00:00:00 2001 From: Mike Matiunin Date: Thu, 30 Jul 2026 20:21:48 +0400 Subject: [PATCH 11/30] =?UTF-8?q?fix(selection):=20review=20findings=20?= =?UTF-8?q?=E2=80=94=20list=20offsets,=20handle-drag=20collapse,=20ctrl=20?= =?UTF-8?q?swap?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adversarial review of the new selection code surfaced three issues: 1. (correctness) markdownBlockRenderedText joined list items conditionally (`if (buffer.isNotEmpty)`) while the painter joins every item with '\n' unconditionally, so a list with an empty leading/middle item shifted the model offset space vs the painter — wrong highlight and truncated copy. Linearize lists as a plain depth-first join('\n') to match the painter. Regression test added (`- [ ]\n- [x] Done` -> "\nDone"). 2. (ux) A handle drag that dragged one handle exactly onto the other collapsed the selection and disposed the live SelectionOverlay mid-gesture (drag went dead). moveSelectionEdgeToGlobal (handle-only) now refuses a move that would collapse, keeping >=1 caret gap. 3. (robustness) didUpdateWidget controller swap left stale handle leaders / overlay bound to the old controller; now clears handles and resyncs. 402 unit tests green; analyze --fatal-infos + format clean; example smoke green. Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/src/selection.dart | 22 ++++++++++++++-------- lib/src/selection_scope.dart | 3 +++ test/selection/selection_test.dart | 7 +++++++ 3 files changed, 24 insertions(+), 8 deletions(-) diff --git a/lib/src/selection.dart b/lib/src/selection.dart index 7b78418..39c42c6 100644 --- a/lib/src/selection.dart +++ b/lib/src/selection.dart @@ -21,9 +21,9 @@ String markdownBlockRenderedText(MD$Block block) => block.map( alert: (a) => _spans(a.spans), code: (c) => c.text, list: (l) { - final buffer = StringBuffer(); - _listItems(l.items, buffer); - return buffer.toString(); + final parts = []; + _collectListItems(l.items, parts); + return parts.join('\n'); }, table: (t) => [ t.header.cells.map(_spans).join('\t'), @@ -39,11 +39,13 @@ String _spans(List spans) { return buffer.toString(); } -void _listItems(List items, StringBuffer buffer) { +// Flattens list items depth-first into one text run per item. Joined with `\n` +// unconditionally (one separator between every item, matching the painter's +// per-item fragments) so empty items still occupy their own line. +void _collectListItems(List items, List out) { for (final item in items) { - if (buffer.isNotEmpty) buffer.write('\n'); - buffer.write(_spans(item.spans)); - if (item.children.isNotEmpty) _listItems(item.children, buffer); + out.add(_spans(item.spans)); + if (item.children.isNotEmpty) _collectListItems(item.children, out); } } @@ -780,9 +782,13 @@ class MarkdownSelectionController extends ChangeNotifier { final moved = positionForGlobal(globalPosition); if (moved == null) return; final (a, b) = _ordered(sel); - selection = isStart + 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; } // --- geometry (mounted surfaces) ----------------------------------------- diff --git a/lib/src/selection_scope.dart b/lib/src/selection_scope.dart index b557cf2..8234a91 100644 --- a/lib/src/selection_scope.dart +++ b/lib/src/selection_scope.dart @@ -165,9 +165,12 @@ class MarkdownSelectionScopeState extends State { void didUpdateWidget(covariant MarkdownSelectionScope oldWidget) { super.didUpdateWidget(oldWidget); if (!identical(oldWidget.controller, widget.controller)) { + _clearHandles(); // drop leaders/overlay bound to the old controller oldWidget.controller.removeListener(_onControllerChanged); widget.controller.addListener(_onControllerChanged); _lastSelection = widget.controller.selection; + _applySelectionColor(); + _syncOverlay(); } if (oldWidget.selectionColor != widget.selectionColor) { _applySelectionColor(); diff --git a/test/selection/selection_test.dart b/test/selection/selection_test.dart index 05e91e4..08d2a9f 100644 --- a/test/selection/selection_test.dart +++ b/test/selection/selection_test.dart @@ -46,6 +46,13 @@ void main() { .blocks .firstWhere((b) => b.type == 'list'); expect(markdownBlockRenderedText(tasks), 'done\ntodo'); + + // An empty leading item still occupies its own line, so the model offset + // space matches the painter's one-fragment-per-item layout. + final emptyLead = Markdown.fromString('- [ ]\n- [x] Done') + .blocks + .firstWhere((b) => b.type == 'list'); + expect(markdownBlockRenderedText(emptyLead), '\nDone'); }); test('cross-document extraction with default formatter', () { From 435a26ccff41b3cfdad6584f77e5e27256f0541a Mon Sep 17 00:00:00 2001 From: Mike Matiunin Date: Mon, 3 Aug 2026 14:36:21 +0400 Subject: [PATCH 12/30] Refactor code formatting for consistency across multiple test files and widget implementations - Standardized line breaks and indentation in various test files including block_test.dart, edge_cases_test.dart, gfm_test.dart, inline_test.dart, math_test.dart, parser_test.dart, regression_test.dart, selection_handles_test.dart, selection_keyboard_test.dart, selection_test.dart, selection_widget_test.dart, theme_test.dart, and render_test.dart. - Improved readability by aligning code and removing unnecessary line breaks. - Ensured consistent use of method chaining and parameter formatting. --- benchmark/compare.dart | 3 +- .../experiments/s2_custom_delegate_test.dart | 3 +- .../s3_selectable_renderobject_test.dart | 16 +--- .../s4_logical_controller_test.dart | 33 +++---- .../s5_cross_widget_topology_test.dart | 7 +- benchmark/experiments/s7_caching_test.dart | 3 +- benchmark/parse_benchmark.dart | 11 +-- benchmark/parser_benchmark.dart | 4 +- benchmark/render_benchmark.dart | 3 +- example/lib/experiments/s6_platforms.dart | 10 +- example/lib/main.dart | 27 ++---- example/lib/tabs/chat_tab.dart | 26 ++--- example/lib/tabs/lorem_tab.dart | 22 ++--- example/test/smoke_test.dart | 6 +- lib/flutter_md.dart | 3 +- lib/src/nodes.dart | 7 +- lib/src/parser.dart | 50 ++++------ lib/src/render.dart | 94 +++++++------------ lib/src/selection.dart | 67 +++++-------- lib/src/selection_scope.dart | 45 +++------ lib/src/theme.dart | 40 +++----- lib/src/widget.dart | 3 +- test/parser/block_test.dart | 22 ++--- test/parser/edge_cases_test.dart | 6 +- test/parser/gfm_test.dart | 59 +++++------- test/parser/inline_test.dart | 15 +-- test/parser/math_test.dart | 9 +- test/parser/parser_test.dart | 6 +- test/parser/regression_test.dart | 14 ++- test/selection/selection_handles_test.dart | 24 ++--- test/selection/selection_keyboard_test.dart | 28 ++---- test/selection/selection_test.dart | 18 ++-- test/selection/selection_widget_test.dart | 87 ++++++----------- test/theme/theme_test.dart | 21 ++--- test/widget/render_test.dart | 4 +- 35 files changed, 283 insertions(+), 513 deletions(-) diff --git a/benchmark/compare.dart b/benchmark/compare.dart index e953b7f..303968f 100644 --- a/benchmark/compare.dart +++ b/benchmark/compare.dart @@ -68,8 +68,7 @@ void main(List args) { } /// Returns the minimum per-op time in microseconds for parsing [input]. -double _bench(String input, - {int warmupMs = 200, int batches = 25, int minBatchMs = 8}) { +double _bench(String input, {int warmupMs = 200, int batches = 25, int minBatchMs = 8}) { // Warmup to trigger JIT compilation / reach steady state. final warmupSw = Stopwatch()..start(); while (warmupSw.elapsedMilliseconds < warmupMs) { diff --git a/benchmark/experiments/s2_custom_delegate_test.dart b/benchmark/experiments/s2_custom_delegate_test.dart index 384b4eb..50007be 100644 --- a/benchmark/experiments/s2_custom_delegate_test.dart +++ b/benchmark/experiments/s2_custom_delegate_test.dart @@ -167,8 +167,7 @@ void main() { expect(result, 'Item0\nItem1\nItem2'); }); - testWidgets('S2.4 LIMIT: screen-Y snapshot key collides on reflow', - (tester) async { + testWidgets('S2.4 LIMIT: screen-Y snapshot key collides on reflow', (tester) async { final delegate = MdDelegate(); final result = await _removeWhileSelected(tester, delegate, removeIndex: 1); debugPrint('S2.4 snapshots=${delegate.snapshotCount} ' diff --git a/benchmark/experiments/s3_selectable_renderobject_test.dart b/benchmark/experiments/s3_selectable_renderobject_test.dart index b2633c3..bd00477 100644 --- a/benchmark/experiments/s3_selectable_renderobject_test.dart +++ b/benchmark/experiments/s3_selectable_renderobject_test.dart @@ -12,7 +12,6 @@ // // Run: flutter test benchmark/experiments/s3_selectable_renderobject_test.dart import 'dart:math' as math; -import 'dart:ui' as ui; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; @@ -142,9 +141,8 @@ class MdSelectableRenderBox extends RenderBox with Selectable, SelectionRegistra return SelectionResult.end; case final SelectWordSelectionEvent e: final pos = _positionForLocal(globalToLocal(e.globalPosition)); - final range = _blocks[pos.block] - .painter - .getWordBoundary(TextPosition(offset: pos.offset)); + final range = + _blocks[pos.block].painter.getWordBoundary(TextPosition(offset: pos.offset)); _start = _Pos(pos.block, range.start); _end = _Pos(pos.block, range.end); _recompute(); @@ -207,9 +205,7 @@ class MdSelectableRenderBox extends RenderBox with Selectable, SelectionRegistra startSelectionPoint: _pointFor(_start!, TextSelectionHandleType.left), endSelectionPoint: _pointFor(_end!, TextSelectionHandleType.right), selectionRects: rects, - status: collapsed - ? SelectionStatus.collapsed - : SelectionStatus.uncollapsed, + status: collapsed ? SelectionStatus.collapsed : SelectionStatus.uncollapsed, hasContent: true, ); } @@ -302,8 +298,7 @@ class MdSelectableWidget extends LeafRenderObjectWidget { void main() { const style = TextStyle(fontSize: 20, color: Color(0xFF000000)); - testWidgets('S3 single selectable box spans blocks with separators', - (tester) async { + testWidgets('S3 single selectable box spans blocks with separators', (tester) async { String? captured; await tester.pumpWidget(MaterialApp( home: Scaffold( @@ -330,8 +325,7 @@ void main() { // Drag-select from the very top-left to the bottom-right (everything). final topLeft = tester.getTopLeft(find.byType(MdSelectableWidget)); - final bottomRight = - tester.getBottomRight(find.byType(MdSelectableWidget)); + final bottomRight = tester.getBottomRight(find.byType(MdSelectableWidget)); final g = await tester.startGesture(topLeft + const Offset(1, 3), kind: PointerDeviceKind.mouse); await tester.pump(const Duration(milliseconds: 200)); diff --git a/benchmark/experiments/s4_logical_controller_test.dart b/benchmark/experiments/s4_logical_controller_test.dart index a3a51c4..dfc1e8d 100644 --- a/benchmark/experiments/s4_logical_controller_test.dart +++ b/benchmark/experiments/s4_logical_controller_test.dart @@ -29,8 +29,7 @@ String renderedBlockText(MD$Block b) => b.map( quote: (q) => q.spans.map((s) => s.text).join(), alert: (a) => a.spans.map((s) => s.text).join(), code: (c) => c.text, - list: (l) => - l.items.map((i) => i.spans.map((s) => s.text).join()).join('\n'), + list: (l) => l.items.map((i) => i.spans.map((s) => s.text).join()).join('\n'), table: (t) => [ t.header.cells.map((c) => c.map((s) => s.text).join()).join('\t'), for (final r in t.rows) @@ -68,8 +67,7 @@ MdPos reconcile(MdPos anchor, Markdown oldM, Markdown newM) { bool prefixUnchanged() { if (anchor.block >= newB.length) return false; for (var i = 0; i < anchor.block; i++) { - if (i >= newB.length || - renderedBlockText(oldB[i]) != renderedBlockText(newB[i])) { + if (i >= newB.length || renderedBlockText(oldB[i]) != renderedBlockText(newB[i])) { return false; } } @@ -135,8 +133,8 @@ class MdController extends ChangeNotifier { if (text.isEmpty) continue; // skip structural blocks (spacer/divider) final from = (d == startDoc && bi == a.block) ? a.offset : 0; final to = (d == endDoc && bi == b.block) ? b.offset : text.length; - blockChunks.add(text.substring( - from.clamp(0, text.length), to.clamp(0, text.length))); + blockChunks + .add(text.substring(from.clamp(0, text.length), to.clamp(0, text.length))); } docChunks.add(blockChunks.join(blockSep)); } @@ -149,24 +147,21 @@ class MdController extends ChangeNotifier { int compareScreenOrder(Rect a, Rect b, TextDirection dir) { const threshold = 4.0; if ((a.top - b.top).abs() > threshold) return a.top.compareTo(b.top); - return dir == TextDirection.rtl - ? b.left.compareTo(a.left) - : a.left.compareTo(b.left); + return dir == TextDirection.rtl ? b.left.compareTo(a.left) : a.left.compareTo(b.left); } void main() { final docA = Markdown.fromString('Alpha one\n\nAlpha two'); final docB = Markdown.fromString('Bravo one\n\nBravo two'); - MdController freshController() => MdController() - ..docs.addAll([MdDoc('a', docA), MdDoc('b', docB)]); + MdController freshController() => + MdController()..docs.addAll([MdDoc('a', docA), MdDoc('b', docB)]); // Block indices: 0 = paragraph, 1 = spacer (blank line), 2 = paragraph. test('T1 cross-document extraction with separators', () { final c = freshController(); c.selection = const MdSel(MdPos('a', 0, 0), MdPos('b', 2, 9)); - expect(c.getPlainText(), - 'Alpha one\nAlpha two\n\nBravo one\nBravo two'); + expect(c.getPlainText(), 'Alpha one\nAlpha two\n\nBravo one\nBravo two'); c.selection = const MdSel(MdPos('a', 2, 6), MdPos('b', 0, 5)); expect(c.getPlainText(), 'two\n\nBravo'); // 'Alpha two'[6:]='two' @@ -195,9 +190,7 @@ void main() { itemCount: c.docs.length, itemBuilder: (_, i) => SizedBox( height: 80, - child: Text(c.docs[i].model.blocks - .map(renderedBlockText) - .join()), + child: Text(c.docs[i].model.blocks.map(renderedBlockText).join()), ), ), ), @@ -221,8 +214,8 @@ void main() { final base = c.getPlainText(); // (a) Append-only streaming: grow the last block + add a new block. - c.updateDocument('b', Markdown.fromString( - 'Bravo one\n\nBravo two three\n\nBravo appended')); + c.updateDocument( + 'b', Markdown.fromString('Bravo one\n\nBravo two three\n\nBravo appended')); // block 2 grew as a prefix ("Bravo two" -> "Bravo two three"), so the fast // path keeps the anchor; the originally-selected text is unchanged. expect(c.selection!.extent.block, 2); @@ -235,8 +228,8 @@ void main() { final c2 = freshController(); c2.selection = const MdSel(MdPos('b', 0, 0), MdPos('b', 0, 9)); final beforeInsert = c2.getPlainText(); // "Bravo one" - c2.updateDocument('b', Markdown.fromString( - 'INSERTED HEADER\n\nBravo one\n\nBravo two')); + c2.updateDocument( + 'b', Markdown.fromString('INSERTED HEADER\n\nBravo one\n\nBravo two')); final afterInsert = c2.getPlainText(); expect(beforeInsert, 'Bravo one'); expect(afterInsert, isNot('Bravo one'), diff --git a/benchmark/experiments/s5_cross_widget_topology_test.dart b/benchmark/experiments/s5_cross_widget_topology_test.dart index c1da23a..7bb79bb 100644 --- a/benchmark/experiments/s5_cross_widget_topology_test.dart +++ b/benchmark/experiments/s5_cross_widget_topology_test.dart @@ -228,8 +228,7 @@ void main() { // across m0..m1 by hit-testing their mounted surfaces — exactly what the // scope gesture layer does on a real drag. final p0 = tester.getTopLeft(find.byType(MdMessage).first) + const Offset(1, 3); - final m1 = find.byWidgetPredicate( - (w) => w is MdMessage && w.docId == 'm1'); + final m1 = find.byWidgetPredicate((w) => w is MdMessage && w.docId == 'm1'); final p1 = tester.getBottomRight(m1) - const Offset(1, 3); controller.startAt(p0); controller.extendTo(p1); @@ -242,8 +241,8 @@ void main() { // Scroll so m0 is disposed. scroll.jumpTo(80.0 * 6); await tester.pumpAndSettle(); - expect(find.byWidgetPredicate((w) => w is MdMessage && w.docId == 'm0'), - findsNothing); + expect( + find.byWidgetPredicate((w) => w is MdMessage && w.docId == 'm0'), findsNothing); expect(controller.mountedDocIds, isNot(contains('m0')), reason: 'm0 surface unregistered on disposal'); diff --git a/benchmark/experiments/s7_caching_test.dart b/benchmark/experiments/s7_caching_test.dart index a9016f7..1df9c68 100644 --- a/benchmark/experiments/s7_caching_test.dart +++ b/benchmark/experiments/s7_caching_test.dart @@ -146,8 +146,7 @@ void main() { expect(box.paintCount, greaterThan(paintsAfterFirst)); }); - testWidgets('S7.2 content or size change DOES rebuild the Picture', - (tester) async { + testWidgets('S7.2 content or size change DOES rebuild the Picture', (tester) async { var text = 'first'; var rev = 0; late StateSetter setOuter; diff --git a/benchmark/parse_benchmark.dart b/benchmark/parse_benchmark.dart index d9db1c2..cbf10d0 100644 --- a/benchmark/parse_benchmark.dart +++ b/benchmark/parse_benchmark.dart @@ -47,8 +47,7 @@ class Current$Benchmark extends BenchmarkBase { super.teardown(); // Ensure the result is not null after running the benchmark // to disable compilation optimizations that might skip the run. - if (result == null) - throw StateError('Result is null, did you run the benchmark?'); + if (result == null) throw StateError('Result is null, did you run the benchmark?'); } } @@ -59,9 +58,8 @@ class Google$Benchmark extends BenchmarkBase { @override void run() { - result = - markdown.Document(extensionSet: markdown.ExtensionSet.gitHubFlavored) - .parse(_testSample); + result = markdown.Document(extensionSet: markdown.ExtensionSet.gitHubFlavored) + .parse(_testSample); } @override @@ -69,8 +67,7 @@ class Google$Benchmark extends BenchmarkBase { super.teardown(); // Ensure the result is not null after running the benchmark // to disable compilation optimizations that might skip the run. - if (result == null) - throw StateError('Result is null, did you run the benchmark?'); + if (result == null) throw StateError('Result is null, did you run the benchmark?'); } } diff --git a/benchmark/parser_benchmark.dart b/benchmark/parser_benchmark.dart index b779c6f..7949de0 100644 --- a/benchmark/parser_benchmark.dart +++ b/benchmark/parser_benchmark.dart @@ -73,8 +73,8 @@ class _GoogleBenchmark extends BenchmarkBase { List? _result; @override - void run() => _result = - gmd.Document(extensionSet: gmd.ExtensionSet.gitHubFlavored).parse(input); + void run() => + _result = gmd.Document(extensionSet: gmd.ExtensionSet.gitHubFlavored).parse(input); @override void teardown() { diff --git a/benchmark/render_benchmark.dart b/benchmark/render_benchmark.dart index 267bd1b..8a700b0 100644 --- a/benchmark/render_benchmark.dart +++ b/benchmark/render_benchmark.dart @@ -123,8 +123,7 @@ void main() { hitPainter.dispose(); // stream_append (toggle model so update() always sees a change) - final p = MarkdownPainter(markdown: large, theme: theme) - ..layout(maxWidth: _kWidth); + final p = MarkdownPainter(markdown: large, theme: theme)..layout(maxWidth: _kWidth); var flip = false; _results['stream_append'] = _bench(() { flip = !flip; diff --git a/example/lib/experiments/s6_platforms.dart b/example/lib/experiments/s6_platforms.dart index 602951d..17874a5 100644 --- a/example/lib/experiments/s6_platforms.dart +++ b/example/lib/experiments/s6_platforms.dart @@ -178,8 +178,7 @@ class MdSelectionController extends ChangeNotifier { final text = docs[d].text; final from = d == start ? a.offset : 0; final to = d == end ? b.offset : text.length; - chunks.add( - text.substring(from.clamp(0, text.length), to.clamp(0, text.length))); + chunks.add(text.substring(from.clamp(0, text.length), to.clamp(0, text.length))); } return chunks.join(docSep); } @@ -234,8 +233,7 @@ class MarkdownSelectionScope extends StatelessWidget { () => LongPressGestureRecognizer(), (r) => r ..onLongPressStart = ((d) => controller.startAt(d.globalPosition)) - ..onLongPressMoveUpdate = - ((d) => controller.extendTo(d.globalPosition)), + ..onLongPressMoveUpdate = ((d) => controller.extendTo(d.globalPosition)), ), }, child: child, @@ -271,9 +269,7 @@ class _MdMessageBox extends RenderBox implements MdSurface { text: doc.text, style: TextStyle( fontSize: 16, - color: doc.isLink - ? const Color(0xFF1565C0) - : const Color(0xFF111111), + color: doc.isLink ? const Color(0xFF1565C0) : const Color(0xFF111111), decoration: doc.isLink ? TextDecoration.underline : null, ), ), diff --git a/example/lib/main.dart b/example/lib/main.dart index cd2a527..061e6d2 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -10,8 +10,7 @@ import 'tabs/lorem_tab.dart'; void main() => runZonedGuarded( () => runApp(ThemeModel( - notifier: ValueNotifier(ThemeMode.dark), - child: const App())), + notifier: ValueNotifier(ThemeMode.dark), child: const App())), (e, s) => print(e), ); @@ -62,8 +61,7 @@ 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}) => + static ValueNotifier? maybeOf(BuildContext context, {bool listen = true}) => listen ? context.dependOnInheritedWidgetOfExactType()?.notifier : context.getInheritedWidgetOfExactType()?.notifier; @@ -77,8 +75,7 @@ class ThemeModel extends InheritedNotifier> { /// 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 @@ -102,8 +99,7 @@ class HomeScreen extends StatefulWidget { } /// State for widget HomeScreen. -class _HomeScreenState extends State - with SingleTickerProviderStateMixin { +class _HomeScreenState extends State with SingleTickerProviderStateMixin { late final TabController _tabs = TabController(length: 3, vsync: this); @override @@ -121,8 +117,7 @@ class _HomeScreenState extends State Switch.adaptive( value: ThemeModel.of(context).value == ThemeMode.dark, onChanged: (value) { - ThemeModel.of(context).value = - value ? ThemeMode.dark : ThemeMode.light; + ThemeModel.of(context).value = value ? ThemeMode.dark : ThemeMode.light; }, ), ], @@ -172,8 +167,7 @@ class _EditorTabState extends State { 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); } @@ -246,8 +240,7 @@ class _EditorTabState extends State { icon: const Icon( Icons.refresh, ), - onPressed: () => - _inputController.text = _markdownExample, + onPressed: () => _inputController.text = _markdownExample, ), ], ), @@ -287,16 +280,14 @@ 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 d122152..e9c1a72 100644 --- a/example/lib/tabs/chat_tab.dart +++ b/example/lib/tabs/chat_tab.dart @@ -82,11 +82,9 @@ class _ChatTabState extends State { _streamCursor = 0; _streamBuffer = ''; 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) { @@ -147,8 +145,7 @@ class _ChatTabState extends State { controller: _controller, child: ListView.builder( controller: _scroll, - padding: - const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), itemCount: _messages.length, itemBuilder: (context, i) => _Bubble(message: _messages[i]), ), @@ -248,8 +245,7 @@ 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) ...[ @@ -259,12 +255,10 @@ class _Bubble extends StatelessWidget { Flexible( child: Container( padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), - constraints: BoxConstraints( - maxWidth: MediaQuery.sizeOf(context).width * 0.78), + constraints: + BoxConstraints(maxWidth: MediaQuery.sizeOf(context).width * 0.78), decoration: BoxDecoration( - color: isUser - ? scheme.primaryContainer - : scheme.surfaceContainerHighest, + color: isUser ? scheme.primaryContainer : scheme.surfaceContainerHighest, borderRadius: BorderRadius.only( topLeft: const Radius.circular(16), topRight: const Radius.circular(16), @@ -274,8 +268,7 @@ class _Bubble extends StatelessWidget { ), child: message.markdown.isEmpty ? const _TypingDots() - : MarkdownWidget( - markdown: message.markdown, documentId: message.id), + : MarkdownWidget(markdown: message.markdown, documentId: message.id), ), ), if (isUser) ...[ @@ -312,8 +305,7 @@ class _TypingDots extends StatefulWidget { State<_TypingDots> createState() => _TypingDotsState(); } -class _TypingDotsState extends State<_TypingDots> - with SingleTickerProviderStateMixin { +class _TypingDotsState extends State<_TypingDots> with SingleTickerProviderStateMixin { late final AnimationController _c = AnimationController( vsync: this, duration: const Duration(milliseconds: 900), diff --git a/example/lib/tabs/lorem_tab.dart b/example/lib/tabs/lorem_tab.dart index 3e3f78b..e19797e 100644 --- a/example/lib/tabs/lorem_tab.dart +++ b/example/lib/tabs/lorem_tab.dart @@ -64,10 +64,8 @@ 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); @@ -79,10 +77,8 @@ 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); } @@ -110,8 +106,7 @@ class _LoremTabState extends State { } Future _copy() async { - final text = - _isActive(_a) ? _a.getText() : (_isActive(_b) ? _b.getText() : ''); + final text = _isActive(_a) ? _a.getText() : (_isActive(_b) ? _b.getText() : ''); if (text.isEmpty) return; await Clipboard.setData(ClipboardData(text: text)); if (!mounted) return; @@ -138,8 +133,8 @@ class _LoremTabState extends State { ContextMenuButtonItem( label: 'Copy LOUD', onPressed: () { - Clipboard.setData(ClipboardData( - text: state.controller.getText().toUpperCase())); + Clipboard.setData( + ClipboardData(text: state.controller.getText().toUpperCase())); state.hideToolbar(); }, ), @@ -171,8 +166,7 @@ class _LoremTabState extends State { child: MarkdownWidget(markdown: _docB, documentId: 'B'), ), const Divider(height: 40), - _label( - 'Plain SelectableText — resets with the Markdown ones'), + _label('Plain SelectableText — resets with the Markdown ones'), SelectionArea( key: ValueKey(_plainEpoch), onSelectionChanged: (content) { diff --git a/example/test/smoke_test.dart b/example/test/smoke_test.dart index 424ae2d..bc28632 100644 --- a/example/test/smoke_test.dart +++ b/example/test/smoke_test.dart @@ -5,8 +5,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:md_example/main.dart'; void main() { - testWidgets('all tabs build and selection drags do not crash', - (tester) async { + testWidgets('all tabs build and selection drags do not crash', (tester) async { await tester.pumpWidget(ThemeModel( notifier: ValueNotifier(ThemeMode.light), child: const App(), @@ -21,8 +20,7 @@ void main() { 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), + 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)); diff --git a/lib/flutter_md.dart b/lib/flutter_md.dart index 14d8e59..5b9bbff 100644 --- a/lib/flutter_md.dart +++ b/lib/flutter_md.dart @@ -3,8 +3,7 @@ library; export 'src/markdown.dart'; export 'src/nodes.dart'; export 'src/parser.dart'; -export 'src/render.dart' - show BlockPainter, SelectableBlockPainter, SelectableTextBlock; +export 'src/render.dart' show BlockPainter, SelectableBlockPainter, SelectableTextBlock; export 'src/selection.dart'; export 'src/selection_scope.dart'; export 'src/theme.dart'; diff --git a/lib/src/nodes.dart b/lib/src/nodes.dart index 818b0b3..b4c9bc8 100644 --- a/lib/src/nodes.dart +++ b/lib/src/nodes.dart @@ -697,10 +697,9 @@ final class MD$Table extends MD$Block { /// Returns the alignment for the given column [index], /// defaulting to [MD$TableColumnAlign.none] when unspecified. - MD$TableColumnAlign alignmentFor(int index) => - index >= 0 && index < alignments.length - ? alignments[index] - : MD$TableColumnAlign.none; + MD$TableColumnAlign alignmentFor(int index) => index >= 0 && index < alignments.length + ? alignments[index] + : MD$TableColumnAlign.none; @override T map({ diff --git a/lib/src/parser.dart b/lib/src/parser.dart index 312190a..221a8cb 100644 --- a/lib/src/parser.dart +++ b/lib/src/parser.dart @@ -61,8 +61,7 @@ class MarkdownDecoder extends Converter { /// space/tab or the end of the line (so `#hashtag` and 7+ `#` are not /// headings). Group 2 captures the heading text; a trailing run of `#` /// characters is stripped separately. - static final RegExp _headingPattern = - RegExp(r'^(#{1,6})(?:[ \t]+(.*?))?[ \t]*$'); + static final RegExp _headingPattern = RegExp(r'^(#{1,6})(?:[ \t]+(.*?))?[ \t]*$'); /// Matches an optional ATX closing sequence of `#` characters. static final RegExp _headingClosingPattern = RegExp(r'[ \t]+#+$'); @@ -75,14 +74,12 @@ class MarkdownDecoder extends Converter { /// every candidate line (once to open a list, then once per line to find its /// end). The returned [text] still includes leading whitespace, matching the /// old capture group; callers trim it as before. - static ({int indent, String marker, String text})? _parseListLine( - String line) { + static ({int indent, String marker, String text})? _parseListLine(String line) { final len = line.length; // Leading indent: at most 8 spaces or tabs. var i = 0; - while (i < len && - i < 8 && - (line.codeUnitAt(i) == 0x20 || line.codeUnitAt(i) == 0x09)) { + while ( + i < len && i < 8 && (line.codeUnitAt(i) == 0x20 || line.codeUnitAt(i) == 0x09)) { i++; } if (i >= len) return null; @@ -129,9 +126,8 @@ class MarkdownDecoder extends Converter { /// A regular expression pattern to match GitHub alert markers, /// e.g. `[!NOTE]`, `[!WARNING]`. Matched case-insensitively against the /// first line of a blockquote. - static final RegExp _alertPattern = RegExp( - r'^\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\]$', - caseSensitive: false); + static final RegExp _alertPattern = + RegExp(r'^\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\]$', caseSensitive: false); /// A regular expression pattern to match a GitHub task-list checkbox at the /// start of a list item, e.g. `[ ] todo`, `[x] done`, `[X] done`. @@ -188,8 +184,7 @@ class MarkdownDecoder extends Converter { // Resolve the inline-math replacement table once; `null` disables math and // keeps `_parseInlineSpans` from touching it at all. - final math = - inlineMath ? (mathReplacements ?? kMarkdownMathCommands) : null; + final math = inlineMath ? (mathReplacements ?? kMarkdownMathCommands) : null; final paragraph = StringBuffer(); // To accumulate lines for paragraphs @@ -245,12 +240,9 @@ class MarkdownDecoder extends Converter { } final level = match.group(1)!.length; // Strip an optional closing sequence of `#` (e.g. "## Heading ##"). - final text = - (match.group(2) ?? '').replaceFirst(_headingClosingPattern, ''); + final text = (match.group(2) ?? '').replaceFirst(_headingClosingPattern, ''); pushBlock(MD$Heading( - level: level, - text: text, - spans: _parseInlineSpans(text, math: math))); + level: level, text: text, spans: _parseInlineSpans(text, math: math))); continue; } else if (c0 == 0x3E /* > */) { // Parse quotes and GitHub-style alerts. @@ -263,9 +255,8 @@ class MarkdownDecoder extends Converter { // A blockquote whose first line is `[!TYPE]` becomes an alert block. final alertMatch = _alertPattern.firstMatch(quoteLines.first); - final alertType = alertMatch != null - ? MD$AlertType.tryParse(alertMatch.group(1)!) - : null; + final alertType = + alertMatch != null ? MD$AlertType.tryParse(alertMatch.group(1)!) : null; if (alertType != null) { // The alert body is everything after the marker line. final body = quoteLines.skip(1).join('\n').trim(); @@ -314,8 +305,7 @@ class MarkdownDecoder extends Converter { continue; } final firstTask = _parseTask(first.text.trim()); - final list = - <({int intent, String marker, String text, bool? checked})>[ + final list = <({int intent, String marker, String text, bool? checked})>[ ( intent: 0, marker: first.marker, @@ -357,8 +347,8 @@ class MarkdownDecoder extends Converter { final children = traverse(indent: item.intent); if (items.isNotEmpty) { // If we have a parent item, add children to it - items.last = items.last.copyWith( - children: List.unmodifiable(children)); + items.last = items.last + .copyWith(children: List.unmodifiable(children)); } else { // If this is the first item, just add children items.add(MD$ListItem( @@ -787,8 +777,7 @@ bool _hasClosingBacktick(List codes, int length, int from) { /// [markerLen]) exists at or after [from]. A closer must be right-flanking /// (preceded by a non-space); underscore closers must also be at a word /// boundary. Escaped characters are skipped. -bool _hasEmphasisCloser( - List codes, int length, int from, int ch, int markerLen) { +bool _hasEmphasisCloser(List codes, int length, int from, int ch, int markerLen) { for (var j = from; j < length; j++) { if (codes[j] == 0x5C /* \ */) { j++; // Skip the escaped character. @@ -828,8 +817,7 @@ bool _emphasisValid( if (i == 0 || _isInlineSpace(codes[i - 1])) return false; // Underscore cannot close inside a word. final after = i + markerLen; - if (ch == 0x5F /* _ */ && after < length && _isWordChar(codes[after])) - return false; + if (ch == 0x5F /* _ */ && after < length && _isWordChar(codes[after])) return false; return true; } } @@ -997,12 +985,10 @@ List _parseInlineSpans(String text, {Map? math}) { var segmentStart = start; for (var e = 0; e < excluded.length; e++) { final idx = excluded[e]; - if (idx > segmentStart) - buffer.write(text.substring(segmentStart, idx)); + if (idx > segmentStart) buffer.write(text.substring(segmentStart, idx)); segmentStart = idx + 1; } - if (segmentStart < end) - buffer.write(text.substring(segmentStart, end)); + if (segmentStart < end) buffer.write(text.substring(segmentStart, end)); spans.add( MD$Span( start: start, diff --git a/lib/src/render.dart b/lib/src/render.dart index 11d18df..1155b66 100644 --- a/lib/src/render.dart +++ b/lib/src/render.dart @@ -19,8 +19,7 @@ import 'theme.dart'; const Color _kSelectionColor = Color(0x552196F3); @meta.internal -class MarkdownRenderObject extends RenderBox - implements MarkdownSelectionSurface { +class MarkdownRenderObject extends RenderBox implements MarkdownSelectionSurface { MarkdownRenderObject({ required Markdown markdown, required MarkdownThemeData theme, @@ -131,8 +130,7 @@ class MarkdownRenderObject extends RenderBox Offset? endLocal, }) { var changed = false; - if (!identical(startLink, _startHandleLink) || - startLocal != _startHandleLocal) { + if (!identical(startLink, _startHandleLink) || startLocal != _startHandleLocal) { _startHandleLink = startLink; _startHandleLocal = startLocal; changed = true; @@ -186,8 +184,7 @@ class MarkdownRenderObject extends RenderBox @override void performLayout() { // Set the size of the render box to match the painter's size. - size = - constraints.constrain(_painter.layout(maxWidth: constraints.maxWidth)); + size = constraints.constrain(_painter.layout(maxWidth: constraints.maxWidth)); } @override @@ -255,8 +252,7 @@ class MarkdownRenderObject extends RenderBox @override @protected void detach() { - PaintingBinding.instance.systemFonts - .removeListener(_handleSystemFontsChange); + PaintingBinding.instance.systemFonts.removeListener(_handleSystemFontsChange); _controller?.detachSurface(this); super.detach(); } @@ -273,8 +269,7 @@ class MarkdownRenderObject extends RenderBox @override @protected void paint(PaintingContext context, Offset offset) { - if (_painter.isEmpty) - return; // If the markdown is empty, do not paint anything. + if (_painter.isEmpty) return; // If the markdown is empty, do not paint anything. final canvas = context.canvas ..save() @@ -423,8 +418,7 @@ class MarkdownPainter { for (var i = 0; i < blocks.length; i++) { final block = blocks[i]; if (filter != null && !filter(block)) continue; - painters - .add(builder(block, _theme) ?? _defaultBlockBuilder(block, _theme)); + painters.add(builder(block, _theme) ?? _defaultBlockBuilder(block, _theme)); sources.add(i); } _blockPainters = painters; @@ -508,8 +502,7 @@ class MarkdownPainter { required Markdown markdown, required MarkdownThemeData theme, }) { - if (identical(_markdown, markdown) && identical(_theme, theme)) - return false; + if (identical(_markdown, markdown) && identical(_theme, theme)) return false; _lastSize = null; _lastPicture = null; _markdown = markdown; @@ -957,8 +950,7 @@ mixin MultiPainterSelectable implements SelectableBlockPainter { } } final fragment = best!; - final inner = - fragment.painter.getPositionForOffset(local - fragment.origin).offset; + final inner = fragment.painter.getPositionForOffset(local - fragment.origin).offset; return fragment.textStart + inner.clamp(0, fragment.length); } @@ -966,8 +958,8 @@ mixin MultiPainterSelectable implements SelectableBlockPainter { List boxesForRange(int start, int end) { final out = []; for (final fragment in fragments) { - final localStart = start.clamp(fragment.textStart, fragment.textEnd) - - fragment.textStart; + final localStart = + start.clamp(fragment.textStart, fragment.textEnd) - fragment.textStart; final localEnd = end.clamp(fragment.textStart, fragment.textEnd) - fragment.textStart; if (localEnd <= localStart) continue; @@ -1190,8 +1182,8 @@ class BlockPainter$Quote textScaler: theme.textScaler, ), linePaint = Paint() - ..color = theme.dividerColor ?? - const Color(0x7F7F7F7F) // Gray color for the line. + ..color = + theme.dividerColor ?? const Color(0x7F7F7F7F) // Gray color for the line. ..isAntiAlias = false ..strokeWidth = 4.0 ..style = PaintingStyle.fill; @@ -1443,10 +1435,8 @@ class _ListItemMetrics { final TextPainter contentPainter; final Offset offset; - late final double height = - math.max(bulletPainter.height, contentPainter.height); - late final Size size = - Size(bulletPainter.width + contentPainter.width, height); + late final double height = math.max(bulletPainter.height, contentPainter.height); + late final Size size = Size(bulletPainter.width + contentPainter.width, height); void dispose() { bulletPainter.dispose(); @@ -1492,8 +1482,7 @@ class BlockPainter$List InlineSpan? _getSpanForPosition(Offset localPosition) { for (final metrics in _painters) { - final contentOffset = - metrics.offset + Offset(metrics.bulletPainter.width, 0); + final contentOffset = metrics.offset + Offset(metrics.bulletPainter.width, 0); final contentRect = contentOffset & metrics.contentPainter.size; if (contentRect.contains(localPosition)) { final painterPosition = localPosition - contentOffset; @@ -1516,8 +1505,7 @@ class BlockPainter$List if (_lastSpan == null) return; // No span was hit on tap down. final newSpan = _getSpanForPosition(event.localPosition); if (newSpan != null && _lastSpan == newSpan) { - if (newSpan - case TextSpan(recognizer: final TapGestureRecognizer recognizer)) { + if (newSpan case TextSpan(recognizer: final TapGestureRecognizer recognizer)) { recognizer.onTap?.call(); } } @@ -1567,8 +1555,7 @@ class BlockPainter$List _painters.add(metrics); currentHeight += metrics.height; - maxContentWidth = - math.max(maxContentWidth, indent + metrics.size.width); + maxContentWidth = math.max(maxContentWidth, indent + metrics.size.width); if (item.children.isNotEmpty) { layoutItems(item.children, level + 1); @@ -1602,8 +1589,7 @@ class BlockPainter$List final bulletOffset = metrics.offset + Offset(0, offset); metrics.bulletPainter.paint(canvas, bulletOffset); - final contentOffset = - bulletOffset + Offset(metrics.bulletPainter.width, 0); + final contentOffset = bulletOffset + Offset(metrics.bulletPainter.width, 0); metrics.contentPainter.paint(canvas, contentOffset); } } @@ -1813,8 +1799,7 @@ class BlockPainter$Table _rowBackgroundPaint = Paint() ..style = PaintingStyle.fill ..isAntiAlias = false - ..color = - theme.surfaceColor ?? const Color.fromARGB(255, 235, 235, 235); + ..color = theme.surfaceColor ?? const Color.fromARGB(255, 235, 235, 235); /// Padding for table cells. static const double padding = 8.0; @@ -1830,9 +1815,8 @@ class BlockPainter$Table /// Resolves the alignment for column [c], defaulting to /// [MD$TableColumnAlign.none] when unspecified. - MD$TableColumnAlign _columnAlign(int c) => c >= 0 && c < alignments.length - ? alignments[c] - : MD$TableColumnAlign.none; + MD$TableColumnAlign _columnAlign(int c) => + c >= 0 && c < alignments.length ? alignments[c] : MD$TableColumnAlign.none; /// The horizontal offset of a cell's text within its column, honoring the /// column alignment (falling back to centered headers / left-aligned data). @@ -1898,8 +1882,7 @@ class BlockPainter$Table } TextSpan? _getSpanForOffset(Offset position) { - final rowHeights = - List.generate(_cellPainters.length, (r) => _rowHeights[r]); + final rowHeights = List.generate(_cellPainters.length, (r) => _rowHeights[r]); double currentY = 0.0; @@ -1920,11 +1903,10 @@ class BlockPainter$Table if (position.dx >= currentX && position.dx < currentX + columnWidth) { // In this cell. final verticalPadding = (rowHeight - painter.height) / 2; - final horizontalPadding = - _cellHorizontalPadding(r, c, painter.width); + final horizontalPadding = _cellHorizontalPadding(r, c, painter.width); - final painterOffset = Offset( - currentX + horizontalPadding, currentY + verticalPadding); + final painterOffset = + Offset(currentX + horizontalPadding, currentY + verticalPadding); final localPosition = position - painterOffset; // Check if inside the actual painted text area. @@ -1974,18 +1956,15 @@ class BlockPainter$Table return TextPainter(textDirection: theme.textDirection); } final cell = row.cells[c]; - final style = (r == 0) - ? theme.textStyle.copyWith(fontWeight: FontWeight.bold) - : null; + final style = + (r == 0) ? theme.textStyle.copyWith(fontWeight: FontWeight.bold) : null; final textPainter = TextPainter( - text: _paragraphFromMarkdownSpans( - spans: cell, theme: theme, textStyle: style), + text: _paragraphFromMarkdownSpans(spans: cell, theme: theme, textStyle: style), textAlign: switch (_columnAlign(c)) { MD$TableColumnAlign.left => TextAlign.left, MD$TableColumnAlign.center => TextAlign.center, MD$TableColumnAlign.right => TextAlign.right, - MD$TableColumnAlign.none => - (r == 0) ? TextAlign.center : TextAlign.start, + MD$TableColumnAlign.none => (r == 0) ? TextAlign.center : TextAlign.start, }, textDirection: theme.textDirection, textScaler: theme.textScaler, @@ -1993,21 +1972,18 @@ class BlockPainter$Table // Calculate natural width textPainter.layout(maxWidth: double.infinity); - naturalWidths[c] = - math.max(naturalWidths[c], textPainter.width + padding * 2); + naturalWidths[c] = math.max(naturalWidths[c], textPainter.width + padding * 2); // Calculate min width (longest word) final cellText = cell.map((s) => s.text).join(); final words = cellText.split(RegExp(r'\s+')); if (words.isNotEmpty) { - final longestWord = - words.reduce((a, b) => a.length > b.length ? a : b); + final longestWord = words.reduce((a, b) => a.length > b.length ? a : b); final wordPainter = TextPainter( text: TextSpan(text: longestWord, style: style), textDirection: theme.textDirection, )..layout(); - minWidths[c] = - math.max(minWidths[c], wordPainter.width + padding * 2); + minWidths[c] = math.max(minWidths[c], wordPainter.width + padding * 2); wordPainter.dispose(); } @@ -2083,8 +2059,7 @@ class BlockPainter$Table final painter = _cellPainters[r][c]; final verticalPadding = (_rowHeights[r] - painter.height) / 2; final horizontalPadding = _cellHorizontalPadding(r, c, painter.width); - final origin = - Offset(colLeft + horizontalPadding, rowTop + verticalPadding); + final origin = Offset(colLeft + horizontalPadding, rowTop + verticalPadding); frags.add(SelectableFragment(painter, origin, text.length)); text.write(painter.plainText); } @@ -2102,8 +2077,7 @@ class BlockPainter$Table if (columns < 1) return; double currentY = offset; - final rowHeights = - List.generate(_cellPainters.length, (r) => _rowHeights[r]); + final rowHeights = List.generate(_cellPainters.length, (r) => _rowHeights[r]); for (int r = 0; r < _cellPainters.length; r++) { double currentX = 0; diff --git a/lib/src/selection.dart b/lib/src/selection.dart index 39c42c6..8b58a4b 100644 --- a/lib/src/selection.dart +++ b/lib/src/selection.dart @@ -121,9 +121,7 @@ final class MarkdownSelection { @override bool operator ==(Object other) => - other is MarkdownSelection && - other.base == base && - other.extent == extent; + other is MarkdownSelection && other.base == base && other.extent == extent; @override int get hashCode => Object.hash(base, extent); @@ -136,8 +134,7 @@ final class MarkdownSelection { @immutable final class MarkdownDocumentRef { /// Creates a reference binding a stable [id] to its immutable [model]. - const MarkdownDocumentRef( - {required this.id, required this.model, this.order}); + const MarkdownDocumentRef({required this.id, required this.model, this.order}); /// Stable id of the document (e.g. a chat message id). final Object id; @@ -267,13 +264,11 @@ final class MarkdownPlainTextFormatter implements MarkdownSelectionFormatter { abstract interface class MarkdownReconciliationPolicy { /// Append-only fast path, else clamp indices/offsets into the new bounds. /// Cheapest; correct for streaming appends, drifts on front/mid inserts. - const factory MarkdownReconciliationPolicy.appendFastPath() = - _AppendFastPathPolicy; + const factory MarkdownReconciliationPolicy.appendFastPath() = _AppendFastPathPolicy; /// Append fast path, then relocate by matching block rendered text, else /// clamp. The default — robust to inserts/reorders without a model id. - const factory MarkdownReconciliationPolicy.contentAnchored() = - _ContentAnchoredPolicy; + const factory MarkdownReconciliationPolicy.contentAnchored() = _ContentAnchoredPolicy; /// Drop the selection whenever the anchor's document changes at all. const factory MarkdownReconciliationPolicy.clearOnChange() = _ClearPolicy; @@ -288,8 +283,7 @@ abstract interface class MarkdownReconciliationPolicy { MarkdownPosition _clampInto(MarkdownPosition anchor, Markdown model) { if (model.blocks.isEmpty) { - return MarkdownPosition( - documentId: anchor.documentId, blockIndex: 0, offset: 0); + return MarkdownPosition(documentId: anchor.documentId, blockIndex: 0, offset: 0); } final bi = anchor.blockIndex.clamp(0, model.blocks.length - 1); final len = markdownBlockRenderedText(model.blocks[bi]).length; @@ -301,8 +295,7 @@ MarkdownPosition _clampInto(MarkdownPosition anchor, Markdown model) { } bool _appendPrefixKeeps(MarkdownPosition anchor, Markdown o, Markdown n) { - if (anchor.blockIndex >= o.blocks.length || - anchor.blockIndex >= n.blocks.length) { + if (anchor.blockIndex >= o.blocks.length || anchor.blockIndex >= n.blocks.length) { return false; } for (var i = 0; i < anchor.blockIndex; i++) { @@ -369,8 +362,7 @@ class _ContentAnchoredPolicy implements MarkdownReconciliationPolicy { class _ClearPolicy implements MarkdownReconciliationPolicy { const _ClearPolicy(); @override - MarkdownPosition? remap(MarkdownPosition anchor, Markdown o, Markdown n) => - null; + MarkdownPosition? remap(MarkdownPosition anchor, Markdown o, Markdown n) => null; } /// A mounted document's geometry bridge — the controller's window onto a live @@ -465,8 +457,8 @@ class MarkdownSelectionController extends ChangeNotifier { MarkdownReconciliationPolicy? reconciliation, MarkdownSelectionFormatter formatter = const MarkdownPlainTextFormatter(), MarkdownSelectionGroup? group, - }) : reconciliation = reconciliation ?? - const MarkdownReconciliationPolicy.contentAnchored(), + }) : reconciliation = + reconciliation ?? const MarkdownReconciliationPolicy.contentAnchored(), _formatter = formatter, _group = group { group?._add(this); @@ -540,8 +532,7 @@ class MarkdownSelectionController extends ChangeNotifier { _docs ..clear() ..addAll(<_DocEntry>[ - for (final (i, d) in docs.indexed) - _DocEntry(d.id, d.model, d.order ?? i), + for (final (i, d) in docs.indexed) _DocEntry(d.id, d.model, d.order ?? i), ]); _sort(); _validateSelection(); @@ -576,8 +567,7 @@ class MarkdownSelectionController extends ChangeNotifier { void removeDocument(Object id) { _docs.removeWhere((e) => e.id == id); final sel = _selection; - if (sel != null && - (sel.base.documentId == id || sel.extent.documentId == id)) { + if (sel != null && (sel.base.documentId == id || sel.extent.documentId == id)) { _selection = null; } notifyListeners(); @@ -604,8 +594,7 @@ class MarkdownSelectionController extends ChangeNotifier { void _validateSelection() { final sel = _selection; if (sel == null) return; - if (_orderIndex(sel.base.documentId) < 0 || - _orderIndex(sel.extent.documentId) < 0) { + if (_orderIndex(sel.base.documentId) < 0 || _orderIndex(sel.extent.documentId) < 0) { _selection = null; return; } @@ -666,10 +655,8 @@ class MarkdownSelectionController extends ChangeNotifier { final maxX = bounds.right - 0.01; final maxY = bounds.bottom - 0.01; final clamped = Offset( - globalPosition.dx - .clamp(bounds.left, maxX < bounds.left ? bounds.left : maxX), - globalPosition.dy - .clamp(bounds.top, maxY < bounds.top ? bounds.top : maxY), + globalPosition.dx.clamp(bounds.left, maxX < bounds.left ? bounds.left : maxX), + globalPosition.dy.clamp(bounds.top, maxY < bounds.top ? bounds.top : maxY), ); return nearest.positionForGlobal(clamped); } @@ -881,15 +868,13 @@ class MarkdownSelectionController extends ChangeNotifier { MarkdownSelectedContent selectedContent() { final sel = _selection; if (sel == null) { - return const MarkdownSelectedContent( - documents: []); + return const MarkdownSelectedContent(documents: []); } final (a, b) = _ordered(sel); final startDoc = _orderIndex(a.documentId); final endDoc = _orderIndex(b.documentId); if (startDoc < 0 || endDoc < 0) { - return const MarkdownSelectedContent( - documents: []); + return const MarkdownSelectedContent(documents: []); } final out = []; for (var d = startDoc; d <= endDoc; d++) { @@ -901,9 +886,8 @@ class MarkdownSelectionController extends ChangeNotifier { for (var bi = fromBlock; bi <= toBlock && bi < blocks.length; bi++) { if (bi < 0) continue; final text = markdownBlockRenderedText(blocks[bi]); - final from = (d == startDoc && bi == a.blockIndex) - ? a.offset.clamp(0, text.length) - : 0; + final from = + (d == startDoc && bi == a.blockIndex) ? a.offset.clamp(0, text.length) : 0; final to = (d == endDoc && bi == b.blockIndex) ? b.offset.clamp(0, text.length) : text.length; @@ -936,8 +920,7 @@ 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) - return a.blockIndex.compareTo(b.blockIndex); + if (a.blockIndex != b.blockIndex) return a.blockIndex.compareTo(b.blockIndex); return a.offset.compareTo(b.offset); } @@ -1025,8 +1008,7 @@ class MarkdownSelectionController extends ChangeNotifier { MarkdownPosition _stepLineBreak(MarkdownPosition p, {required bool forward}) { final di = _orderIndex(p.documentId); if (di < 0) return p; - return p.copyWith( - offset: forward ? _blockTextAt(di, p.blockIndex).length : 0); + return p.copyWith(offset: forward ? _blockTextAt(di, p.blockIndex).length : 0); } MarkdownPosition _documentBoundary({required bool forward}) { @@ -1038,8 +1020,7 @@ class MarkdownSelectionController extends ChangeNotifier { blockIndex: bi < 0 ? 0 : bi, offset: bi < 0 ? 0 : _blockTextAt(di, bi).length); } - return MarkdownPosition( - documentId: _docs.first.id, blockIndex: 0, offset: 0); + return MarkdownPosition(documentId: _docs.first.id, blockIndex: 0, offset: 0); } } @@ -1049,13 +1030,11 @@ class MarkdownSelectionController extends ChangeNotifier { /// are cleared. Call [clearExternal] when a non-Markdown selectable (e.g. a /// plain `SelectableText` / `SelectionArea`) begins its own selection. class MarkdownSelectionGroup { - final Set _members = - {}; + final Set _members = {}; void _add(MarkdownSelectionController controller) => _members.add(controller); - void _remove(MarkdownSelectionController controller) => - _members.remove(controller); + void _remove(MarkdownSelectionController controller) => _members.remove(controller); void _claim(MarkdownSelectionController owner) { for (final member in _members) { diff --git a/lib/src/selection_scope.dart b/lib/src/selection_scope.dart index 8234a91..678ae80 100644 --- a/lib/src/selection_scope.dart +++ b/lib/src/selection_scope.dart @@ -117,8 +117,7 @@ class MarkdownSelectionScope extends StatefulWidget { context.dependOnInheritedWidgetOfExactType<_ScopeMarker>()?.controller; /// The nearest ambient controller. Throws if there is no enclosing scope. - static MarkdownSelectionController of(BuildContext context) => - maybeOf(context)!; + static MarkdownSelectionController of(BuildContext context) => maybeOf(context)!; /// The nearest ambient scope state, or null when there is no enclosing scope. static MarkdownSelectionScopeState? stateOf(BuildContext context) => @@ -188,8 +187,8 @@ class MarkdownSelectionScopeState extends State { } void _applySelectionColor() { - controller.selectionColor = widget.selectionColor ?? - DefaultSelectionStyle.of(context).selectionColor; + controller.selectionColor = + widget.selectionColor ?? DefaultSelectionStyle.of(context).selectionColor; } void _onControllerChanged() { @@ -207,10 +206,7 @@ class MarkdownSelectionScopeState extends State { bool get _handlesEnabled => widget.enabled && switch (Theme.of(context).platform) { - TargetPlatform.android || - TargetPlatform.iOS || - TargetPlatform.fuchsia => - true, + TargetPlatform.android || TargetPlatform.iOS || TargetPlatform.fuchsia => true, _ => false, }; @@ -228,8 +224,7 @@ class MarkdownSelectionScopeState extends State { }; TextMagnifierConfiguration get _effectiveMagnifier => - widget.magnifierConfiguration ?? - TextMagnifier.adaptiveMagnifierConfiguration; + 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`). @@ -264,8 +259,7 @@ class MarkdownSelectionScopeState extends State { TextDirection.ltr, ); final endPoint = TextSelectionPoint( - box.globalToLocal( - Offset(endpoints.endGlobal.right, endpoints.endGlobal.bottom)), + box.globalToLocal(Offset(endpoints.endGlobal.right, endpoints.endGlobal.bottom)), TextDirection.ltr, ); final overlay = _selectionOverlay; @@ -307,10 +301,8 @@ class MarkdownSelectionScopeState extends State { void _applyHandles(MarkdownHandleEndpoints? e) { final startSurface = e?.startSurface; final endSurface = e?.endSurface; - final startLocal = - e == null ? null : Offset(e.startLocal.left, e.startLocal.bottom); - final endLocal = - e == null ? null : Offset(e.endLocal.right, e.endLocal.bottom); + final startLocal = e == null ? null : Offset(e.startLocal.left, e.startLocal.bottom); + final endLocal = e == null ? null : Offset(e.endLocal.right, e.endLocal.bottom); for (final surface in controller.mountedSurfaces) { final isStart = identical(surface, startSurface); final isEnd = identical(surface, endSurface); @@ -331,8 +323,7 @@ class MarkdownSelectionScopeState extends State { } void _onHandleDragStart(DragStartDetails d, {required bool isStart}) { - _selectionOverlay - ?.showMagnifier(_magnifierInfo(d.globalPosition, isStart: isStart)); + _selectionOverlay?.showMagnifier(_magnifierInfo(d.globalPosition, isStart: isStart)); } void _onHandleDragUpdate(DragUpdateDetails d, {required bool isStart}) { @@ -475,10 +466,8 @@ class MarkdownSelectionScopeState extends State { controller.startAtGlobal(globalPosition); } - Map get _gestures => - { - PanGestureRecognizer: - GestureRecognizerFactoryWithHandlers( + Map get _gestures => { + PanGestureRecognizer: GestureRecognizerFactoryWithHandlers( () => PanGestureRecognizer( supportedDevices: const { PointerDeviceKind.mouse, @@ -501,13 +490,11 @@ class MarkdownSelectionScopeState extends State { ((d) => controller.extendToGlobal(d.globalPosition)) ..onLongPressEnd = ((_) => showToolbar()), ), - TapGestureRecognizer: - GestureRecognizerFactoryWithHandlers( + TapGestureRecognizer: GestureRecognizerFactoryWithHandlers( () => TapGestureRecognizer(), (recognizer) => recognizer ..onTapDown = ((_) => hideToolbar()) - ..onSecondaryTapDown = - ((d) => _lastSecondaryTapDown = d.globalPosition) + ..onSecondaryTapDown = ((d) => _lastSecondaryTapDown = d.globalPosition) ..onSecondaryTapUp = ((d) { _focusNode.requestFocus(); showToolbar(d.globalPosition); @@ -528,8 +515,7 @@ class MarkdownSelectionScopeState extends State { return null; }, ), - ExtendSelectionByCharacterIntent: - CallbackAction( + ExtendSelectionByCharacterIntent: CallbackAction( onInvoke: (intent) { if (!intent.collapseSelection) { controller.extendSelectionByCharacter(forward: intent.forward); @@ -546,8 +532,7 @@ class MarkdownSelectionScopeState extends State { return null; }, ), - ExtendSelectionToLineBreakIntent: - CallbackAction( + ExtendSelectionToLineBreakIntent: CallbackAction( onInvoke: (intent) { if (!intent.collapseSelection) { controller.extendSelectionToLineBreak(forward: intent.forward); diff --git a/lib/src/theme.dart b/lib/src/theme.dart index 60be404..0bebefc 100644 --- a/lib/src/theme.dart +++ b/lib/src/theme.dart @@ -74,8 +74,7 @@ class MarkdownThemeData implements ThemeExtension { h6Style: h6Style ?? theme.textTheme.titleSmall, quoteStyle: quoteStyle ?? theme.textTheme.bodyMedium?.copyWith( - color: - theme.textTheme.bodyMedium?.color?.withValues(alpha: 0.75)), + color: theme.textTheme.bodyMedium?.color?.withValues(alpha: 0.75)), linkColor: linkColor ?? theme.colorScheme.primary, linkStyle: linkStyle, surfaceColor: surfaceColor ?? theme.colorScheme.surfaceContainerHigh, @@ -150,8 +149,7 @@ class MarkdownThemeData implements ThemeExtension { final Map? alertColors; /// The default GitHub-style accent color for each alert type (light theme). - static const Map _defaultAlertColors = - { + static const Map _defaultAlertColors = { MD$AlertType.note: Color(0xFF0969DA), // blue MD$AlertType.tip: Color(0xFF1A7F37), // green MD$AlertType.important: Color(0xFF8250DF), // purple @@ -247,11 +245,9 @@ class MarkdownThemeData implements ThemeExtension { var s when s.contains(MD$Style.highlight) => FontWeight.bold, _ => null, }, - fontStyle: - style.contains(MD$Style.italic) ? FontStyle.italic : null, + fontStyle: style.contains(MD$Style.italic) ? FontStyle.italic : null, decoration: switch (style) { - var s when s.contains(MD$Style.underline) => - TextDecoration.underline, + var s when s.contains(MD$Style.underline) => TextDecoration.underline, var s when s.contains(MD$Style.strikethrough) => TextDecoration.lineThrough, _ => null, @@ -262,10 +258,8 @@ class MarkdownThemeData implements ThemeExtension { _ => null, }, backgroundColor: switch (style) { - var s when s.contains(MD$Style.highlight) => - highlightBackgroundColor, - var s when s.contains(MD$Style.monospace) => - monospaceBackgroundColor, + var s when s.contains(MD$Style.highlight) => highlightBackgroundColor, + var s when s.contains(MD$Style.monospace) => monospaceBackgroundColor, _ => null, }, ); @@ -335,10 +329,8 @@ class MarkdownThemeData implements ThemeExtension { if (identical(this, other)) return this; return MarkdownThemeData( - textDirection: - t < 0.5 ? textDirection : other?.textDirection ?? TextDirection.ltr, - textScaler: - t < 0.5 ? textScaler : other?.textScaler ?? TextScaler.noScaling, + textDirection: t < 0.5 ? textDirection : other?.textDirection ?? TextDirection.ltr, + textScaler: t < 0.5 ? textScaler : other?.textScaler ?? TextScaler.noScaling, textStyle: TextStyle.lerp(textStyle, other?.textStyle, t)!, h1Style: TextStyle.lerp(h1Style, other?.h1Style, t), h2Style: TextStyle.lerp(h2Style, other?.h2Style, t), @@ -350,10 +342,10 @@ class MarkdownThemeData implements ThemeExtension { linkColor: Color.lerp(linkColor, other?.linkColor, t), linkStyle: TextStyle.lerp(linkStyle, other?.linkStyle, t), surfaceColor: Color.lerp(surfaceColor, other?.surfaceColor, t), - highlightBackgroundColor: Color.lerp( - highlightBackgroundColor, other?.highlightBackgroundColor, t), - monospaceBackgroundColor: Color.lerp( - monospaceBackgroundColor, other?.monospaceBackgroundColor, t), + highlightBackgroundColor: + Color.lerp(highlightBackgroundColor, other?.highlightBackgroundColor, t), + monospaceBackgroundColor: + Color.lerp(monospaceBackgroundColor, other?.monospaceBackgroundColor, t), dividerColor: Color.lerp(dividerColor, other?.dividerColor, t), alertColors: t < 0.5 ? alertColors : other?.alertColors, blockFilter: t < 0.5 ? blockFilter : other?.blockFilter, @@ -381,11 +373,9 @@ class MarkdownTheme extends InheritedWidget { /// The state from the closest instance of this class /// that encloses the given context, if any. /// e.g. `Theme.maybeOf(context)`. - static MarkdownThemeData? maybeOf(BuildContext context, - {bool listen = true}) => - listen - ? context.dependOnInheritedWidgetOfExactType()?.data - : context.getInheritedWidgetOfExactType()?.data; + static MarkdownThemeData? maybeOf(BuildContext context, {bool listen = true}) => listen + ? context.dependOnInheritedWidgetOfExactType()?.data + : context.getInheritedWidgetOfExactType()?.data; static Never _notFoundInheritedWidgetOfExactType() => throw ArgumentError( 'Out of scope, not found inherited widget ' diff --git a/lib/src/widget.dart b/lib/src/widget.dart index 1d65e8a..05493c9 100644 --- a/lib/src/widget.dart +++ b/lib/src/widget.dart @@ -40,8 +40,7 @@ class MarkdownWidget extends LeafRenderObjectWidget { MarkdownThemeData( textStyle: DefaultTextStyle.of(context).style, textDirection: Directionality.maybeOf(context) ?? TextDirection.ltr, - textScaler: - MediaQuery.maybeTextScalerOf(context) ?? TextScaler.noScaling, + textScaler: MediaQuery.maybeTextScalerOf(context) ?? TextScaler.noScaling, ); MarkdownSelectionController? _resolveController(BuildContext context) => diff --git a/test/parser/block_test.dart b/test/parser/block_test.dart index 652cce4..7df8f67 100644 --- a/test/parser/block_test.dart +++ b/test/parser/block_test.dart @@ -28,8 +28,7 @@ void main() => group('Block parsing', () { }); test('trailing hashes are stripped', () { - expect( - (_blocks('## Heading ##').single as MD$Heading).text, 'Heading'); + expect((_blocks('## Heading ##').single as MD$Heading).text, 'Heading'); expect((_blocks('### Title ###').single as MD$Heading).text, 'Title'); }); @@ -58,16 +57,14 @@ void main() => group('Block parsing', () { final q = _blocks('> quote with **bold**').single as MD$Quote; expect( q.spans, - contains( - isA().having((s) => s.style, 'style', MD$Style.bold)), + contains(isA().having((s) => s.style, 'style', MD$Style.bold)), ); }); }); group('Fenced code', () { test('backtick fence with language', () { - final code = - _blocks('```dart\nvoid main() {}\n```').single as MD$Code; + final code = _blocks('```dart\nvoid main() {}\n```').single as MD$Code; expect(code.language, 'dart'); expect(code.text, 'void main() {}'); }); @@ -79,8 +76,8 @@ void main() => group('Block parsing', () { }); test('code content is never interpreted as markdown', () { - final code = _blocks('```\n# not a heading\n- not a list\n```').single - as MD$Code; + final code = + _blocks('```\n# not a heading\n- not a list\n```').single as MD$Code; expect(code.text, '# not a heading\n- not a list'); }); @@ -126,8 +123,7 @@ void main() => group('Block parsing', () { final list = _blocks('- item with *italic*').single as MD$List; expect( list.items.single.spans, - contains(isA() - .having((s) => s.style, 'style', MD$Style.italic)), + contains(isA().having((s) => s.style, 'style', MD$Style.italic)), ); }); @@ -135,8 +131,7 @@ void main() => group('Block parsing', () { final list = _blocks('- see [docs](https://x.com)').single as MD$List; expect( list.items.single.spans, - contains( - isA().having((s) => s.style, 'style', MD$Style.link)), + contains(isA().having((s) => s.style, 'style', MD$Style.link)), ); }); }); @@ -162,8 +157,7 @@ void main() => group('Block parsing', () { final firstCell = table.rows.single.cells.first; expect( firstCell, - contains( - isA().having((s) => s.style, 'style', MD$Style.bold)), + contains(isA().having((s) => s.style, 'style', MD$Style.bold)), ); }); diff --git a/test/parser/edge_cases_test.dart b/test/parser/edge_cases_test.dart index a4b1150..9ce9f68 100644 --- a/test/parser/edge_cases_test.dart +++ b/test/parser/edge_cases_test.dart @@ -62,8 +62,7 @@ void main() => group('Edge cases & robustness', () { test('emphasis works around unicode content', () { final md = markdownDecoder.convert('**жирный**'); - expect((md.blocks.single as MD$Paragraph).spans.single.style, - MD$Style.bold); + expect((md.blocks.single as MD$Paragraph).spans.single.style, MD$Style.bold); }); }); @@ -104,8 +103,7 @@ void main() => group('Edge cases & robustness', () { }); test('a lone hash line with text after space is a heading', () { - expect( - markdownDecoder.convert('# ok').blocks.single, isA()); + expect(markdownDecoder.convert('# ok').blocks.single, isA()); }); }); }); diff --git a/test/parser/gfm_test.dart b/test/parser/gfm_test.dart index 81da1c9..f62103e 100644 --- a/test/parser/gfm_test.dart +++ b/test/parser/gfm_test.dart @@ -15,16 +15,15 @@ void main() => group('GFM extensions', () { 'CAUTION': MD$AlertType.caution, }; for (final entry in cases.entries) { - final md = markdownDecoder - .convert('> [!${entry.key}]\n> Body of the alert.'); + final md = + markdownDecoder.convert('> [!${entry.key}]\n> Body of the alert.'); expect(md.blocks, hasLength(1), reason: 'alert ${entry.key} is a single block'); expect( md.blocks.single, isA() .having((a) => a.alert, 'alert', entry.value) - .having( - (a) => _spanText(a.spans), 'body', 'Body of the alert.'), + .having((a) => _spanText(a.spans), 'body', 'Body of the alert.'), ); } }); @@ -104,24 +103,21 @@ void main() => group('GFM extensions', () { test('checked task item (lowercase and uppercase x)', () { for (final input in ['- [x] done', '- [X] done']) { - final list = - markdownDecoder.convert(input).blocks.single as MD$List; + final list = markdownDecoder.convert(input).blocks.single as MD$List; expect(list.items.single.checked, isTrue); expect(list.items.single.text, 'done'); } }); test('empty brackets are not a task item', () { - final list = - markdownDecoder.convert('- [] literal').blocks.single as MD$List; + final list = markdownDecoder.convert('- [] literal').blocks.single as MD$List; expect(list.items.single.checked, isNull); expect(list.items.single.isTask, isFalse); expect(list.items.single.text, '[] literal'); }); test('checkbox with no label yields empty text', () { - final list = - markdownDecoder.convert('- [ ]').blocks.single as MD$List; + final list = markdownDecoder.convert('- [ ]').blocks.single as MD$List; expect(list.items.single.checked, isFalse); expect(list.items.single.text, isEmpty); }); @@ -131,8 +127,7 @@ void main() => group('GFM extensions', () { .convert('- [ ] a\n- [x] b\n- normal') .blocks .single as MD$List; - expect(list.items.map((i) => i.checked).toList(), - [false, true, null]); + expect(list.items.map((i) => i.checked).toList(), [false, true, null]); }); test('nested task items keep their state', () { @@ -150,46 +145,37 @@ void main() => group('GFM extensions', () { .convert('1. [x] first\n2. [ ] second') .blocks .single as MD$List; - expect( - list.items.map((i) => i.checked).toList(), [true, false]); + expect(list.items.map((i) => i.checked).toList(), [true, false]); }); }); group('Thematic breaks', () { for (final input in ['---', '***', '___', '- - -', '* * *', '_ _ _']) { test('"$input" is a divider', () { - expect(markdownDecoder.convert(input).blocks.single, - isA()); + expect(markdownDecoder.convert(input).blocks.single, isA()); }); } test('four or more markers still form a divider', () { - expect( - markdownDecoder.convert('----').blocks.single, isA()); - expect(markdownDecoder.convert('**********').blocks.single, - isA()); + expect(markdownDecoder.convert('----').blocks.single, isA()); + expect(markdownDecoder.convert('**********').blocks.single, isA()); }); test('marker followed by text is NOT a divider', () { - expect(markdownDecoder.convert('----text').blocks.single, - isA()); + expect(markdownDecoder.convert('----text').blocks.single, isA()); }); test('fewer than three markers is NOT a divider', () { - expect( - markdownDecoder.convert('--').blocks.single, isA()); - expect( - markdownDecoder.convert('**').blocks.single, isA()); + expect(markdownDecoder.convert('--').blocks.single, isA()); + expect(markdownDecoder.convert('**').blocks.single, isA()); }); test('mixed markers are NOT a divider', () { - expect(markdownDecoder.convert('-*-').blocks.single, - isA()); + expect(markdownDecoder.convert('-*-').blocks.single, isA()); }); test('up to three leading spaces are allowed', () { - expect(markdownDecoder.convert(' ---').blocks.single, - isA()); + expect(markdownDecoder.convert(' ---').blocks.single, isA()); }); }); @@ -209,17 +195,14 @@ void main() => group('GFM extensions', () { }); test('plain dashes yield no alignment', () { - final table = markdownDecoder - .convert('|a|b|\n|---|---|\n|1|2|') - .blocks - .single as MD$Table; - expect( - table.alignments, everyElement(equals(MD$TableColumnAlign.none))); + final table = markdownDecoder.convert('|a|b|\n|---|---|\n|1|2|').blocks.single + as MD$Table; + expect(table.alignments, everyElement(equals(MD$TableColumnAlign.none))); }); test('alignmentFor is safe for out-of-range indices', () { - final table = markdownDecoder.convert('|a|\n|:-:|\n|1|').blocks.single - as MD$Table; + final table = + markdownDecoder.convert('|a|\n|:-:|\n|1|').blocks.single as MD$Table; expect(table.alignmentFor(5), MD$TableColumnAlign.none); expect(table.alignmentFor(-1), MD$TableColumnAlign.none); }); diff --git a/test/parser/inline_test.dart b/test/parser/inline_test.dart index 04abf79..022ea8e 100644 --- a/test/parser/inline_test.dart +++ b/test/parser/inline_test.dart @@ -77,10 +77,8 @@ void main() => group('Inline parsing', () { expect(_styleOf(spans, 'You').contains(MD$Style.monospace), isTrue); expect(_styleOf(spans, 'You').contains(MD$Style.italic), isTrue); expect(_styleOf(spans, 'can').contains(MD$Style.bold), isTrue); - expect( - _styleOf(spans, 'combine').contains(MD$Style.underline), isTrue); - expect( - _styleOf(spans, 'them').contains(MD$Style.strikethrough), isTrue); + expect(_styleOf(spans, 'combine').contains(MD$Style.underline), isTrue); + expect(_styleOf(spans, 'them').contains(MD$Style.strikethrough), isTrue); }); }); @@ -142,8 +140,7 @@ void main() => group('Inline parsing', () { final spans = _spans('[text](https://example.com)'); expect(spans.single.style, MD$Style.link); expect(spans.single.text, 'text'); - expect( - spans.single.extra, containsPair('url', 'https://example.com')); + expect(spans.single.extra, containsPair('url', 'https://example.com')); }); test('link with double-quoted title', () { @@ -164,15 +161,13 @@ void main() => group('Inline parsing', () { test('url containing balanced parentheses', () { final spans = _spans('[a](https://x.com/a_(b)_c)'); - expect( - spans.single.extra, containsPair('url', 'https://x.com/a_(b)_c')); + expect(spans.single.extra, containsPair('url', 'https://x.com/a_(b)_c')); }); test('image exposes src and image style', () { final spans = _spans('![alt](https://x.com/i.png)'); expect(spans.single.style, MD$Style.image); - expect( - spans.single.extra, containsPair('src', 'https://x.com/i.png')); + expect(spans.single.extra, containsPair('src', 'https://x.com/i.png')); }); test('emphasis wrapping a link merges styles', () { diff --git a/test/parser/math_test.dart b/test/parser/math_test.dart index 862fa06..1aa1744 100644 --- a/test/parser/math_test.dart +++ b/test/parser/math_test.dart @@ -106,14 +106,12 @@ void main() { }); test('text around code is still converted', () { - final rendered = - _spans(r'$\alpha$ `$\beta$` $\gamma$').map((s) => s.text).join(); + final rendered = _spans(r'$\alpha$ `$\beta$` $\gamma$').map((s) => s.text).join(); expect(rendered, r'α $\beta$ γ'); }); test('fenced code block is never converted', () { - final code = - _math.convert('```\n\$\\alpha\$\n```').blocks.single as MD$Code; + final code = _math.convert('```\n\$\\alpha\$\n```').blocks.single as MD$Code; expect(code.text, r'$\alpha$'); }); }); @@ -177,8 +175,7 @@ void main() { ]) { final spans = _spans(input); for (var i = 0; i < spans.length; i++) { - expect(spans[i].start, lessThanOrEqualTo(spans[i].end), - reason: input); + expect(spans[i].start, lessThanOrEqualTo(spans[i].end), reason: input); if (i > 0) { expect(spans[i].start, greaterThanOrEqualTo(spans[i - 1].start), reason: input); diff --git a/test/parser/parser_test.dart b/test/parser/parser_test.dart index c6a6ee1..89100f2 100644 --- a/test/parser/parser_test.dart +++ b/test/parser/parser_test.dart @@ -358,8 +358,7 @@ void main() => group('Parse', () { allOf( isA>(), isNotEmpty, - containsPair( - 'url', 'https://example.com/image.jpg'), + containsPair('url', 'https://example.com/image.jpg'), ), ), ), @@ -406,8 +405,7 @@ void main() => group('Parse', () { final codeSpan = spans[i]; expect(codeSpan.text, expectedText); expect(codeSpan.style, MD$Style.monospace, - reason: - 'Span for "$expectedText" should only have monospace style'); + reason: 'Span for "$expectedText" should only have monospace style'); } }); diff --git a/test/parser/regression_test.dart b/test/parser/regression_test.dart index a218f68..63051e3 100644 --- a/test/parser/regression_test.dart +++ b/test/parser/regression_test.dart @@ -115,8 +115,7 @@ void main() { expect(_text(para.spans), '_italic_ at the start'.replaceAll('_', '')); expect( para.spans, - contains( - isA().having((s) => s.style, 'style', MD$Style.italic)), + contains(isA().having((s) => s.style, 'style', MD$Style.italic)), ); }); @@ -176,8 +175,8 @@ void main() { }); test('image carries the src key and image style', () { - final span = _spans('![alt](https://x.io/i.png)') - .firstWhere((s) => s.extra != null); + final span = + _spans('![alt](https://x.io/i.png)').firstWhere((s) => s.extra != null); expect(span.style.contains(MD$Style.image), isTrue); expect(span.extra?['src'], 'https://x.io/i.png'); }); @@ -226,8 +225,8 @@ void main() { }); test('currency is unchanged', () { - expect(_text(_spans(r'It costs $5 and $10 today.')), - r'It costs $5 and $10 today.'); + expect( + _text(_spans(r'It costs $5 and $10 today.')), r'It costs $5 and $10 today.'); }); test(r'escaped dollar \$ becomes a literal dollar', () { @@ -236,8 +235,7 @@ void main() { }); group('Link & emphasis edge cases', () { - MD$Span linkOf(String input) => - _spans(input).firstWhere((s) => s.extra != null); + MD$Span linkOf(String input) => _spans(input).firstWhere((s) => s.extra != null); test('unterminated angle-bracket url keeps the rest as url', () { expect(linkOf('[t]( _mouseDrag(WidgetTester tester, Offset from, Offset to) async { await tester.pumpAndSettle(); } -Widget _wrap(MarkdownSelectionController controller, Widget child) => - MaterialApp( +Widget _wrap(MarkdownSelectionController controller, Widget child) => MaterialApp( home: Scaffold( body: MarkdownSelectionScope(controller: controller, child: child), ), @@ -34,12 +33,10 @@ class _Doc extends StatelessWidget { void main() { group('selection handles', () { - testWidgets('moveSelectionEdgeToGlobal adjusts the moving edge', - (tester) async { + testWidgets('moveSelectionEdgeToGlobal adjusts the moving edge', (tester) async { final md = Markdown.fromString('Hello selectable world'); final controller = MarkdownSelectionController() - ..setDocuments( - [MarkdownDocumentRef(id: 'd', model: md)]); + ..setDocuments([MarkdownDocumentRef(id: 'd', model: md)]); await tester.pumpWidget(_wrap( controller, @@ -56,8 +53,7 @@ void main() { // Pull the end edge back to near the start of the line. final tl = tester.getTopLeft(find.byType(MarkdownWidget)); - controller.moveSelectionEdgeToGlobal(tl + const Offset(40, 8), - isStart: false); + controller.moveSelectionEdgeToGlobal(tl + const Offset(40, 8), isStart: false); await tester.pump(); final text = controller.getText(); @@ -70,8 +66,7 @@ void main() { debugDefaultTargetPlatformOverride = TargetPlatform.android; final md = Markdown.fromString('Hello selectable world'); final controller = MarkdownSelectionController() - ..setDocuments( - [MarkdownDocumentRef(id: 'd', model: md)]); + ..setDocuments([MarkdownDocumentRef(id: 'd', model: md)]); await tester.pumpWidget(_wrap( controller, @@ -84,8 +79,7 @@ void main() { 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)); + await _mouseDrag(tester, tl + const Offset(1, 3), br - const Offset(1, 3)); expect(controller.getText(), isNotEmpty); // Two handles are composited to follow the content. @@ -98,8 +92,7 @@ void main() { debugDefaultTargetPlatformOverride = TargetPlatform.linux; final md = Markdown.fromString('Hello selectable world'); final controller = MarkdownSelectionController() - ..setDocuments( - [MarkdownDocumentRef(id: 'd', model: md)]); + ..setDocuments([MarkdownDocumentRef(id: 'd', model: md)]); await tester.pumpWidget(_wrap( controller, @@ -112,8 +105,7 @@ void main() { 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)); + await _mouseDrag(tester, tl + const Offset(1, 3), br - const Offset(1, 3)); expect(controller.getText(), isNotEmpty); expect(find.byType(CompositedTransformFollower), findsNothing); diff --git a/test/selection/selection_keyboard_test.dart b/test/selection/selection_keyboard_test.dart index 00551d2..9bfbdf7 100644 --- a/test/selection/selection_keyboard_test.dart +++ b/test/selection/selection_keyboard_test.dart @@ -38,8 +38,7 @@ void main() { debugDefaultTargetPlatformOverride = TargetPlatform.linux; final md = Markdown.fromString('Hello keyboard world'); final controller = MarkdownSelectionController() - ..setDocuments( - [MarkdownDocumentRef(id: 'd', model: md)]); + ..setDocuments([MarkdownDocumentRef(id: 'd', model: md)]); final focus = FocusNode(); addTearDown(focus.dispose); @@ -65,8 +64,7 @@ void main() { debugDefaultTargetPlatformOverride = TargetPlatform.linux; final md = Markdown.fromString('Hello keyboard world'); final controller = MarkdownSelectionController() - ..setDocuments( - [MarkdownDocumentRef(id: 'd', model: md)]) + ..setDocuments([MarkdownDocumentRef(id: 'd', model: md)]) ..selectAll(); final focus = FocusNode(); addTearDown(focus.dispose); @@ -88,13 +86,11 @@ void main() { expect(controller.selection, isNull); }); - testWidgets('Shift+ArrowRight extends the selection by a character', - (tester) async { + testWidgets('Shift+ArrowRight extends the selection by a character', (tester) async { debugDefaultTargetPlatformOverride = TargetPlatform.linux; final md = Markdown.fromString('Hello'); final controller = MarkdownSelectionController() - ..setDocuments( - [MarkdownDocumentRef(id: 'd', model: md)]) + ..setDocuments([MarkdownDocumentRef(id: 'd', model: md)]) ..selection = const MarkdownSelection.collapsed( MarkdownPosition(documentId: 'd', blockIndex: 0, offset: 0)); final focus = FocusNode(); @@ -123,8 +119,7 @@ void main() { debugDefaultTargetPlatformOverride = TargetPlatform.linux; final md = Markdown.fromString('Copy this text'); final controller = MarkdownSelectionController() - ..setDocuments( - [MarkdownDocumentRef(id: 'd', model: md)]) + ..setDocuments([MarkdownDocumentRef(id: 'd', model: md)]) ..selectAll(); final focus = FocusNode(); addTearDown(focus.dispose); @@ -161,13 +156,11 @@ void main() { }); group('context toolbar', () { - testWidgets('right-click over a selection shows a Copy button', - (tester) async { + testWidgets('right-click over a selection shows a Copy button', (tester) async { debugDefaultTargetPlatformOverride = TargetPlatform.linux; final md = Markdown.fromString('Toolbar target text'); final controller = MarkdownSelectionController() - ..setDocuments( - [MarkdownDocumentRef(id: 'd', model: md)]) + ..setDocuments([MarkdownDocumentRef(id: 'd', model: md)]) ..selectAll(); await tester.pumpWidget(_wrap( @@ -192,8 +185,7 @@ void main() { debugDefaultTargetPlatformOverride = TargetPlatform.linux; final md = Markdown.fromString('State driven toolbar'); final controller = MarkdownSelectionController() - ..setDocuments( - [MarkdownDocumentRef(id: 'd', model: md)]) + ..setDocuments([MarkdownDocumentRef(id: 'd', model: md)]) ..selectAll(); await tester.pumpWidget(_wrap( @@ -202,8 +194,8 @@ void main() { )); await tester.pumpAndSettle(); - final state = tester.state( - find.byType(MarkdownSelectionScope)); + final state = + tester.state(find.byType(MarkdownSelectionScope)); state.showToolbar(); await tester.pumpAndSettle(); expect(find.text('Copy'), findsOneWidget); diff --git a/test/selection/selection_test.dart b/test/selection/selection_test.dart index 08d2a9f..a9cb9a0 100644 --- a/test/selection/selection_test.dart +++ b/test/selection/selection_test.dart @@ -83,8 +83,7 @@ void main() { test('reconcile: append keeps the anchor verbatim', () { final c = two()..selection = full; final before = c.getText(); - c.putDocument( - 'b', Markdown.fromString('Bravo one\n\nBravo two three\n\nEnd')); + c.putDocument('b', Markdown.fromString('Bravo one\n\nBravo two three\n\nEnd')); expect(c.selection!.extent.blockIndex, 2); expect(c.selection!.extent.offset, 9); expect(c.getText(), before); @@ -92,15 +91,13 @@ void main() { test('reconcile: content-anchored survives a front-insert', () { final c = MarkdownSelectionController() - ..setDocuments( - [MarkdownDocumentRef(id: 'b', model: docB)]) + ..setDocuments([MarkdownDocumentRef(id: 'b', model: docB)]) ..selection = const MarkdownSelection( base: MarkdownPosition(documentId: 'b', blockIndex: 2, offset: 0), extent: MarkdownPosition(documentId: 'b', blockIndex: 2, offset: 9), ); expect(c.getText(), 'Bravo two'); - c.putDocument( - 'b', Markdown.fromString('HEADER\n\nBravo one\n\nBravo two')); + c.putDocument('b', Markdown.fromString('HEADER\n\nBravo one\n\nBravo two')); expect(c.getText(), 'Bravo two'); // relocated by content, not index }); @@ -108,15 +105,13 @@ void main() { final c = MarkdownSelectionController( reconciliation: const MarkdownReconciliationPolicy.appendFastPath(), ) - ..setDocuments( - [MarkdownDocumentRef(id: 'b', model: docB)]) + ..setDocuments([MarkdownDocumentRef(id: 'b', model: docB)]) ..selection = const MarkdownSelection( base: MarkdownPosition(documentId: 'b', blockIndex: 0, offset: 0), extent: MarkdownPosition(documentId: 'b', blockIndex: 0, offset: 9), ); expect(c.getText(), 'Bravo one'); - c.putDocument( - 'b', Markdown.fromString('HEADER LINE\n\nBravo one\n\nBravo two')); + c.putDocument('b', Markdown.fromString('HEADER LINE\n\nBravo one\n\nBravo two')); expect(c.getText(), isNot('Bravo one')); // clamped to new block 0 }); @@ -124,8 +119,7 @@ void main() { final c = MarkdownSelectionController( reconciliation: const MarkdownReconciliationPolicy.clearOnChange(), ) - ..setDocuments( - [MarkdownDocumentRef(id: 'b', model: docB)]) + ..setDocuments([MarkdownDocumentRef(id: 'b', model: docB)]) ..selection = const MarkdownSelection( base: MarkdownPosition(documentId: 'b', blockIndex: 0, offset: 0), extent: MarkdownPosition(documentId: 'b', blockIndex: 2, offset: 9), diff --git a/test/selection/selection_widget_test.dart b/test/selection/selection_widget_test.dart index b2ff0f5..ef3bd98 100644 --- a/test/selection/selection_widget_test.dart +++ b/test/selection/selection_widget_test.dart @@ -12,8 +12,7 @@ Future _mouseDrag(WidgetTester tester, Offset from, Offset to) async { await tester.pumpAndSettle(); } -Widget _wrap(MarkdownSelectionController controller, Widget child) => - MaterialApp( +Widget _wrap(MarkdownSelectionController controller, Widget child) => MaterialApp( home: Scaffold( body: MarkdownSelectionScope(controller: controller, child: child), ), @@ -24,8 +23,7 @@ void main() { testWidgets('drag selects a single paragraph', (tester) async { final md = Markdown.fromString('Hello selectable world'); final controller = MarkdownSelectionController() - ..setDocuments( - [MarkdownDocumentRef(id: 'd', model: md)]); + ..setDocuments([MarkdownDocumentRef(id: 'd', model: md)]); await tester.pumpWidget(_wrap( controller, @@ -38,8 +36,7 @@ void main() { 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)); + await _mouseDrag(tester, tl + const Offset(1, 3), br - const Offset(1, 3)); expect(controller.getText(), 'Hello selectable world'); expect(tester.takeException(), isNull); @@ -82,8 +79,7 @@ void main() { expect(tester.takeException(), isNull); }); - testWidgets('cross-widget selection survives ListView disposal', - (tester) async { + testWidgets('cross-widget selection survives ListView disposal', (tester) async { final controller = MarkdownSelectionController() ..setDocuments([ for (var i = 0; i < 10; i++) @@ -112,8 +108,8 @@ void main() { )); await tester.pumpAndSettle(); - final p0 = tester.getTopLeft(find.byType(MarkdownWidget).first) + - const Offset(1, 3); + final p0 = + tester.getTopLeft(find.byType(MarkdownWidget).first) + const Offset(1, 3); final p1 = tester.getCenter(find.byType(MarkdownWidget).at(1)); await _mouseDrag(tester, p0, p1); final before = controller.getText(); @@ -122,9 +118,7 @@ void main() { scroll.jumpTo(80.0 * 7); // dispose the first messages await tester.pumpAndSettle(); - expect( - find.byWidgetPredicate( - (w) => w is MarkdownWidget && w.documentId == 'm0'), + expect(find.byWidgetPredicate((w) => w is MarkdownWidget && w.documentId == 'm0'), findsNothing); // Text is derived from the model registry → intact after disposal. @@ -133,17 +127,14 @@ void main() { expect(tester.takeException(), isNull); }); - testWidgets('selecting in one controller clears the other (group)', - (tester) async { + testWidgets('selecting in one controller clears the other (group)', (tester) async { final group = MarkdownSelectionGroup(); final a = Markdown.fromString('Alpha body text'); final b = Markdown.fromString('Bravo body text'); final ca = MarkdownSelectionController(group: group) - ..setDocuments( - [MarkdownDocumentRef(id: 'a', model: a)]); + ..setDocuments([MarkdownDocumentRef(id: 'a', model: a)]); final cb = MarkdownSelectionController(group: group) - ..setDocuments( - [MarkdownDocumentRef(id: 'b', model: b)]); + ..setDocuments([MarkdownDocumentRef(id: 'b', model: b)]); await tester.pumpWidget(MaterialApp( home: Scaffold( @@ -165,8 +156,8 @@ void main() { await tester.pumpAndSettle(); // Select in controller B first. - final bw = find - .byWidgetPredicate((w) => w is MarkdownWidget && w.documentId == 'b'); + final bw = + find.byWidgetPredicate((w) => w is MarkdownWidget && w.documentId == 'b'); await _mouseDrag( tester, tester.getTopLeft(bw) + const Offset(1, 3), @@ -175,25 +166,22 @@ void main() { expect(cb.getText(), isNotEmpty); // Now select in controller A — B must be cleared. - final aw = find - .byWidgetPredicate((w) => w is MarkdownWidget && w.documentId == 'a'); + final aw = + find.byWidgetPredicate((w) => w is MarkdownWidget && w.documentId == 'a'); await _mouseDrag( tester, tester.getTopLeft(aw) + const Offset(1, 3), tester.getBottomRight(aw) - const Offset(1, 3), ); expect(ca.getText(), isNotEmpty); - expect(cb.selection, isNull, - reason: 'group cleared the other controller'); + expect(cb.selection, isNull, reason: 'group cleared the other controller'); expect(tester.takeException(), isNull); }); - testWidgets('MarkdownWidget without documentId stays inert', - (tester) async { + testWidgets('MarkdownWidget without documentId stays inert', (tester) async { final md = Markdown.fromString('Not selectable here'); final controller = MarkdownSelectionController() - ..setDocuments( - [MarkdownDocumentRef(id: 'x', model: md)]); + ..setDocuments([MarkdownDocumentRef(id: 'x', model: md)]); await tester.pumpWidget(_wrap( controller, SizedBox(width: 400, child: MarkdownWidget(markdown: md)), @@ -202,8 +190,7 @@ void main() { 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)); + await _mouseDrag(tester, tl + const Offset(1, 3), br - const Offset(1, 3)); expect(controller.getText(), '', reason: 'no documentId => inert'); expect(tester.takeException(), isNull); @@ -228,16 +215,12 @@ void main() { expect(controller.getText(), ''); }); - testWidgets('drag past an empty document still selects a real one', - (tester) async { + testWidgets('drag past an empty document still selects a real one', (tester) async { final controller = MarkdownSelectionController() ..setDocuments([ - const MarkdownDocumentRef( - id: 'empty', model: Markdown.empty(), order: 0), + const MarkdownDocumentRef(id: 'empty', model: Markdown.empty(), order: 0), MarkdownDocumentRef( - id: 'real', - model: Markdown.fromString('Real content here'), - order: 1), + id: 'real', model: Markdown.fromString('Real content here'), order: 1), ]); await tester.pumpWidget(_wrap( controller, @@ -254,8 +237,8 @@ void main() { )); await tester.pumpAndSettle(); - final realWidget = find.byWidgetPredicate( - (w) => w is MarkdownWidget && w.documentId == 'real'); + final realWidget = + find.byWidgetPredicate((w) => w is MarkdownWidget && w.documentId == 'real'); await _mouseDrag( tester, tester.getTopLeft(realWidget) + const Offset(1, 3), @@ -266,12 +249,10 @@ void main() { }); testWidgets('drag selects the cells of a table', (tester) async { - final md = - Markdown.fromString('| A | B |\n|---|---|\n| 1 | 2 |\n| 3 | 4 |'); + final md = Markdown.fromString('| A | B |\n|---|---|\n| 1 | 2 |\n| 3 | 4 |'); final table = md.blocks.firstWhere((b) => b.type == 'table'); final controller = MarkdownSelectionController() - ..setDocuments( - [MarkdownDocumentRef(id: 'd', model: md)]); + ..setDocuments([MarkdownDocumentRef(id: 'd', model: md)]); await tester.pumpWidget(_wrap( controller, @@ -284,8 +265,7 @@ void main() { 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)); + await _mouseDrag(tester, tl + const Offset(2, 2), br - const Offset(2, 2)); expect(controller.getText(), markdownBlockRenderedText(table)); expect(controller.getText(), 'A\tB\n1\t2\n3\t4'); @@ -295,8 +275,7 @@ void main() { testWidgets('drag selects the items of a list', (tester) async { final md = Markdown.fromString('- alpha\n- beta\n- gamma'); final controller = MarkdownSelectionController() - ..setDocuments( - [MarkdownDocumentRef(id: 'd', model: md)]); + ..setDocuments([MarkdownDocumentRef(id: 'd', model: md)]); await tester.pumpWidget(_wrap( controller, @@ -309,20 +288,17 @@ void main() { 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)); + await _mouseDrag(tester, tl + const Offset(2, 2), br - const Offset(2, 2)); expect(controller.getText(), 'alpha\nbeta\ngamma'); expect(tester.takeException(), isNull); }); - testWidgets('selection spanning a table includes its cells', - (tester) async { + testWidgets('selection spanning a table includes its cells', (tester) async { final md = Markdown.fromString( 'Intro line\n\n| A | B |\n|---|---|\n| 1 | 2 |\n\nOutro line'); final controller = MarkdownSelectionController() - ..setDocuments( - [MarkdownDocumentRef(id: 'd', model: md)]); + ..setDocuments([MarkdownDocumentRef(id: 'd', model: md)]); await tester.pumpWidget(_wrap( controller, @@ -335,8 +311,7 @@ void main() { 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)); + await _mouseDrag(tester, tl + const Offset(2, 2), br - const Offset(2, 2)); expect(controller.getText(), 'Intro line\nA\tB\n1\t2\nOutro line'); expect(tester.takeException(), isNull); diff --git a/test/theme/theme_test.dart b/test/theme/theme_test.dart index aedde4f..a253cf2 100644 --- a/test/theme/theme_test.dart +++ b/test/theme/theme_test.dart @@ -9,12 +9,9 @@ void main() => group('MarkdownThemeData', () { group('Alert colors', () { test('defaults to the GitHub palette', () { final theme = base(); - expect( - theme.alertColorFor(MD$AlertType.note), const Color(0xFF0969DA)); - expect(theme.alertColorFor(MD$AlertType.warning), - const Color(0xFF9A6700)); - expect(theme.alertColorFor(MD$AlertType.caution), - const Color(0xFFCF222E)); + expect(theme.alertColorFor(MD$AlertType.note), const Color(0xFF0969DA)); + expect(theme.alertColorFor(MD$AlertType.warning), const Color(0xFF9A6700)); + expect(theme.alertColorFor(MD$AlertType.caution), const Color(0xFFCF222E)); }); test('respects overrides while keeping defaults for the rest', () { @@ -24,11 +21,9 @@ void main() => group('MarkdownThemeData', () { MD$AlertType.note: Color(0xFF123456), }, ); - expect( - theme.alertColorFor(MD$AlertType.note), const Color(0xFF123456)); + expect(theme.alertColorFor(MD$AlertType.note), const Color(0xFF123456)); // Unspecified types still use the default palette. - expect( - theme.alertColorFor(MD$AlertType.tip), const Color(0xFF1A7F37)); + expect(theme.alertColorFor(MD$AlertType.tip), const Color(0xFF1A7F37)); }); }); @@ -110,8 +105,7 @@ void main() => group('MarkdownThemeData', () { ), ); expect(derived.linkStyle?.color, Colors.purple); - expect( - derived.alertColorFor(MD$AlertType.note), const Color(0xFF0969DA)); + expect(derived.alertColorFor(MD$AlertType.note), const Color(0xFF0969DA)); }); group('headingStyleFor', () { @@ -202,8 +196,7 @@ void main() => group('MarkdownThemeData', () { test('updateShouldNotify compares data identity', () { final a = MarkdownTheme(data: base(), child: const SizedBox()); final same = MarkdownTheme(data: a.data, child: const SizedBox()); - final different = - MarkdownTheme(data: base(), child: const SizedBox()); + final different = MarkdownTheme(data: base(), child: const SizedBox()); expect(a.updateShouldNotify(same), isFalse); expect(a.updateShouldNotify(different), isTrue); }); diff --git a/test/widget/render_test.dart b/test/widget/render_test.dart index 02bd4ce..57513c8 100644 --- a/test/widget/render_test.dart +++ b/test/widget/render_test.dart @@ -139,8 +139,8 @@ void main() { '[styled](https://example.com)', theme: MarkdownThemeData( textStyle: const TextStyle(fontSize: 14), - linkStyle: const TextStyle( - color: Colors.red, decoration: TextDecoration.underline), + linkStyle: + const TextStyle(color: Colors.red, decoration: TextDecoration.underline), ), ); expect(tester.takeException(), isNull); From c76dc1d3f978c43f784a0c879c9be5afba2a2e6b Mon Sep 17 00:00:00 2001 From: Mike Matiunin Date: Mon, 3 Aug 2026 14:36:54 +0400 Subject: [PATCH 13/30] chore: remove unused imports from caching and logical controller tests --- benchmark/experiments/s2_custom_delegate_test.dart | 1 - benchmark/experiments/s4_logical_controller_test.dart | 9 ++++----- benchmark/experiments/s7_caching_test.dart | 5 ++--- 3 files changed, 6 insertions(+), 9 deletions(-) diff --git a/benchmark/experiments/s2_custom_delegate_test.dart b/benchmark/experiments/s2_custom_delegate_test.dart index 50007be..3e572a0 100644 --- a/benchmark/experiments/s2_custom_delegate_test.dart +++ b/benchmark/experiments/s2_custom_delegate_test.dart @@ -17,7 +17,6 @@ // // Throwaway spike; outside lib/ and test/. // Run: flutter test benchmark/experiments/s2_custom_delegate_test.dart -import 'dart:ui' show Offset; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; diff --git a/benchmark/experiments/s4_logical_controller_test.dart b/benchmark/experiments/s4_logical_controller_test.dart index dfc1e8d..c01a27a 100644 --- a/benchmark/experiments/s4_logical_controller_test.dart +++ b/benchmark/experiments/s4_logical_controller_test.dart @@ -18,7 +18,6 @@ // Uses the REAL flutter_md model. Throwaway spike; outside lib/ and test/. // Run: flutter test benchmark/experiments/s4_logical_controller_test.dart import 'package:flutter/material.dart'; -import 'package:flutter/rendering.dart'; import 'package:flutter_md/flutter_md.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -237,12 +236,12 @@ void main() { }); test('T4 SCREEN ORDER: vertical, then horizontal with RTL flip', () { - final vTop = const Rect.fromLTWH(0, 0, 100, 40); - final vBot = const Rect.fromLTWH(0, 60, 100, 40); + const vTop = Rect.fromLTWH(0, 0, 100, 40); + const vBot = Rect.fromLTWH(0, 60, 100, 40); expect(compareScreenOrder(vTop, vBot, TextDirection.ltr) < 0, isTrue); - final left = const Rect.fromLTWH(0, 0, 100, 40); - final right = const Rect.fromLTWH(120, 1, 100, 40); // same row (±threshold) + const left = Rect.fromLTWH(0, 0, 100, 40); + const right = Rect.fromLTWH(120, 1, 100, 40); // same row (±threshold) expect(compareScreenOrder(left, right, TextDirection.ltr) < 0, isTrue, reason: 'LTR: left comes first'); expect(compareScreenOrder(left, right, TextDirection.rtl) > 0, isTrue, diff --git a/benchmark/experiments/s7_caching_test.dart b/benchmark/experiments/s7_caching_test.dart index 1df9c68..cf6fe9b 100644 --- a/benchmark/experiments/s7_caching_test.dart +++ b/benchmark/experiments/s7_caching_test.dart @@ -12,7 +12,6 @@ import 'dart:ui' as ui; import 'package:flutter/material.dart'; -import 'package:flutter/rendering.dart'; import 'package:flutter_test/flutter_test.dart'; class CachingBox extends RenderBox { @@ -201,8 +200,8 @@ void main() { setOuter(() => selA = const Rect.fromLTWH(0, 0, 40, 18)); await tester.pump(); - debugPrint('S7.3 A paints ${aPaints}->${a.paintCount}, ' - 'B paints ${bPaints}->${b.paintCount}'); + debugPrint('S7.3 A paints $aPaints->${a.paintCount}, ' + 'B paints $bPaints->${b.paintCount}'); expect(a.paintCount, greaterThan(aPaints), reason: 'A repainted'); expect(b.paintCount, bPaints, reason: 'B did NOT repaint (isolated)'); }); From 6f2ed800530a5cf85ecb7eef709961c63c1450f7 Mon Sep 17 00:00:00 2001 From: Mike Matiunin Date: Mon, 3 Aug 2026 14:43:29 +0400 Subject: [PATCH 14/30] Refactor code formatting and improve readability across multiple test files and widget implementations - Adjusted line breaks and indentation for better readability in `widget.dart`, `block_test.dart`, `edge_cases_test.dart`, `gfm_test.dart`, `inline_test.dart`, `math_test.dart`, `parser_test.dart`, `regression_test.dart`, `selection_handles_test.dart`, `selection_keyboard_test.dart`, `selection_test.dart`, `selection_widget_test.dart`, `theme_test.dart`, and `render_test.dart`. - Ensured consistent formatting in test cases, including spacing and line length. - Enhanced clarity of assertions and expectations in tests. --- benchmark/compare.dart | 3 +- .../experiments/s1_stock_baseline_test.dart | 21 +++-- .../experiments/s2_custom_delegate_test.dart | 12 ++- .../s3_selectable_renderobject_test.dart | 26 +++-- .../s4_logical_controller_test.dart | 21 +++-- .../s5_cross_widget_topology_test.dart | 16 ++-- benchmark/experiments/s7_caching_test.dart | 13 ++- benchmark/parse_benchmark.dart | 11 ++- benchmark/parser_benchmark.dart | 4 +- benchmark/render_benchmark.dart | 3 +- example/lib/experiments/s6_platforms.dart | 10 +- example/lib/main.dart | 27 ++++-- example/lib/tabs/chat_tab.dart | 26 +++-- example/lib/tabs/lorem_tab.dart | 22 +++-- example/test/smoke_test.dart | 6 +- lib/flutter_md.dart | 3 +- lib/src/nodes.dart | 7 +- lib/src/parser.dart | 50 ++++++---- lib/src/render.dart | 94 ++++++++++++------- lib/src/selection.dart | 67 ++++++++----- lib/src/selection_scope.dart | 45 ++++++--- lib/src/theme.dart | 40 +++++--- lib/src/widget.dart | 3 +- test/parser/block_test.dart | 22 +++-- test/parser/edge_cases_test.dart | 6 +- test/parser/gfm_test.dart | 59 +++++++----- test/parser/inline_test.dart | 15 ++- test/parser/math_test.dart | 9 +- test/parser/parser_test.dart | 6 +- test/parser/regression_test.dart | 14 +-- test/selection/selection_handles_test.dart | 24 +++-- test/selection/selection_keyboard_test.dart | 28 ++++-- test/selection/selection_test.dart | 18 ++-- test/selection/selection_widget_test.dart | 87 +++++++++++------ test/theme/theme_test.dart | 21 +++-- test/widget/render_test.dart | 4 +- 36 files changed, 544 insertions(+), 299 deletions(-) diff --git a/benchmark/compare.dart b/benchmark/compare.dart index 303968f..e953b7f 100644 --- a/benchmark/compare.dart +++ b/benchmark/compare.dart @@ -68,7 +68,8 @@ void main(List args) { } /// Returns the minimum per-op time in microseconds for parsing [input]. -double _bench(String input, {int warmupMs = 200, int batches = 25, int minBatchMs = 8}) { +double _bench(String input, + {int warmupMs = 200, int batches = 25, int minBatchMs = 8}) { // Warmup to trigger JIT compilation / reach steady state. final warmupSw = Stopwatch()..start(); while (warmupSw.elapsedMilliseconds < warmupMs) { diff --git a/benchmark/experiments/s1_stock_baseline_test.dart b/benchmark/experiments/s1_stock_baseline_test.dart index c780813..3564f86 100644 --- a/benchmark/experiments/s1_stock_baseline_test.dart +++ b/benchmark/experiments/s1_stock_baseline_test.dart @@ -39,21 +39,25 @@ void main() { // Mouse-drag select from the very start of "Alpha" to the very end of // "Charlie" — i.e. everything. final start = tester.getTopLeft(find.text('Alpha')) + const Offset(1, 3); - final end = tester.getBottomRight(find.text('Charlie')) - const Offset(1, 3); - final gesture = await tester.startGesture(start, kind: PointerDeviceKind.mouse); + final end = + tester.getBottomRight(find.text('Charlie')) - const Offset(1, 3); + final gesture = + await tester.startGesture(start, kind: PointerDeviceKind.mouse); await tester.pump(const Duration(milliseconds: 200)); await gesture.moveTo(end); await tester.pump(const Duration(milliseconds: 200)); await gesture.up(); await tester.pumpAndSettle(); - debugPrint('S1.1 captured plainText = ${captured!.replaceAll('\n', r'\n')}'); + debugPrint( + 'S1.1 captured plainText = ${captured!.replaceAll('\n', r'\n')}'); // The defect: the three fragments are glued with no separator between them. expect(captured, isNotNull); expect(captured, contains('Bravo')); expect(captured, isNot(contains('\n')), - reason: 'DEFECT CONFIRMED if this passes: no separators inserted between ' + reason: + 'DEFECT CONFIRMED if this passes: no separators inserted between ' 'selectables — "Alpha", "Bravo", "Charlie" are glued.'); // Concretely, the whole selection is the bare concatenation. expect(captured, 'AlphaBravoCharlie'); @@ -91,7 +95,8 @@ void main() { // Select across Item0 and Item1 (both on screen). final start = tester.getTopLeft(find.text('Item0')) + const Offset(1, 3); final end = tester.getBottomRight(find.text('Item1')) - const Offset(1, 3); - final gesture = await tester.startGesture(start, kind: PointerDeviceKind.mouse); + final gesture = + await tester.startGesture(start, kind: PointerDeviceKind.mouse); await tester.pump(const Duration(milliseconds: 200)); await gesture.moveTo(end); await tester.pump(const Duration(milliseconds: 200)); @@ -105,7 +110,8 @@ void main() { // Scroll far so Item0 (and Item1) are disposed. controller.jumpTo(itemExtent * 20); await tester.pumpAndSettle(); - expect(find.text('Item0'), findsNothing, reason: 'Item0 should be disposed'); + expect(find.text('Item0'), findsNothing, + reason: 'Item0 should be disposed'); debugPrint('S1.2 after scroll = ${captured?.replaceAll('\n', r'\n')}'); // FINDING: disposing the selectable does NOT re-fire onSelectionChanged, so @@ -116,6 +122,7 @@ void main() { // app's perspective. (S2 proves at the delegate level that the LIVE // getSelectedContent() actually drops the disposed text.) expect(captured, equals(beforeScroll), - reason: 'onSelectionChanged did not re-fire on disposal → stale value.'); + reason: + 'onSelectionChanged did not re-fire on disposal → stale value.'); }); } diff --git a/benchmark/experiments/s2_custom_delegate_test.dart b/benchmark/experiments/s2_custom_delegate_test.dart index 3e572a0..ec9401d 100644 --- a/benchmark/experiments/s2_custom_delegate_test.dart +++ b/benchmark/experiments/s2_custom_delegate_test.dart @@ -93,7 +93,8 @@ Future _dragSelect(WidgetTester tester, Finder from, Finder to) async { } void main() { - testWidgets('S2.1 separators work in a NON-scrolling subtree', (tester) async { + testWidgets('S2.1 separators work in a NON-scrolling subtree', + (tester) async { final delegate = MdDelegate(); await tester.pumpWidget(MaterialApp( home: Scaffold( @@ -119,7 +120,8 @@ void main() { expect(text, 'Item0\nItem1\nItem2'); // separators inserted }); - testWidgets('S2.2 BLOCKER: a ListView hides its items behind its own container', + testWidgets( + 'S2.2 BLOCKER: a ListView hides its items behind its own container', (tester) async { final delegate = MdDelegate(); await tester.pumpWidget(MaterialApp( @@ -148,7 +150,8 @@ void main() { 'text=${text?.replaceAll('\n', r'\n')}'); // The Scrollable interposes ONE aggregated child; our delegate can't split. expect(delegate.liveChildCount, 1, - reason: 'Scrollable._ScrollableSelectionContainerDelegate is the child'); + reason: + 'Scrollable._ScrollableSelectionContainerDelegate is the child'); expect(text, 'Item0Item1', reason: 'gluing happened inside the private scrollable delegate, ' 'below us — the delegate route cannot fix the chat case'); @@ -166,7 +169,8 @@ void main() { expect(result, 'Item0\nItem1\nItem2'); }); - testWidgets('S2.4 LIMIT: screen-Y snapshot key collides on reflow', (tester) async { + testWidgets('S2.4 LIMIT: screen-Y snapshot key collides on reflow', + (tester) async { final delegate = MdDelegate(); final result = await _removeWhileSelected(tester, delegate, removeIndex: 1); debugPrint('S2.4 snapshots=${delegate.snapshotCount} ' diff --git a/benchmark/experiments/s3_selectable_renderobject_test.dart b/benchmark/experiments/s3_selectable_renderobject_test.dart index bd00477..287b8c2 100644 --- a/benchmark/experiments/s3_selectable_renderobject_test.dart +++ b/benchmark/experiments/s3_selectable_renderobject_test.dart @@ -42,7 +42,8 @@ class _Block { const String _blockSeparator = '\n'; -class MdSelectableRenderBox extends RenderBox with Selectable, SelectionRegistrant { +class MdSelectableRenderBox extends RenderBox + with Selectable, SelectionRegistrant { MdSelectableRenderBox(List blocks, TextStyle style) : _blocks = [for (final b in blocks) _Block(b, style)]; @@ -141,8 +142,9 @@ class MdSelectableRenderBox extends RenderBox with Selectable, SelectionRegistra return SelectionResult.end; case final SelectWordSelectionEvent e: final pos = _positionForLocal(globalToLocal(e.globalPosition)); - final range = - _blocks[pos.block].painter.getWordBoundary(TextPosition(offset: pos.offset)); + final range = _blocks[pos.block] + .painter + .getWordBoundary(TextPosition(offset: pos.offset)); _start = _Pos(pos.block, range.start); _end = _Pos(pos.block, range.end); _recompute(); @@ -196,8 +198,8 @@ class MdSelectableRenderBox extends RenderBox with Selectable, SelectionRegistra void _recompute() { if (_start == null || _end == null) { - _geometry = - const SelectionGeometry(status: SelectionStatus.none, hasContent: true); + _geometry = const SelectionGeometry( + status: SelectionStatus.none, hasContent: true); } else { final rects = _selectionRects(); final collapsed = _start!.compareTo(_end!) == 0; @@ -205,7 +207,8 @@ class MdSelectableRenderBox extends RenderBox with Selectable, SelectionRegistra startSelectionPoint: _pointFor(_start!, TextSelectionHandleType.left), endSelectionPoint: _pointFor(_end!, TextSelectionHandleType.right), selectionRects: rects, - status: collapsed ? SelectionStatus.collapsed : SelectionStatus.uncollapsed, + status: + collapsed ? SelectionStatus.collapsed : SelectionStatus.uncollapsed, hasContent: true, ); } @@ -237,8 +240,9 @@ class MdSelectableRenderBox extends RenderBox with Selectable, SelectionRegistra // ---- geometry required by the delegate ---- @override - List get boundingBoxes => - [for (final b in _blocks) Rect.fromLTWH(0, b.top, size.width, b.height)]; + List get boundingBoxes => [ + for (final b in _blocks) Rect.fromLTWH(0, b.top, size.width, b.height) + ]; @override void pushHandleLayers(LayerLink? startHandle, LayerLink? endHandle) { @@ -280,7 +284,8 @@ class MdSelectableRenderBox extends RenderBox with Selectable, SelectionRegistra } class MdSelectableWidget extends LeafRenderObjectWidget { - const MdSelectableWidget({required this.blocks, required this.style, super.key}); + const MdSelectableWidget( + {required this.blocks, required this.style, super.key}); final List blocks; final TextStyle style; @@ -298,7 +303,8 @@ class MdSelectableWidget extends LeafRenderObjectWidget { void main() { const style = TextStyle(fontSize: 20, color: Color(0xFF000000)); - testWidgets('S3 single selectable box spans blocks with separators', (tester) async { + testWidgets('S3 single selectable box spans blocks with separators', + (tester) async { String? captured; await tester.pumpWidget(MaterialApp( home: Scaffold( diff --git a/benchmark/experiments/s4_logical_controller_test.dart b/benchmark/experiments/s4_logical_controller_test.dart index c01a27a..f3ad397 100644 --- a/benchmark/experiments/s4_logical_controller_test.dart +++ b/benchmark/experiments/s4_logical_controller_test.dart @@ -28,7 +28,8 @@ String renderedBlockText(MD$Block b) => b.map( quote: (q) => q.spans.map((s) => s.text).join(), alert: (a) => a.spans.map((s) => s.text).join(), code: (c) => c.text, - list: (l) => l.items.map((i) => i.spans.map((s) => s.text).join()).join('\n'), + list: (l) => + l.items.map((i) => i.spans.map((s) => s.text).join()).join('\n'), table: (t) => [ t.header.cells.map((c) => c.map((s) => s.text).join()).join('\t'), for (final r in t.rows) @@ -66,7 +67,8 @@ MdPos reconcile(MdPos anchor, Markdown oldM, Markdown newM) { bool prefixUnchanged() { if (anchor.block >= newB.length) return false; for (var i = 0; i < anchor.block; i++) { - if (i >= newB.length || renderedBlockText(oldB[i]) != renderedBlockText(newB[i])) { + if (i >= newB.length || + renderedBlockText(oldB[i]) != renderedBlockText(newB[i])) { return false; } } @@ -132,8 +134,8 @@ class MdController extends ChangeNotifier { if (text.isEmpty) continue; // skip structural blocks (spacer/divider) final from = (d == startDoc && bi == a.block) ? a.offset : 0; final to = (d == endDoc && bi == b.block) ? b.offset : text.length; - blockChunks - .add(text.substring(from.clamp(0, text.length), to.clamp(0, text.length))); + blockChunks.add(text.substring( + from.clamp(0, text.length), to.clamp(0, text.length))); } docChunks.add(blockChunks.join(blockSep)); } @@ -146,7 +148,9 @@ class MdController extends ChangeNotifier { int compareScreenOrder(Rect a, Rect b, TextDirection dir) { const threshold = 4.0; if ((a.top - b.top).abs() > threshold) return a.top.compareTo(b.top); - return dir == TextDirection.rtl ? b.left.compareTo(a.left) : a.left.compareTo(b.left); + return dir == TextDirection.rtl + ? b.left.compareTo(a.left) + : a.left.compareTo(b.left); } void main() { @@ -213,8 +217,8 @@ void main() { final base = c.getPlainText(); // (a) Append-only streaming: grow the last block + add a new block. - c.updateDocument( - 'b', Markdown.fromString('Bravo one\n\nBravo two three\n\nBravo appended')); + c.updateDocument('b', + Markdown.fromString('Bravo one\n\nBravo two three\n\nBravo appended')); // block 2 grew as a prefix ("Bravo two" -> "Bravo two three"), so the fast // path keeps the anchor; the originally-selected text is unchanged. expect(c.selection!.extent.block, 2); @@ -232,7 +236,8 @@ void main() { final afterInsert = c2.getPlainText(); expect(beforeInsert, 'Bravo one'); expect(afterInsert, isNot('Bravo one'), - reason: 'index-only anchors mis-track a front-insert → needs stable id'); + reason: + 'index-only anchors mis-track a front-insert → needs stable id'); }); test('T4 SCREEN ORDER: vertical, then horizontal with RTL flip', () { diff --git a/benchmark/experiments/s5_cross_widget_topology_test.dart b/benchmark/experiments/s5_cross_widget_topology_test.dart index 7bb79bb..0392938 100644 --- a/benchmark/experiments/s5_cross_widget_topology_test.dart +++ b/benchmark/experiments/s5_cross_widget_topology_test.dart @@ -38,7 +38,8 @@ abstract interface class MdSurface { } class MdController extends ChangeNotifier { - MdController(this.docs); // app-supplied, ordered, ALL messages (mounted or not) + MdController( + this.docs); // app-supplied, ordered, ALL messages (mounted or not) final List<(Object id, String text)> docs; final Map _surfaces = {}; @@ -98,7 +99,8 @@ class MdController extends ChangeNotifier { final text = docs[d].$2; final from = d == start ? a.offset : 0; final to = d == end ? b.offset : text.length; - chunks.add(text.substring(from.clamp(0, text.length), to.clamp(0, text.length))); + chunks.add( + text.substring(from.clamp(0, text.length), to.clamp(0, text.length))); } return chunks.join(docSep); } @@ -114,7 +116,8 @@ class _Scope extends InheritedWidget { } class MarkdownScope extends StatelessWidget { - const MarkdownScope({required this.controller, required this.child, super.key}); + const MarkdownScope( + {required this.controller, required this.child, super.key}); final MdController controller; final Widget child; @@ -227,7 +230,8 @@ void main() { // Only the first few messages are mounted (surfaces). Anchor the selection // across m0..m1 by hit-testing their mounted surfaces — exactly what the // scope gesture layer does on a real drag. - final p0 = tester.getTopLeft(find.byType(MdMessage).first) + const Offset(1, 3); + final p0 = + tester.getTopLeft(find.byType(MdMessage).first) + const Offset(1, 3); final m1 = find.byWidgetPredicate((w) => w is MdMessage && w.docId == 'm1'); final p1 = tester.getBottomRight(m1) - const Offset(1, 3); controller.startAt(p0); @@ -241,8 +245,8 @@ void main() { // Scroll so m0 is disposed. scroll.jumpTo(80.0 * 6); await tester.pumpAndSettle(); - expect( - find.byWidgetPredicate((w) => w is MdMessage && w.docId == 'm0'), findsNothing); + expect(find.byWidgetPredicate((w) => w is MdMessage && w.docId == 'm0'), + findsNothing); expect(controller.mountedDocIds, isNot(contains('m0')), reason: 'm0 surface unregistered on disposal'); diff --git a/benchmark/experiments/s7_caching_test.dart b/benchmark/experiments/s7_caching_test.dart index cf6fe9b..4bc8456 100644 --- a/benchmark/experiments/s7_caching_test.dart +++ b/benchmark/experiments/s7_caching_test.dart @@ -81,7 +81,8 @@ class CachingBox extends RenderBox { canvas.translate(offset.dx, offset.dy); // Dynamic overlay drawn fresh each paint, OUTSIDE the cached Picture. if (_selectionRect != null) { - canvas.drawRect(_selectionRect!, Paint()..color = const Color(0x552196F3)); + canvas.drawRect( + _selectionRect!, Paint()..color = const Color(0x552196F3)); } canvas.drawPicture(_content!); canvas.restore(); @@ -121,7 +122,9 @@ void main() { child: StatefulBuilder(builder: (_, setState) { setOuter = setState; return CacheWidget( - text: 'Selectable content here', revision: 0, selectionRect: sel); + text: 'Selectable content here', + revision: 0, + selectionRect: sel); }), ), ), @@ -141,11 +144,13 @@ void main() { debugPrint('S7.1 contentRebuilds=${box.contentRebuilds} ' 'paints=${box.paintCount}'); // The overlay redrew every frame (paints grew) but the Picture never rebuilt. - expect(box.contentRebuilds, 1, reason: 'content Picture reused across drag'); + expect(box.contentRebuilds, 1, + reason: 'content Picture reused across drag'); expect(box.paintCount, greaterThan(paintsAfterFirst)); }); - testWidgets('S7.2 content or size change DOES rebuild the Picture', (tester) async { + testWidgets('S7.2 content or size change DOES rebuild the Picture', + (tester) async { var text = 'first'; var rev = 0; late StateSetter setOuter; diff --git a/benchmark/parse_benchmark.dart b/benchmark/parse_benchmark.dart index cbf10d0..d9db1c2 100644 --- a/benchmark/parse_benchmark.dart +++ b/benchmark/parse_benchmark.dart @@ -47,7 +47,8 @@ class Current$Benchmark extends BenchmarkBase { super.teardown(); // Ensure the result is not null after running the benchmark // to disable compilation optimizations that might skip the run. - if (result == null) throw StateError('Result is null, did you run the benchmark?'); + if (result == null) + throw StateError('Result is null, did you run the benchmark?'); } } @@ -58,8 +59,9 @@ class Google$Benchmark extends BenchmarkBase { @override void run() { - result = markdown.Document(extensionSet: markdown.ExtensionSet.gitHubFlavored) - .parse(_testSample); + result = + markdown.Document(extensionSet: markdown.ExtensionSet.gitHubFlavored) + .parse(_testSample); } @override @@ -67,7 +69,8 @@ class Google$Benchmark extends BenchmarkBase { super.teardown(); // Ensure the result is not null after running the benchmark // to disable compilation optimizations that might skip the run. - if (result == null) throw StateError('Result is null, did you run the benchmark?'); + if (result == null) + throw StateError('Result is null, did you run the benchmark?'); } } diff --git a/benchmark/parser_benchmark.dart b/benchmark/parser_benchmark.dart index 7949de0..b779c6f 100644 --- a/benchmark/parser_benchmark.dart +++ b/benchmark/parser_benchmark.dart @@ -73,8 +73,8 @@ class _GoogleBenchmark extends BenchmarkBase { List? _result; @override - void run() => - _result = gmd.Document(extensionSet: gmd.ExtensionSet.gitHubFlavored).parse(input); + void run() => _result = + gmd.Document(extensionSet: gmd.ExtensionSet.gitHubFlavored).parse(input); @override void teardown() { diff --git a/benchmark/render_benchmark.dart b/benchmark/render_benchmark.dart index 8a700b0..267bd1b 100644 --- a/benchmark/render_benchmark.dart +++ b/benchmark/render_benchmark.dart @@ -123,7 +123,8 @@ void main() { hitPainter.dispose(); // stream_append (toggle model so update() always sees a change) - final p = MarkdownPainter(markdown: large, theme: theme)..layout(maxWidth: _kWidth); + final p = MarkdownPainter(markdown: large, theme: theme) + ..layout(maxWidth: _kWidth); var flip = false; _results['stream_append'] = _bench(() { flip = !flip; diff --git a/example/lib/experiments/s6_platforms.dart b/example/lib/experiments/s6_platforms.dart index 17874a5..602951d 100644 --- a/example/lib/experiments/s6_platforms.dart +++ b/example/lib/experiments/s6_platforms.dart @@ -178,7 +178,8 @@ class MdSelectionController extends ChangeNotifier { final text = docs[d].text; final from = d == start ? a.offset : 0; final to = d == end ? b.offset : text.length; - chunks.add(text.substring(from.clamp(0, text.length), to.clamp(0, text.length))); + chunks.add( + text.substring(from.clamp(0, text.length), to.clamp(0, text.length))); } return chunks.join(docSep); } @@ -233,7 +234,8 @@ class MarkdownSelectionScope extends StatelessWidget { () => LongPressGestureRecognizer(), (r) => r ..onLongPressStart = ((d) => controller.startAt(d.globalPosition)) - ..onLongPressMoveUpdate = ((d) => controller.extendTo(d.globalPosition)), + ..onLongPressMoveUpdate = + ((d) => controller.extendTo(d.globalPosition)), ), }, child: child, @@ -269,7 +271,9 @@ class _MdMessageBox extends RenderBox implements MdSurface { text: doc.text, style: TextStyle( fontSize: 16, - color: doc.isLink ? const Color(0xFF1565C0) : const Color(0xFF111111), + color: doc.isLink + ? const Color(0xFF1565C0) + : const Color(0xFF111111), decoration: doc.isLink ? TextDecoration.underline : null, ), ), diff --git a/example/lib/main.dart b/example/lib/main.dart index 061e6d2..cd2a527 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -10,7 +10,8 @@ import 'tabs/lorem_tab.dart'; void main() => runZonedGuarded( () => runApp(ThemeModel( - notifier: ValueNotifier(ThemeMode.dark), child: const App())), + notifier: ValueNotifier(ThemeMode.dark), + child: const App())), (e, s) => print(e), ); @@ -61,7 +62,8 @@ 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}) => + static ValueNotifier? maybeOf(BuildContext context, + {bool listen = true}) => listen ? context.dependOnInheritedWidgetOfExactType()?.notifier : context.getInheritedWidgetOfExactType()?.notifier; @@ -75,7 +77,8 @@ class ThemeModel extends InheritedNotifier> { /// 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 @@ -99,7 +102,8 @@ class HomeScreen extends StatefulWidget { } /// State for widget HomeScreen. -class _HomeScreenState extends State with SingleTickerProviderStateMixin { +class _HomeScreenState extends State + with SingleTickerProviderStateMixin { late final TabController _tabs = TabController(length: 3, vsync: this); @override @@ -117,7 +121,8 @@ class _HomeScreenState extends State with SingleTickerProviderStateM Switch.adaptive( value: ThemeModel.of(context).value == ThemeMode.dark, onChanged: (value) { - ThemeModel.of(context).value = value ? ThemeMode.dark : ThemeMode.light; + ThemeModel.of(context).value = + value ? ThemeMode.dark : ThemeMode.light; }, ), ], @@ -167,7 +172,8 @@ class _EditorTabState extends State { 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); } @@ -240,7 +246,8 @@ class _EditorTabState extends State { icon: const Icon( Icons.refresh, ), - onPressed: () => _inputController.text = _markdownExample, + onPressed: () => + _inputController.text = _markdownExample, ), ], ), @@ -280,14 +287,16 @@ 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 e9c1a72..d122152 100644 --- a/example/lib/tabs/chat_tab.dart +++ b/example/lib/tabs/chat_tab.dart @@ -82,9 +82,11 @@ class _ChatTabState extends State { _streamCursor = 0; _streamBuffer = ''; 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) { @@ -145,7 +147,8 @@ class _ChatTabState extends State { controller: _controller, child: ListView.builder( controller: _scroll, - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + padding: + const EdgeInsets.symmetric(horizontal: 12, vertical: 8), itemCount: _messages.length, itemBuilder: (context, i) => _Bubble(message: _messages[i]), ), @@ -245,7 +248,8 @@ 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) ...[ @@ -255,10 +259,12 @@ class _Bubble extends StatelessWidget { Flexible( child: Container( padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), - constraints: - BoxConstraints(maxWidth: MediaQuery.sizeOf(context).width * 0.78), + constraints: BoxConstraints( + maxWidth: MediaQuery.sizeOf(context).width * 0.78), decoration: BoxDecoration( - color: isUser ? scheme.primaryContainer : scheme.surfaceContainerHighest, + color: isUser + ? scheme.primaryContainer + : scheme.surfaceContainerHighest, borderRadius: BorderRadius.only( topLeft: const Radius.circular(16), topRight: const Radius.circular(16), @@ -268,7 +274,8 @@ class _Bubble extends StatelessWidget { ), child: message.markdown.isEmpty ? const _TypingDots() - : MarkdownWidget(markdown: message.markdown, documentId: message.id), + : MarkdownWidget( + markdown: message.markdown, documentId: message.id), ), ), if (isUser) ...[ @@ -305,7 +312,8 @@ class _TypingDots extends StatefulWidget { State<_TypingDots> createState() => _TypingDotsState(); } -class _TypingDotsState extends State<_TypingDots> with SingleTickerProviderStateMixin { +class _TypingDotsState extends State<_TypingDots> + with SingleTickerProviderStateMixin { late final AnimationController _c = AnimationController( vsync: this, duration: const Duration(milliseconds: 900), diff --git a/example/lib/tabs/lorem_tab.dart b/example/lib/tabs/lorem_tab.dart index e19797e..3e3f78b 100644 --- a/example/lib/tabs/lorem_tab.dart +++ b/example/lib/tabs/lorem_tab.dart @@ -64,8 +64,10 @@ 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); @@ -77,8 +79,10 @@ 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); } @@ -106,7 +110,8 @@ class _LoremTabState extends State { } Future _copy() async { - final text = _isActive(_a) ? _a.getText() : (_isActive(_b) ? _b.getText() : ''); + final text = + _isActive(_a) ? _a.getText() : (_isActive(_b) ? _b.getText() : ''); if (text.isEmpty) return; await Clipboard.setData(ClipboardData(text: text)); if (!mounted) return; @@ -133,8 +138,8 @@ class _LoremTabState extends State { ContextMenuButtonItem( label: 'Copy LOUD', onPressed: () { - Clipboard.setData( - ClipboardData(text: state.controller.getText().toUpperCase())); + Clipboard.setData(ClipboardData( + text: state.controller.getText().toUpperCase())); state.hideToolbar(); }, ), @@ -166,7 +171,8 @@ class _LoremTabState extends State { child: MarkdownWidget(markdown: _docB, documentId: 'B'), ), const Divider(height: 40), - _label('Plain SelectableText — resets with the Markdown ones'), + _label( + 'Plain SelectableText — resets with the Markdown ones'), SelectionArea( key: ValueKey(_plainEpoch), onSelectionChanged: (content) { diff --git a/example/test/smoke_test.dart b/example/test/smoke_test.dart index bc28632..424ae2d 100644 --- a/example/test/smoke_test.dart +++ b/example/test/smoke_test.dart @@ -5,7 +5,8 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:md_example/main.dart'; void main() { - testWidgets('all tabs build and selection drags do not crash', (tester) async { + testWidgets('all tabs build and selection drags do not crash', + (tester) async { await tester.pumpWidget(ThemeModel( notifier: ValueNotifier(ThemeMode.light), child: const App(), @@ -20,7 +21,8 @@ void main() { 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), + 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)); diff --git a/lib/flutter_md.dart b/lib/flutter_md.dart index 5b9bbff..14d8e59 100644 --- a/lib/flutter_md.dart +++ b/lib/flutter_md.dart @@ -3,7 +3,8 @@ library; export 'src/markdown.dart'; export 'src/nodes.dart'; export 'src/parser.dart'; -export 'src/render.dart' show BlockPainter, SelectableBlockPainter, SelectableTextBlock; +export 'src/render.dart' + show BlockPainter, SelectableBlockPainter, SelectableTextBlock; export 'src/selection.dart'; export 'src/selection_scope.dart'; export 'src/theme.dart'; diff --git a/lib/src/nodes.dart b/lib/src/nodes.dart index b4c9bc8..818b0b3 100644 --- a/lib/src/nodes.dart +++ b/lib/src/nodes.dart @@ -697,9 +697,10 @@ final class MD$Table extends MD$Block { /// Returns the alignment for the given column [index], /// defaulting to [MD$TableColumnAlign.none] when unspecified. - MD$TableColumnAlign alignmentFor(int index) => index >= 0 && index < alignments.length - ? alignments[index] - : MD$TableColumnAlign.none; + MD$TableColumnAlign alignmentFor(int index) => + index >= 0 && index < alignments.length + ? alignments[index] + : MD$TableColumnAlign.none; @override T map({ diff --git a/lib/src/parser.dart b/lib/src/parser.dart index 221a8cb..312190a 100644 --- a/lib/src/parser.dart +++ b/lib/src/parser.dart @@ -61,7 +61,8 @@ class MarkdownDecoder extends Converter { /// space/tab or the end of the line (so `#hashtag` and 7+ `#` are not /// headings). Group 2 captures the heading text; a trailing run of `#` /// characters is stripped separately. - static final RegExp _headingPattern = RegExp(r'^(#{1,6})(?:[ \t]+(.*?))?[ \t]*$'); + static final RegExp _headingPattern = + RegExp(r'^(#{1,6})(?:[ \t]+(.*?))?[ \t]*$'); /// Matches an optional ATX closing sequence of `#` characters. static final RegExp _headingClosingPattern = RegExp(r'[ \t]+#+$'); @@ -74,12 +75,14 @@ class MarkdownDecoder extends Converter { /// every candidate line (once to open a list, then once per line to find its /// end). The returned [text] still includes leading whitespace, matching the /// old capture group; callers trim it as before. - static ({int indent, String marker, String text})? _parseListLine(String line) { + static ({int indent, String marker, String text})? _parseListLine( + String line) { final len = line.length; // Leading indent: at most 8 spaces or tabs. var i = 0; - while ( - i < len && i < 8 && (line.codeUnitAt(i) == 0x20 || line.codeUnitAt(i) == 0x09)) { + while (i < len && + i < 8 && + (line.codeUnitAt(i) == 0x20 || line.codeUnitAt(i) == 0x09)) { i++; } if (i >= len) return null; @@ -126,8 +129,9 @@ class MarkdownDecoder extends Converter { /// A regular expression pattern to match GitHub alert markers, /// e.g. `[!NOTE]`, `[!WARNING]`. Matched case-insensitively against the /// first line of a blockquote. - static final RegExp _alertPattern = - RegExp(r'^\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\]$', caseSensitive: false); + static final RegExp _alertPattern = RegExp( + r'^\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\]$', + caseSensitive: false); /// A regular expression pattern to match a GitHub task-list checkbox at the /// start of a list item, e.g. `[ ] todo`, `[x] done`, `[X] done`. @@ -184,7 +188,8 @@ class MarkdownDecoder extends Converter { // Resolve the inline-math replacement table once; `null` disables math and // keeps `_parseInlineSpans` from touching it at all. - final math = inlineMath ? (mathReplacements ?? kMarkdownMathCommands) : null; + final math = + inlineMath ? (mathReplacements ?? kMarkdownMathCommands) : null; final paragraph = StringBuffer(); // To accumulate lines for paragraphs @@ -240,9 +245,12 @@ class MarkdownDecoder extends Converter { } final level = match.group(1)!.length; // Strip an optional closing sequence of `#` (e.g. "## Heading ##"). - final text = (match.group(2) ?? '').replaceFirst(_headingClosingPattern, ''); + final text = + (match.group(2) ?? '').replaceFirst(_headingClosingPattern, ''); pushBlock(MD$Heading( - level: level, text: text, spans: _parseInlineSpans(text, math: math))); + level: level, + text: text, + spans: _parseInlineSpans(text, math: math))); continue; } else if (c0 == 0x3E /* > */) { // Parse quotes and GitHub-style alerts. @@ -255,8 +263,9 @@ class MarkdownDecoder extends Converter { // A blockquote whose first line is `[!TYPE]` becomes an alert block. final alertMatch = _alertPattern.firstMatch(quoteLines.first); - final alertType = - alertMatch != null ? MD$AlertType.tryParse(alertMatch.group(1)!) : null; + final alertType = alertMatch != null + ? MD$AlertType.tryParse(alertMatch.group(1)!) + : null; if (alertType != null) { // The alert body is everything after the marker line. final body = quoteLines.skip(1).join('\n').trim(); @@ -305,7 +314,8 @@ class MarkdownDecoder extends Converter { continue; } final firstTask = _parseTask(first.text.trim()); - final list = <({int intent, String marker, String text, bool? checked})>[ + final list = + <({int intent, String marker, String text, bool? checked})>[ ( intent: 0, marker: first.marker, @@ -347,8 +357,8 @@ class MarkdownDecoder extends Converter { final children = traverse(indent: item.intent); if (items.isNotEmpty) { // If we have a parent item, add children to it - items.last = items.last - .copyWith(children: List.unmodifiable(children)); + items.last = items.last.copyWith( + children: List.unmodifiable(children)); } else { // If this is the first item, just add children items.add(MD$ListItem( @@ -777,7 +787,8 @@ bool _hasClosingBacktick(List codes, int length, int from) { /// [markerLen]) exists at or after [from]. A closer must be right-flanking /// (preceded by a non-space); underscore closers must also be at a word /// boundary. Escaped characters are skipped. -bool _hasEmphasisCloser(List codes, int length, int from, int ch, int markerLen) { +bool _hasEmphasisCloser( + List codes, int length, int from, int ch, int markerLen) { for (var j = from; j < length; j++) { if (codes[j] == 0x5C /* \ */) { j++; // Skip the escaped character. @@ -817,7 +828,8 @@ bool _emphasisValid( if (i == 0 || _isInlineSpace(codes[i - 1])) return false; // Underscore cannot close inside a word. final after = i + markerLen; - if (ch == 0x5F /* _ */ && after < length && _isWordChar(codes[after])) return false; + if (ch == 0x5F /* _ */ && after < length && _isWordChar(codes[after])) + return false; return true; } } @@ -985,10 +997,12 @@ List _parseInlineSpans(String text, {Map? math}) { var segmentStart = start; for (var e = 0; e < excluded.length; e++) { final idx = excluded[e]; - if (idx > segmentStart) buffer.write(text.substring(segmentStart, idx)); + if (idx > segmentStart) + buffer.write(text.substring(segmentStart, idx)); segmentStart = idx + 1; } - if (segmentStart < end) buffer.write(text.substring(segmentStart, end)); + if (segmentStart < end) + buffer.write(text.substring(segmentStart, end)); spans.add( MD$Span( start: start, diff --git a/lib/src/render.dart b/lib/src/render.dart index 1155b66..11d18df 100644 --- a/lib/src/render.dart +++ b/lib/src/render.dart @@ -19,7 +19,8 @@ import 'theme.dart'; const Color _kSelectionColor = Color(0x552196F3); @meta.internal -class MarkdownRenderObject extends RenderBox implements MarkdownSelectionSurface { +class MarkdownRenderObject extends RenderBox + implements MarkdownSelectionSurface { MarkdownRenderObject({ required Markdown markdown, required MarkdownThemeData theme, @@ -130,7 +131,8 @@ class MarkdownRenderObject extends RenderBox implements MarkdownSelectionSurface Offset? endLocal, }) { var changed = false; - if (!identical(startLink, _startHandleLink) || startLocal != _startHandleLocal) { + if (!identical(startLink, _startHandleLink) || + startLocal != _startHandleLocal) { _startHandleLink = startLink; _startHandleLocal = startLocal; changed = true; @@ -184,7 +186,8 @@ class MarkdownRenderObject extends RenderBox implements MarkdownSelectionSurface @override void performLayout() { // Set the size of the render box to match the painter's size. - size = constraints.constrain(_painter.layout(maxWidth: constraints.maxWidth)); + size = + constraints.constrain(_painter.layout(maxWidth: constraints.maxWidth)); } @override @@ -252,7 +255,8 @@ class MarkdownRenderObject extends RenderBox implements MarkdownSelectionSurface @override @protected void detach() { - PaintingBinding.instance.systemFonts.removeListener(_handleSystemFontsChange); + PaintingBinding.instance.systemFonts + .removeListener(_handleSystemFontsChange); _controller?.detachSurface(this); super.detach(); } @@ -269,7 +273,8 @@ class MarkdownRenderObject extends RenderBox implements MarkdownSelectionSurface @override @protected void paint(PaintingContext context, Offset offset) { - if (_painter.isEmpty) return; // If the markdown is empty, do not paint anything. + if (_painter.isEmpty) + return; // If the markdown is empty, do not paint anything. final canvas = context.canvas ..save() @@ -418,7 +423,8 @@ class MarkdownPainter { for (var i = 0; i < blocks.length; i++) { final block = blocks[i]; if (filter != null && !filter(block)) continue; - painters.add(builder(block, _theme) ?? _defaultBlockBuilder(block, _theme)); + painters + .add(builder(block, _theme) ?? _defaultBlockBuilder(block, _theme)); sources.add(i); } _blockPainters = painters; @@ -502,7 +508,8 @@ class MarkdownPainter { required Markdown markdown, required MarkdownThemeData theme, }) { - if (identical(_markdown, markdown) && identical(_theme, theme)) return false; + if (identical(_markdown, markdown) && identical(_theme, theme)) + return false; _lastSize = null; _lastPicture = null; _markdown = markdown; @@ -950,7 +957,8 @@ mixin MultiPainterSelectable implements SelectableBlockPainter { } } final fragment = best!; - final inner = fragment.painter.getPositionForOffset(local - fragment.origin).offset; + final inner = + fragment.painter.getPositionForOffset(local - fragment.origin).offset; return fragment.textStart + inner.clamp(0, fragment.length); } @@ -958,8 +966,8 @@ mixin MultiPainterSelectable implements SelectableBlockPainter { List boxesForRange(int start, int end) { final out = []; for (final fragment in fragments) { - final localStart = - start.clamp(fragment.textStart, fragment.textEnd) - fragment.textStart; + final localStart = start.clamp(fragment.textStart, fragment.textEnd) - + fragment.textStart; final localEnd = end.clamp(fragment.textStart, fragment.textEnd) - fragment.textStart; if (localEnd <= localStart) continue; @@ -1182,8 +1190,8 @@ class BlockPainter$Quote textScaler: theme.textScaler, ), linePaint = Paint() - ..color = - theme.dividerColor ?? const Color(0x7F7F7F7F) // Gray color for the line. + ..color = theme.dividerColor ?? + const Color(0x7F7F7F7F) // Gray color for the line. ..isAntiAlias = false ..strokeWidth = 4.0 ..style = PaintingStyle.fill; @@ -1435,8 +1443,10 @@ class _ListItemMetrics { final TextPainter contentPainter; final Offset offset; - late final double height = math.max(bulletPainter.height, contentPainter.height); - late final Size size = Size(bulletPainter.width + contentPainter.width, height); + late final double height = + math.max(bulletPainter.height, contentPainter.height); + late final Size size = + Size(bulletPainter.width + contentPainter.width, height); void dispose() { bulletPainter.dispose(); @@ -1482,7 +1492,8 @@ class BlockPainter$List InlineSpan? _getSpanForPosition(Offset localPosition) { for (final metrics in _painters) { - final contentOffset = metrics.offset + Offset(metrics.bulletPainter.width, 0); + final contentOffset = + metrics.offset + Offset(metrics.bulletPainter.width, 0); final contentRect = contentOffset & metrics.contentPainter.size; if (contentRect.contains(localPosition)) { final painterPosition = localPosition - contentOffset; @@ -1505,7 +1516,8 @@ class BlockPainter$List if (_lastSpan == null) return; // No span was hit on tap down. final newSpan = _getSpanForPosition(event.localPosition); if (newSpan != null && _lastSpan == newSpan) { - if (newSpan case TextSpan(recognizer: final TapGestureRecognizer recognizer)) { + if (newSpan + case TextSpan(recognizer: final TapGestureRecognizer recognizer)) { recognizer.onTap?.call(); } } @@ -1555,7 +1567,8 @@ class BlockPainter$List _painters.add(metrics); currentHeight += metrics.height; - maxContentWidth = math.max(maxContentWidth, indent + metrics.size.width); + maxContentWidth = + math.max(maxContentWidth, indent + metrics.size.width); if (item.children.isNotEmpty) { layoutItems(item.children, level + 1); @@ -1589,7 +1602,8 @@ class BlockPainter$List final bulletOffset = metrics.offset + Offset(0, offset); metrics.bulletPainter.paint(canvas, bulletOffset); - final contentOffset = bulletOffset + Offset(metrics.bulletPainter.width, 0); + final contentOffset = + bulletOffset + Offset(metrics.bulletPainter.width, 0); metrics.contentPainter.paint(canvas, contentOffset); } } @@ -1799,7 +1813,8 @@ class BlockPainter$Table _rowBackgroundPaint = Paint() ..style = PaintingStyle.fill ..isAntiAlias = false - ..color = theme.surfaceColor ?? const Color.fromARGB(255, 235, 235, 235); + ..color = + theme.surfaceColor ?? const Color.fromARGB(255, 235, 235, 235); /// Padding for table cells. static const double padding = 8.0; @@ -1815,8 +1830,9 @@ class BlockPainter$Table /// Resolves the alignment for column [c], defaulting to /// [MD$TableColumnAlign.none] when unspecified. - MD$TableColumnAlign _columnAlign(int c) => - c >= 0 && c < alignments.length ? alignments[c] : MD$TableColumnAlign.none; + MD$TableColumnAlign _columnAlign(int c) => c >= 0 && c < alignments.length + ? alignments[c] + : MD$TableColumnAlign.none; /// The horizontal offset of a cell's text within its column, honoring the /// column alignment (falling back to centered headers / left-aligned data). @@ -1882,7 +1898,8 @@ class BlockPainter$Table } TextSpan? _getSpanForOffset(Offset position) { - final rowHeights = List.generate(_cellPainters.length, (r) => _rowHeights[r]); + final rowHeights = + List.generate(_cellPainters.length, (r) => _rowHeights[r]); double currentY = 0.0; @@ -1903,10 +1920,11 @@ class BlockPainter$Table if (position.dx >= currentX && position.dx < currentX + columnWidth) { // In this cell. final verticalPadding = (rowHeight - painter.height) / 2; - final horizontalPadding = _cellHorizontalPadding(r, c, painter.width); + final horizontalPadding = + _cellHorizontalPadding(r, c, painter.width); - final painterOffset = - Offset(currentX + horizontalPadding, currentY + verticalPadding); + final painterOffset = Offset( + currentX + horizontalPadding, currentY + verticalPadding); final localPosition = position - painterOffset; // Check if inside the actual painted text area. @@ -1956,15 +1974,18 @@ class BlockPainter$Table return TextPainter(textDirection: theme.textDirection); } final cell = row.cells[c]; - final style = - (r == 0) ? theme.textStyle.copyWith(fontWeight: FontWeight.bold) : null; + final style = (r == 0) + ? theme.textStyle.copyWith(fontWeight: FontWeight.bold) + : null; final textPainter = TextPainter( - text: _paragraphFromMarkdownSpans(spans: cell, theme: theme, textStyle: style), + text: _paragraphFromMarkdownSpans( + spans: cell, theme: theme, textStyle: style), textAlign: switch (_columnAlign(c)) { MD$TableColumnAlign.left => TextAlign.left, MD$TableColumnAlign.center => TextAlign.center, MD$TableColumnAlign.right => TextAlign.right, - MD$TableColumnAlign.none => (r == 0) ? TextAlign.center : TextAlign.start, + MD$TableColumnAlign.none => + (r == 0) ? TextAlign.center : TextAlign.start, }, textDirection: theme.textDirection, textScaler: theme.textScaler, @@ -1972,18 +1993,21 @@ class BlockPainter$Table // Calculate natural width textPainter.layout(maxWidth: double.infinity); - naturalWidths[c] = math.max(naturalWidths[c], textPainter.width + padding * 2); + naturalWidths[c] = + math.max(naturalWidths[c], textPainter.width + padding * 2); // Calculate min width (longest word) final cellText = cell.map((s) => s.text).join(); final words = cellText.split(RegExp(r'\s+')); if (words.isNotEmpty) { - final longestWord = words.reduce((a, b) => a.length > b.length ? a : b); + final longestWord = + words.reduce((a, b) => a.length > b.length ? a : b); final wordPainter = TextPainter( text: TextSpan(text: longestWord, style: style), textDirection: theme.textDirection, )..layout(); - minWidths[c] = math.max(minWidths[c], wordPainter.width + padding * 2); + minWidths[c] = + math.max(minWidths[c], wordPainter.width + padding * 2); wordPainter.dispose(); } @@ -2059,7 +2083,8 @@ class BlockPainter$Table final painter = _cellPainters[r][c]; final verticalPadding = (_rowHeights[r] - painter.height) / 2; final horizontalPadding = _cellHorizontalPadding(r, c, painter.width); - final origin = Offset(colLeft + horizontalPadding, rowTop + verticalPadding); + final origin = + Offset(colLeft + horizontalPadding, rowTop + verticalPadding); frags.add(SelectableFragment(painter, origin, text.length)); text.write(painter.plainText); } @@ -2077,7 +2102,8 @@ class BlockPainter$Table if (columns < 1) return; double currentY = offset; - final rowHeights = List.generate(_cellPainters.length, (r) => _rowHeights[r]); + final rowHeights = + List.generate(_cellPainters.length, (r) => _rowHeights[r]); for (int r = 0; r < _cellPainters.length; r++) { double currentX = 0; diff --git a/lib/src/selection.dart b/lib/src/selection.dart index 8b58a4b..39c42c6 100644 --- a/lib/src/selection.dart +++ b/lib/src/selection.dart @@ -121,7 +121,9 @@ final class MarkdownSelection { @override bool operator ==(Object other) => - other is MarkdownSelection && other.base == base && other.extent == extent; + other is MarkdownSelection && + other.base == base && + other.extent == extent; @override int get hashCode => Object.hash(base, extent); @@ -134,7 +136,8 @@ final class MarkdownSelection { @immutable final class MarkdownDocumentRef { /// Creates a reference binding a stable [id] to its immutable [model]. - const MarkdownDocumentRef({required this.id, required this.model, this.order}); + const MarkdownDocumentRef( + {required this.id, required this.model, this.order}); /// Stable id of the document (e.g. a chat message id). final Object id; @@ -264,11 +267,13 @@ final class MarkdownPlainTextFormatter implements MarkdownSelectionFormatter { abstract interface class MarkdownReconciliationPolicy { /// Append-only fast path, else clamp indices/offsets into the new bounds. /// Cheapest; correct for streaming appends, drifts on front/mid inserts. - const factory MarkdownReconciliationPolicy.appendFastPath() = _AppendFastPathPolicy; + const factory MarkdownReconciliationPolicy.appendFastPath() = + _AppendFastPathPolicy; /// Append fast path, then relocate by matching block rendered text, else /// clamp. The default — robust to inserts/reorders without a model id. - const factory MarkdownReconciliationPolicy.contentAnchored() = _ContentAnchoredPolicy; + const factory MarkdownReconciliationPolicy.contentAnchored() = + _ContentAnchoredPolicy; /// Drop the selection whenever the anchor's document changes at all. const factory MarkdownReconciliationPolicy.clearOnChange() = _ClearPolicy; @@ -283,7 +288,8 @@ abstract interface class MarkdownReconciliationPolicy { MarkdownPosition _clampInto(MarkdownPosition anchor, Markdown model) { if (model.blocks.isEmpty) { - return MarkdownPosition(documentId: anchor.documentId, blockIndex: 0, offset: 0); + return MarkdownPosition( + documentId: anchor.documentId, blockIndex: 0, offset: 0); } final bi = anchor.blockIndex.clamp(0, model.blocks.length - 1); final len = markdownBlockRenderedText(model.blocks[bi]).length; @@ -295,7 +301,8 @@ MarkdownPosition _clampInto(MarkdownPosition anchor, Markdown model) { } bool _appendPrefixKeeps(MarkdownPosition anchor, Markdown o, Markdown n) { - if (anchor.blockIndex >= o.blocks.length || anchor.blockIndex >= n.blocks.length) { + if (anchor.blockIndex >= o.blocks.length || + anchor.blockIndex >= n.blocks.length) { return false; } for (var i = 0; i < anchor.blockIndex; i++) { @@ -362,7 +369,8 @@ class _ContentAnchoredPolicy implements MarkdownReconciliationPolicy { class _ClearPolicy implements MarkdownReconciliationPolicy { const _ClearPolicy(); @override - MarkdownPosition? remap(MarkdownPosition anchor, Markdown o, Markdown n) => null; + MarkdownPosition? remap(MarkdownPosition anchor, Markdown o, Markdown n) => + null; } /// A mounted document's geometry bridge — the controller's window onto a live @@ -457,8 +465,8 @@ class MarkdownSelectionController extends ChangeNotifier { MarkdownReconciliationPolicy? reconciliation, MarkdownSelectionFormatter formatter = const MarkdownPlainTextFormatter(), MarkdownSelectionGroup? group, - }) : reconciliation = - reconciliation ?? const MarkdownReconciliationPolicy.contentAnchored(), + }) : reconciliation = reconciliation ?? + const MarkdownReconciliationPolicy.contentAnchored(), _formatter = formatter, _group = group { group?._add(this); @@ -532,7 +540,8 @@ class MarkdownSelectionController extends ChangeNotifier { _docs ..clear() ..addAll(<_DocEntry>[ - for (final (i, d) in docs.indexed) _DocEntry(d.id, d.model, d.order ?? i), + for (final (i, d) in docs.indexed) + _DocEntry(d.id, d.model, d.order ?? i), ]); _sort(); _validateSelection(); @@ -567,7 +576,8 @@ class MarkdownSelectionController extends ChangeNotifier { void removeDocument(Object id) { _docs.removeWhere((e) => e.id == id); final sel = _selection; - if (sel != null && (sel.base.documentId == id || sel.extent.documentId == id)) { + if (sel != null && + (sel.base.documentId == id || sel.extent.documentId == id)) { _selection = null; } notifyListeners(); @@ -594,7 +604,8 @@ class MarkdownSelectionController extends ChangeNotifier { void _validateSelection() { final sel = _selection; if (sel == null) return; - if (_orderIndex(sel.base.documentId) < 0 || _orderIndex(sel.extent.documentId) < 0) { + if (_orderIndex(sel.base.documentId) < 0 || + _orderIndex(sel.extent.documentId) < 0) { _selection = null; return; } @@ -655,8 +666,10 @@ class MarkdownSelectionController extends ChangeNotifier { final maxX = bounds.right - 0.01; final maxY = bounds.bottom - 0.01; final clamped = Offset( - globalPosition.dx.clamp(bounds.left, maxX < bounds.left ? bounds.left : maxX), - globalPosition.dy.clamp(bounds.top, maxY < bounds.top ? bounds.top : maxY), + globalPosition.dx + .clamp(bounds.left, maxX < bounds.left ? bounds.left : maxX), + globalPosition.dy + .clamp(bounds.top, maxY < bounds.top ? bounds.top : maxY), ); return nearest.positionForGlobal(clamped); } @@ -868,13 +881,15 @@ class MarkdownSelectionController extends ChangeNotifier { MarkdownSelectedContent selectedContent() { final sel = _selection; if (sel == null) { - return const MarkdownSelectedContent(documents: []); + return const MarkdownSelectedContent( + documents: []); } final (a, b) = _ordered(sel); final startDoc = _orderIndex(a.documentId); final endDoc = _orderIndex(b.documentId); if (startDoc < 0 || endDoc < 0) { - return const MarkdownSelectedContent(documents: []); + return const MarkdownSelectedContent( + documents: []); } final out = []; for (var d = startDoc; d <= endDoc; d++) { @@ -886,8 +901,9 @@ class MarkdownSelectionController extends ChangeNotifier { for (var bi = fromBlock; bi <= toBlock && bi < blocks.length; bi++) { if (bi < 0) continue; final text = markdownBlockRenderedText(blocks[bi]); - final from = - (d == startDoc && bi == a.blockIndex) ? a.offset.clamp(0, text.length) : 0; + final from = (d == startDoc && bi == a.blockIndex) + ? a.offset.clamp(0, text.length) + : 0; final to = (d == endDoc && bi == b.blockIndex) ? b.offset.clamp(0, text.length) : text.length; @@ -920,7 +936,8 @@ 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) return a.blockIndex.compareTo(b.blockIndex); + if (a.blockIndex != b.blockIndex) + return a.blockIndex.compareTo(b.blockIndex); return a.offset.compareTo(b.offset); } @@ -1008,7 +1025,8 @@ class MarkdownSelectionController extends ChangeNotifier { MarkdownPosition _stepLineBreak(MarkdownPosition p, {required bool forward}) { final di = _orderIndex(p.documentId); if (di < 0) return p; - return p.copyWith(offset: forward ? _blockTextAt(di, p.blockIndex).length : 0); + return p.copyWith( + offset: forward ? _blockTextAt(di, p.blockIndex).length : 0); } MarkdownPosition _documentBoundary({required bool forward}) { @@ -1020,7 +1038,8 @@ class MarkdownSelectionController extends ChangeNotifier { blockIndex: bi < 0 ? 0 : bi, offset: bi < 0 ? 0 : _blockTextAt(di, bi).length); } - return MarkdownPosition(documentId: _docs.first.id, blockIndex: 0, offset: 0); + return MarkdownPosition( + documentId: _docs.first.id, blockIndex: 0, offset: 0); } } @@ -1030,11 +1049,13 @@ class MarkdownSelectionController extends ChangeNotifier { /// are cleared. Call [clearExternal] when a non-Markdown selectable (e.g. a /// plain `SelectableText` / `SelectionArea`) begins its own selection. class MarkdownSelectionGroup { - final Set _members = {}; + final Set _members = + {}; void _add(MarkdownSelectionController controller) => _members.add(controller); - void _remove(MarkdownSelectionController controller) => _members.remove(controller); + void _remove(MarkdownSelectionController controller) => + _members.remove(controller); void _claim(MarkdownSelectionController owner) { for (final member in _members) { diff --git a/lib/src/selection_scope.dart b/lib/src/selection_scope.dart index 678ae80..8234a91 100644 --- a/lib/src/selection_scope.dart +++ b/lib/src/selection_scope.dart @@ -117,7 +117,8 @@ class MarkdownSelectionScope extends StatefulWidget { context.dependOnInheritedWidgetOfExactType<_ScopeMarker>()?.controller; /// The nearest ambient controller. Throws if there is no enclosing scope. - static MarkdownSelectionController of(BuildContext context) => maybeOf(context)!; + static MarkdownSelectionController of(BuildContext context) => + maybeOf(context)!; /// The nearest ambient scope state, or null when there is no enclosing scope. static MarkdownSelectionScopeState? stateOf(BuildContext context) => @@ -187,8 +188,8 @@ class MarkdownSelectionScopeState extends State { } void _applySelectionColor() { - controller.selectionColor = - widget.selectionColor ?? DefaultSelectionStyle.of(context).selectionColor; + controller.selectionColor = widget.selectionColor ?? + DefaultSelectionStyle.of(context).selectionColor; } void _onControllerChanged() { @@ -206,7 +207,10 @@ class MarkdownSelectionScopeState extends State { bool get _handlesEnabled => widget.enabled && switch (Theme.of(context).platform) { - TargetPlatform.android || TargetPlatform.iOS || TargetPlatform.fuchsia => true, + TargetPlatform.android || + TargetPlatform.iOS || + TargetPlatform.fuchsia => + true, _ => false, }; @@ -224,7 +228,8 @@ class MarkdownSelectionScopeState extends State { }; TextMagnifierConfiguration get _effectiveMagnifier => - widget.magnifierConfiguration ?? TextMagnifier.adaptiveMagnifierConfiguration; + 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`). @@ -259,7 +264,8 @@ class MarkdownSelectionScopeState extends State { TextDirection.ltr, ); final endPoint = TextSelectionPoint( - box.globalToLocal(Offset(endpoints.endGlobal.right, endpoints.endGlobal.bottom)), + box.globalToLocal( + Offset(endpoints.endGlobal.right, endpoints.endGlobal.bottom)), TextDirection.ltr, ); final overlay = _selectionOverlay; @@ -301,8 +307,10 @@ class MarkdownSelectionScopeState extends State { void _applyHandles(MarkdownHandleEndpoints? e) { final startSurface = e?.startSurface; final endSurface = e?.endSurface; - final startLocal = e == null ? null : Offset(e.startLocal.left, e.startLocal.bottom); - final endLocal = e == null ? null : Offset(e.endLocal.right, e.endLocal.bottom); + final startLocal = + e == null ? null : Offset(e.startLocal.left, e.startLocal.bottom); + final endLocal = + e == null ? null : Offset(e.endLocal.right, e.endLocal.bottom); for (final surface in controller.mountedSurfaces) { final isStart = identical(surface, startSurface); final isEnd = identical(surface, endSurface); @@ -323,7 +331,8 @@ class MarkdownSelectionScopeState extends State { } void _onHandleDragStart(DragStartDetails d, {required bool isStart}) { - _selectionOverlay?.showMagnifier(_magnifierInfo(d.globalPosition, isStart: isStart)); + _selectionOverlay + ?.showMagnifier(_magnifierInfo(d.globalPosition, isStart: isStart)); } void _onHandleDragUpdate(DragUpdateDetails d, {required bool isStart}) { @@ -466,8 +475,10 @@ class MarkdownSelectionScopeState extends State { controller.startAtGlobal(globalPosition); } - Map get _gestures => { - PanGestureRecognizer: GestureRecognizerFactoryWithHandlers( + Map get _gestures => + { + PanGestureRecognizer: + GestureRecognizerFactoryWithHandlers( () => PanGestureRecognizer( supportedDevices: const { PointerDeviceKind.mouse, @@ -490,11 +501,13 @@ class MarkdownSelectionScopeState extends State { ((d) => controller.extendToGlobal(d.globalPosition)) ..onLongPressEnd = ((_) => showToolbar()), ), - TapGestureRecognizer: GestureRecognizerFactoryWithHandlers( + TapGestureRecognizer: + GestureRecognizerFactoryWithHandlers( () => TapGestureRecognizer(), (recognizer) => recognizer ..onTapDown = ((_) => hideToolbar()) - ..onSecondaryTapDown = ((d) => _lastSecondaryTapDown = d.globalPosition) + ..onSecondaryTapDown = + ((d) => _lastSecondaryTapDown = d.globalPosition) ..onSecondaryTapUp = ((d) { _focusNode.requestFocus(); showToolbar(d.globalPosition); @@ -515,7 +528,8 @@ class MarkdownSelectionScopeState extends State { return null; }, ), - ExtendSelectionByCharacterIntent: CallbackAction( + ExtendSelectionByCharacterIntent: + CallbackAction( onInvoke: (intent) { if (!intent.collapseSelection) { controller.extendSelectionByCharacter(forward: intent.forward); @@ -532,7 +546,8 @@ class MarkdownSelectionScopeState extends State { return null; }, ), - ExtendSelectionToLineBreakIntent: CallbackAction( + ExtendSelectionToLineBreakIntent: + CallbackAction( onInvoke: (intent) { if (!intent.collapseSelection) { controller.extendSelectionToLineBreak(forward: intent.forward); diff --git a/lib/src/theme.dart b/lib/src/theme.dart index 0bebefc..60be404 100644 --- a/lib/src/theme.dart +++ b/lib/src/theme.dart @@ -74,7 +74,8 @@ class MarkdownThemeData implements ThemeExtension { h6Style: h6Style ?? theme.textTheme.titleSmall, quoteStyle: quoteStyle ?? theme.textTheme.bodyMedium?.copyWith( - color: theme.textTheme.bodyMedium?.color?.withValues(alpha: 0.75)), + color: + theme.textTheme.bodyMedium?.color?.withValues(alpha: 0.75)), linkColor: linkColor ?? theme.colorScheme.primary, linkStyle: linkStyle, surfaceColor: surfaceColor ?? theme.colorScheme.surfaceContainerHigh, @@ -149,7 +150,8 @@ class MarkdownThemeData implements ThemeExtension { final Map? alertColors; /// The default GitHub-style accent color for each alert type (light theme). - static const Map _defaultAlertColors = { + static const Map _defaultAlertColors = + { MD$AlertType.note: Color(0xFF0969DA), // blue MD$AlertType.tip: Color(0xFF1A7F37), // green MD$AlertType.important: Color(0xFF8250DF), // purple @@ -245,9 +247,11 @@ class MarkdownThemeData implements ThemeExtension { var s when s.contains(MD$Style.highlight) => FontWeight.bold, _ => null, }, - fontStyle: style.contains(MD$Style.italic) ? FontStyle.italic : null, + fontStyle: + style.contains(MD$Style.italic) ? FontStyle.italic : null, decoration: switch (style) { - var s when s.contains(MD$Style.underline) => TextDecoration.underline, + var s when s.contains(MD$Style.underline) => + TextDecoration.underline, var s when s.contains(MD$Style.strikethrough) => TextDecoration.lineThrough, _ => null, @@ -258,8 +262,10 @@ class MarkdownThemeData implements ThemeExtension { _ => null, }, backgroundColor: switch (style) { - var s when s.contains(MD$Style.highlight) => highlightBackgroundColor, - var s when s.contains(MD$Style.monospace) => monospaceBackgroundColor, + var s when s.contains(MD$Style.highlight) => + highlightBackgroundColor, + var s when s.contains(MD$Style.monospace) => + monospaceBackgroundColor, _ => null, }, ); @@ -329,8 +335,10 @@ class MarkdownThemeData implements ThemeExtension { if (identical(this, other)) return this; return MarkdownThemeData( - textDirection: t < 0.5 ? textDirection : other?.textDirection ?? TextDirection.ltr, - textScaler: t < 0.5 ? textScaler : other?.textScaler ?? TextScaler.noScaling, + textDirection: + t < 0.5 ? textDirection : other?.textDirection ?? TextDirection.ltr, + textScaler: + t < 0.5 ? textScaler : other?.textScaler ?? TextScaler.noScaling, textStyle: TextStyle.lerp(textStyle, other?.textStyle, t)!, h1Style: TextStyle.lerp(h1Style, other?.h1Style, t), h2Style: TextStyle.lerp(h2Style, other?.h2Style, t), @@ -342,10 +350,10 @@ class MarkdownThemeData implements ThemeExtension { linkColor: Color.lerp(linkColor, other?.linkColor, t), linkStyle: TextStyle.lerp(linkStyle, other?.linkStyle, t), surfaceColor: Color.lerp(surfaceColor, other?.surfaceColor, t), - highlightBackgroundColor: - Color.lerp(highlightBackgroundColor, other?.highlightBackgroundColor, t), - monospaceBackgroundColor: - Color.lerp(monospaceBackgroundColor, other?.monospaceBackgroundColor, t), + highlightBackgroundColor: Color.lerp( + highlightBackgroundColor, other?.highlightBackgroundColor, t), + monospaceBackgroundColor: Color.lerp( + monospaceBackgroundColor, other?.monospaceBackgroundColor, t), dividerColor: Color.lerp(dividerColor, other?.dividerColor, t), alertColors: t < 0.5 ? alertColors : other?.alertColors, blockFilter: t < 0.5 ? blockFilter : other?.blockFilter, @@ -373,9 +381,11 @@ class MarkdownTheme extends InheritedWidget { /// The state from the closest instance of this class /// that encloses the given context, if any. /// e.g. `Theme.maybeOf(context)`. - static MarkdownThemeData? maybeOf(BuildContext context, {bool listen = true}) => listen - ? context.dependOnInheritedWidgetOfExactType()?.data - : context.getInheritedWidgetOfExactType()?.data; + static MarkdownThemeData? maybeOf(BuildContext context, + {bool listen = true}) => + listen + ? context.dependOnInheritedWidgetOfExactType()?.data + : context.getInheritedWidgetOfExactType()?.data; static Never _notFoundInheritedWidgetOfExactType() => throw ArgumentError( 'Out of scope, not found inherited widget ' diff --git a/lib/src/widget.dart b/lib/src/widget.dart index 05493c9..1d65e8a 100644 --- a/lib/src/widget.dart +++ b/lib/src/widget.dart @@ -40,7 +40,8 @@ class MarkdownWidget extends LeafRenderObjectWidget { MarkdownThemeData( textStyle: DefaultTextStyle.of(context).style, textDirection: Directionality.maybeOf(context) ?? TextDirection.ltr, - textScaler: MediaQuery.maybeTextScalerOf(context) ?? TextScaler.noScaling, + textScaler: + MediaQuery.maybeTextScalerOf(context) ?? TextScaler.noScaling, ); MarkdownSelectionController? _resolveController(BuildContext context) => diff --git a/test/parser/block_test.dart b/test/parser/block_test.dart index 7df8f67..652cce4 100644 --- a/test/parser/block_test.dart +++ b/test/parser/block_test.dart @@ -28,7 +28,8 @@ void main() => group('Block parsing', () { }); test('trailing hashes are stripped', () { - expect((_blocks('## Heading ##').single as MD$Heading).text, 'Heading'); + expect( + (_blocks('## Heading ##').single as MD$Heading).text, 'Heading'); expect((_blocks('### Title ###').single as MD$Heading).text, 'Title'); }); @@ -57,14 +58,16 @@ void main() => group('Block parsing', () { final q = _blocks('> quote with **bold**').single as MD$Quote; expect( q.spans, - contains(isA().having((s) => s.style, 'style', MD$Style.bold)), + contains( + isA().having((s) => s.style, 'style', MD$Style.bold)), ); }); }); group('Fenced code', () { test('backtick fence with language', () { - final code = _blocks('```dart\nvoid main() {}\n```').single as MD$Code; + final code = + _blocks('```dart\nvoid main() {}\n```').single as MD$Code; expect(code.language, 'dart'); expect(code.text, 'void main() {}'); }); @@ -76,8 +79,8 @@ void main() => group('Block parsing', () { }); test('code content is never interpreted as markdown', () { - final code = - _blocks('```\n# not a heading\n- not a list\n```').single as MD$Code; + final code = _blocks('```\n# not a heading\n- not a list\n```').single + as MD$Code; expect(code.text, '# not a heading\n- not a list'); }); @@ -123,7 +126,8 @@ void main() => group('Block parsing', () { final list = _blocks('- item with *italic*').single as MD$List; expect( list.items.single.spans, - contains(isA().having((s) => s.style, 'style', MD$Style.italic)), + contains(isA() + .having((s) => s.style, 'style', MD$Style.italic)), ); }); @@ -131,7 +135,8 @@ void main() => group('Block parsing', () { final list = _blocks('- see [docs](https://x.com)').single as MD$List; expect( list.items.single.spans, - contains(isA().having((s) => s.style, 'style', MD$Style.link)), + contains( + isA().having((s) => s.style, 'style', MD$Style.link)), ); }); }); @@ -157,7 +162,8 @@ void main() => group('Block parsing', () { final firstCell = table.rows.single.cells.first; expect( firstCell, - contains(isA().having((s) => s.style, 'style', MD$Style.bold)), + contains( + isA().having((s) => s.style, 'style', MD$Style.bold)), ); }); diff --git a/test/parser/edge_cases_test.dart b/test/parser/edge_cases_test.dart index 9ce9f68..a4b1150 100644 --- a/test/parser/edge_cases_test.dart +++ b/test/parser/edge_cases_test.dart @@ -62,7 +62,8 @@ void main() => group('Edge cases & robustness', () { test('emphasis works around unicode content', () { final md = markdownDecoder.convert('**жирный**'); - expect((md.blocks.single as MD$Paragraph).spans.single.style, MD$Style.bold); + expect((md.blocks.single as MD$Paragraph).spans.single.style, + MD$Style.bold); }); }); @@ -103,7 +104,8 @@ void main() => group('Edge cases & robustness', () { }); test('a lone hash line with text after space is a heading', () { - expect(markdownDecoder.convert('# ok').blocks.single, isA()); + expect( + markdownDecoder.convert('# ok').blocks.single, isA()); }); }); }); diff --git a/test/parser/gfm_test.dart b/test/parser/gfm_test.dart index f62103e..81da1c9 100644 --- a/test/parser/gfm_test.dart +++ b/test/parser/gfm_test.dart @@ -15,15 +15,16 @@ void main() => group('GFM extensions', () { 'CAUTION': MD$AlertType.caution, }; for (final entry in cases.entries) { - final md = - markdownDecoder.convert('> [!${entry.key}]\n> Body of the alert.'); + final md = markdownDecoder + .convert('> [!${entry.key}]\n> Body of the alert.'); expect(md.blocks, hasLength(1), reason: 'alert ${entry.key} is a single block'); expect( md.blocks.single, isA() .having((a) => a.alert, 'alert', entry.value) - .having((a) => _spanText(a.spans), 'body', 'Body of the alert.'), + .having( + (a) => _spanText(a.spans), 'body', 'Body of the alert.'), ); } }); @@ -103,21 +104,24 @@ void main() => group('GFM extensions', () { test('checked task item (lowercase and uppercase x)', () { for (final input in ['- [x] done', '- [X] done']) { - final list = markdownDecoder.convert(input).blocks.single as MD$List; + final list = + markdownDecoder.convert(input).blocks.single as MD$List; expect(list.items.single.checked, isTrue); expect(list.items.single.text, 'done'); } }); test('empty brackets are not a task item', () { - final list = markdownDecoder.convert('- [] literal').blocks.single as MD$List; + final list = + markdownDecoder.convert('- [] literal').blocks.single as MD$List; expect(list.items.single.checked, isNull); expect(list.items.single.isTask, isFalse); expect(list.items.single.text, '[] literal'); }); test('checkbox with no label yields empty text', () { - final list = markdownDecoder.convert('- [ ]').blocks.single as MD$List; + final list = + markdownDecoder.convert('- [ ]').blocks.single as MD$List; expect(list.items.single.checked, isFalse); expect(list.items.single.text, isEmpty); }); @@ -127,7 +131,8 @@ void main() => group('GFM extensions', () { .convert('- [ ] a\n- [x] b\n- normal') .blocks .single as MD$List; - expect(list.items.map((i) => i.checked).toList(), [false, true, null]); + expect(list.items.map((i) => i.checked).toList(), + [false, true, null]); }); test('nested task items keep their state', () { @@ -145,37 +150,46 @@ void main() => group('GFM extensions', () { .convert('1. [x] first\n2. [ ] second') .blocks .single as MD$List; - expect(list.items.map((i) => i.checked).toList(), [true, false]); + expect( + list.items.map((i) => i.checked).toList(), [true, false]); }); }); group('Thematic breaks', () { for (final input in ['---', '***', '___', '- - -', '* * *', '_ _ _']) { test('"$input" is a divider', () { - expect(markdownDecoder.convert(input).blocks.single, isA()); + expect(markdownDecoder.convert(input).blocks.single, + isA()); }); } test('four or more markers still form a divider', () { - expect(markdownDecoder.convert('----').blocks.single, isA()); - expect(markdownDecoder.convert('**********').blocks.single, isA()); + expect( + markdownDecoder.convert('----').blocks.single, isA()); + expect(markdownDecoder.convert('**********').blocks.single, + isA()); }); test('marker followed by text is NOT a divider', () { - expect(markdownDecoder.convert('----text').blocks.single, isA()); + expect(markdownDecoder.convert('----text').blocks.single, + isA()); }); test('fewer than three markers is NOT a divider', () { - expect(markdownDecoder.convert('--').blocks.single, isA()); - expect(markdownDecoder.convert('**').blocks.single, isA()); + expect( + markdownDecoder.convert('--').blocks.single, isA()); + expect( + markdownDecoder.convert('**').blocks.single, isA()); }); test('mixed markers are NOT a divider', () { - expect(markdownDecoder.convert('-*-').blocks.single, isA()); + expect(markdownDecoder.convert('-*-').blocks.single, + isA()); }); test('up to three leading spaces are allowed', () { - expect(markdownDecoder.convert(' ---').blocks.single, isA()); + expect(markdownDecoder.convert(' ---').blocks.single, + isA()); }); }); @@ -195,14 +209,17 @@ void main() => group('GFM extensions', () { }); test('plain dashes yield no alignment', () { - final table = markdownDecoder.convert('|a|b|\n|---|---|\n|1|2|').blocks.single - as MD$Table; - expect(table.alignments, everyElement(equals(MD$TableColumnAlign.none))); + final table = markdownDecoder + .convert('|a|b|\n|---|---|\n|1|2|') + .blocks + .single as MD$Table; + expect( + table.alignments, everyElement(equals(MD$TableColumnAlign.none))); }); test('alignmentFor is safe for out-of-range indices', () { - final table = - markdownDecoder.convert('|a|\n|:-:|\n|1|').blocks.single as MD$Table; + final table = markdownDecoder.convert('|a|\n|:-:|\n|1|').blocks.single + as MD$Table; expect(table.alignmentFor(5), MD$TableColumnAlign.none); expect(table.alignmentFor(-1), MD$TableColumnAlign.none); }); diff --git a/test/parser/inline_test.dart b/test/parser/inline_test.dart index 022ea8e..04abf79 100644 --- a/test/parser/inline_test.dart +++ b/test/parser/inline_test.dart @@ -77,8 +77,10 @@ void main() => group('Inline parsing', () { expect(_styleOf(spans, 'You').contains(MD$Style.monospace), isTrue); expect(_styleOf(spans, 'You').contains(MD$Style.italic), isTrue); expect(_styleOf(spans, 'can').contains(MD$Style.bold), isTrue); - expect(_styleOf(spans, 'combine').contains(MD$Style.underline), isTrue); - expect(_styleOf(spans, 'them').contains(MD$Style.strikethrough), isTrue); + expect( + _styleOf(spans, 'combine').contains(MD$Style.underline), isTrue); + expect( + _styleOf(spans, 'them').contains(MD$Style.strikethrough), isTrue); }); }); @@ -140,7 +142,8 @@ void main() => group('Inline parsing', () { final spans = _spans('[text](https://example.com)'); expect(spans.single.style, MD$Style.link); expect(spans.single.text, 'text'); - expect(spans.single.extra, containsPair('url', 'https://example.com')); + expect( + spans.single.extra, containsPair('url', 'https://example.com')); }); test('link with double-quoted title', () { @@ -161,13 +164,15 @@ void main() => group('Inline parsing', () { test('url containing balanced parentheses', () { final spans = _spans('[a](https://x.com/a_(b)_c)'); - expect(spans.single.extra, containsPair('url', 'https://x.com/a_(b)_c')); + expect( + spans.single.extra, containsPair('url', 'https://x.com/a_(b)_c')); }); test('image exposes src and image style', () { final spans = _spans('![alt](https://x.com/i.png)'); expect(spans.single.style, MD$Style.image); - expect(spans.single.extra, containsPair('src', 'https://x.com/i.png')); + expect( + spans.single.extra, containsPair('src', 'https://x.com/i.png')); }); test('emphasis wrapping a link merges styles', () { diff --git a/test/parser/math_test.dart b/test/parser/math_test.dart index 1aa1744..862fa06 100644 --- a/test/parser/math_test.dart +++ b/test/parser/math_test.dart @@ -106,12 +106,14 @@ void main() { }); test('text around code is still converted', () { - final rendered = _spans(r'$\alpha$ `$\beta$` $\gamma$').map((s) => s.text).join(); + final rendered = + _spans(r'$\alpha$ `$\beta$` $\gamma$').map((s) => s.text).join(); expect(rendered, r'α $\beta$ γ'); }); test('fenced code block is never converted', () { - final code = _math.convert('```\n\$\\alpha\$\n```').blocks.single as MD$Code; + final code = + _math.convert('```\n\$\\alpha\$\n```').blocks.single as MD$Code; expect(code.text, r'$\alpha$'); }); }); @@ -175,7 +177,8 @@ void main() { ]) { final spans = _spans(input); for (var i = 0; i < spans.length; i++) { - expect(spans[i].start, lessThanOrEqualTo(spans[i].end), reason: input); + expect(spans[i].start, lessThanOrEqualTo(spans[i].end), + reason: input); if (i > 0) { expect(spans[i].start, greaterThanOrEqualTo(spans[i - 1].start), reason: input); diff --git a/test/parser/parser_test.dart b/test/parser/parser_test.dart index 89100f2..c6a6ee1 100644 --- a/test/parser/parser_test.dart +++ b/test/parser/parser_test.dart @@ -358,7 +358,8 @@ void main() => group('Parse', () { allOf( isA>(), isNotEmpty, - containsPair('url', 'https://example.com/image.jpg'), + containsPair( + 'url', 'https://example.com/image.jpg'), ), ), ), @@ -405,7 +406,8 @@ void main() => group('Parse', () { final codeSpan = spans[i]; expect(codeSpan.text, expectedText); expect(codeSpan.style, MD$Style.monospace, - reason: 'Span for "$expectedText" should only have monospace style'); + reason: + 'Span for "$expectedText" should only have monospace style'); } }); diff --git a/test/parser/regression_test.dart b/test/parser/regression_test.dart index 63051e3..a218f68 100644 --- a/test/parser/regression_test.dart +++ b/test/parser/regression_test.dart @@ -115,7 +115,8 @@ void main() { expect(_text(para.spans), '_italic_ at the start'.replaceAll('_', '')); expect( para.spans, - contains(isA().having((s) => s.style, 'style', MD$Style.italic)), + contains( + isA().having((s) => s.style, 'style', MD$Style.italic)), ); }); @@ -175,8 +176,8 @@ void main() { }); test('image carries the src key and image style', () { - final span = - _spans('![alt](https://x.io/i.png)').firstWhere((s) => s.extra != null); + final span = _spans('![alt](https://x.io/i.png)') + .firstWhere((s) => s.extra != null); expect(span.style.contains(MD$Style.image), isTrue); expect(span.extra?['src'], 'https://x.io/i.png'); }); @@ -225,8 +226,8 @@ void main() { }); test('currency is unchanged', () { - expect( - _text(_spans(r'It costs $5 and $10 today.')), r'It costs $5 and $10 today.'); + expect(_text(_spans(r'It costs $5 and $10 today.')), + r'It costs $5 and $10 today.'); }); test(r'escaped dollar \$ becomes a literal dollar', () { @@ -235,7 +236,8 @@ void main() { }); group('Link & emphasis edge cases', () { - MD$Span linkOf(String input) => _spans(input).firstWhere((s) => s.extra != null); + MD$Span linkOf(String input) => + _spans(input).firstWhere((s) => s.extra != null); test('unterminated angle-bracket url keeps the rest as url', () { expect(linkOf('[t]( _mouseDrag(WidgetTester tester, Offset from, Offset to) async { await tester.pumpAndSettle(); } -Widget _wrap(MarkdownSelectionController controller, Widget child) => MaterialApp( +Widget _wrap(MarkdownSelectionController controller, Widget child) => + MaterialApp( home: Scaffold( body: MarkdownSelectionScope(controller: controller, child: child), ), @@ -33,10 +34,12 @@ class _Doc extends StatelessWidget { void main() { group('selection handles', () { - testWidgets('moveSelectionEdgeToGlobal adjusts the moving edge', (tester) async { + testWidgets('moveSelectionEdgeToGlobal adjusts the moving edge', + (tester) async { final md = Markdown.fromString('Hello selectable world'); final controller = MarkdownSelectionController() - ..setDocuments([MarkdownDocumentRef(id: 'd', model: md)]); + ..setDocuments( + [MarkdownDocumentRef(id: 'd', model: md)]); await tester.pumpWidget(_wrap( controller, @@ -53,7 +56,8 @@ void main() { // Pull the end edge back to near the start of the line. final tl = tester.getTopLeft(find.byType(MarkdownWidget)); - controller.moveSelectionEdgeToGlobal(tl + const Offset(40, 8), isStart: false); + controller.moveSelectionEdgeToGlobal(tl + const Offset(40, 8), + isStart: false); await tester.pump(); final text = controller.getText(); @@ -66,7 +70,8 @@ void main() { debugDefaultTargetPlatformOverride = TargetPlatform.android; final md = Markdown.fromString('Hello selectable world'); final controller = MarkdownSelectionController() - ..setDocuments([MarkdownDocumentRef(id: 'd', model: md)]); + ..setDocuments( + [MarkdownDocumentRef(id: 'd', model: md)]); await tester.pumpWidget(_wrap( controller, @@ -79,7 +84,8 @@ void main() { 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)); + await _mouseDrag( + tester, tl + const Offset(1, 3), br - const Offset(1, 3)); expect(controller.getText(), isNotEmpty); // Two handles are composited to follow the content. @@ -92,7 +98,8 @@ void main() { debugDefaultTargetPlatformOverride = TargetPlatform.linux; final md = Markdown.fromString('Hello selectable world'); final controller = MarkdownSelectionController() - ..setDocuments([MarkdownDocumentRef(id: 'd', model: md)]); + ..setDocuments( + [MarkdownDocumentRef(id: 'd', model: md)]); await tester.pumpWidget(_wrap( controller, @@ -105,7 +112,8 @@ void main() { 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)); + await _mouseDrag( + tester, tl + const Offset(1, 3), br - const Offset(1, 3)); expect(controller.getText(), isNotEmpty); expect(find.byType(CompositedTransformFollower), findsNothing); diff --git a/test/selection/selection_keyboard_test.dart b/test/selection/selection_keyboard_test.dart index 9bfbdf7..00551d2 100644 --- a/test/selection/selection_keyboard_test.dart +++ b/test/selection/selection_keyboard_test.dart @@ -38,7 +38,8 @@ void main() { debugDefaultTargetPlatformOverride = TargetPlatform.linux; final md = Markdown.fromString('Hello keyboard world'); final controller = MarkdownSelectionController() - ..setDocuments([MarkdownDocumentRef(id: 'd', model: md)]); + ..setDocuments( + [MarkdownDocumentRef(id: 'd', model: md)]); final focus = FocusNode(); addTearDown(focus.dispose); @@ -64,7 +65,8 @@ void main() { debugDefaultTargetPlatformOverride = TargetPlatform.linux; final md = Markdown.fromString('Hello keyboard world'); final controller = MarkdownSelectionController() - ..setDocuments([MarkdownDocumentRef(id: 'd', model: md)]) + ..setDocuments( + [MarkdownDocumentRef(id: 'd', model: md)]) ..selectAll(); final focus = FocusNode(); addTearDown(focus.dispose); @@ -86,11 +88,13 @@ void main() { expect(controller.selection, isNull); }); - testWidgets('Shift+ArrowRight extends the selection by a character', (tester) async { + testWidgets('Shift+ArrowRight extends the selection by a character', + (tester) async { debugDefaultTargetPlatformOverride = TargetPlatform.linux; final md = Markdown.fromString('Hello'); final controller = MarkdownSelectionController() - ..setDocuments([MarkdownDocumentRef(id: 'd', model: md)]) + ..setDocuments( + [MarkdownDocumentRef(id: 'd', model: md)]) ..selection = const MarkdownSelection.collapsed( MarkdownPosition(documentId: 'd', blockIndex: 0, offset: 0)); final focus = FocusNode(); @@ -119,7 +123,8 @@ void main() { debugDefaultTargetPlatformOverride = TargetPlatform.linux; final md = Markdown.fromString('Copy this text'); final controller = MarkdownSelectionController() - ..setDocuments([MarkdownDocumentRef(id: 'd', model: md)]) + ..setDocuments( + [MarkdownDocumentRef(id: 'd', model: md)]) ..selectAll(); final focus = FocusNode(); addTearDown(focus.dispose); @@ -156,11 +161,13 @@ void main() { }); group('context toolbar', () { - testWidgets('right-click over a selection shows a Copy button', (tester) async { + testWidgets('right-click over a selection shows a Copy button', + (tester) async { debugDefaultTargetPlatformOverride = TargetPlatform.linux; final md = Markdown.fromString('Toolbar target text'); final controller = MarkdownSelectionController() - ..setDocuments([MarkdownDocumentRef(id: 'd', model: md)]) + ..setDocuments( + [MarkdownDocumentRef(id: 'd', model: md)]) ..selectAll(); await tester.pumpWidget(_wrap( @@ -185,7 +192,8 @@ void main() { debugDefaultTargetPlatformOverride = TargetPlatform.linux; final md = Markdown.fromString('State driven toolbar'); final controller = MarkdownSelectionController() - ..setDocuments([MarkdownDocumentRef(id: 'd', model: md)]) + ..setDocuments( + [MarkdownDocumentRef(id: 'd', model: md)]) ..selectAll(); await tester.pumpWidget(_wrap( @@ -194,8 +202,8 @@ void main() { )); await tester.pumpAndSettle(); - final state = - tester.state(find.byType(MarkdownSelectionScope)); + final state = tester.state( + find.byType(MarkdownSelectionScope)); state.showToolbar(); await tester.pumpAndSettle(); expect(find.text('Copy'), findsOneWidget); diff --git a/test/selection/selection_test.dart b/test/selection/selection_test.dart index a9cb9a0..08d2a9f 100644 --- a/test/selection/selection_test.dart +++ b/test/selection/selection_test.dart @@ -83,7 +83,8 @@ void main() { test('reconcile: append keeps the anchor verbatim', () { final c = two()..selection = full; final before = c.getText(); - c.putDocument('b', Markdown.fromString('Bravo one\n\nBravo two three\n\nEnd')); + c.putDocument( + 'b', Markdown.fromString('Bravo one\n\nBravo two three\n\nEnd')); expect(c.selection!.extent.blockIndex, 2); expect(c.selection!.extent.offset, 9); expect(c.getText(), before); @@ -91,13 +92,15 @@ void main() { test('reconcile: content-anchored survives a front-insert', () { final c = MarkdownSelectionController() - ..setDocuments([MarkdownDocumentRef(id: 'b', model: docB)]) + ..setDocuments( + [MarkdownDocumentRef(id: 'b', model: docB)]) ..selection = const MarkdownSelection( base: MarkdownPosition(documentId: 'b', blockIndex: 2, offset: 0), extent: MarkdownPosition(documentId: 'b', blockIndex: 2, offset: 9), ); expect(c.getText(), 'Bravo two'); - c.putDocument('b', Markdown.fromString('HEADER\n\nBravo one\n\nBravo two')); + c.putDocument( + 'b', Markdown.fromString('HEADER\n\nBravo one\n\nBravo two')); expect(c.getText(), 'Bravo two'); // relocated by content, not index }); @@ -105,13 +108,15 @@ void main() { final c = MarkdownSelectionController( reconciliation: const MarkdownReconciliationPolicy.appendFastPath(), ) - ..setDocuments([MarkdownDocumentRef(id: 'b', model: docB)]) + ..setDocuments( + [MarkdownDocumentRef(id: 'b', model: docB)]) ..selection = const MarkdownSelection( base: MarkdownPosition(documentId: 'b', blockIndex: 0, offset: 0), extent: MarkdownPosition(documentId: 'b', blockIndex: 0, offset: 9), ); expect(c.getText(), 'Bravo one'); - c.putDocument('b', Markdown.fromString('HEADER LINE\n\nBravo one\n\nBravo two')); + c.putDocument( + 'b', Markdown.fromString('HEADER LINE\n\nBravo one\n\nBravo two')); expect(c.getText(), isNot('Bravo one')); // clamped to new block 0 }); @@ -119,7 +124,8 @@ void main() { final c = MarkdownSelectionController( reconciliation: const MarkdownReconciliationPolicy.clearOnChange(), ) - ..setDocuments([MarkdownDocumentRef(id: 'b', model: docB)]) + ..setDocuments( + [MarkdownDocumentRef(id: 'b', model: docB)]) ..selection = const MarkdownSelection( base: MarkdownPosition(documentId: 'b', blockIndex: 0, offset: 0), extent: MarkdownPosition(documentId: 'b', blockIndex: 2, offset: 9), diff --git a/test/selection/selection_widget_test.dart b/test/selection/selection_widget_test.dart index ef3bd98..b2ff0f5 100644 --- a/test/selection/selection_widget_test.dart +++ b/test/selection/selection_widget_test.dart @@ -12,7 +12,8 @@ Future _mouseDrag(WidgetTester tester, Offset from, Offset to) async { await tester.pumpAndSettle(); } -Widget _wrap(MarkdownSelectionController controller, Widget child) => MaterialApp( +Widget _wrap(MarkdownSelectionController controller, Widget child) => + MaterialApp( home: Scaffold( body: MarkdownSelectionScope(controller: controller, child: child), ), @@ -23,7 +24,8 @@ void main() { testWidgets('drag selects a single paragraph', (tester) async { final md = Markdown.fromString('Hello selectable world'); final controller = MarkdownSelectionController() - ..setDocuments([MarkdownDocumentRef(id: 'd', model: md)]); + ..setDocuments( + [MarkdownDocumentRef(id: 'd', model: md)]); await tester.pumpWidget(_wrap( controller, @@ -36,7 +38,8 @@ void main() { 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)); + await _mouseDrag( + tester, tl + const Offset(1, 3), br - const Offset(1, 3)); expect(controller.getText(), 'Hello selectable world'); expect(tester.takeException(), isNull); @@ -79,7 +82,8 @@ void main() { expect(tester.takeException(), isNull); }); - testWidgets('cross-widget selection survives ListView disposal', (tester) async { + testWidgets('cross-widget selection survives ListView disposal', + (tester) async { final controller = MarkdownSelectionController() ..setDocuments([ for (var i = 0; i < 10; i++) @@ -108,8 +112,8 @@ void main() { )); await tester.pumpAndSettle(); - final p0 = - tester.getTopLeft(find.byType(MarkdownWidget).first) + const Offset(1, 3); + final p0 = tester.getTopLeft(find.byType(MarkdownWidget).first) + + const Offset(1, 3); final p1 = tester.getCenter(find.byType(MarkdownWidget).at(1)); await _mouseDrag(tester, p0, p1); final before = controller.getText(); @@ -118,7 +122,9 @@ void main() { scroll.jumpTo(80.0 * 7); // dispose the first messages await tester.pumpAndSettle(); - expect(find.byWidgetPredicate((w) => w is MarkdownWidget && w.documentId == 'm0'), + expect( + find.byWidgetPredicate( + (w) => w is MarkdownWidget && w.documentId == 'm0'), findsNothing); // Text is derived from the model registry → intact after disposal. @@ -127,14 +133,17 @@ void main() { expect(tester.takeException(), isNull); }); - testWidgets('selecting in one controller clears the other (group)', (tester) async { + testWidgets('selecting in one controller clears the other (group)', + (tester) async { final group = MarkdownSelectionGroup(); final a = Markdown.fromString('Alpha body text'); final b = Markdown.fromString('Bravo body text'); final ca = MarkdownSelectionController(group: group) - ..setDocuments([MarkdownDocumentRef(id: 'a', model: a)]); + ..setDocuments( + [MarkdownDocumentRef(id: 'a', model: a)]); final cb = MarkdownSelectionController(group: group) - ..setDocuments([MarkdownDocumentRef(id: 'b', model: b)]); + ..setDocuments( + [MarkdownDocumentRef(id: 'b', model: b)]); await tester.pumpWidget(MaterialApp( home: Scaffold( @@ -156,8 +165,8 @@ void main() { await tester.pumpAndSettle(); // Select in controller B first. - final bw = - find.byWidgetPredicate((w) => w is MarkdownWidget && w.documentId == 'b'); + final bw = find + .byWidgetPredicate((w) => w is MarkdownWidget && w.documentId == 'b'); await _mouseDrag( tester, tester.getTopLeft(bw) + const Offset(1, 3), @@ -166,22 +175,25 @@ void main() { expect(cb.getText(), isNotEmpty); // Now select in controller A — B must be cleared. - final aw = - find.byWidgetPredicate((w) => w is MarkdownWidget && w.documentId == 'a'); + final aw = find + .byWidgetPredicate((w) => w is MarkdownWidget && w.documentId == 'a'); await _mouseDrag( tester, tester.getTopLeft(aw) + const Offset(1, 3), tester.getBottomRight(aw) - const Offset(1, 3), ); expect(ca.getText(), isNotEmpty); - expect(cb.selection, isNull, reason: 'group cleared the other controller'); + expect(cb.selection, isNull, + reason: 'group cleared the other controller'); expect(tester.takeException(), isNull); }); - testWidgets('MarkdownWidget without documentId stays inert', (tester) async { + testWidgets('MarkdownWidget without documentId stays inert', + (tester) async { final md = Markdown.fromString('Not selectable here'); final controller = MarkdownSelectionController() - ..setDocuments([MarkdownDocumentRef(id: 'x', model: md)]); + ..setDocuments( + [MarkdownDocumentRef(id: 'x', model: md)]); await tester.pumpWidget(_wrap( controller, SizedBox(width: 400, child: MarkdownWidget(markdown: md)), @@ -190,7 +202,8 @@ void main() { 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)); + await _mouseDrag( + tester, tl + const Offset(1, 3), br - const Offset(1, 3)); expect(controller.getText(), '', reason: 'no documentId => inert'); expect(tester.takeException(), isNull); @@ -215,12 +228,16 @@ void main() { expect(controller.getText(), ''); }); - testWidgets('drag past an empty document still selects a real one', (tester) async { + testWidgets('drag past an empty document still selects a real one', + (tester) async { final controller = MarkdownSelectionController() ..setDocuments([ - const MarkdownDocumentRef(id: 'empty', model: Markdown.empty(), order: 0), + const MarkdownDocumentRef( + id: 'empty', model: Markdown.empty(), order: 0), MarkdownDocumentRef( - id: 'real', model: Markdown.fromString('Real content here'), order: 1), + id: 'real', + model: Markdown.fromString('Real content here'), + order: 1), ]); await tester.pumpWidget(_wrap( controller, @@ -237,8 +254,8 @@ void main() { )); await tester.pumpAndSettle(); - final realWidget = - find.byWidgetPredicate((w) => w is MarkdownWidget && w.documentId == 'real'); + final realWidget = find.byWidgetPredicate( + (w) => w is MarkdownWidget && w.documentId == 'real'); await _mouseDrag( tester, tester.getTopLeft(realWidget) + const Offset(1, 3), @@ -249,10 +266,12 @@ void main() { }); testWidgets('drag selects the cells of a table', (tester) async { - final md = Markdown.fromString('| A | B |\n|---|---|\n| 1 | 2 |\n| 3 | 4 |'); + final md = + Markdown.fromString('| A | B |\n|---|---|\n| 1 | 2 |\n| 3 | 4 |'); final table = md.blocks.firstWhere((b) => b.type == 'table'); final controller = MarkdownSelectionController() - ..setDocuments([MarkdownDocumentRef(id: 'd', model: md)]); + ..setDocuments( + [MarkdownDocumentRef(id: 'd', model: md)]); await tester.pumpWidget(_wrap( controller, @@ -265,7 +284,8 @@ void main() { 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)); + await _mouseDrag( + tester, tl + const Offset(2, 2), br - const Offset(2, 2)); expect(controller.getText(), markdownBlockRenderedText(table)); expect(controller.getText(), 'A\tB\n1\t2\n3\t4'); @@ -275,7 +295,8 @@ void main() { testWidgets('drag selects the items of a list', (tester) async { final md = Markdown.fromString('- alpha\n- beta\n- gamma'); final controller = MarkdownSelectionController() - ..setDocuments([MarkdownDocumentRef(id: 'd', model: md)]); + ..setDocuments( + [MarkdownDocumentRef(id: 'd', model: md)]); await tester.pumpWidget(_wrap( controller, @@ -288,17 +309,20 @@ void main() { 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)); + await _mouseDrag( + tester, tl + const Offset(2, 2), br - const Offset(2, 2)); expect(controller.getText(), 'alpha\nbeta\ngamma'); expect(tester.takeException(), isNull); }); - testWidgets('selection spanning a table includes its cells', (tester) async { + testWidgets('selection spanning a table includes its cells', + (tester) async { final md = Markdown.fromString( 'Intro line\n\n| A | B |\n|---|---|\n| 1 | 2 |\n\nOutro line'); final controller = MarkdownSelectionController() - ..setDocuments([MarkdownDocumentRef(id: 'd', model: md)]); + ..setDocuments( + [MarkdownDocumentRef(id: 'd', model: md)]); await tester.pumpWidget(_wrap( controller, @@ -311,7 +335,8 @@ void main() { 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)); + await _mouseDrag( + tester, tl + const Offset(2, 2), br - const Offset(2, 2)); expect(controller.getText(), 'Intro line\nA\tB\n1\t2\nOutro line'); expect(tester.takeException(), isNull); diff --git a/test/theme/theme_test.dart b/test/theme/theme_test.dart index a253cf2..aedde4f 100644 --- a/test/theme/theme_test.dart +++ b/test/theme/theme_test.dart @@ -9,9 +9,12 @@ void main() => group('MarkdownThemeData', () { group('Alert colors', () { test('defaults to the GitHub palette', () { final theme = base(); - expect(theme.alertColorFor(MD$AlertType.note), const Color(0xFF0969DA)); - expect(theme.alertColorFor(MD$AlertType.warning), const Color(0xFF9A6700)); - expect(theme.alertColorFor(MD$AlertType.caution), const Color(0xFFCF222E)); + expect( + theme.alertColorFor(MD$AlertType.note), const Color(0xFF0969DA)); + expect(theme.alertColorFor(MD$AlertType.warning), + const Color(0xFF9A6700)); + expect(theme.alertColorFor(MD$AlertType.caution), + const Color(0xFFCF222E)); }); test('respects overrides while keeping defaults for the rest', () { @@ -21,9 +24,11 @@ void main() => group('MarkdownThemeData', () { MD$AlertType.note: Color(0xFF123456), }, ); - expect(theme.alertColorFor(MD$AlertType.note), const Color(0xFF123456)); + expect( + theme.alertColorFor(MD$AlertType.note), const Color(0xFF123456)); // Unspecified types still use the default palette. - expect(theme.alertColorFor(MD$AlertType.tip), const Color(0xFF1A7F37)); + expect( + theme.alertColorFor(MD$AlertType.tip), const Color(0xFF1A7F37)); }); }); @@ -105,7 +110,8 @@ void main() => group('MarkdownThemeData', () { ), ); expect(derived.linkStyle?.color, Colors.purple); - expect(derived.alertColorFor(MD$AlertType.note), const Color(0xFF0969DA)); + expect( + derived.alertColorFor(MD$AlertType.note), const Color(0xFF0969DA)); }); group('headingStyleFor', () { @@ -196,7 +202,8 @@ void main() => group('MarkdownThemeData', () { test('updateShouldNotify compares data identity', () { final a = MarkdownTheme(data: base(), child: const SizedBox()); final same = MarkdownTheme(data: a.data, child: const SizedBox()); - final different = MarkdownTheme(data: base(), child: const SizedBox()); + final different = + MarkdownTheme(data: base(), child: const SizedBox()); expect(a.updateShouldNotify(same), isFalse); expect(a.updateShouldNotify(different), isTrue); }); diff --git a/test/widget/render_test.dart b/test/widget/render_test.dart index 57513c8..02bd4ce 100644 --- a/test/widget/render_test.dart +++ b/test/widget/render_test.dart @@ -139,8 +139,8 @@ void main() { '[styled](https://example.com)', theme: MarkdownThemeData( textStyle: const TextStyle(fontSize: 14), - linkStyle: - const TextStyle(color: Colors.red, decoration: TextDecoration.underline), + linkStyle: const TextStyle( + color: Colors.red, decoration: TextDecoration.underline), ), ); expect(tester.takeException(), isNull); From 022508eb7be14954b8c78f7931b025745f12a820 Mon Sep 17 00:00:00 2001 From: Mike Matiunin Date: Mon, 3 Aug 2026 15:08:31 +0400 Subject: [PATCH 15/30] Split render by blocks --- lib/flutter_md.dart | 20 +- lib/src/render.dart | 2220 +------------------- lib/src/render/block_painter.dart | 190 ++ lib/src/render/blocks/alert.dart | 174 ++ lib/src/render/blocks/code.dart | 98 + lib/src/render/blocks/divider.dart | 60 + lib/src/render/blocks/heading.dart | 94 + lib/src/render/blocks/list.dart | 203 ++ lib/src/render/blocks/paragraph.dart | 92 + lib/src/render/blocks/quote.dart | 135 ++ lib/src/render/blocks/spacer.dart | 56 + lib/src/render/blocks/table.dart | 420 ++++ lib/src/render/markdown_painter.dart | 396 ++++ lib/src/render/markdown_render_object.dart | 324 +++ lib/src/render/span_builder.dart | 54 + lib/src/selection.dart | 55 +- lib/src/selection_scope.dart | 2 +- 17 files changed, 2377 insertions(+), 2216 deletions(-) create mode 100644 lib/src/render/block_painter.dart create mode 100644 lib/src/render/blocks/alert.dart create mode 100644 lib/src/render/blocks/code.dart create mode 100644 lib/src/render/blocks/divider.dart create mode 100644 lib/src/render/blocks/heading.dart create mode 100644 lib/src/render/blocks/list.dart create mode 100644 lib/src/render/blocks/paragraph.dart create mode 100644 lib/src/render/blocks/quote.dart create mode 100644 lib/src/render/blocks/spacer.dart create mode 100644 lib/src/render/blocks/table.dart create mode 100644 lib/src/render/markdown_painter.dart create mode 100644 lib/src/render/markdown_render_object.dart create mode 100644 lib/src/render/span_builder.dart diff --git a/lib/flutter_md.dart b/lib/flutter_md.dart index 14d8e59..09dbed0 100644 --- a/lib/flutter_md.dart +++ b/lib/flutter_md.dart @@ -4,7 +4,25 @@ export 'src/markdown.dart'; export 'src/nodes.dart'; export 'src/parser.dart'; export 'src/render.dart' - show BlockPainter, SelectableBlockPainter, SelectableTextBlock; + show + // Block-painter framework — implement/extend to customize rendering. + BlockPainter, + SelectableBlockPainter, + SelectableTextBlock, + MultiPainterSelectable, + SelectableFragment, + ParagraphGestureHandler, + paragraphFromMarkdownSpans, + // Default block painters — reuse, wrap or subclass them. + BlockPainter$Paragraph, + BlockPainter$Heading, + BlockPainter$Quote, + BlockPainter$Alert, + BlockPainter$Code, + BlockPainter$List, + BlockPainter$Table, + BlockPainter$Divider, + BlockPainter$Spacer; export 'src/selection.dart'; export 'src/selection_scope.dart'; export 'src/theme.dart'; diff --git a/lib/src/render.dart b/lib/src/render.dart index 11d18df..8e9d5fd 100644 --- a/lib/src/render.dart +++ b/lib/src/render.dart @@ -1,2201 +1,21 @@ -//ignore_for_file: unnecessary_import - -import 'dart:math' as math; -import 'dart:ui'; - -import 'package:flutter/cupertino.dart'; -import 'package:flutter/foundation.dart'; -import 'package:flutter/gestures.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/rendering.dart'; -import 'package:meta/meta.dart' as meta show internal; - -import 'markdown.dart'; -import 'nodes.dart'; -import 'selection.dart'; -import 'theme.dart'; - -/// Default color used to paint the selection highlight beneath the glyphs. -const Color _kSelectionColor = Color(0x552196F3); - -@meta.internal -class MarkdownRenderObject extends RenderBox - implements MarkdownSelectionSurface { - MarkdownRenderObject({ - required Markdown markdown, - required MarkdownThemeData theme, - }) : _painter = MarkdownPainter( - markdown: markdown, - theme: theme, - ); - - /// Painter for rendering markdown content. - final MarkdownPainter _painter; - - /// The selection controller this render object participates in, if any. - MarkdownSelectionController? _controller; - - /// The stable document id used to anchor selection positions. - Object? _documentId; - - final Paint _highlightPaint = Paint()..color = _kSelectionColor; - - // Selection-handle leader layers pushed during paint so native handles - // (drawn by the scope's SelectionOverlay) follow the content as it scrolls. - LayerLink? _startHandleLink; - Offset? _startHandleLocal; - LayerLink? _endHandleLink; - Offset? _endHandleLocal; - - void _onSelectionChange() { - if (!_disposed) markNeedsPaint(); - } - - bool _disposed = false; - - void _attachController() { - final controller = _controller; - if (controller == null) return; - controller.addListener(_onSelectionChange); - if (attached) controller.attachSurface(this); - } - - void _detachController() { - final controller = _controller; - if (controller == null) return; - controller.removeListener(_onSelectionChange); - controller.detachSurface(this); - } - - /// Wires (or rewires) this render object to a selection [controller] under - /// [documentId]. Passing a null controller makes it non-selectable (inert). - @meta.internal - void updateSelection( - MarkdownSelectionController? controller, - Object? documentId, - ) { - if (identical(controller, _controller) && documentId == _documentId) return; - _detachController(); - _controller = controller; - _documentId = documentId; - _attachController(); - if (attached) { - markNeedsCompositingBitsUpdate(); - markNeedsPaint(); - } - } - - // --- MarkdownSelectionSurface --- - - @override - Object get documentId => _documentId!; - - @override - Rect get globalBounds => localToGlobal(Offset.zero) & size; - - @override - MarkdownPosition? positionForGlobal(Offset globalPosition) { - final id = _documentId; - if (id == null) return null; - final local = globalToLocal(globalPosition); - final hit = _painter.positionForLocal(local); - if (hit == null) return null; - return MarkdownPosition( - documentId: id, - blockIndex: hit.$1, - offset: hit.$2, - ); - } - - @override - List globalSelectionRects() { - final local = localSelectionRects(); - if (local.isEmpty) return const []; - final origin = localToGlobal(Offset.zero); - return [for (final rect in local) rect.shift(origin)]; - } - - @override - List localSelectionRects() { - final controller = _controller; - final id = _documentId; - if (controller == null || id == null) return const []; - return _painter.selectionBoxes((s) => controller.rangeFor(id, s)); - } - - @override - void setSelectionHandleLayers({ - LayerLink? startLink, - Offset? startLocal, - LayerLink? endLink, - Offset? endLocal, - }) { - var changed = false; - if (!identical(startLink, _startHandleLink) || - startLocal != _startHandleLocal) { - _startHandleLink = startLink; - _startHandleLocal = startLocal; - changed = true; - } - if (!identical(endLink, _endHandleLink) || endLocal != _endHandleLocal) { - _endHandleLink = endLink; - _endHandleLocal = endLocal; - changed = true; - } - if (changed && !_disposed && attached) markNeedsPaint(); - } - - @override - void repaintSelection() { - if (!_disposed && attached) markNeedsPaint(); - } - - /// Current size of the render box. - @override - Size get size => _size; - Size _size = Size.zero; - - @override - bool get isRepaintBoundary => _controller != null; - - @override - bool get alwaysNeedsCompositing => false; - - @override - bool get sizedByParent => false; - - @override - set size(Size value) { - final prev = super.hasSize ? super.size : null; - super.size = value; - if (prev == value) return; - _size = value; - } - - @override - void debugResetSize() { - super.debugResetSize(); - if (!super.hasSize) return; - _size = super.size; - } - - @override - Size computeDryLayout(BoxConstraints constraints) => - constraints.constrain(_painter.layout(maxWidth: constraints.maxWidth)); - - @override - void performLayout() { - // Set the size of the render box to match the painter's size. - size = - constraints.constrain(_painter.layout(maxWidth: constraints.maxWidth)); - } - - @override - // ignore: unnecessary_overrides - void performResize() { - size = computeDryLayout(constraints); - } - - @override - bool hitTestSelf(Offset position) => true; - - @override - bool hitTestChildren( - BoxHitTestResult result, { - required Offset position, - }) => - false; - - @override - bool hitTest(BoxHitTestResult result, {required Offset position}) { - var hitTarget = false; - if (size.contains(position)) { - hitTarget = hitTestSelf(position); - result.add(BoxHitTestEntry(this, position)); - } - return hitTarget; - } - - @override - void handleEvent(PointerEvent event, BoxHitTestEntry entry) { - _painter.handleEvent(event); - } - - /// Handles system font changes by marking the render object as needing layout - void _handleSystemFontsChange() { - // Invalidate cached layouts in painter and all block painters - _painter.invalidateLayout(); - // Request new layout and paint - markNeedsLayout(); - } - - @override - void attach(PipelineOwner owner) { - super.attach(owner); - PaintingBinding.instance.systemFonts.addListener(_handleSystemFontsChange); - _controller?.attachSurface(this); - } - - /// Updates the render object with a new values. - /// This method should be called whenever the markdown or theme changes. - @meta.internal - void update({ - required Markdown markdown, - required MarkdownThemeData theme, - }) { - if (_painter.update( - markdown: markdown, - theme: theme, - )) { - // Mark the render object as needing layout. - markNeedsLayout(); - } - } - - @override - @protected - void detach() { - PaintingBinding.instance.systemFonts - .removeListener(_handleSystemFontsChange); - _controller?.detachSurface(this); - super.detach(); - } - - @override - @protected - void dispose() { - _disposed = true; - _controller?.removeListener(_onSelectionChange); - super.dispose(); - _painter.dispose(); - } - - @override - @protected - void paint(PaintingContext context, Offset offset) { - if (_painter.isEmpty) - return; // If the markdown is empty, do not paint anything. - - final canvas = context.canvas - ..save() - ..translate(offset.dx, offset.dy); - //..clipRect(Rect.fromLTWH(0, 0, size.width, size.height)); - - // Paint the selection highlight OUTSIDE the cached content Picture, beneath - // the glyphs, so drag/streaming repaints never rebuild the glyph cache. - final controller = _controller; - final id = _documentId; - if (controller != null && id != null) { - _highlightPaint.color = controller.selectionColor ?? _kSelectionColor; - _painter.paintHighlight( - canvas, - (source) => controller.rangeFor(id, source), - _highlightPaint, - ); - } - - _painter.paint(canvas, size); - - canvas.restore(); - - // Push handle leader layers (empty layers) so the scope's SelectionOverlay - // handles follow this content as it scrolls. - final startLink = _startHandleLink; - final startLocal = _startHandleLocal; - if (startLink != null && startLocal != null) { - context.pushLayer( - LeaderLayer(link: startLink, offset: offset + startLocal), - _paintNothing, - Offset.zero, - ); - } - final endLink = _endHandleLink; - final endLocal = _endHandleLocal; - if (endLink != null && endLocal != null) { - context.pushLayer( - LeaderLayer(link: endLink, offset: offset + endLocal), - _paintNothing, - Offset.zero, - ); - } - } -} - -/// A no-op paint callback for pushing childless [LeaderLayer]s. -void _paintNothing(PaintingContext context, Offset offset) {} - -/// A painter for rendering markdown content via blocks and spans. -@meta.internal -class MarkdownPainter { - /// Creates a [MarkdownPainter] instance. - MarkdownPainter({ - required Markdown markdown, - required MarkdownThemeData theme, - }) : _markdown = markdown, - _theme = theme, - _isEmpty = markdown.isEmpty, - _size = Size.zero { - _rebuild(); - } - - /// Is the markdown entity empty? - bool get isEmpty => _isEmpty; - bool _isEmpty; - - /// Current markdown entity to render. - Markdown _markdown; - - /// Current theme for the markdown widget. - MarkdownThemeData _theme; - - /// The size of the painted markdown content. - Size get size => _size; - Size _size; - - /// Indicates if the layout needs to be recalculated. - bool _needsLayout = true; - - Float32List _blockOffsets = Float32List(0); - List _blockPainters = const []; - - /// Source `Markdown.blocks` index for each painter (differs from the painter - /// index whenever a `blockFilter` drops blocks). - List _sourceIndices = const []; - - static BlockPainter _defaultBlockBuilder( - MD$Block block, - MarkdownThemeData theme, - ) => - block.map( - paragraph: (p) => BlockPainter$Paragraph( - spans: p.spans, - theme: theme, - ), - heading: (h) => BlockPainter$Heading( - level: h.level, - spans: h.spans, - theme: theme, - ), - quote: (q) => BlockPainter$Quote( - spans: q.spans, - indent: q.indent, - theme: theme, - ), - code: (c) => BlockPainter$Code( - language: c.language, - text: c.text, - theme: theme, - ), - list: (l) => BlockPainter$List( - items: l.items, - theme: theme, - ), - divider: (d) => BlockPainter$Divider( - theme: theme, - ), - table: (t) => BlockPainter$Table( - header: t.header, - rows: t.rows, - alignments: t.alignments, - theme: theme, - ), - alert: (a) => BlockPainter$Alert( - alert: a.alert, - spans: a.spans, - theme: theme, - ), - spacer: (s) => BlockPainter$Spacer( - count: s.count, - theme: theme, - ), - ); - - /// Rebuilds the block painters from the markdown blocks. - /// This method is called whenever the markdown or theme changes. - void _rebuild() { - _needsLayout = true; // Mark that layout needs to be recalculated. - _size = Size.zero; // Reset size before rebuilding. - final filter = _theme.blockFilter; - final builder = _theme.builder ?? _defaultBlockBuilder; - final blocks = _markdown.blocks; - final painters = []; - final sources = []; - for (var i = 0; i < blocks.length; i++) { - final block = blocks[i]; - if (filter != null && !filter(block)) continue; - painters - .add(builder(block, _theme) ?? _defaultBlockBuilder(block, _theme)); - sources.add(i); - } - _blockPainters = painters; - _sourceIndices = sources; - _blockOffsets = Float32List(_blockPainters.length); - } - - /// Binary-searches the painter index whose vertical band contains [dy]. - int _blockIndexForDy(double dy) { - var min = 0; - var max = _blockOffsets.length; - var idx = 0; - while (min < max) { - final mid = min + ((max - min) >> 1); - final offset = _blockOffsets[mid]; - if (offset > dy) { - max = mid; - } else { - idx = mid; - if (offset == dy) break; - min = mid + 1; - } - } - return idx; - } - - /// Maps a content-local [local] offset to `(sourceBlockIndex, offset)`, or - /// null if the hit block does not support selection. - (int, int)? positionForLocal(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); - } - - /// Paints the selection highlight of every selectable block, using [rangeOf] - /// to look up the selected rendered range for a source block index. - void paintHighlight( - Canvas canvas, - TextRange? Function(int sourceIndex) rangeOf, - Paint paint, - ) { - for (var i = 0; i < _blockPainters.length; i++) { - final painter = _blockPainters[i]; - if (painter is! SelectableBlockPainter) continue; - final range = rangeOf(_sourceIndices[i]); - if (range == null || range.start >= range.end) continue; - final top = _blockOffsets[i]; - for (final rect in painter.boxesForRange(range.start, range.end)) { - canvas.drawRect(rect.shift(Offset(0, top)), paint); - } - } - } - - /// Content-local rectangles covering the selection described by [rangeOf], in - /// reading order. Same geometry [paintHighlight] draws, collected instead of - /// painted — used to position selection handles, the magnifier and toolbar. - List selectionBoxes(TextRange? Function(int sourceIndex) rangeOf) { - final out = []; - for (var i = 0; i < _blockPainters.length; i++) { - final painter = _blockPainters[i]; - if (painter is! SelectableBlockPainter) continue; - final range = rangeOf(_sourceIndices[i]); - if (range == null || range.start >= range.end) continue; - final top = _blockOffsets[i]; - for (final rect in painter.boxesForRange(range.start, range.end)) { - out.add(rect.shift(Offset(0, top))); - } - } - return out; - } - - /// Update the painter with new values. - /// If the values are the same, - /// no update is required and the method returns false. - bool update({ - required Markdown markdown, - required MarkdownThemeData theme, - }) { - if (identical(_markdown, markdown) && identical(_theme, theme)) - return false; - _lastSize = null; - _lastPicture = null; - _markdown = markdown; - _theme = theme; - _isEmpty = markdown.isEmpty; - _rebuild(); - return true; // Indicate that the painter was updated. - } - - /// Invalidate cached layouts when system fonts change. - /// This forces TextPainters to recreate their layouts with new fonts. - void invalidateLayout() { - _needsLayout = true; - _lastSize = null; - _lastPicture = null; - // Dispose and rebuild all block painters to recreate TextPainters - // with the new system fonts - for (final painter in _blockPainters) { - painter.dispose(); - } - _rebuild(); - } - - /// Layouts the markdown content with the given width. - Size layout({required double maxWidth}) { - if (_isEmpty) { - _size = Size.zero; - _needsLayout = false; // No need to layout if the markdown is empty. - return _size; // If the markdown is empty, return zero size. - } - var width = .0, height = .0; - final blocks = _blockPainters; - if (_blockOffsets.length != blocks.length) { - // Resize the block sizes array - // if it does not match the number of painters. - _blockOffsets = Float32List(blocks.length); - } - final offsets = _blockOffsets; - for (var i = 0; i < blocks.length; i++) { - offsets[i] = height; - final block = blocks[i]; - final size = block.layout(maxWidth); - width = math.max(width, size.width); - height += size.height; - } - _needsLayout = false; // No need to layout if the markdown is empty. - return _size = Size(width, height); - } - - /// Get the painter from the array by the vertical local position (dy). - /* static BlockPainter? _getPainterByHeight( - Iterable painters, - double dy, - ) { - var offset = .0; - BlockPainter? result; - for (var painter in painters) { - if (dy < offset) break; - result = painter; - offset += painter.size.height; // Update the offset for the next block. - } - return result; - } */ - - void handleEvent(PointerEvent event) { - if (_blockPainters.isEmpty) return; - // event.buttons, event.kind, event.position - // event.localPosition, event.delta, event.down - - // Only handle pointer down events for now. - // You can extend this to handle other pointer events if needed. - if (event is! PointerDownEvent && event is! PointerUpEvent) return; - - final pos = event.localPosition; - { - // Binary search to find the block painter by the vertical position. - final dy = pos.dy; - var min = 0; - var max = _blockPainters.length; - var idx = 0; - while (min < max) { - final mid = min + ((max - min) >> 1); - final offset = _blockOffsets[mid]; - //final comp = offset.compareTo(dy); - var comp = 0; - if (offset > dy) { - // The offset is greater than the position. - comp = 1; - } else { - idx = mid; // Remember the index of the block painter. - // The offset is less than or equal to the position. - comp = offset < dy ? -1 : 0; - } - if (comp == 0) { - break; // Found the exact match. - } else if (comp < 0) { - min = mid + 1; - } else { - max = mid; - } - } - switch (event) { - case PointerDownEvent(): - final blockTapEvent = PointerDownEvent( - // Adjust the position by the block offset. - position: Offset( - pos.dx, - pos.dy - _blockOffsets[idx], - ), - viewId: event.viewId, - timeStamp: event.timeStamp, - pointer: event.pointer, - kind: event.kind, - device: event.device, - buttons: event.buttons, - obscured: event.obscured, - pressure: event.pressure, - pressureMin: event.pressureMin, - pressureMax: event.pressureMax, - distanceMax: event.distanceMax, - size: event.size, - radiusMajor: event.radiusMajor, - radiusMinor: event.radiusMinor, - radiusMin: event.radiusMin, - radiusMax: event.radiusMax, - orientation: event.orientation, - tilt: event.tilt, - embedderId: event.embedderId, - ); - _blockPainters[idx].handleTapDown(blockTapEvent); - case PointerUpEvent(): - final blockTapEvent = PointerUpEvent( - // Adjust the position by the block offset. - position: Offset( - pos.dx, - pos.dy - _blockOffsets[idx], - ), - viewId: event.viewId, - timeStamp: event.timeStamp, - pointer: event.pointer, - kind: event.kind, - device: event.device, - buttons: event.buttons, - obscured: event.obscured, - pressure: event.pressure, - pressureMin: event.pressureMin, - pressureMax: event.pressureMax, - distanceMax: event.distanceMax, - size: event.size, - radiusMajor: event.radiusMajor, - radiusMinor: event.radiusMinor, - radiusMin: event.radiusMin, - radiusMax: event.radiusMax, - orientation: event.orientation, - tilt: event.tilt, - embedderId: event.embedderId, - ); - _blockPainters[idx].handleTapUp(blockTapEvent); - } - } - - // We can use the position to determine which block was hit. - //_getPainterByHeight(_blockPainters, pos.dy)?.handleEvent(event); - - // Handle taps for the links with urls. - /* switch (event) { - case PointerDownEvent(down: true): - // Handle pointer down events. - default: - // Handle other pointer events if needed. - break; - } */ - } - - /// The last size and picture used for painting. - /// This is used to avoid unnecessary recreation of the canvas picture. - /// If the size is the same as the last painted size, - Size? _lastSize; - - /// The last picture used for painting, - /// to avoid unnecessary recreation of the canvas picture. - /// If the size is the same as the last painted size, - /// we can reuse the last picture. - Picture? _lastPicture; - - /// The markdown content to paint. - void paint(Canvas canvas, Size size) { - assert( - !_needsLayout, - 'MarkdownPainter.paint() called without layout.', - ); - assert( - size.isFinite, - 'MarkdownPainter.paint() called with non-finite size: $size', - ); - - // Do not paint if the markdown is empty, - // or if the size is empty or infinite. - if (_isEmpty || size.isEmpty || size.isInfinite) return; - - if (_lastSize == size && _lastPicture != null) { - // If the size is the same as the last painted size, - // we can reuse the last picture. - canvas.drawPicture(_lastPicture!); - return; - } - - final recorder = PictureRecorder(); - final $canvas = Canvas(recorder); - - // Paint each block painter on the canvas. - var overflow = _size.height > size.height; - var offset = .0; - for (var painter in _blockPainters) { - if (overflow && offset > size.height) { - // If the painter's height exceeds the available height, - // we stop painting further blocks. - break; - } - painter.paint($canvas, size, offset); - offset += painter.size.height; // Update the offset for the next block. - } - - final picture = recorder.endRecording(); - canvas.drawPicture(picture); - _lastSize = size; - _lastPicture = picture; - } - - void dispose() { - _lastPicture?.dispose(); - _lastPicture = null; - for (final painter in _blockPainters) { - painter.dispose(); - } - _blockPainters = const []; - } -} - -/* InlineSpan _imageFromMarkdownSpan({ - required MD$Span span, - required MarkdownThemeData theme, -}) { - final url = span.extra?['url']; - if (url is! String || url.isEmpty) return const TextSpan(); - ImageProvider? provider; - if (url.startsWith('http://') || url.startsWith('https://')) { - provider = NetworkImage(url); - } else if (url.startsWith('asset://')) { - provider = AssetImage(Uri.parse(url).toFilePath()); - } else if (kIsWeb) { - provider = NetworkImage(url); - } else { - return const TextSpan(); - } - return WidgetSpan( - alignment: PlaceholderAlignment.middle, - child: SizedBox.square( - dimension: 48, // Fixed size for the image. - child: Image( - image: provider, - width: 48, - height: 48, - filterQuality: FilterQuality.medium, - fit: BoxFit.scaleDown, - ), - ), - ); -} */ - -/// Builds a tap recognizer for the given markdown span. -TapGestureRecognizer? _buildTapRecognizer( - MD$Span span, - void Function(String title, String url)? onTap, -) { - if (onTap == null) return null; - if (span.extra case {'url': String url}) { - return TapGestureRecognizer() - ..onTap = () { - onTap(span.extra?['alt']?.toString() ?? span.text, url); - }; - } - return null; -} - -/// 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. -TextSpan _paragraphFromMarkdownSpans({ - required Iterable spans, - required MarkdownThemeData theme, - TextStyle? textStyle, -}) { - final style = textStyle ?? theme.textStyle; - final spanFilter = theme.spanFilter; - final filtered = spanFilter != null ? spans.where(spanFilter) : spans; - final mapper = textStyle != null - ? (MD$Span span) { - return TextSpan( - text: span.text, - style: theme.textStyleFor(span.style).merge(style), - recognizer: span.style.contains(MD$Style.link) - ? _buildTapRecognizer(span, theme.onLinkTap) - : null, - ); - } - : (MD$Span span) { - return TextSpan( - text: span.text, - style: theme.textStyleFor(span.style), - recognizer: span.style.contains(MD$Style.link) - ? _buildTapRecognizer(span, theme.onLinkTap) - : null, - ); - }; - return TextSpan( - style: textStyle ?? theme.textStyle, - children: filtered.map(mapper).toList(growable: false), - ); -} - -/// A class for painting blocks in markdown. -/// You can implement this interface to create custom block painters. -abstract interface class BlockPainter { - /// The current size of the block. - /// Available only after [layout]. - abstract final Size size; - - /// Handle tap pointer down events for the block. - void handleTapDown(PointerDownEvent event); - - /// Handle tap pointer up events for the block. - void handleTapUp(PointerUpEvent event); - - /// Measure the block size with the given width. - Size layout(double width); - - /// Paint the block on the canvas at the given offset. - /// [canvas] is the canvas to paint on - /// [size] the whole size of the markdown content - /// [offset] is the vertical offset to paint the block at - void paint(Canvas canvas, Size size, double offset); - - /// Dispose all resources used by the painter. - void dispose(); -} - -/// A [BlockPainter] that supports text selection. All coordinates are local to -/// the block's top-left corner (as passed to [paint]'s `offset`). -/// -/// The [renderedText] should match [markdownBlockRenderedText] for the same -/// block so that hit-testing, highlighting, and model-side extraction agree on -/// the offset space. -/// -/// Caveat: a [MarkdownThemeData.spanFilter] that drops text-bearing spans -/// shifts the painter's offset space relative to the (unfiltered) model, so the -/// on-screen highlight stays correct but copied text may be misaligned. Avoid -/// dropping text-bearing spans when selection is enabled. -abstract interface class SelectableBlockPainter implements BlockPainter { - /// The block's rendered plain text. - String get renderedText; - - /// Maps a block-local [local] offset to a rendered-text index. - int offsetForLocalPosition(Offset local); - - /// Highlight rectangles (block-local) for the rendered range `[start, end)`. - List boxesForRange(int start, int end); -} - -/// Provides [SelectableBlockPainter] for a block backed by a single -/// [TextPainter]. Subclasses supply [selectionPainter] and, when the glyphs are -/// not painted at the block origin, [selectionOrigin]. -mixin SelectableTextBlock implements SelectableBlockPainter { - /// The text painter that owns the selectable glyphs. - TextPainter get selectionPainter; - - /// Block-local origin where [selectionPainter] is painted. - Offset get selectionOrigin => Offset.zero; - - @override - String get renderedText => selectionPainter.plainText; - - @override - int offsetForLocalPosition(Offset local) => - selectionPainter.getPositionForOffset(local - selectionOrigin).offset; - - @override - List boxesForRange(int start, int end) => selectionPainter - .getBoxesForSelection(TextSelection(baseOffset: start, extentOffset: end)) - .map((box) => box.toRect().shift(selectionOrigin)) - .toList(growable: false); -} - -/// One selectable text run inside a multi-painter block (a list item or a table -/// cell): the [painter] that owns its glyphs, the block-local [origin] where it -/// is painted, and the [textStart] index of its text within the block's -/// [markdownBlockRenderedText] linearization. -@meta.internal -class SelectableFragment { - /// Creates a fragment for [painter] painted at [origin], whose text begins at - /// [textStart] in the block's rendered text. - SelectableFragment(this.painter, this.origin, this.textStart); - - /// The text painter that owns this run's glyphs. - final TextPainter painter; - - /// Block-local top-left where [painter] is painted. - final Offset origin; - - /// Index of this run's first character in the block's rendered text. - final int textStart; - - /// Length of this run's text. - int get length => painter.plainText.length; - - /// One-past-the-last index of this run's text in the block's rendered text. - int get textEnd => textStart + length; -} - -/// Provides [SelectableBlockPainter] for a block whose text is spread across -/// several [TextPainter]s painted at different origins (lists, tables). +/// Rendering layer for `flutter_md`. /// -/// [fragments] must be listed in the same order their text appears in -/// [markdownBlockRenderedText], with each fragment's `textStart` matching that -/// linearization (the gaps between fragments are the `\n`/`\t` separators, which -/// have no glyphs of their own). [renderedText] must equal that linearization. -mixin MultiPainterSelectable implements SelectableBlockPainter { - /// The selectable runs of this block, in rendered-text order. Rebuilt on each - /// [BlockPainter.layout]. - List get fragments; - - @override - int offsetForLocalPosition(Offset local) { - final frags = fragments; - if (frags.isEmpty) return 0; - SelectableFragment? best; - var bestDistance = double.infinity; - for (final fragment in frags) { - final rect = fragment.origin & fragment.painter.size; - final distance = _distanceToRect(local, rect); - if (distance < bestDistance) { - bestDistance = distance; - best = fragment; - if (distance == 0) break; - } - } - final fragment = best!; - final inner = - fragment.painter.getPositionForOffset(local - fragment.origin).offset; - return fragment.textStart + inner.clamp(0, fragment.length); - } - - @override - List boxesForRange(int start, int end) { - final out = []; - for (final fragment in fragments) { - final localStart = start.clamp(fragment.textStart, fragment.textEnd) - - fragment.textStart; - final localEnd = - end.clamp(fragment.textStart, fragment.textEnd) - fragment.textStart; - if (localEnd <= localStart) continue; - final boxes = fragment.painter.getBoxesForSelection( - TextSelection(baseOffset: localStart, extentOffset: localEnd)); - for (final box in boxes) { - out.add(box.toRect().shift(fragment.origin)); - } - } - return out; - } -} - -/// Shortest distance from [point] to [rect] (0 when the point is inside). -double _distanceToRect(Offset point, Rect rect) { - final dx = point.dx < rect.left - ? rect.left - point.dx - : (point.dx > rect.right ? point.dx - rect.right : 0.0); - final dy = point.dy < rect.top - ? rect.top - point.dy - : (point.dy > rect.bottom ? point.dy - rect.bottom : 0.0); - if (dx == 0) return dy; - if (dy == 0) return dx; - return math.sqrt(dx * dx + dy * dy); -} - -@meta.internal -mixin ParagraphGestureHandler { - /// Handle tap events with a [TextPainter]. - @protected - InlineSpan? hitTestInlineSpanWithPointerEvent( - PointerEvent event, TextPainter painter) { - final pos = painter.getPositionForOffset(event.localPosition); - //final int index = pos.offset; - final span = painter.text?.getSpanForPosition(pos); - //final plainText = span?.toPlainText(); - //print('[${pos.offset}] $plainText'); - return span; - } -} - -/// A class for painting a paragraph block in markdown. -@meta.internal -class BlockPainter$Paragraph - with ParagraphGestureHandler, SelectableTextBlock - implements BlockPainter { - @override - TextPainter get selectionPainter => painter; - - BlockPainter$Paragraph({ - required List spans, - required this.theme, - }) : painter = TextPainter( - text: _paragraphFromMarkdownSpans( - spans: spans, - theme: theme, - ), - textAlign: TextAlign.start, - textDirection: theme.textDirection, - textScaler: theme.textScaler, - ); - - final MarkdownThemeData theme; - - final TextPainter painter; - - @override - Size get size => _size; - Size _size = Size.zero; - - /// Last span hit by the tap down event. - TextSpan? _lastSpan; - - @override - void handleTapDown(PointerDownEvent event) { - _lastSpan = null; // Reset the span on tap down. - final span = hitTestInlineSpanWithPointerEvent(event, painter); - 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 (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))) - onTap?.call(); - } - _lastSpan = null; // Clear the span after handling the tap. - } - - @override - Size layout(double width) { - painter.layout( - minWidth: 0, - maxWidth: width, - ); - return _size = painter.size; - } - - @override - void paint(Canvas canvas, Size size, double offset) { - // If the width is less than required do not paint anything. - if (size.width < _size.width) return; - painter.paint( - canvas, - Offset(0, offset), - ); - } - - @override - void dispose() { - painter.dispose(); - } -} - -/// A class for painting a paragraph block in markdown. -@meta.internal -class BlockPainter$Heading - with ParagraphGestureHandler, SelectableTextBlock - implements BlockPainter { - @override - TextPainter get selectionPainter => painter; - - BlockPainter$Heading({ - required int level, - required List spans, - required this.theme, - }) : painter = TextPainter( - text: _paragraphFromMarkdownSpans( - spans: spans, - theme: theme, - textStyle: theme.headingStyleFor(level), - ), - textAlign: TextAlign.start, - textDirection: theme.textDirection, - textScaler: theme.textScaler, - ); - - final MarkdownThemeData theme; - - final TextPainter painter; - - @override - Size get size => _size; - Size _size = Size.zero; - - /// Last span hit by the tap down event. - TextSpan? _lastSpan; - - @override - void handleTapDown(PointerDownEvent event) { - _lastSpan = null; // Reset the span on tap down. - final span = hitTestInlineSpanWithPointerEvent(event, painter); - 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 (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))) - onTap?.call(); - } - _lastSpan = null; // Clear the span after handling the tap. - } - - @override - Size layout(double width) { - painter.layout( - minWidth: 0, - maxWidth: width, - ); - return _size = painter.size; - } - - @override - void paint(Canvas canvas, Size size, double offset) { - // If the width is less than required do not paint anything. - if (size.width < _size.width) return; - painter.paint( - canvas, - Offset(0, offset), - ); - } - - @override - void dispose() { - painter.dispose(); - } -} - -/// A class for painting a quote block in markdown. -@meta.internal -class BlockPainter$Quote - with ParagraphGestureHandler, SelectableTextBlock - implements BlockPainter { - @override - TextPainter get selectionPainter => painter; - @override - Offset get selectionOrigin => Offset(lineIndent + indent * lineIndent, 0); - - 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, - ), - linePaint = Paint() - ..color = theme.dividerColor ?? - const Color(0x7F7F7F7F) // Gray color for the line. - ..isAntiAlias = false - ..strokeWidth = 4.0 - ..style = PaintingStyle.fill; - - final MarkdownThemeData theme; - - final TextPainter painter; - - final int indent; // Indentation for quote blocks. - - static const double lineIndent = 10.0; // Indentation for quote blocks. - - final Paint linePaint; - - @override - Size get size => _size; - Size _size = Size.zero; - - /// Last span hit by the tap down event. - TextSpan? _lastSpan; - - @override - void handleTapDown(PointerDownEvent event) { - _lastSpan = null; // Reset the span on tap down. - final span = hitTestInlineSpanWithPointerEvent(event, painter); - 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 (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))) - onTap?.call(); - } - _lastSpan = null; // Clear the span after handling the tap. - } - - @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, - ); - } - - @override - void paint(Canvas canvas, Size size, double offset) { - // If the width is less than required do not paint anything. - if (size.width < _size.width) return; - - // --- Draw vertical lines --- // - for (var i = 1; i <= indent; i++) - canvas.drawLine( - Offset( - i * lineIndent, - offset, - ), - Offset( - i * lineIndent, - offset + _size.height, - ), - linePaint, - ); - - painter.paint( - canvas, - Offset( - lineIndent + indent * lineIndent, - offset, - ), - ); - } - - @override - void dispose() { - painter.dispose(); - } -} - -/// A class for painting a GitHub-style alert (admonition) block in markdown. -@meta.internal -class BlockPainter$Alert - with ParagraphGestureHandler, SelectableTextBlock - implements BlockPainter { - @override - TextPainter get selectionPainter => bodyPainter; - @override - Offset get selectionOrigin => _bodyOrigin; - - BlockPainter$Alert({ - required this.alert, - required List spans, - required this.theme, - }) : _accent = theme.alertColorFor(alert), - titlePainter = TextPainter( - text: TextSpan( - text: alert.title, - style: theme.textStyle.copyWith( - color: theme.alertColorFor(alert), - fontWeight: FontWeight.bold, - ), - ), - textAlign: TextAlign.start, - textDirection: theme.textDirection, - textScaler: theme.textScaler, - ), - bodyPainter = TextPainter( - text: _paragraphFromMarkdownSpans(spans: spans, theme: theme), - textAlign: TextAlign.start, - textDirection: theme.textDirection, - textScaler: theme.textScaler, - ); - - /// The kind of the alert being painted. - final MD$AlertType alert; - - final MarkdownThemeData theme; - - final Color _accent; - - /// Painter for the alert title (e.g. "Note"). - final TextPainter titlePainter; - - /// Painter for the alert body content. - final TextPainter bodyPainter; - - static const double padding = 10.0; - static const double barWidth = 4.0; - static const double gap = 10.0; - static const double titleGap = 4.0; - - /// Left offset where the title/body content begins. - double get _contentLeft => padding + barWidth + gap; - - @override - Size get size => _size; - Size _size = Size.zero; - - /// Whether the body has any content to paint. - bool _hasBody = false; - - /// Last span hit by the tap down event. - TextSpan? _lastSpan; - - Offset get _bodyOrigin => - Offset(_contentLeft, padding + titlePainter.height + titleGap); - - 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; - } - - @override - void handleTapDown(PointerDownEvent event) { - _lastSpan = _spanForPosition(event.localPosition); - } - - @override - void handleTapUp(PointerUpEvent event) { - if (_lastSpan == null) return; - final span = _spanForPosition(event.localPosition); - if (span != null && _lastSpan == span) { - if (span case TextSpan(recognizer: TapGestureRecognizer(:var onTap))) - onTap?.call(); - } - _lastSpan = null; - } - - @override - 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; - - final contentWidth = math.max( - titlePainter.width, - _hasBody ? bodyPainter.width : 0.0, - ); - final contentHeight = - titlePainter.height + (_hasBody ? titleGap + bodyPainter.height : 0.0); - return _size = Size( - _contentLeft + contentWidth + padding, - contentHeight + padding * 2, - ); - } - - @override - void paint(Canvas canvas, Size size, double offset) { - if (size.width < _size.width) return; - - final rect = Rect.fromLTWH(0, offset, size.width, _size.height); - // Tinted background. - canvas.drawRRect( - RRect.fromRectAndRadius(rect, const Radius.circular(6.0)), - Paint() - ..color = _accent.withValues(alpha: 0.10) - ..style = PaintingStyle.fill, - ); - // Accent bar on the left. - canvas.drawRRect( - RRect.fromRectAndRadius( - Rect.fromLTWH(0, offset, barWidth, _size.height), - const Radius.circular(barWidth / 2), - ), - Paint() - ..color = _accent - ..style = PaintingStyle.fill, - ); - - titlePainter.paint(canvas, Offset(_contentLeft, offset + padding)); - if (_hasBody) { - bodyPainter.paint(canvas, _bodyOrigin + Offset(0, offset)); - } - } - - @override - void dispose() { - titlePainter.dispose(); - bodyPainter.dispose(); - } -} - -/// A helper class to store layout information for a single list item. -class _ListItemMetrics { - _ListItemMetrics({ - required this.bulletPainter, - required this.contentPainter, - required this.offset, - }); - - final TextPainter bulletPainter; - final TextPainter contentPainter; - final Offset offset; - - late final double height = - math.max(bulletPainter.height, contentPainter.height); - late final Size size = - Size(bulletPainter.width + contentPainter.width, height); - - void dispose() { - bulletPainter.dispose(); - contentPainter.dispose(); - } -} - -/// A class for painting a list block in markdown. -@meta.internal -class BlockPainter$List - with ParagraphGestureHandler, MultiPainterSelectable - implements BlockPainter { - BlockPainter$List({ - required List items, - required this.theme, - }) : _items = items, - _painters = <_ListItemMetrics>[]; - - final MarkdownThemeData theme; - final List _items; - final List<_ListItemMetrics> _painters; - - @override - List get fragments => _fragments; - List _fragments = const []; - - @override - String get renderedText => _renderedText; - String _renderedText = ''; - - // Indentation for the entire list block. - static const double _baseIndent = 8.0; - - // Indentation for each level of nesting. - static const double _levelIndent = 16.0; - - @override - Size get size => _size; - Size _size = Size.zero; - - /// Last span hit by the tap down event. - InlineSpan? _lastSpan; - - InlineSpan? _getSpanForPosition(Offset localPosition) { - for (final metrics in _painters) { - final contentOffset = - metrics.offset + Offset(metrics.bulletPainter.width, 0); - final contentRect = contentOffset & metrics.contentPainter.size; - if (contentRect.contains(localPosition)) { - final painterPosition = localPosition - contentOffset; - final textPosition = - metrics.contentPainter.getPositionForOffset(painterPosition); - return metrics.contentPainter.text?.getSpanForPosition(textPosition); - } - } - return null; - } - - @override - void handleTapDown(PointerDownEvent event) { - _lastSpan = null; // Reset the span on tap down. - _lastSpan = _getSpanForPosition(event.localPosition); - } - - @override - void handleTapUp(PointerUpEvent event) { - if (_lastSpan == null) return; // No span was hit on tap down. - final newSpan = _getSpanForPosition(event.localPosition); - if (newSpan != null && _lastSpan == newSpan) { - if (newSpan - case TextSpan(recognizer: final TapGestureRecognizer recognizer)) { - recognizer.onTap?.call(); - } - } - - _lastSpan = null; // Clear the span after handling the tap. - } - - @override - Size layout(double width) { - for (final painter in _painters) { - painter.dispose(); - } - _painters.clear(); - - double currentHeight = 0; - double maxContentWidth = 0; - - void layoutItems(List items, int level) { - final indent = _baseIndent + level * _levelIndent; - for (final item in items) { - // Task-list items render a checkbox instead of a bullet/number. - final bulletText = switch (item.checked) { - true => '☑', - false => '☐', - null => switch (item.marker) { - '-' || '*' || '+' => '•', - _ => item.marker, - }, - }; - final bulletPainter = TextPainter( - text: TextSpan(text: '$bulletText ', style: theme.textStyle), - textDirection: theme.textDirection, - textScaler: theme.textScaler, - )..layout(); - - final contentPainter = TextPainter( - text: _paragraphFromMarkdownSpans(spans: item.spans, theme: theme), - textDirection: theme.textDirection, - textScaler: theme.textScaler, - )..layout(maxWidth: math.max(0, width - indent - bulletPainter.width)); - - final metrics = _ListItemMetrics( - bulletPainter: bulletPainter, - contentPainter: contentPainter, - offset: Offset(indent, currentHeight), - ); - _painters.add(metrics); - - currentHeight += metrics.height; - maxContentWidth = - math.max(maxContentWidth, indent + metrics.size.width); - - if (item.children.isNotEmpty) { - layoutItems(item.children, level + 1); - } - } - } - - layoutItems(_items, 0); - _rebuildFragments(); - return _size = Size(maxContentWidth, currentHeight); - } - - /// Rebuilds the selectable fragments from the laid-out item metrics. Items - /// (depth-first, matching `markdownBlockRenderedText`) are joined by `\n`. - void _rebuildFragments() { - final frags = []; - var textPos = 0; - for (final metrics in _painters) { - if (frags.isNotEmpty) textPos += 1; // the '\n' item separator - final origin = metrics.offset + Offset(metrics.bulletPainter.width, 0); - frags.add(SelectableFragment(metrics.contentPainter, origin, textPos)); - textPos += metrics.contentPainter.plainText.length; - } - _fragments = frags; - _renderedText = frags.map((f) => f.painter.plainText).join('\n'); - } - - @override - void paint(Canvas canvas, Size size, double offset) { - for (final metrics in _painters) { - final bulletOffset = metrics.offset + Offset(0, offset); - metrics.bulletPainter.paint(canvas, bulletOffset); - - final contentOffset = - bulletOffset + Offset(metrics.bulletPainter.width, 0); - metrics.contentPainter.paint(canvas, contentOffset); - } - } - - @override - void dispose() { - for (final metrics in _painters) { - metrics.dispose(); - } - _painters.clear(); - _fragments = const []; - } -} - -/// A class for painting a spacer block in markdown. -@meta.internal -class BlockPainter$Spacer implements BlockPainter { - BlockPainter$Spacer({ - required this.count, - required this.theme, - }); - - final int count; - - final MarkdownThemeData theme; - - @override - Size get size => _size; - Size _size = Size.zero; - - @override - void handleTapDown(PointerDownEvent _) {/* Do nothing */} - - @override - void handleTapUp(PointerUpEvent _) {/* Do nothing */} - - @override - Size layout(double width) { - final height = theme.textStyle.fontSize ?? kDefaultFontSize; - return _size = Size(0, height * count); - } - - @override - void paint(Canvas canvas, Size size, double offset) { - // Do not paint anything - /* canvas.drawRect( - Rect.fromLTWH(0, offset, size.width, _size.height), - Paint()..color = theme.textStyle.color ?? const Color(0x00000000), - ); */ - } - - @override - void dispose() { - // Noting to dispose - } -} - -/// A class for painting a spacer block in markdown. -@meta.internal -class BlockPainter$Divider implements BlockPainter { - BlockPainter$Divider({ - required this.theme, - }) : _paint = Paint() - ..color = theme.textStyle.color ?? const Color(0xFF000000) - ..isAntiAlias = false - ..strokeWidth = 1.0 - ..style = PaintingStyle.fill; - - final Paint _paint; - final MarkdownThemeData theme; - - @override - Size get size => _size; - Size _size = Size.zero; - - @override - void handleTapDown(PointerDownEvent _) {/* Do nothing */} - - @override - void handleTapUp(PointerUpEvent _) {/* Do nothing */} - - @override - Size layout(double width) { - final height = theme.textStyle.fontSize ?? kDefaultFontSize; - return _size = Size(0, height); - } - - @override - void paint(Canvas canvas, Size size, double offset) { - // Draw a horizontal line across the width of the canvas. - final center = offset + _size.height / 2; - canvas.drawLine( - Offset(0, center), - Offset(size.width, center), - _paint, - ); - } - - @override - void dispose() { - // Noting to dispose - } -} - -/// A class for painting a code block in markdown. -@meta.internal -class BlockPainter$Code with SelectableTextBlock implements BlockPainter { - @override - TextPainter get selectionPainter => painter; - @override - Offset get selectionOrigin => const Offset(padding, padding); - - BlockPainter$Code({ - required String text, - required String? language, - required this.theme, - }) : painter = TextPainter( - text: TextSpan( - text: text, - style: theme.textStyle.copyWith( - fontFamily: 'monospace', - fontSize: theme.textStyle.fontSize ?? kDefaultFontSize, - ), - ), - textAlign: TextAlign.start, - textDirection: theme.textDirection, - textScaler: theme.textScaler, - ); - - static const double padding = 8.0; // Padding for code blocks. - - final MarkdownThemeData theme; - - final TextPainter painter; - - @override - Size get size => _size; - Size _size = Size.zero; - - @override - void handleTapDown(PointerDownEvent _) {/* Do nothing */} - - @override - void handleTapUp(PointerUpEvent _) {/* Do nothing */} - - @override - Size layout(double width) { - if (width <= padding * 2) { - // If the width is less than or equal to padding, return zero size. - _size = Size.zero; - return _size; - } - painter.layout( - minWidth: 0, - maxWidth: width - padding * 2, - ); - return _size = Size( - painter.size.width + padding * 2, // Add padding to the width. - painter.size.height + padding * 2, // Add padding to the height. - ); - } - - @override - void paint(Canvas canvas, Size size, double offset) { - // If the width is less than required do not paint anything. - if (size.width < _size.width) return; - canvas.drawRRect( - RRect.fromRectAndRadius( - Rect.fromLTWH(0, offset, size.width, _size.height), - const Radius.circular(padding), - ), - Paint() - ..color = theme.surfaceColor ?? const Color.fromARGB(255, 235, 235, 235) - ..isAntiAlias = false - ..style = PaintingStyle.fill, - ); - painter.paint( - canvas, - Offset(padding, offset + padding), - ); - } - - @override - void dispose() { - painter.dispose(); - } -} - -/// A class for painting a table block in markdown. -@meta.internal -class BlockPainter$Table - with ParagraphGestureHandler, MultiPainterSelectable - implements BlockPainter { - BlockPainter$Table({ - required this.header, - required this.rows, - required this.theme, - this.alignments = const [], - }) : columns = header.cells.length, - _columnWidths = List.filled(header.cells.length, 0.0), - _rowHeights = List.filled(rows.length + 1, 0.0), - _borderPaint = Paint() - ..color = theme.dividerColor ?? const Color(0x1F000000) - ..style = PaintingStyle.stroke - ..isAntiAlias = false - ..strokeWidth = 1.0, - _rowBackgroundPaint = Paint() - ..style = PaintingStyle.fill - ..isAntiAlias = false - ..color = - theme.surfaceColor ?? const Color.fromARGB(255, 235, 235, 235); - - /// Padding for table cells. - static const double padding = 8.0; - - /// The theme for the markdown table. - final MarkdownThemeData theme; - - /// The number of columns in the table. - final int columns; - - /// The per-column alignment derived from the delimiter row. - final List alignments; - - /// Resolves the alignment for column [c], defaulting to - /// [MD$TableColumnAlign.none] when unspecified. - MD$TableColumnAlign _columnAlign(int c) => c >= 0 && c < alignments.length - ? alignments[c] - : MD$TableColumnAlign.none; - - /// The horizontal offset of a cell's text within its column, honoring the - /// column alignment (falling back to centered headers / left-aligned data). - double _cellHorizontalPadding(int r, int c, double painterWidth) => - switch (_columnAlign(c)) { - MD$TableColumnAlign.left => padding, - MD$TableColumnAlign.center => (_columnWidths[c] - painterWidth) / 2, - MD$TableColumnAlign.right => _columnWidths[c] - painterWidth - padding, - MD$TableColumnAlign.none => - (r == 0) ? (_columnWidths[c] - painterWidth) / 2 : padding, - }; - - final List _columnWidths; - final List _rowHeights; - final Paint _borderPaint; - final Paint _rowBackgroundPaint; - - Float32List? _borderPoints; - - /// The header row of the table. - final MD$TableRow header; - - /// The rows of the table. - final List rows; - - @override - Size get size => _size; - Size _size = Size.zero; - - List> _cellPainters = const []; - - @override - List get fragments => _fragments; - List _fragments = const []; - - @override - String get renderedText => _renderedText; - String _renderedText = ''; - - /// Last span hit by the tap down event. - TextSpan? _lastSpan; - - @override - void handleTapDown(PointerDownEvent event) { - _lastSpan = null; // Reset the span on tap down. - final span = _getSpanForOffset(event.localPosition); - if (span != null) { - _lastSpan = span; - } - } - - @override - void handleTapUp(PointerUpEvent event) { - if (_lastSpan == null) return; // No span was hit on tap down. - final span = _getSpanForOffset(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))) - onTap?.call(); - } - _lastSpan = null; // Clear the span after handling the tap. - } - - TextSpan? _getSpanForOffset(Offset position) { - final rowHeights = - List.generate(_cellPainters.length, (r) => _rowHeights[r]); - - double currentY = 0.0; - - for (int r = 0; r < _cellPainters.length; r++) { - final rowHeight = rowHeights[r]; - double currentX = 0.0; - - if (position.dy >= currentY && position.dy < currentY + rowHeight) { - // In this row. - for (int c = 0; c < _cellPainters[r].length; c++) { - final painter = _cellPainters[r][c]; - if (painter.text == null) { - currentX += _columnWidths[c]; - continue; - } - final columnWidth = _columnWidths[c]; - - if (position.dx >= currentX && position.dx < currentX + columnWidth) { - // In this cell. - final verticalPadding = (rowHeight - painter.height) / 2; - final horizontalPadding = - _cellHorizontalPadding(r, c, painter.width); - - final painterOffset = Offset( - currentX + horizontalPadding, currentY + verticalPadding); - final localPosition = position - painterOffset; - - // Check if inside the actual painted text area. - if (localPosition.dx < 0 || - localPosition.dx > painter.width || - localPosition.dy < 0 || - localPosition.dy > painter.height) { - currentX += columnWidth; - continue; - } - - final textPosition = painter.getPositionForOffset(localPosition); - final span = painter.text!.getSpanForPosition(textPosition); - if (span is TextSpan) { - return span; - } - return null; // Found cell, but no span. - } - currentX += columnWidth; - } - } - currentY += rowHeight; - } - return null; - } - - @override - Size layout(double width) { - if (columns < 1) return _size = Size.zero; - - // Dispose old painters - for (final row in _cellPainters) { - for (final painter in row) { - painter.dispose(); - } - } - - final allRows = [header, ...rows]; - final naturalWidths = List.filled(columns, 0.0); - final minWidths = List.filled(columns, 0.0); - - // Create painters for each row and column and calculate natural widths - _cellPainters = List.generate(allRows.length, (r) { - final row = allRows[r]; - return List.generate(columns, (c) { - if (c >= row.cells.length) { - return TextPainter(textDirection: theme.textDirection); - } - final cell = row.cells[c]; - final style = (r == 0) - ? theme.textStyle.copyWith(fontWeight: FontWeight.bold) - : null; - final textPainter = TextPainter( - text: _paragraphFromMarkdownSpans( - spans: cell, theme: theme, textStyle: style), - textAlign: switch (_columnAlign(c)) { - MD$TableColumnAlign.left => TextAlign.left, - MD$TableColumnAlign.center => TextAlign.center, - MD$TableColumnAlign.right => TextAlign.right, - MD$TableColumnAlign.none => - (r == 0) ? TextAlign.center : TextAlign.start, - }, - textDirection: theme.textDirection, - textScaler: theme.textScaler, - ); - - // Calculate natural width - textPainter.layout(maxWidth: double.infinity); - naturalWidths[c] = - math.max(naturalWidths[c], textPainter.width + padding * 2); - - // Calculate min width (longest word) - final cellText = cell.map((s) => s.text).join(); - final words = cellText.split(RegExp(r'\s+')); - if (words.isNotEmpty) { - final longestWord = - words.reduce((a, b) => a.length > b.length ? a : b); - final wordPainter = TextPainter( - text: TextSpan(text: longestWord, style: style), - textDirection: theme.textDirection, - )..layout(); - minWidths[c] = - math.max(minWidths[c], wordPainter.width + padding * 2); - wordPainter.dispose(); - } - - return textPainter; - }); - }); - - _columnWidths.setAll(0, _distributeWidths(naturalWidths, minWidths, width)); - - final totalWidth = _columnWidths.reduce((a, b) => a + b); - - // Layout painters with final widths and calculate row heights - - double totalHeight = 0.0; - for (int r = 0; r < allRows.length; r++) { - double rowHeight = 0.0; - for (int c = 0; c < columns; c++) { - final painter = _cellPainters[r][c]; - if (painter.text == null) continue; - painter.layout(maxWidth: math.max(0.0, _columnWidths[c] - padding * 2)); - rowHeight = math.max( - rowHeight, - painter.height, - ); - } - - _rowHeights[r] = rowHeight + padding * 2; - totalHeight += _rowHeights[r]; - } - - // Cache border points - final points = Float32List(((allRows.length - 1) + (columns - 1)) * 4); - var pointIndex = 0; - // Horizontal lines - double lineY = 0; - for (int r = 0; r < allRows.length - 1; r++) { - lineY += _rowHeights[r]; - points[pointIndex++] = 0; - points[pointIndex++] = lineY; - points[pointIndex++] = totalWidth; - points[pointIndex++] = lineY; - } - // Vertical lines - double lineX = 0; - for (int c = 0; c < columns - 1; c++) { - lineX += _columnWidths[c]; - points[pointIndex++] = lineX; - points[pointIndex++] = 0; - points[pointIndex++] = lineX; - points[pointIndex++] = totalHeight; - } - _borderPoints = points; - - _rebuildFragments(allRows); - - return _size = Size(totalWidth, totalHeight); - } - - /// Rebuilds the selectable fragments (row-major: cells joined by `\t`, rows by - /// `\n`) so they line up with `markdownBlockRenderedText`. Only the cells that - /// exist in the source row contribute text, matching the painted cells. - void _rebuildFragments(List allRows) { - final frags = []; - final text = StringBuffer(); - var rowTop = 0.0; - for (var r = 0; r < allRows.length; r++) { - if (r > 0) text.write('\n'); - final cells = allRows[r].cells; - var colLeft = 0.0; - for (var c = 0; c < columns; c++) { - if (c < cells.length) { - if (c > 0) text.write('\t'); - final painter = _cellPainters[r][c]; - final verticalPadding = (_rowHeights[r] - painter.height) / 2; - final horizontalPadding = _cellHorizontalPadding(r, c, painter.width); - final origin = - Offset(colLeft + horizontalPadding, rowTop + verticalPadding); - frags.add(SelectableFragment(painter, origin, text.length)); - text.write(painter.plainText); - } - colLeft += _columnWidths[c]; - } - rowTop += _rowHeights[r]; - } - _fragments = frags; - _renderedText = text.toString(); - } - - @override - void paint(Canvas canvas, Size size, double offset) { - // If the width is less than required do not paint anything. - if (columns < 1) return; - - double currentY = offset; - final rowHeights = - List.generate(_cellPainters.length, (r) => _rowHeights[r]); - - for (int r = 0; r < _cellPainters.length; r++) { - double currentX = 0; - - // Draw background for even data rows. - if (r % 2 == 0 && r != 0) { - canvas.drawRect( - Rect.fromLTWH(0, currentY, _size.width, rowHeights[r]), - _rowBackgroundPaint, - ); - } - - for (int c = 0; c < columns; c++) { - final painter = _cellPainters[r][c]; - if (painter.text == null) { - currentX += _cellPainters[r].length > c ? _columnWidths[c] : 0; - continue; - } - - final verticalPadding = (rowHeights[r] - painter.height) / 2; - final horizontalPadding = _cellHorizontalPadding(r, c, painter.width); - - painter.paint( - canvas, - Offset( - currentX + horizontalPadding, - currentY + verticalPadding, - ), - ); - currentX += _columnWidths[c]; - } - currentY += rowHeights[r]; - } - - // Draw inner borders - if (_borderPoints != null) { - canvas.save(); - canvas.translate(0, offset); - canvas.drawRawPoints(PointMode.lines, _borderPoints!, _borderPaint); - canvas.restore(); - } - - // Draw outer borders - canvas.drawRect( - Rect.fromLTRB( - 0, - offset, - _size.width, - offset + _size.height, - ), - _borderPaint, - ); - } - - @override - void dispose() { - for (final row in _cellPainters) { - for (final painter in row) { - painter.dispose(); - } - } - _cellPainters = const []; - _fragments = const []; - } - - /// Helper function to distribute widths among columns, respecting minimums. - /// If total minimum width exceeds availableWidth, - /// it returns the minimum widths as-is, - /// implying that the content will overflow and require scrolling. - List _distributeWidths( - List natural, List min, double availableWidth) { - final totalNatural = natural.reduce((a, b) => a + b); - final totalMin = min.reduce((a, b) => a + b); - - if (totalNatural <= availableWidth) { - return natural; - } - - if (totalMin <= availableWidth) { - final remainingSpace = availableWidth - totalMin; - final extraSpacePerColumn = [ - for (var i = 0; i < natural.length; i++) natural[i] - min[i] - ]; - final totalExtraSpace = extraSpacePerColumn.reduce((a, b) => a + b); - - if (totalExtraSpace <= 0.001) return min; - - return [ - for (var i = 0; i < natural.length; i++) - min[i] + remainingSpace * (extraSpacePerColumn[i] / totalExtraSpace) - ]; - } - return min; - } -} +/// The block-painter framework, the default block painters, and the render +/// object / painter that drive them live under `render/`. This file re-exports +/// them so existing imports of `src/render.dart` keep resolving unchanged. +library; + +export 'render/block_painter.dart'; +export 'render/markdown_painter.dart'; +export 'render/markdown_render_object.dart'; +export 'render/span_builder.dart'; + +export 'render/blocks/alert.dart'; +export 'render/blocks/code.dart'; +export 'render/blocks/divider.dart'; +export 'render/blocks/heading.dart'; +export 'render/blocks/list.dart'; +export 'render/blocks/paragraph.dart'; +export 'render/blocks/quote.dart'; +export 'render/blocks/spacer.dart'; +export 'render/blocks/table.dart'; diff --git a/lib/src/render/block_painter.dart b/lib/src/render/block_painter.dart new file mode 100644 index 0000000..cd2d931 --- /dev/null +++ b/lib/src/render/block_painter.dart @@ -0,0 +1,190 @@ +//ignore_for_file: unnecessary_import + +import 'dart:math' as math; +import 'dart:ui'; + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; + +/// A class for painting blocks in markdown. +/// You can implement this interface to create custom block painters. +abstract interface class BlockPainter { + /// The current size of the block. + /// Available only after [layout]. + abstract final Size size; + + /// Handle tap pointer down events for the block. + void handleTapDown(PointerDownEvent event); + + /// Handle tap pointer up events for the block. + void handleTapUp(PointerUpEvent event); + + /// Measure the block size with the given width. + Size layout(double width); + + /// Paint the block on the canvas at the given offset. + /// [canvas] is the canvas to paint on + /// [size] the whole size of the markdown content + /// [offset] is the vertical offset to paint the block at + void paint(Canvas canvas, Size size, double offset); + + /// Dispose all resources used by the painter. + void dispose(); +} + +/// A [BlockPainter] that supports text selection. All coordinates are local to +/// the block's top-left corner (as passed to [paint]'s `offset`). +/// +/// The [renderedText] should match [markdownBlockRenderedText] for the same +/// block so that hit-testing, highlighting, and model-side extraction agree on +/// the offset space. +/// +/// Caveat: a [MarkdownThemeData.spanFilter] that drops text-bearing spans +/// shifts the painter's offset space relative to the (unfiltered) model, so the +/// on-screen highlight stays correct but copied text may be misaligned. Avoid +/// dropping text-bearing spans when selection is enabled. +abstract interface class SelectableBlockPainter implements BlockPainter { + /// The block's rendered plain text. + String get renderedText; + + /// Maps a block-local [local] offset to a rendered-text index. + int offsetForLocalPosition(Offset local); + + /// Highlight rectangles (block-local) for the rendered range `[start, end)`. + List boxesForRange(int start, int end); +} + +/// Provides [SelectableBlockPainter] for a block backed by a single +/// [TextPainter]. Subclasses supply [selectionPainter] and, when the glyphs are +/// not painted at the block origin, [selectionOrigin]. +mixin SelectableTextBlock implements SelectableBlockPainter { + /// The text painter that owns the selectable glyphs. + TextPainter get selectionPainter; + + /// Block-local origin where [selectionPainter] is painted. + Offset get selectionOrigin => Offset.zero; + + @override + String get renderedText => selectionPainter.plainText; + + @override + int offsetForLocalPosition(Offset local) => + selectionPainter.getPositionForOffset(local - selectionOrigin).offset; + + @override + List boxesForRange(int start, int end) => selectionPainter + .getBoxesForSelection(TextSelection(baseOffset: start, extentOffset: end)) + .map((box) => box.toRect().shift(selectionOrigin)) + .toList(growable: false); +} + +/// One selectable text run inside a multi-painter block (a list item or a table +/// cell): the [painter] that owns its glyphs, the block-local [origin] where it +/// is painted, and the [textStart] index of its text within the block's +/// [markdownBlockRenderedText] linearization. +class SelectableFragment { + /// Creates a fragment for [painter] painted at [origin], whose text begins at + /// [textStart] in the block's rendered text. + SelectableFragment(this.painter, this.origin, this.textStart); + + /// The text painter that owns this run's glyphs. + final TextPainter painter; + + /// Block-local top-left where [painter] is painted. + final Offset origin; + + /// Index of this run's first character in the block's rendered text. + final int textStart; + + /// Length of this run's text. + int get length => painter.plainText.length; + + /// One-past-the-last index of this run's text in the block's rendered text. + int get textEnd => textStart + length; +} + +/// Provides [SelectableBlockPainter] for a block whose text is spread across +/// several [TextPainter]s painted at different origins (lists, tables). +/// +/// [fragments] must be listed in the same order their text appears in +/// [markdownBlockRenderedText], with each fragment's `textStart` matching that +/// linearization (the gaps between fragments are the `\n`/`\t` separators, which +/// have no glyphs of their own). [renderedText] must equal that linearization. +mixin MultiPainterSelectable implements SelectableBlockPainter { + /// The selectable runs of this block, in rendered-text order. Rebuilt on each + /// [BlockPainter.layout]. + List get fragments; + + @override + int offsetForLocalPosition(Offset local) { + final frags = fragments; + if (frags.isEmpty) return 0; + SelectableFragment? best; + var bestDistance = double.infinity; + for (final fragment in frags) { + final rect = fragment.origin & fragment.painter.size; + final distance = _distanceToRect(local, rect); + if (distance < bestDistance) { + bestDistance = distance; + best = fragment; + if (distance == 0) break; + } + } + final fragment = best!; + final inner = + fragment.painter.getPositionForOffset(local - fragment.origin).offset; + return fragment.textStart + inner.clamp(0, fragment.length); + } + + @override + List boxesForRange(int start, int end) { + final out = []; + for (final fragment in fragments) { + final localStart = start.clamp(fragment.textStart, fragment.textEnd) - + fragment.textStart; + final localEnd = + end.clamp(fragment.textStart, fragment.textEnd) - fragment.textStart; + if (localEnd <= localStart) continue; + final boxes = fragment.painter.getBoxesForSelection( + TextSelection(baseOffset: localStart, extentOffset: localEnd)); + for (final box in boxes) { + out.add(box.toRect().shift(fragment.origin)); + } + } + return out; + } +} + +/// Shortest distance from [point] to [rect] (0 when the point is inside). +double _distanceToRect(Offset point, Rect rect) { + final dx = point.dx < rect.left + ? rect.left - point.dx + : (point.dx > rect.right ? point.dx - rect.right : 0.0); + final dy = point.dy < rect.top + ? rect.top - point.dy + : (point.dy > rect.bottom ? point.dy - rect.bottom : 0.0); + if (dx == 0) return dy; + if (dy == 0) return dx; + return math.sqrt(dx * dx + dy * dy); +} + +/// Mixin for block painters backed by a [TextPainter] that want to fire link +/// (or other) tap recognizers: [hitTestInlineSpanWithPointerEvent] resolves the +/// [InlineSpan] under a pointer so [BlockPainter.handleTapDown] / +/// [BlockPainter.handleTapUp] can match down and up on the same span. +mixin ParagraphGestureHandler { + /// The [InlineSpan] under [event] within [painter], or null if none. + @protected + InlineSpan? hitTestInlineSpanWithPointerEvent( + PointerEvent event, TextPainter painter) { + final pos = painter.getPositionForOffset(event.localPosition); + //final int index = pos.offset; + final span = painter.text?.getSpanForPosition(pos); + //final plainText = span?.toPlainText(); + //print('[${pos.offset}] $plainText'); + return span; + } +} diff --git a/lib/src/render/blocks/alert.dart b/lib/src/render/blocks/alert.dart new file mode 100644 index 0000000..e543755 --- /dev/null +++ b/lib/src/render/blocks/alert.dart @@ -0,0 +1,174 @@ +//ignore_for_file: unnecessary_import + +import 'dart:math' as math; +import 'dart:ui'; + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; + +import '../../nodes.dart'; +import '../../theme.dart'; +import '../block_painter.dart'; +import '../span_builder.dart'; + +/// A class for painting a GitHub-style alert (admonition) block in markdown. +class BlockPainter$Alert + with ParagraphGestureHandler, SelectableTextBlock + 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]. + BlockPainter$Alert({ + required this.alert, + required List spans, + required this.theme, + }) : _accent = theme.alertColorFor(alert), + titlePainter = TextPainter( + text: TextSpan( + text: alert.title, + style: theme.textStyle.copyWith( + color: theme.alertColorFor(alert), + fontWeight: FontWeight.bold, + ), + ), + textAlign: TextAlign.start, + textDirection: theme.textDirection, + textScaler: theme.textScaler, + ), + bodyPainter = TextPainter( + text: paragraphFromMarkdownSpans(spans: spans, theme: theme), + textAlign: TextAlign.start, + textDirection: theme.textDirection, + textScaler: theme.textScaler, + ); + + /// The kind of the alert being painted. + final MD$AlertType alert; + + /// The theme used to style the alert. + final MarkdownThemeData theme; + + final Color _accent; + + /// 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; + + /// Width of the accent bar on the left edge. + static const double barWidth = 4.0; + + /// Gap between the accent bar and the content. + static const double gap = 10.0; + + /// Vertical gap between the title and the body. + static const double titleGap = 4.0; + + /// Left offset where the title/body content begins. + double get _contentLeft => padding + barWidth + gap; + + @override + Size get size => _size; + Size _size = Size.zero; + + /// Whether the body has any content to paint. + bool _hasBody = false; + + /// Last span hit by the tap down event. + TextSpan? _lastSpan; + + Offset get _bodyOrigin => + Offset(_contentLeft, padding + titlePainter.height + titleGap); + + 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; + } + + @override + void handleTapDown(PointerDownEvent event) { + _lastSpan = _spanForPosition(event.localPosition); + } + + @override + void handleTapUp(PointerUpEvent event) { + if (_lastSpan == null) return; + final span = _spanForPosition(event.localPosition); + if (span != null && _lastSpan == span) { + if (span case TextSpan(recognizer: TapGestureRecognizer(:var onTap))) + onTap?.call(); + } + _lastSpan = null; + } + + @override + 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; + + final contentWidth = math.max( + titlePainter.width, + _hasBody ? bodyPainter.width : 0.0, + ); + final contentHeight = + titlePainter.height + (_hasBody ? titleGap + bodyPainter.height : 0.0); + return _size = Size( + _contentLeft + contentWidth + padding, + contentHeight + padding * 2, + ); + } + + @override + void paint(Canvas canvas, Size size, double offset) { + if (size.width < _size.width) return; + + final rect = Rect.fromLTWH(0, offset, size.width, _size.height); + // Tinted background. + canvas.drawRRect( + RRect.fromRectAndRadius(rect, const Radius.circular(6.0)), + Paint() + ..color = _accent.withValues(alpha: 0.10) + ..style = PaintingStyle.fill, + ); + // Accent bar on the left. + canvas.drawRRect( + RRect.fromRectAndRadius( + Rect.fromLTWH(0, offset, barWidth, _size.height), + const Radius.circular(barWidth / 2), + ), + Paint() + ..color = _accent + ..style = PaintingStyle.fill, + ); + + titlePainter.paint(canvas, Offset(_contentLeft, offset + padding)); + if (_hasBody) { + bodyPainter.paint(canvas, _bodyOrigin + Offset(0, offset)); + } + } + + @override + void dispose() { + titlePainter.dispose(); + bodyPainter.dispose(); + } +} diff --git a/lib/src/render/blocks/code.dart b/lib/src/render/blocks/code.dart new file mode 100644 index 0000000..f14e180 --- /dev/null +++ b/lib/src/render/blocks/code.dart @@ -0,0 +1,98 @@ +//ignore_for_file: unnecessary_import + +import 'dart:ui'; + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; + +import '../../theme.dart'; +import '../block_painter.dart'; + +/// A class for painting a code block in markdown. +class BlockPainter$Code with SelectableTextBlock implements BlockPainter { + @override + TextPainter get selectionPainter => painter; + @override + Offset get selectionOrigin => const Offset(padding, padding); + + /// Creates a code-block painter for [text] in [language], styled by [theme]. + BlockPainter$Code({ + required String text, + required String? language, + required this.theme, + }) : painter = TextPainter( + text: TextSpan( + text: text, + style: theme.textStyle.copyWith( + fontFamily: 'monospace', + fontSize: theme.textStyle.fontSize ?? kDefaultFontSize, + ), + ), + textAlign: TextAlign.start, + textDirection: theme.textDirection, + textScaler: theme.textScaler, + ); + + /// Padding around the code text, inside its rounded background. + static const double padding = 8.0; + + /// The theme used to style the code block. + final MarkdownThemeData theme; + + /// The text painter that owns the code's glyphs. + final TextPainter painter; + + @override + Size get size => _size; + Size _size = Size.zero; + + @override + void handleTapDown(PointerDownEvent _) {/* Do nothing */} + + @override + void handleTapUp(PointerUpEvent _) {/* Do nothing */} + + @override + Size layout(double width) { + if (width <= padding * 2) { + // If the width is less than or equal to padding, return zero size. + _size = Size.zero; + return _size; + } + painter.layout( + minWidth: 0, + maxWidth: width - padding * 2, + ); + return _size = Size( + painter.size.width + padding * 2, // Add padding to the width. + painter.size.height + padding * 2, // Add padding to the height. + ); + } + + @override + void paint(Canvas canvas, Size size, double offset) { + // If the width is less than required do not paint anything. + if (size.width < _size.width) return; + canvas.drawRRect( + RRect.fromRectAndRadius( + Rect.fromLTWH(0, offset, size.width, _size.height), + const Radius.circular(padding), + ), + Paint() + ..color = theme.surfaceColor ?? const Color.fromARGB(255, 235, 235, 235) + ..isAntiAlias = false + ..style = PaintingStyle.fill, + ); + painter.paint( + canvas, + Offset(padding, offset + padding), + ); + } + + @override + void dispose() { + painter.dispose(); + } +} diff --git a/lib/src/render/blocks/divider.dart b/lib/src/render/blocks/divider.dart new file mode 100644 index 0000000..9bfb3c3 --- /dev/null +++ b/lib/src/render/blocks/divider.dart @@ -0,0 +1,60 @@ +//ignore_for_file: unnecessary_import + +import 'dart:ui'; + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; + +import '../../theme.dart'; +import '../block_painter.dart'; + +/// A class for painting a spacer block in markdown. +class BlockPainter$Divider implements BlockPainter { + /// Creates a thematic-break (horizontal rule) painter styled by [theme]. + BlockPainter$Divider({ + required this.theme, + }) : _paint = Paint() + ..color = theme.textStyle.color ?? const Color(0xFF000000) + ..isAntiAlias = false + ..strokeWidth = 1.0 + ..style = PaintingStyle.fill; + + final Paint _paint; + + /// The theme used to color and size the divider. + final MarkdownThemeData theme; + + @override + Size get size => _size; + Size _size = Size.zero; + + @override + void handleTapDown(PointerDownEvent _) {/* Do nothing */} + + @override + void handleTapUp(PointerUpEvent _) {/* Do nothing */} + + @override + Size layout(double width) { + final height = theme.textStyle.fontSize ?? kDefaultFontSize; + return _size = Size(0, height); + } + + @override + void paint(Canvas canvas, Size size, double offset) { + // Draw a horizontal line across the width of the canvas. + final center = offset + _size.height / 2; + canvas.drawLine( + Offset(0, center), + Offset(size.width, center), + _paint, + ); + } + + @override + void dispose() { + // Noting to dispose + } +} diff --git a/lib/src/render/blocks/heading.dart b/lib/src/render/blocks/heading.dart new file mode 100644 index 0000000..8315d10 --- /dev/null +++ b/lib/src/render/blocks/heading.dart @@ -0,0 +1,94 @@ +//ignore_for_file: unnecessary_import + +import 'dart:ui'; + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; + +import '../../nodes.dart'; +import '../../theme.dart'; +import '../block_painter.dart'; +import '../span_builder.dart'; + +/// A class for painting a heading block in markdown. +class BlockPainter$Heading + with ParagraphGestureHandler, SelectableTextBlock + implements BlockPainter { + @override + TextPainter get selectionPainter => painter; + + /// Creates a heading painter for [spans] at [level] (1-6), styled by [theme]. + BlockPainter$Heading({ + required int level, + required List spans, + required this.theme, + }) : painter = TextPainter( + text: paragraphFromMarkdownSpans( + spans: spans, + theme: theme, + textStyle: theme.headingStyleFor(level), + ), + textAlign: TextAlign.start, + textDirection: theme.textDirection, + textScaler: theme.textScaler, + ); + + /// The theme used to style the heading. + final MarkdownThemeData theme; + + /// The text painter that owns the heading's glyphs. + final TextPainter painter; + + @override + Size get size => _size; + Size _size = Size.zero; + + /// Last span hit by the tap down event. + TextSpan? _lastSpan; + + @override + void handleTapDown(PointerDownEvent event) { + _lastSpan = null; // Reset the span on tap down. + final span = hitTestInlineSpanWithPointerEvent(event, painter); + 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 (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))) + onTap?.call(); + } + _lastSpan = null; // Clear the span after handling the tap. + } + + @override + Size layout(double width) { + painter.layout( + minWidth: 0, + maxWidth: width, + ); + return _size = painter.size; + } + + @override + void paint(Canvas canvas, Size size, double offset) { + // If the width is less than required do not paint anything. + if (size.width < _size.width) return; + painter.paint( + canvas, + Offset(0, offset), + ); + } + + @override + void dispose() { + painter.dispose(); + } +} diff --git a/lib/src/render/blocks/list.dart b/lib/src/render/blocks/list.dart new file mode 100644 index 0000000..fe538db --- /dev/null +++ b/lib/src/render/blocks/list.dart @@ -0,0 +1,203 @@ +//ignore_for_file: unnecessary_import + +import 'dart:math' as math; +import 'dart:ui'; + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; + +import '../../nodes.dart'; +import '../../theme.dart'; +import '../block_painter.dart'; +import '../span_builder.dart'; + +/// A helper class to store layout information for a single list item. +class _ListItemMetrics { + _ListItemMetrics({ + required this.bulletPainter, + required this.contentPainter, + required this.offset, + }); + + final TextPainter bulletPainter; + final TextPainter contentPainter; + final Offset offset; + + late final double height = + math.max(bulletPainter.height, contentPainter.height); + late final Size size = + Size(bulletPainter.width + contentPainter.width, height); + + void dispose() { + bulletPainter.dispose(); + contentPainter.dispose(); + } +} + +/// A class for painting a list block in markdown. +class BlockPainter$List + with ParagraphGestureHandler, MultiPainterSelectable + implements BlockPainter { + /// Creates a list painter for [items] (bulleted, ordered or task list), + /// styled by [theme]. + BlockPainter$List({ + required List items, + required this.theme, + }) : _items = items, + _painters = <_ListItemMetrics>[]; + + /// The theme used to style the list. + final MarkdownThemeData theme; + final List _items; + final List<_ListItemMetrics> _painters; + + @override + List get fragments => _fragments; + List _fragments = const []; + + @override + String get renderedText => _renderedText; + String _renderedText = ''; + + // Indentation for the entire list block. + static const double _baseIndent = 8.0; + + // Indentation for each level of nesting. + static const double _levelIndent = 16.0; + + @override + Size get size => _size; + Size _size = Size.zero; + + /// Last span hit by the tap down event. + InlineSpan? _lastSpan; + + InlineSpan? _getSpanForPosition(Offset localPosition) { + for (final metrics in _painters) { + final contentOffset = + metrics.offset + Offset(metrics.bulletPainter.width, 0); + final contentRect = contentOffset & metrics.contentPainter.size; + if (contentRect.contains(localPosition)) { + final painterPosition = localPosition - contentOffset; + final textPosition = + metrics.contentPainter.getPositionForOffset(painterPosition); + return metrics.contentPainter.text?.getSpanForPosition(textPosition); + } + } + return null; + } + + @override + void handleTapDown(PointerDownEvent event) { + _lastSpan = null; // Reset the span on tap down. + _lastSpan = _getSpanForPosition(event.localPosition); + } + + @override + void handleTapUp(PointerUpEvent event) { + if (_lastSpan == null) return; // No span was hit on tap down. + final newSpan = _getSpanForPosition(event.localPosition); + if (newSpan != null && _lastSpan == newSpan) { + if (newSpan + case TextSpan(recognizer: final TapGestureRecognizer recognizer)) { + recognizer.onTap?.call(); + } + } + + _lastSpan = null; // Clear the span after handling the tap. + } + + @override + Size layout(double width) { + for (final painter in _painters) { + painter.dispose(); + } + _painters.clear(); + + double currentHeight = 0; + double maxContentWidth = 0; + + void layoutItems(List items, int level) { + final indent = _baseIndent + level * _levelIndent; + for (final item in items) { + // Task-list items render a checkbox instead of a bullet/number. + final bulletText = switch (item.checked) { + true => '☑', + false => '☐', + null => switch (item.marker) { + '-' || '*' || '+' => '•', + _ => item.marker, + }, + }; + final bulletPainter = TextPainter( + text: TextSpan(text: '$bulletText ', style: theme.textStyle), + textDirection: theme.textDirection, + textScaler: theme.textScaler, + )..layout(); + + final contentPainter = TextPainter( + text: paragraphFromMarkdownSpans(spans: item.spans, theme: theme), + textDirection: theme.textDirection, + textScaler: theme.textScaler, + )..layout(maxWidth: math.max(0, width - indent - bulletPainter.width)); + + final metrics = _ListItemMetrics( + bulletPainter: bulletPainter, + contentPainter: contentPainter, + offset: Offset(indent, currentHeight), + ); + _painters.add(metrics); + + currentHeight += metrics.height; + maxContentWidth = + math.max(maxContentWidth, indent + metrics.size.width); + + if (item.children.isNotEmpty) { + layoutItems(item.children, level + 1); + } + } + } + + layoutItems(_items, 0); + _rebuildFragments(); + return _size = Size(maxContentWidth, currentHeight); + } + + /// Rebuilds the selectable fragments from the laid-out item metrics. Items + /// (depth-first, matching `markdownBlockRenderedText`) are joined by `\n`. + void _rebuildFragments() { + final frags = []; + var textPos = 0; + for (final metrics in _painters) { + if (frags.isNotEmpty) textPos += 1; // the '\n' item separator + final origin = metrics.offset + Offset(metrics.bulletPainter.width, 0); + frags.add(SelectableFragment(metrics.contentPainter, origin, textPos)); + textPos += metrics.contentPainter.plainText.length; + } + _fragments = frags; + _renderedText = frags.map((f) => f.painter.plainText).join('\n'); + } + + @override + void paint(Canvas canvas, Size size, double offset) { + for (final metrics in _painters) { + final bulletOffset = metrics.offset + Offset(0, offset); + metrics.bulletPainter.paint(canvas, bulletOffset); + + final contentOffset = + bulletOffset + Offset(metrics.bulletPainter.width, 0); + metrics.contentPainter.paint(canvas, contentOffset); + } + } + + @override + void dispose() { + for (final metrics in _painters) { + metrics.dispose(); + } + _painters.clear(); + _fragments = const []; + } +} diff --git a/lib/src/render/blocks/paragraph.dart b/lib/src/render/blocks/paragraph.dart new file mode 100644 index 0000000..aac47e0 --- /dev/null +++ b/lib/src/render/blocks/paragraph.dart @@ -0,0 +1,92 @@ +//ignore_for_file: unnecessary_import + +import 'dart:ui'; + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; + +import '../../nodes.dart'; +import '../../theme.dart'; +import '../block_painter.dart'; +import '../span_builder.dart'; + +/// A class for painting a paragraph block in markdown. +class BlockPainter$Paragraph + with ParagraphGestureHandler, SelectableTextBlock + implements BlockPainter { + @override + TextPainter get selectionPainter => painter; + + /// Creates a paragraph painter for [spans], styled by [theme]. + BlockPainter$Paragraph({ + required List spans, + required this.theme, + }) : painter = TextPainter( + text: paragraphFromMarkdownSpans( + spans: spans, + theme: theme, + ), + textAlign: TextAlign.start, + textDirection: theme.textDirection, + textScaler: theme.textScaler, + ); + + /// The theme used to style the paragraph. + final MarkdownThemeData theme; + + /// The text painter that owns the paragraph's glyphs. + final TextPainter painter; + + @override + Size get size => _size; + Size _size = Size.zero; + + /// Last span hit by the tap down event. + TextSpan? _lastSpan; + + @override + void handleTapDown(PointerDownEvent event) { + _lastSpan = null; // Reset the span on tap down. + final span = hitTestInlineSpanWithPointerEvent(event, painter); + 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 (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))) + onTap?.call(); + } + _lastSpan = null; // Clear the span after handling the tap. + } + + @override + Size layout(double width) { + painter.layout( + minWidth: 0, + maxWidth: width, + ); + return _size = painter.size; + } + + @override + void paint(Canvas canvas, Size size, double offset) { + // If the width is less than required do not paint anything. + if (size.width < _size.width) return; + painter.paint( + canvas, + Offset(0, offset), + ); + } + + @override + void dispose() { + painter.dispose(); + } +} diff --git a/lib/src/render/blocks/quote.dart b/lib/src/render/blocks/quote.dart new file mode 100644 index 0000000..5a8fb3c --- /dev/null +++ b/lib/src/render/blocks/quote.dart @@ -0,0 +1,135 @@ +//ignore_for_file: unnecessary_import + +import 'dart:math' as math; +import 'dart:ui'; + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; + +import '../../nodes.dart'; +import '../../theme.dart'; +import '../block_painter.dart'; +import '../span_builder.dart'; + +/// A class for painting a quote block in markdown. +class BlockPainter$Quote + with ParagraphGestureHandler, SelectableTextBlock + 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]. + 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, + ), + linePaint = Paint() + ..color = theme.dividerColor ?? + const Color(0x7F7F7F7F) // Gray color for the line. + ..isAntiAlias = false + ..strokeWidth = 4.0 + ..style = PaintingStyle.fill; + + /// 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; + + /// Paint used for the vertical accent bar(s) on the left. + final Paint linePaint; + + @override + Size get size => _size; + Size _size = Size.zero; + + /// Last span hit by the tap down event. + TextSpan? _lastSpan; + + @override + void handleTapDown(PointerDownEvent event) { + _lastSpan = null; // Reset the span on tap down. + final span = hitTestInlineSpanWithPointerEvent(event, painter); + 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 (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))) + onTap?.call(); + } + _lastSpan = null; // Clear the span after handling the tap. + } + + @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, + ); + } + + @override + void paint(Canvas canvas, Size size, double offset) { + // If the width is less than required do not paint anything. + if (size.width < _size.width) return; + + // --- Draw vertical lines --- // + for (var i = 1; i <= indent; i++) + canvas.drawLine( + Offset( + i * lineIndent, + offset, + ), + Offset( + i * lineIndent, + offset + _size.height, + ), + linePaint, + ); + + painter.paint( + canvas, + Offset( + lineIndent + indent * lineIndent, + offset, + ), + ); + } + + @override + void dispose() { + painter.dispose(); + } +} diff --git a/lib/src/render/blocks/spacer.dart b/lib/src/render/blocks/spacer.dart new file mode 100644 index 0000000..68be44a --- /dev/null +++ b/lib/src/render/blocks/spacer.dart @@ -0,0 +1,56 @@ +//ignore_for_file: unnecessary_import + +import 'dart:ui'; + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; + +import '../../theme.dart'; +import '../block_painter.dart'; + +/// A class for painting a spacer block in markdown. +class BlockPainter$Spacer implements BlockPainter { + /// Creates a spacer painter [count] blank lines tall, sized by [theme]. + BlockPainter$Spacer({ + required this.count, + required this.theme, + }); + + /// Number of blank lines this spacer occupies. + final int count; + + /// The theme whose text size determines the spacer's height. + final MarkdownThemeData theme; + + @override + Size get size => _size; + Size _size = Size.zero; + + @override + void handleTapDown(PointerDownEvent _) {/* Do nothing */} + + @override + void handleTapUp(PointerUpEvent _) {/* Do nothing */} + + @override + Size layout(double width) { + final height = theme.textStyle.fontSize ?? kDefaultFontSize; + return _size = Size(0, height * count); + } + + @override + void paint(Canvas canvas, Size size, double offset) { + // Do not paint anything + /* canvas.drawRect( + Rect.fromLTWH(0, offset, size.width, _size.height), + Paint()..color = theme.textStyle.color ?? const Color(0x00000000), + ); */ + } + + @override + void dispose() { + // Noting to dispose + } +} diff --git a/lib/src/render/blocks/table.dart b/lib/src/render/blocks/table.dart new file mode 100644 index 0000000..8e8b55c --- /dev/null +++ b/lib/src/render/blocks/table.dart @@ -0,0 +1,420 @@ +//ignore_for_file: unnecessary_import + +import 'dart:math' as math; +import 'dart:ui'; + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; + +import '../../nodes.dart'; +import '../../theme.dart'; +import '../block_painter.dart'; +import '../span_builder.dart'; + +/// A class for painting a table block in markdown. +class BlockPainter$Table + with ParagraphGestureHandler, MultiPainterSelectable + implements BlockPainter { + /// Creates a table painter for the [header] row and data [rows], with + /// per-column [alignments], styled by [theme]. + BlockPainter$Table({ + required this.header, + required this.rows, + required this.theme, + this.alignments = const [], + }) : columns = header.cells.length, + _columnWidths = List.filled(header.cells.length, 0.0), + _rowHeights = List.filled(rows.length + 1, 0.0), + _borderPaint = Paint() + ..color = theme.dividerColor ?? const Color(0x1F000000) + ..style = PaintingStyle.stroke + ..isAntiAlias = false + ..strokeWidth = 1.0, + _rowBackgroundPaint = Paint() + ..style = PaintingStyle.fill + ..isAntiAlias = false + ..color = + theme.surfaceColor ?? const Color.fromARGB(255, 235, 235, 235); + + /// Padding for table cells. + static const double padding = 8.0; + + /// The theme for the markdown table. + final MarkdownThemeData theme; + + /// The number of columns in the table. + final int columns; + + /// The per-column alignment derived from the delimiter row. + final List alignments; + + /// Resolves the alignment for column [c], defaulting to + /// [MD$TableColumnAlign.none] when unspecified. + MD$TableColumnAlign _columnAlign(int c) => c >= 0 && c < alignments.length + ? alignments[c] + : MD$TableColumnAlign.none; + + /// The horizontal offset of a cell's text within its column, honoring the + /// column alignment (falling back to centered headers / left-aligned data). + double _cellHorizontalPadding(int r, int c, double painterWidth) => + switch (_columnAlign(c)) { + MD$TableColumnAlign.left => padding, + MD$TableColumnAlign.center => (_columnWidths[c] - painterWidth) / 2, + MD$TableColumnAlign.right => _columnWidths[c] - painterWidth - padding, + MD$TableColumnAlign.none => + (r == 0) ? (_columnWidths[c] - painterWidth) / 2 : padding, + }; + + final List _columnWidths; + final List _rowHeights; + final Paint _borderPaint; + final Paint _rowBackgroundPaint; + + Float32List? _borderPoints; + + /// The header row of the table. + final MD$TableRow header; + + /// The rows of the table. + final List rows; + + @override + Size get size => _size; + Size _size = Size.zero; + + List> _cellPainters = const []; + + @override + List get fragments => _fragments; + List _fragments = const []; + + @override + String get renderedText => _renderedText; + String _renderedText = ''; + + /// Last span hit by the tap down event. + TextSpan? _lastSpan; + + @override + void handleTapDown(PointerDownEvent event) { + _lastSpan = null; // Reset the span on tap down. + final span = _getSpanForOffset(event.localPosition); + if (span != null) { + _lastSpan = span; + } + } + + @override + void handleTapUp(PointerUpEvent event) { + if (_lastSpan == null) return; // No span was hit on tap down. + final span = _getSpanForOffset(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))) + onTap?.call(); + } + _lastSpan = null; // Clear the span after handling the tap. + } + + TextSpan? _getSpanForOffset(Offset position) { + double currentY = 0.0; + + for (int r = 0; r < _cellPainters.length; r++) { + final rowHeight = _rowHeights[r]; + double currentX = 0.0; + + if (position.dy >= currentY && position.dy < currentY + rowHeight) { + // In this row. + for (int c = 0; c < _cellPainters[r].length; c++) { + final painter = _cellPainters[r][c]; + if (painter.text == null) { + currentX += _columnWidths[c]; + continue; + } + final columnWidth = _columnWidths[c]; + + if (position.dx >= currentX && position.dx < currentX + columnWidth) { + // In this cell. + final verticalPadding = (rowHeight - painter.height) / 2; + final horizontalPadding = + _cellHorizontalPadding(r, c, painter.width); + + final painterOffset = Offset( + currentX + horizontalPadding, currentY + verticalPadding); + final localPosition = position - painterOffset; + + // Check if inside the actual painted text area. + if (localPosition.dx < 0 || + localPosition.dx > painter.width || + localPosition.dy < 0 || + localPosition.dy > painter.height) { + currentX += columnWidth; + continue; + } + + final textPosition = painter.getPositionForOffset(localPosition); + final span = painter.text!.getSpanForPosition(textPosition); + if (span is TextSpan) { + return span; + } + return null; // Found cell, but no span. + } + currentX += columnWidth; + } + } + currentY += rowHeight; + } + return null; + } + + @override + Size layout(double width) { + if (columns < 1) return _size = Size.zero; + + // Dispose old painters + for (final row in _cellPainters) { + for (final painter in row) { + painter.dispose(); + } + } + + final allRows = [header, ...rows]; + final naturalWidths = List.filled(columns, 0.0); + final minWidths = List.filled(columns, 0.0); + + // Create painters for each row and column and calculate natural widths + _cellPainters = List.generate(allRows.length, (r) { + final row = allRows[r]; + return List.generate(columns, (c) { + if (c >= row.cells.length) { + return TextPainter(textDirection: theme.textDirection); + } + final cell = row.cells[c]; + final style = (r == 0) + ? theme.textStyle.copyWith(fontWeight: FontWeight.bold) + : null; + final textPainter = TextPainter( + text: paragraphFromMarkdownSpans( + spans: cell, theme: theme, textStyle: style), + textAlign: switch (_columnAlign(c)) { + MD$TableColumnAlign.left => TextAlign.left, + MD$TableColumnAlign.center => TextAlign.center, + MD$TableColumnAlign.right => TextAlign.right, + MD$TableColumnAlign.none => + (r == 0) ? TextAlign.center : TextAlign.start, + }, + textDirection: theme.textDirection, + textScaler: theme.textScaler, + ); + + // Calculate natural width + textPainter.layout(maxWidth: double.infinity); + naturalWidths[c] = + math.max(naturalWidths[c], textPainter.width + padding * 2); + + // Calculate min width (longest word) + final cellText = cell.map((s) => s.text).join(); + final words = cellText.split(RegExp(r'\s+')); + if (words.isNotEmpty) { + final longestWord = + words.reduce((a, b) => a.length > b.length ? a : b); + final wordPainter = TextPainter( + text: TextSpan(text: longestWord, style: style), + textDirection: theme.textDirection, + )..layout(); + minWidths[c] = + math.max(minWidths[c], wordPainter.width + padding * 2); + wordPainter.dispose(); + } + + return textPainter; + }); + }); + + _columnWidths.setAll(0, _distributeWidths(naturalWidths, minWidths, width)); + + final totalWidth = _columnWidths.reduce((a, b) => a + b); + + // Layout painters with final widths and calculate row heights + + double totalHeight = 0.0; + for (int r = 0; r < allRows.length; r++) { + double rowHeight = 0.0; + for (int c = 0; c < columns; c++) { + final painter = _cellPainters[r][c]; + if (painter.text == null) continue; + painter.layout(maxWidth: math.max(0.0, _columnWidths[c] - padding * 2)); + rowHeight = math.max( + rowHeight, + painter.height, + ); + } + + _rowHeights[r] = rowHeight + padding * 2; + totalHeight += _rowHeights[r]; + } + + // Cache border points + final points = Float32List(((allRows.length - 1) + (columns - 1)) * 4); + var pointIndex = 0; + // Horizontal lines + double lineY = 0; + for (int r = 0; r < allRows.length - 1; r++) { + lineY += _rowHeights[r]; + points[pointIndex++] = 0; + points[pointIndex++] = lineY; + points[pointIndex++] = totalWidth; + points[pointIndex++] = lineY; + } + // Vertical lines + double lineX = 0; + for (int c = 0; c < columns - 1; c++) { + lineX += _columnWidths[c]; + points[pointIndex++] = lineX; + points[pointIndex++] = 0; + points[pointIndex++] = lineX; + points[pointIndex++] = totalHeight; + } + _borderPoints = points; + + _rebuildFragments(allRows); + + return _size = Size(totalWidth, totalHeight); + } + + /// Rebuilds the selectable fragments (row-major: cells joined by `\t`, rows by + /// `\n`) so they line up with `markdownBlockRenderedText`. Only the cells that + /// exist in the source row contribute text, matching the painted cells. + void _rebuildFragments(List allRows) { + final frags = []; + final text = StringBuffer(); + var rowTop = 0.0; + for (var r = 0; r < allRows.length; r++) { + if (r > 0) text.write('\n'); + final cells = allRows[r].cells; + var colLeft = 0.0; + for (var c = 0; c < columns; c++) { + if (c < cells.length) { + if (c > 0) text.write('\t'); + final painter = _cellPainters[r][c]; + final verticalPadding = (_rowHeights[r] - painter.height) / 2; + final horizontalPadding = _cellHorizontalPadding(r, c, painter.width); + final origin = + Offset(colLeft + horizontalPadding, rowTop + verticalPadding); + frags.add(SelectableFragment(painter, origin, text.length)); + text.write(painter.plainText); + } + colLeft += _columnWidths[c]; + } + rowTop += _rowHeights[r]; + } + _fragments = frags; + _renderedText = text.toString(); + } + + @override + void paint(Canvas canvas, Size size, double offset) { + // If the width is less than required do not paint anything. + if (columns < 1) return; + + double currentY = offset; + + for (int r = 0; r < _cellPainters.length; r++) { + final rowHeight = _rowHeights[r]; + double currentX = 0; + + // Draw background for even data rows. + if (r % 2 == 0 && r != 0) { + canvas.drawRect( + Rect.fromLTWH(0, currentY, _size.width, rowHeight), + _rowBackgroundPaint, + ); + } + + for (int c = 0; c < columns; c++) { + final painter = _cellPainters[r][c]; + if (painter.text == null) { + currentX += _cellPainters[r].length > c ? _columnWidths[c] : 0; + continue; + } + + final verticalPadding = (rowHeight - painter.height) / 2; + final horizontalPadding = _cellHorizontalPadding(r, c, painter.width); + + painter.paint( + canvas, + Offset( + currentX + horizontalPadding, + currentY + verticalPadding, + ), + ); + currentX += _columnWidths[c]; + } + currentY += rowHeight; + } + + // Draw inner borders + if (_borderPoints != null) { + canvas.save(); + canvas.translate(0, offset); + canvas.drawRawPoints(PointMode.lines, _borderPoints!, _borderPaint); + canvas.restore(); + } + + // Draw outer borders + canvas.drawRect( + Rect.fromLTRB( + 0, + offset, + _size.width, + offset + _size.height, + ), + _borderPaint, + ); + } + + @override + void dispose() { + for (final row in _cellPainters) { + for (final painter in row) { + painter.dispose(); + } + } + _cellPainters = const []; + _fragments = const []; + } + + /// Helper function to distribute widths among columns, respecting minimums. + /// If total minimum width exceeds availableWidth, + /// it returns the minimum widths as-is, + /// implying that the content will overflow and require scrolling. + List _distributeWidths( + List natural, List min, double availableWidth) { + final totalNatural = natural.reduce((a, b) => a + b); + final totalMin = min.reduce((a, b) => a + b); + + if (totalNatural <= availableWidth) { + return natural; + } + + if (totalMin <= availableWidth) { + final remainingSpace = availableWidth - totalMin; + final extraSpacePerColumn = [ + for (var i = 0; i < natural.length; i++) natural[i] - min[i] + ]; + final totalExtraSpace = extraSpacePerColumn.reduce((a, b) => a + b); + + if (totalExtraSpace <= 0.001) return min; + + return [ + for (var i = 0; i < natural.length; i++) + min[i] + remainingSpace * (extraSpacePerColumn[i] / totalExtraSpace) + ]; + } + return min; + } +} diff --git a/lib/src/render/markdown_painter.dart b/lib/src/render/markdown_painter.dart new file mode 100644 index 0000000..fdcb946 --- /dev/null +++ b/lib/src/render/markdown_painter.dart @@ -0,0 +1,396 @@ +//ignore_for_file: unnecessary_import + +import 'dart:math' as math; +import 'dart:ui'; + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:meta/meta.dart' as meta show internal; + +import '../markdown.dart'; +import '../nodes.dart'; +import '../theme.dart'; +import 'block_painter.dart'; +import 'blocks/alert.dart'; +import 'blocks/code.dart'; +import 'blocks/divider.dart'; +import 'blocks/heading.dart'; +import 'blocks/list.dart'; +import 'blocks/paragraph.dart'; +import 'blocks/quote.dart'; +import 'blocks/spacer.dart'; +import 'blocks/table.dart'; + +/// A painter for rendering markdown content via blocks and spans. +@meta.internal +class MarkdownPainter { + /// Creates a [MarkdownPainter] instance. + MarkdownPainter({ + required Markdown markdown, + required MarkdownThemeData theme, + }) : _markdown = markdown, + _theme = theme, + _isEmpty = markdown.isEmpty, + _size = Size.zero { + _rebuild(); + } + + /// Is the markdown entity empty? + bool get isEmpty => _isEmpty; + bool _isEmpty; + + /// Current markdown entity to render. + Markdown _markdown; + + /// Current theme for the markdown widget. + MarkdownThemeData _theme; + + /// The size of the painted markdown content. + Size get size => _size; + Size _size; + + /// Indicates if the layout needs to be recalculated. + bool _needsLayout = true; + + Float32List _blockOffsets = Float32List(0); + List _blockPainters = const []; + + /// Source `Markdown.blocks` index for each painter (differs from the painter + /// index whenever a `blockFilter` drops blocks). + List _sourceIndices = const []; + + static BlockPainter _defaultBlockBuilder( + MD$Block block, + MarkdownThemeData theme, + ) => + block.map( + paragraph: (p) => BlockPainter$Paragraph( + spans: p.spans, + theme: theme, + ), + heading: (h) => BlockPainter$Heading( + level: h.level, + spans: h.spans, + theme: theme, + ), + quote: (q) => BlockPainter$Quote( + spans: q.spans, + indent: q.indent, + theme: theme, + ), + code: (c) => BlockPainter$Code( + language: c.language, + text: c.text, + theme: theme, + ), + list: (l) => BlockPainter$List( + items: l.items, + theme: theme, + ), + divider: (d) => BlockPainter$Divider( + theme: theme, + ), + table: (t) => BlockPainter$Table( + header: t.header, + rows: t.rows, + alignments: t.alignments, + theme: theme, + ), + alert: (a) => BlockPainter$Alert( + alert: a.alert, + spans: a.spans, + theme: theme, + ), + spacer: (s) => BlockPainter$Spacer( + count: s.count, + theme: theme, + ), + ); + + /// Rebuilds the block painters from the markdown blocks. + /// This method is called whenever the markdown or theme changes. + void _rebuild() { + _needsLayout = true; // Mark that layout needs to be recalculated. + _size = Size.zero; // Reset size before rebuilding. + final filter = _theme.blockFilter; + final builder = _theme.builder ?? _defaultBlockBuilder; + final blocks = _markdown.blocks; + final painters = []; + final sources = []; + for (var i = 0; i < blocks.length; i++) { + final block = blocks[i]; + if (filter != null && !filter(block)) continue; + painters + .add(builder(block, _theme) ?? _defaultBlockBuilder(block, _theme)); + sources.add(i); + } + _blockPainters = painters; + _sourceIndices = sources; + _blockOffsets = Float32List(_blockPainters.length); + } + + /// Binary-searches the painter index whose vertical band contains [dy]. + int _blockIndexForDy(double dy) { + var min = 0; + var max = _blockOffsets.length; + var idx = 0; + while (min < max) { + final mid = min + ((max - min) >> 1); + final offset = _blockOffsets[mid]; + if (offset > dy) { + max = mid; + } else { + idx = mid; + if (offset == dy) break; + min = mid + 1; + } + } + return idx; + } + + /// Maps a content-local [local] offset to `(sourceBlockIndex, offset)`, or + /// null if the hit block does not support selection. + (int, int)? positionForLocal(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); + } + + /// Paints the selection highlight of every selectable block, using [rangeOf] + /// to look up the selected rendered range for a source block index. + void paintHighlight( + Canvas canvas, + TextRange? Function(int sourceIndex) rangeOf, + Paint paint, + ) { + for (var i = 0; i < _blockPainters.length; i++) { + final painter = _blockPainters[i]; + if (painter is! SelectableBlockPainter) continue; + final range = rangeOf(_sourceIndices[i]); + if (range == null || range.start >= range.end) continue; + final top = _blockOffsets[i]; + for (final rect in painter.boxesForRange(range.start, range.end)) { + canvas.drawRect(rect.shift(Offset(0, top)), paint); + } + } + } + + /// Content-local rectangles covering the selection described by [rangeOf], in + /// reading order. Same geometry [paintHighlight] draws, collected instead of + /// painted — used to position selection handles, the magnifier and toolbar. + List selectionBoxes(TextRange? Function(int sourceIndex) rangeOf) { + final out = []; + for (var i = 0; i < _blockPainters.length; i++) { + final painter = _blockPainters[i]; + if (painter is! SelectableBlockPainter) continue; + final range = rangeOf(_sourceIndices[i]); + if (range == null || range.start >= range.end) continue; + final top = _blockOffsets[i]; + for (final rect in painter.boxesForRange(range.start, range.end)) { + out.add(rect.shift(Offset(0, top))); + } + } + return out; + } + + /// Update the painter with new values. + /// If the values are the same, + /// no update is required and the method returns false. + bool update({ + required Markdown markdown, + required MarkdownThemeData theme, + }) { + if (identical(_markdown, markdown) && identical(_theme, theme)) + return false; + _lastSize = null; + _lastPicture = null; + _markdown = markdown; + _theme = theme; + _isEmpty = markdown.isEmpty; + _rebuild(); + return true; // Indicate that the painter was updated. + } + + /// Invalidate cached layouts when system fonts change. + /// This forces TextPainters to recreate their layouts with new fonts. + void invalidateLayout() { + _needsLayout = true; + _lastSize = null; + _lastPicture = null; + // Dispose and rebuild all block painters to recreate TextPainters + // with the new system fonts + for (final painter in _blockPainters) { + painter.dispose(); + } + _rebuild(); + } + + /// Layouts the markdown content with the given width. + Size layout({required double maxWidth}) { + if (_isEmpty) { + _size = Size.zero; + _needsLayout = false; // No need to layout if the markdown is empty. + return _size; // If the markdown is empty, return zero size. + } + var width = .0, height = .0; + final blocks = _blockPainters; + if (_blockOffsets.length != blocks.length) { + // Resize the block sizes array + // if it does not match the number of painters. + _blockOffsets = Float32List(blocks.length); + } + final offsets = _blockOffsets; + for (var i = 0; i < blocks.length; i++) { + offsets[i] = height; + final block = blocks[i]; + final size = block.layout(maxWidth); + width = math.max(width, size.width); + height += size.height; + } + _needsLayout = false; // No need to layout if the markdown is empty. + return _size = Size(width, height); + } + + /// Routes a tap-down / tap-up to the block under the pointer, re-basing the + /// event's position into that block's local space (only taps are handled; the + /// block painters use it to fire link recognizers). + void handleEvent(PointerEvent event) { + if (_blockPainters.isEmpty) return; + if (event is! PointerDownEvent && event is! PointerUpEvent) return; + + final pos = event.localPosition; + final idx = _blockIndexForDy(pos.dy); + final blockLocal = Offset(pos.dx, pos.dy - _blockOffsets[idx]); + switch (event) { + case final PointerDownEvent down: + _blockPainters[idx].handleTapDown(_rebased(down, blockLocal)); + case final PointerUpEvent up: + _blockPainters[idx].handleTapUp(_rebased(up, blockLocal)); + } + } + + /// Copies [event] with its position moved to [localPosition] (block-local), + /// preserving every other pointer field. `PointerEvent` has no `copyWith`, so + /// the passthrough is spelled out; the return type follows the input type. + static T _rebased(T event, Offset localPosition) { + final rebased = switch (event) { + PointerUpEvent() => PointerUpEvent( + position: localPosition, + viewId: event.viewId, + timeStamp: event.timeStamp, + pointer: event.pointer, + kind: event.kind, + device: event.device, + buttons: event.buttons, + obscured: event.obscured, + pressure: event.pressure, + pressureMin: event.pressureMin, + pressureMax: event.pressureMax, + distanceMax: event.distanceMax, + size: event.size, + radiusMajor: event.radiusMajor, + radiusMinor: event.radiusMinor, + radiusMin: event.radiusMin, + radiusMax: event.radiusMax, + orientation: event.orientation, + tilt: event.tilt, + embedderId: event.embedderId, + ), + _ => PointerDownEvent( + position: localPosition, + viewId: event.viewId, + timeStamp: event.timeStamp, + pointer: event.pointer, + kind: event.kind, + device: event.device, + buttons: event.buttons, + obscured: event.obscured, + pressure: event.pressure, + pressureMin: event.pressureMin, + pressureMax: event.pressureMax, + distanceMax: event.distanceMax, + size: event.size, + radiusMajor: event.radiusMajor, + radiusMinor: event.radiusMinor, + radiusMin: event.radiusMin, + radiusMax: event.radiusMax, + orientation: event.orientation, + tilt: event.tilt, + embedderId: event.embedderId, + ), + }; + return rebased as T; + } + + /// The last size and picture used for painting. + /// This is used to avoid unnecessary recreation of the canvas picture. + /// If the size is the same as the last painted size, + Size? _lastSize; + + /// The last picture used for painting, + /// to avoid unnecessary recreation of the canvas picture. + /// If the size is the same as the last painted size, + /// we can reuse the last picture. + Picture? _lastPicture; + + /// The markdown content to paint. + void paint(Canvas canvas, Size size) { + assert( + !_needsLayout, + 'MarkdownPainter.paint() called without layout.', + ); + assert( + size.isFinite, + 'MarkdownPainter.paint() called with non-finite size: $size', + ); + + // Do not paint if the markdown is empty, + // or if the size is empty or infinite. + if (_isEmpty || size.isEmpty || size.isInfinite) return; + + if (_lastSize == size && _lastPicture != null) { + // If the size is the same as the last painted size, + // we can reuse the last picture. + canvas.drawPicture(_lastPicture!); + return; + } + + final recorder = PictureRecorder(); + final $canvas = Canvas(recorder); + + // Paint each block painter on the canvas. + var overflow = _size.height > size.height; + var offset = .0; + for (var painter in _blockPainters) { + if (overflow && offset > size.height) { + // If the painter's height exceeds the available height, + // we stop painting further blocks. + break; + } + painter.paint($canvas, size, offset); + offset += painter.size.height; // Update the offset for the next block. + } + + final picture = recorder.endRecording(); + canvas.drawPicture(picture); + _lastSize = size; + _lastPicture = picture; + } + + void dispose() { + _lastPicture?.dispose(); + _lastPicture = null; + for (final painter in _blockPainters) { + painter.dispose(); + } + _blockPainters = const []; + } +} diff --git a/lib/src/render/markdown_render_object.dart b/lib/src/render/markdown_render_object.dart new file mode 100644 index 0000000..883b758 --- /dev/null +++ b/lib/src/render/markdown_render_object.dart @@ -0,0 +1,324 @@ +//ignore_for_file: unnecessary_import + +import 'dart:ui'; + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:meta/meta.dart' as meta show internal; + +import '../markdown.dart'; +import '../selection.dart'; +import '../theme.dart'; +import 'markdown_painter.dart'; + +/// Default color used to paint the selection highlight beneath the glyphs. +const Color _kSelectionColor = Color(0x552196F3); + +@meta.internal +class MarkdownRenderObject extends RenderBox + implements MarkdownSelectionSurface { + MarkdownRenderObject({ + required Markdown markdown, + required MarkdownThemeData theme, + }) : _painter = MarkdownPainter( + markdown: markdown, + theme: theme, + ); + + /// Painter for rendering markdown content. + final MarkdownPainter _painter; + + /// The selection controller this render object participates in, if any. + MarkdownSelectionController? _controller; + + /// The stable document id used to anchor selection positions. + Object? _documentId; + + final Paint _highlightPaint = Paint()..color = _kSelectionColor; + + // Selection-handle leader layers pushed during paint so native handles + // (drawn by the scope's SelectionOverlay) follow the content as it scrolls. + LayerLink? _startHandleLink; + Offset? _startHandleLocal; + LayerLink? _endHandleLink; + Offset? _endHandleLocal; + + void _onSelectionChange() { + if (!_disposed) markNeedsPaint(); + } + + bool _disposed = false; + + void _attachController() { + final controller = _controller; + if (controller == null) return; + controller.addListener(_onSelectionChange); + if (attached) controller.attachSurface(this); + } + + void _detachController() { + final controller = _controller; + if (controller == null) return; + controller.removeListener(_onSelectionChange); + controller.detachSurface(this); + } + + /// Wires (or rewires) this render object to a selection [controller] under + /// [documentId]. Passing a null controller makes it non-selectable (inert). + @meta.internal + void updateSelection( + MarkdownSelectionController? controller, + Object? documentId, + ) { + if (identical(controller, _controller) && documentId == _documentId) return; + _detachController(); + _controller = controller; + _documentId = documentId; + _attachController(); + if (attached) { + markNeedsCompositingBitsUpdate(); + markNeedsPaint(); + } + } + + // --- MarkdownSelectionSurface --- + + @override + Object get documentId => _documentId!; + + @override + Rect get globalBounds => localToGlobal(Offset.zero) & size; + + @override + MarkdownPosition? positionForGlobal(Offset globalPosition) { + final id = _documentId; + if (id == null) return null; + final local = globalToLocal(globalPosition); + final hit = _painter.positionForLocal(local); + if (hit == null) return null; + return MarkdownPosition( + documentId: id, + blockIndex: hit.$1, + offset: hit.$2, + ); + } + + @override + List globalSelectionRects() { + final local = localSelectionRects(); + if (local.isEmpty) return const []; + final origin = localToGlobal(Offset.zero); + return [for (final rect in local) rect.shift(origin)]; + } + + @override + List localSelectionRects() { + final controller = _controller; + final id = _documentId; + if (controller == null || id == null) return const []; + return _painter.selectionBoxes((s) => controller.rangeFor(id, s)); + } + + @override + void setSelectionHandleLayers({ + LayerLink? startLink, + Offset? startLocal, + LayerLink? endLink, + Offset? endLocal, + }) { + var changed = false; + if (!identical(startLink, _startHandleLink) || + startLocal != _startHandleLocal) { + _startHandleLink = startLink; + _startHandleLocal = startLocal; + changed = true; + } + if (!identical(endLink, _endHandleLink) || endLocal != _endHandleLocal) { + _endHandleLink = endLink; + _endHandleLocal = endLocal; + changed = true; + } + if (changed && !_disposed && attached) markNeedsPaint(); + } + + @override + void repaintSelection() { + if (!_disposed && attached) markNeedsPaint(); + } + + /// Current size of the render box. + @override + Size get size => _size; + Size _size = Size.zero; + + @override + bool get isRepaintBoundary => _controller != null; + + @override + bool get alwaysNeedsCompositing => false; + + @override + bool get sizedByParent => false; + + @override + set size(Size value) { + final prev = super.hasSize ? super.size : null; + super.size = value; + if (prev == value) return; + _size = value; + } + + @override + void debugResetSize() { + super.debugResetSize(); + if (!super.hasSize) return; + _size = super.size; + } + + @override + Size computeDryLayout(BoxConstraints constraints) => + constraints.constrain(_painter.layout(maxWidth: constraints.maxWidth)); + + @override + void performLayout() { + // Set the size of the render box to match the painter's size. + size = + constraints.constrain(_painter.layout(maxWidth: constraints.maxWidth)); + } + + @override + // ignore: unnecessary_overrides + void performResize() { + size = computeDryLayout(constraints); + } + + @override + bool hitTestSelf(Offset position) => true; + + @override + bool hitTestChildren( + BoxHitTestResult result, { + required Offset position, + }) => + false; + + @override + bool hitTest(BoxHitTestResult result, {required Offset position}) { + var hitTarget = false; + if (size.contains(position)) { + hitTarget = hitTestSelf(position); + result.add(BoxHitTestEntry(this, position)); + } + return hitTarget; + } + + @override + void handleEvent(PointerEvent event, BoxHitTestEntry entry) { + _painter.handleEvent(event); + } + + /// Handles system font changes by marking the render object as needing layout + void _handleSystemFontsChange() { + // Invalidate cached layouts in painter and all block painters + _painter.invalidateLayout(); + // Request new layout and paint + markNeedsLayout(); + } + + @override + void attach(PipelineOwner owner) { + super.attach(owner); + PaintingBinding.instance.systemFonts.addListener(_handleSystemFontsChange); + _controller?.attachSurface(this); + } + + /// Updates the render object with a new values. + /// This method should be called whenever the markdown or theme changes. + @meta.internal + void update({ + required Markdown markdown, + required MarkdownThemeData theme, + }) { + if (_painter.update( + markdown: markdown, + theme: theme, + )) { + // Mark the render object as needing layout. + markNeedsLayout(); + } + } + + @override + @protected + void detach() { + PaintingBinding.instance.systemFonts + .removeListener(_handleSystemFontsChange); + _controller?.detachSurface(this); + super.detach(); + } + + @override + @protected + void dispose() { + _disposed = true; + _controller?.removeListener(_onSelectionChange); + super.dispose(); + _painter.dispose(); + } + + @override + @protected + void paint(PaintingContext context, Offset offset) { + if (_painter.isEmpty) + return; // If the markdown is empty, do not paint anything. + + final canvas = context.canvas + ..save() + ..translate(offset.dx, offset.dy); + //..clipRect(Rect.fromLTWH(0, 0, size.width, size.height)); + + // Paint the selection highlight OUTSIDE the cached content Picture, beneath + // the glyphs, so drag/streaming repaints never rebuild the glyph cache. + final controller = _controller; + final id = _documentId; + if (controller != null && id != null) { + _highlightPaint.color = controller.selectionColor ?? _kSelectionColor; + _painter.paintHighlight( + canvas, + (source) => controller.rangeFor(id, source), + _highlightPaint, + ); + } + + _painter.paint(canvas, size); + + canvas.restore(); + + // Push handle leader layers (empty layers) so the scope's SelectionOverlay + // handles follow this content as it scrolls. + final startLink = _startHandleLink; + final startLocal = _startHandleLocal; + if (startLink != null && startLocal != null) { + context.pushLayer( + LeaderLayer(link: startLink, offset: offset + startLocal), + _paintNothing, + Offset.zero, + ); + } + final endLink = _endHandleLink; + final endLocal = _endHandleLocal; + if (endLink != null && endLocal != null) { + context.pushLayer( + LeaderLayer(link: endLink, offset: offset + endLocal), + _paintNothing, + Offset.zero, + ); + } + } +} + +/// A no-op paint callback for pushing childless [LeaderLayer]s. +void _paintNothing(PaintingContext context, Offset offset) {} diff --git a/lib/src/render/span_builder.dart b/lib/src/render/span_builder.dart new file mode 100644 index 0000000..1adcc56 --- /dev/null +++ b/lib/src/render/span_builder.dart @@ -0,0 +1,54 @@ +//ignore_for_file: unnecessary_import + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; + +import '../nodes.dart'; +import '../theme.dart'; + +/// Builds a tap recognizer for the given markdown span. +TapGestureRecognizer? _buildTapRecognizer( + MD$Span span, + void Function(String title, String url)? onTap, +) { + if (onTap == null) return null; + if (span.extra case {'url': String url}) { + return TapGestureRecognizer() + ..onTap = () { + onTap(span.extra?['alt']?.toString() ?? span.text, url); + }; + } + return null; +} + +/// 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. +TextSpan paragraphFromMarkdownSpans({ + required Iterable spans, + required MarkdownThemeData theme, + TextStyle? textStyle, +}) { + final style = textStyle ?? theme.textStyle; + final spanFilter = theme.spanFilter; + final filtered = spanFilter != null ? spans.where(spanFilter) : spans; + // With an explicit block style, merge it under each span's own style; + // otherwise the span style stands alone (the block style is applied once, on + // the parent TextSpan below). + final merge = textStyle != null; + TextSpan mapper(MD$Span span) => TextSpan( + text: span.text, + style: merge + ? theme.textStyleFor(span.style).merge(style) + : theme.textStyleFor(span.style), + recognizer: span.style.contains(MD$Style.link) + ? _buildTapRecognizer(span, theme.onLinkTap) + : null, + ); + return TextSpan( + style: textStyle ?? theme.textStyle, + children: filtered.map(mapper).toList(growable: false), + ); +} diff --git a/lib/src/selection.dart b/lib/src/selection.dart index 39c42c6..0bda1ed 100644 --- a/lib/src/selection.dart +++ b/lib/src/selection.dart @@ -506,6 +506,13 @@ class MarkdownSelectionController extends ChangeNotifier { } final List<_DocEntry> _docs = <_DocEntry>[]; + + /// id → index into the sorted [_docs], kept in sync by [_reindex]. Makes + /// [_orderIndex] O(1): it is called several times per selectable block on + /// every highlight repaint, so a linear scan here would scale with the chat + /// size and show up during drags. + final Map _indexById = {}; + final Map _surfaces = {}; @@ -526,6 +533,13 @@ class MarkdownSelectionController extends ChangeNotifier { super.dispose(); } + /// The number of registered documents. Prefer this over `documents.length`, + /// which allocates a fresh list on every read. + int get documentCount => _docs.length; + + /// Whether any documents are registered. + bool get hasDocuments => _docs.isNotEmpty; + /// The registered documents, in reading order. List get documents => [ for (final e in _docs) @@ -551,7 +565,7 @@ 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}) { - final idx = _docs.indexWhere((e) => e.id == id); + final idx = _orderIndex(id); if (idx < 0) { _docs.add(_DocEntry(id, model, order ?? _docs.length)); _sort(); @@ -560,21 +574,23 @@ class MarkdownSelectionController extends ChangeNotifier { } final entry = _docs[idx]; final old = entry.model; - if (order != null) entry.order = order; - if (identical(old, model)) { - _sort(); - notifyListeners(); - return; - } - entry.model = model; - _sort(); - _reconcile(id, old, model); + final orderChanged = order != null && order != entry.order; + final modelChanged = !identical(old, model); + // Nothing actually changed — avoid a needless sort/repaint. This matters on + // the streaming path, which can call putDocument once per token. + if (!orderChanged && !modelChanged) return; + if (orderChanged) entry.order = order; + if (modelChanged) entry.model = model; + // Reordering shifts indices; a model-only update keeps [_indexById] valid. + if (orderChanged) _sort(); + if (modelChanged) _reconcile(id, old, model); notifyListeners(); } /// Removes a document. If the selection touched it, the selection is dropped. void removeDocument(Object id) { _docs.removeWhere((e) => e.id == id); + _reindex(); final sel = _selection; if (sel != null && (sel.base.documentId == id || sel.extent.documentId == id)) { @@ -585,6 +601,16 @@ class MarkdownSelectionController extends ChangeNotifier { void _sort() { _docs.sort((a, b) => a.order.compareTo(b.order)); + _reindex(); + } + + /// Rebuilds [_indexById] from the current [_docs] order. Called whenever the + /// set or order of documents changes (not on model-only updates). + void _reindex() { + _indexById.clear(); + for (var i = 0; i < _docs.length; i++) { + _indexById[_docs[i].id] = i; + } } void _reconcile(Object id, Markdown oldModel, Markdown newModel) { @@ -863,7 +889,7 @@ class MarkdownSelectionController extends ChangeNotifier { final startDoc = _orderIndex(a.documentId); final endDoc = _orderIndex(b.documentId); if (di < startDoc || di > endDoc) return null; - final model = _modelOf(documentId); + final model = _docs[di].model; // di is already resolved above if (blockIndex < 0 || blockIndex >= model.blocks.length) return null; final len = markdownBlockRenderedText(model.blocks[blockIndex]).length; final startBlock = di == startDoc ? a.blockIndex : 0; @@ -929,7 +955,7 @@ class MarkdownSelectionController extends ChangeNotifier { // --- ordering helpers ---------------------------------------------------- - int _orderIndex(Object id) => _docs.indexWhere((e) => e.id == id); + int _orderIndex(Object id) => _indexById[id] ?? -1; Markdown _modelOf(Object id) => _docs[_orderIndex(id)].model; @@ -961,7 +987,9 @@ class MarkdownSelectionController extends ChangeNotifier { (int, int)? _adjacentBlock(int di, int bi, {required bool forward}) { var d = di; var b = bi; - for (var guard = 0; guard < 1000000; guard++) { + // Each iteration steps b by one (rolling over document boundaries) and + // returns null once it walks off either end, so the scan always terminates. + while (true) { if (forward) { b++; while (d < _docs.length && b >= _docs[d].model.blocks.length) { @@ -979,7 +1007,6 @@ class MarkdownSelectionController extends ChangeNotifier { } if (_blockTextAt(d, b).isNotEmpty) return (d, b); } - return null; } MarkdownPosition _stepCharacter(MarkdownPosition p, {required bool forward}) { diff --git a/lib/src/selection_scope.dart b/lib/src/selection_scope.dart index 8234a91..5f528d1 100644 --- a/lib/src/selection_scope.dart +++ b/lib/src/selection_scope.dart @@ -404,7 +404,7 @@ class MarkdownSelectionScopeState extends State { onPressed: copySelection, )); } - if (controller.documents.isNotEmpty) { + if (controller.hasDocuments) { items.add(ContextMenuButtonItem( type: ContextMenuButtonType.selectAll, onPressed: () { From afbfbbf20ad4617c4defa2c018be724d05a4a0bf Mon Sep 17 00:00:00 2001 From: Mike Matiunin Date: Mon, 3 Aug 2026 15:29:31 +0400 Subject: [PATCH 16/30] docs: add comprehensive documentation for architecture, development, parser, rendering, and selection layers - Introduced architecture.md detailing the four-layer structure and data flow of `flutter_md`. - Added development.md outlining commands, CI pipeline, lint rules, conventions, and repo layout. - Created parser.md explaining the parser and node model, entry points, and GFM support. - Added rendering.md describing the rendering layer, block painter framework, and theme customization. - Introduced selection.md detailing text selection architecture, controller, surfaces, and interaction. --- AGENTS.md | 110 ++++++++++++++++++++++ docs/architecture.md | 106 +++++++++++++++++++++ docs/development.md | 135 ++++++++++++++++++++++++++ docs/parser.md | 164 ++++++++++++++++++++++++++++++++ docs/rendering.md | 220 +++++++++++++++++++++++++++++++++++++++++++ docs/selection.md | 180 +++++++++++++++++++++++++++++++++++ 6 files changed, 915 insertions(+) create mode 100644 AGENTS.md create mode 100644 docs/architecture.md create mode 100644 docs/development.md create mode 100644 docs/parser.md create mode 100644 docs/rendering.md create mode 100644 docs/selection.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..8644471 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,110 @@ +# AGENTS.md + +High-signal orientation for LLMs/agents working in **`flutter_md`**. Read this +first, every time. Deep detail lives in [`docs/`](docs/) — linked per section. + +`flutter_md` is a Flutter Markdown package: a hand-rolled **parser** → an +immutable **node model** → a **canvas render layer** (one `RenderBox`, no +widget-per-block) → cross-block/cross-widget **text selection**. Repo: +`DoctorinaAI/md`, package `flutter_md`, currently `0.2.0`, branch +`feat/text-selection`. + +## Commands (these are the CI gates — run before you claim done) + +```shell +# Tests — test/unit_test.dart is the single aggregate entrypoint CI runs. +flutter test test/unit_test.dart +flutter test --coverage --concurrency=40 test/unit_test.dart # CI form + +# Analyzer — INFO-level lints FAIL (--fatal-infos). A missing doc comment fails CI. +dart analyze --fatal-infos --fatal-warnings lib/ test/ + +# Format — 80 columns, strict, exactly as CI checks it: +find lib test -name "*.dart" ! -name "*.*.dart" -print0 \ + | xargs -0 dart format --set-exit-if-changed --line-length 80 -o none +# to actually format: dart format lib test example (analysis_options pins page_width: 80) +``` + +Benchmarks and the example app: see [`docs/development.md`](docs/development.md). +The render benchmark runs under `flutter test` (needs `dart:ui`); parser +benchmarks run under `dart run`. + +## Module map (`lib/src/`) + +| Path | What | Doc | +|---|---|---| +| `parser.dart` | `MarkdownDecoder` (a `Converter`); one hand-rolled line loop, perf-tuned | [parser](docs/parser.md) | +| `nodes.dart` | `MD$*` immutable node tree, `MD$Style` bitmask, `.map()` dispatch | [parser](docs/parser.md) | +| `markdown.dart` | `Markdown` model + `Markdown.fromString(...)` entry point | [parser](docs/parser.md) | +| `theme.dart` | `MarkdownThemeData` (a `ThemeExtension`), `MarkdownTheme`; `builder`/`blockFilter`/`spanFilter` hooks | [rendering](docs/rendering.md) | +| `render.dart` | **re-export barrel** for `render/` (keeps `src/render.dart` imports working) | [rendering](docs/rendering.md) | +| `render/block_painter.dart` | `BlockPainter` framework: interfaces + mixins (`SelectableTextBlock`, `MultiPainterSelectable`, `ParagraphGestureHandler`, `SelectableFragment`) | [rendering](docs/rendering.md) | +| `render/span_builder.dart` | `paragraphFromMarkdownSpans(...)` — the public span→`TextSpan` helper | [rendering](docs/rendering.md) | +| `render/markdown_painter.dart` | `MarkdownPainter` orchestrator (`@meta.internal`): block list, layout, cached `ui.Picture`, hit-test | [rendering](docs/rendering.md) | +| `render/markdown_render_object.dart` | `MarkdownRenderObject` (`@meta.internal`) — the `RenderBox`, also a `MarkdownSelectionSurface` | [rendering](docs/rendering.md) | +| `render/blocks/*.dart` | `BlockPainter$Paragraph … $Table` — the 9 default painters | [rendering](docs/rendering.md) | +| `selection.dart` | `MarkdownSelectionController`, `MarkdownPosition/Selection`, registry, reconciliation, formatters, `markdownBlockRenderedText` | [selection](docs/selection.md) | +| `selection_scope.dart` | `MarkdownSelectionScope` — gestures, keyboard, handles, toolbar; `MarkdownSelectionGroup` | [selection](docs/selection.md) | +| `widget.dart` | `MarkdownWidget` (`LeafRenderObjectWidget`) — the public entry widget | [rendering](docs/rendering.md) | + +Public API is the barrel `lib/flutter_md.dart` (`export … show …`). See +[`docs/architecture.md`](docs/architecture.md) for the full data flow. + +## Hard rules (violating these breaks CI or the architecture) + +1. **80-col + `--fatal-infos`.** No line > 80 chars. Every public member needs a + `///` doc (`public_member_api_docs: true`). Infos are fatal in CI. +2. **Imports:** relative inside `lib/` (`../nodes.dart`), `package:flutter_md/…` + in `test/`. `prefer_relative_imports` + `avoid_relative_lib_imports` enforce this. +3. **`$` is the public naming convention** for variant families: `MD$Block`, + `MD$Span`, `BlockPainter$Paragraph`. Not a typo — keep it. +4. **`@meta.internal`** marks non-user-facing types (`MarkdownPainter`, + `MarkdownRenderObject`). They are reachable only via `src/render.dart`, never + in the `flutter_md.dart` `show` list. Everything else in the `show` list is + supported public API — treat additions as permanent. +5. **One test entrypoint:** a new `test/**/foo_test.dart` must be wired into + `test/unit_test.dart` (import + `main()` inside `group('Unit', …)`) or CI won't run it. +6. **CHANGELOG discipline:** the `version:` in `pubspec.yaml` must have a matching + `# ` heading in `CHANGELOG.md` or the CI setup step fails. + +## Load-bearing invariants (don't break silently) + +- **Render is canvas-painter based**, not widget-per-block. One `MarkdownPainter` + holds a `List`; blocks stack vertically (no implicit gaps — a + `MD$Spacer` supplies them); block hit-testing is a binary search over + `_blockOffsets` by `dy`. +- **Glyphs are cached in a `ui.Picture` keyed by size.** It is reused on repaint + and only invalidated by `update`/`invalidateLayout`. Do not route selection or + scroll repaints through it. +- **Selection highlight is painted OUTSIDE that cached Picture**, beneath the + glyphs. This is why a drag/streaming update never rebuilds the glyph cache and + why `isRepaintBoundary => controller != null`. Preserve this if you touch paint. +- **Selection is controller-anchored on immutable models**, not on render objects, + as `(documentId, blockIndex, renderedOffset)`. So selected text survives + `ListView` disposal (scrolled-off chat messages). A block's on-screen offset + space **must** match `markdownBlockRenderedText(block)` (lists join items with + `\n`, tables join cells with `\t` / rows with `\n`) — hit-testing, highlight, + and copied text all depend on that agreement. +- **The span offset invariant:** concatenating a block's `MD$Span.text` reproduces + its rendered text; `MD$Span.start/end` index that visible text (see caveats for + escapes/math/links in [`docs/parser.md`](docs/parser.md)). Selection relies on it. + +## Gotchas quick-reference + +- `MarkdownThemeData.spanFilter` dropping **text-bearing** spans desyncs selection + 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`). +- 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 + paragraphs. Inline `$…$` math is **opt-in** (`inlineMath: true`). +- `example/` is a separate package (`md_example`, `path: ../`). + +## The docs + +- [`docs/architecture.md`](docs/architecture.md) — modules, data flow, invariants, public API surface. +- [`docs/parser.md`](docs/parser.md) — parser pipeline, node model, `MD$Style`, GFM + nonstandard choices, offset invariant. +- [`docs/rendering.md`](docs/rendering.md) — render flow, the `BlockPainter` framework, **writing a custom block painter**, theme customization. +- [`docs/selection.md`](docs/selection.md) — controller-anchored selection, registry, surfaces, reconciliation, extraction, the scope widget. +- [`docs/development.md`](docs/development.md) — commands, CI pipeline, lint rules, conventions, benchmarks, layout. diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..5babe14 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,106 @@ +# Architecture + +`flutter_md` is four layers with a strict one-way dependency flow: + +``` +String ──► Parser ──► Node model ──► Render layer ──► Screen + │ ▲ + └── Selection ─┘ (anchored on the model, geometry from the render layer) +``` + +1. **Parser** (`lib/src/parser.dart`) turns a `String` into an immutable + `Markdown` (`lib/src/markdown.dart`) whose `blocks` are `MD$Block` nodes + (`lib/src/nodes.dart`). +2. **Render layer** (`lib/src/render/`, driven by `MarkdownWidget` in + `lib/src/widget.dart`) paints the model onto a canvas via one `RenderBox` and + a list of `BlockPainter`s — there is no widget per block. +3. **Selection** (`lib/src/selection.dart`, `lib/src/selection_scope.dart`) holds + the selection as logical anchors over the immutable models, and reads geometry + from mounted render objects for hit-testing/highlight/handles. +4. **Theme** (`lib/src/theme.dart`) supplies styles and the customization hooks + (`builder`, `blockFilter`, `spanFilter`) that the render layer reads. + +The parser and node model are stable ground; the render layer was reorganized +into `lib/src/render/**` (see [rendering](rendering.md)). Selection is the newest +subsystem (issue #25). + +## Data flow, end to end + +`Markdown.fromString(src)` → `MarkdownDecoder.convert` → `Markdown{markdown, +blocks}`. You hand that `Markdown` to a `MarkdownWidget`: + +```dart +MarkdownWidget(markdown: Markdown.fromString(src), theme: myTheme) +``` + +`MarkdownWidget` (a `LeafRenderObjectWidget`) creates a `MarkdownRenderObject` +(a `RenderBox`), which owns a `MarkdownPainter`. The painter builds a +`List` (one per non-filtered block, via `theme.builder ??` +defaults), lays them out top-to-bottom recording each block's top-`y` in +`_blockOffsets`, and paints them into a cached `ui.Picture` keyed by size. + +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) beneath the glyphs. See [selection](selection.md). + +## Why the model is immutable and selection anchors to it + +Selection must span multiple blocks and multiple `MarkdownWidget`s (e.g. an +entire chat), including messages whose widgets are scrolled off and disposed by a +`ListView`. So the selection is stored as `(documentId, blockIndex, +renderedOffset)` over an app-supplied registry of immutable `Markdown` models, +not over render objects. Text extraction reads the models directly and works even +when nothing is mounted; only mounted surfaces contribute geometry (highlight +rects, handles). This is the core design decision; the alternatives (Flutter's +`SelectableRegion`, a custom selection delegate) were ruled out in spikes +(`benchmark/experiments/FINDINGS.md`). + +## Load-bearing invariants + +These are cross-cutting; each subsystem doc repeats the ones it owns. + +- **Glyph cache.** `MarkdownPainter` caches one `ui.Picture` keyed by paint size; + it is reused on every repaint and only nulled by `update`/`invalidateLayout` + (model/theme change or system-font change). Repaints from selection or scroll + must not invalidate it. +- **Highlight outside the cache.** The selection highlight is drawn *before* and + *outside* the cached `Picture`, so drags/streaming repaint only the highlight. + `MarkdownRenderObject.isRepaintBoundary` is true whenever a controller is + attached, isolating those repaints. +- **Offset-space agreement.** A `SelectableBlockPainter`'s `renderedText` and + fragment offsets must equal `markdownBlockRenderedText(block)` (lists join items + with `\n`; tables join cells with `\t`, rows with `\n`; dividers/spacers are + empty). Hit-testing, highlight geometry, and copied text all index the same + space. `blockFilter` may drop blocks, but `_sourceIndices` preserves the true + `Markdown.blocks` index so anchors stay valid. +- **Span offsets.** Concatenating a block's `MD$Span.text` reproduces its rendered + text; `start/end` index that visible text. See [parser](parser.md) for the + escape/math/link caveats. + +## Public API surface + +Everything public is re-exported from `lib/flutter_md.dart`: + +- **Whole-file exports:** `markdown.dart`, `nodes.dart`, `parser.dart`, + `selection.dart`, `selection_scope.dart`, `theme.dart`, `widget.dart`. +- **Curated `show` from `render.dart`:** the `BlockPainter` framework + (`BlockPainter`, `SelectableBlockPainter`, `SelectableTextBlock`, + `MultiPainterSelectable`, `SelectableFragment`, `ParagraphGestureHandler`, + `paragraphFromMarkdownSpans`) and the nine default painters + (`BlockPainter$Paragraph … $Spacer`). + +Deliberately **not** public (reachable only via `import 'package:flutter_md/src/render.dart'`, +annotated `@meta.internal`): `MarkdownPainter`, `MarkdownRenderObject`. Treat the +`show` list as the supported surface; additions to it are permanent commitments. + +## Where to make a change + +- New/changed Markdown syntax → `parser.dart` (+ maybe `nodes.dart`); add + regression tests. See [parser](parser.md). +- Change how a block looks → a `BlockPainter$*` in `render/blocks/`, or supply + `MarkdownThemeData.builder` for a custom painter. See [rendering](rendering.md). +- Selection behavior (gestures, keyboard, extraction, streaming) → `selection.dart` + / `selection_scope.dart`. See [selection](selection.md). +- Tooling, CI, conventions → [development](development.md). diff --git a/docs/development.md b/docs/development.md new file mode 100644 index 0000000..e2688df --- /dev/null +++ b/docs/development.md @@ -0,0 +1,135 @@ +# Development + +Commands, CI, conventions, benchmarks, and repo layout. Package `flutter_md` +(`0.2.0`); Dart `>=3.6.0 <4.0.0`, Flutter `>=3.29.0`. + +## Commands + +```shell +# Tests — test/unit_test.dart is the single aggregate entrypoint. +flutter test test/unit_test.dart +flutter test --coverage --concurrency=40 test/unit_test.dart # CI form; LCOV → coverage/lcov.info + +# Analyzer — INFO-level lints FAIL (--fatal-infos). A missing doc comment fails CI. +dart analyze --fatal-infos --fatal-warnings lib/ test/ + +# Format gate — exactly as CI runs it (80 cols; the ! -name "*.*.dart" skips generated files): +find lib test -name "*.dart" ! -name "*.*.dart" -print0 \ + | xargs -0 dart format --set-exit-if-changed --line-length 80 -o none +dart format lib test example # to actually apply (page_width: 80) + +# Example app (separate package md_example, depends on flutter_md via path: ../): +cd example && flutter pub get && flutter run +``` + +### Benchmarks + +Parser benchmarks are pure Dart; the render benchmark needs the Flutter test +engine (real `dart:ui` text layout + `PictureRecorder`). + +```shell +dart run benchmark/parser_benchmark.dart # multi-scenario table + head-to-head vs `markdown` pkg +dart run benchmark/parse_benchmark.dart # single mixed doc vs Google `markdown` pkg +dart run benchmark/compare.dart --save # write baseline → benchmark/.baseline.txt (uncommitted) +dart run benchmark/compare.dart # compare current vs saved baseline, prints delta % +dart compile exe benchmark/parser_benchmark.dart -o /tmp/pb && /tmp/pb # stabler AOT numbers + +flutter test benchmark/render_benchmark.dart # vs benchmark/.render_baseline.txt +flutter test benchmark/render_benchmark.dart --dart-define=RENDER_BASELINE=save # record new baseline +``` + +Render tiers (doc = 50 mixed blocks, width 400): `layout_large`, `paint_miss` +(fresh painter → records a `Picture`), `paint_hit` (re-paint → replays cached +`Picture`), `stream_append` (`update()` + relayout each iter), `selection_drag` +(grow a highlight over the **reused** content Picture), `scroll_frame` (40 +drag+pump frames over a `ListView`). The test **asserts** `paint_hit < paint_miss` +and `selection_drag < paint_miss / 3` — regressions fail. Timings are relative +(headless engine); confirm real FPS with `flutter run --profile` + DevTools. + +Parser scenarios live in `benchmark/scenarios.dart` (`prose, inline, links, lists, +table, code, quotes, escapes, currency, pathological, mixed`). `compare.dart` uses +warmup + auto-calibrated iterations + min-of-batches for low-noise deltas. + +Selection spikes S1–S7 (`benchmark/experiments/`, findings in `FINDINGS.md`) are +throwaway and not in CI. + +## CI pipeline (`.github/workflows/`) + +**`checkout.yml`** (name `Checkout`) — runs on push to `main`/`master` and PRs to +`main|master|dev|develop|feature/**|…`, path-filtered to Dart/config files. Single +job, steps in order (any non-zero fails): + +1. Checkout. +2. **Setup** (`./.github/actions/setup`): sparse-checkout, `flutter pub get`, and a + **CHANGELOG check** — the `version:` in `pubspec.yaml` must appear as a + `# ` heading in `CHANGELOG.md`, else exit 1. +3. **Check code format** — the format gate command above. +4. **Check analyzer** — `dart analyze --fatal-infos --fatal-warnings lib/ test/`. +5. **Run unit tests** — `flutter test --coverage --concurrency=40 test/unit_test.dart`. +6. Codecov upload (present but commented out). + +**`deploy.yml`** (name `Deploy to Pub.dev`) — on `workflow_dispatch` and version +tags `[0-9]+.[0-9]+.[0-9]+*`; delegates to `dart-lang/setup-dart` publish workflow +(OIDC). Benchmarks are never run in CI. + +## Lint rules that bite (`analysis_options.yaml`) + +Base `package:flutter_lints/flutter.yaml`, plus `strict-casts`, `strict-raw-types`, +`strict-inference` (all true). The ones you'll actually trip on: + +- **`public_member_api_docs: true`** — every public member needs a `///`. With + `--fatal-infos`, a missing doc fails CI. (`{@template}`/`{@macro}` is used for + boilerplate.) +- **`lines_longer_than_80_chars: true`** — 80-col lint on top of the format gate. +- **`prefer_relative_imports: true`** (+ `avoid_relative_lib_imports: error`) — + inside `lib/` imports are **relative** (`../nodes.dart`); tests use + `package:flutter_md/flutter_md.dart`. +- **`prefer_const_*` / `use_named_constants`** — const is enforced. + +`errors:` overrides — hard **errors**: `always_use_package_imports`, +`avoid_relative_lib_imports`, `avoid_slow_async_io`, `avoid_types_as_parameter_names`, +`valid_regexps`, `always_require_non_null_named_parameters`. **Ignored**: `todo`, +`curly_braces_in_flow_control_structures` (single-line `if` bodies allowed). +`example/analysis_options.yaml` is identical to root. + +## Conventions + +- **Relative imports inside `lib/`**; `package:` imports in `test/`. The barrel + `lib/flutter_md.dart` re-exports via `export 'src/…'`. +- **`$` separator** in public variant names — `MD$Block`, `MD$Span`, + `BlockPainter$Paragraph`, even benchmark `Current$Benchmark`. Deliberate style. +- **`@meta.internal`** (imported `as meta show internal`) marks non-user-facing + types (`MarkdownPainter`, `MarkdownRenderObject`); `@protected` for subclass-only + members. Public model/nodes import `package:meta/meta.dart` plain. +- **One test entrypoint:** `test/unit_test.dart` imports each suite's `main()` + inside `group('Unit', …)`. A new `*_test.dart` must be wired there or CI skips it. +- **Doc comments required** on all public members (see the lint above). +- **CHANGELOG discipline:** bump `pubspec.yaml` `version:` and add the matching + `# ` heading in `CHANGELOG.md` together. + +## Dependencies + +- `dependencies`: `flutter` (sdk), `meta: ^1.16.0`. +- `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`. + +## Repo layout + +``` +lib/flutter_md.dart public barrel (export 'src/…' [+ show for render.dart]) +lib/src/ + markdown.dart nodes.dart parser.dart # model + parser → docs/parser.md + theme.dart # MarkdownThemeData, MarkdownTheme + widget.dart # MarkdownWidget (LeafRenderObjectWidget) + render.dart render/** # canvas render layer → docs/rendering.md + selection.dart selection_scope.dart # text selection → docs/selection.md +test/ + parser/ nodes/ selection/ theme/ widget/ (aggregated by test/unit_test.dart) +benchmark/ parser + render benchmarks, compare.dart, scenarios.dart, + .render_baseline.txt, experiments/ (spikes + FINDINGS.md) +example/ md_example app: lib/main.dart (Editor/Selection/Chat tabs), lib/tabs/* +AGENTS.md docs/ this documentation set +``` + +`build/`, `coverage/`, `.dart_tool/` are gitignored; `.baseline.txt` is +uncommitted (the render baseline `.render_baseline.txt` is committed). diff --git a/docs/parser.md b/docs/parser.md new file mode 100644 index 0000000..e90b1c1 --- /dev/null +++ b/docs/parser.md @@ -0,0 +1,164 @@ +# Parser & node model + +Files: `lib/src/parser.dart`, `lib/src/nodes.dart`, `lib/src/markdown.dart`. +The parser is stable, hot-path-optimized ground — behavior is locked by +`test/parser/**` (especially `regression_test.dart`). Preserve edge semantics. + +## Entry points + +Three ways from `String` to `Markdown`: + +```dart +Markdown.fromString(src); // convenience factory; inlineMath: false +markdownDecoder.convert(src); // shared const MarkdownDecoder() +const MarkdownDecoder(inlineMath: true).convert(src); +``` + +- `MarkdownDecoder extends Converter` — `const + MarkdownDecoder({bool inlineMath = false, Map? mathReplacements})`. + The whole parse is one `convert(String)` method: a hand-rolled line loop, + branched on the **first code unit** of each line so plain prose runs zero + regexes. Being a `dart:convert` `Converter`, it also gets `.fuse`/`.cast`/streaming. +- `Markdown` — `final class` with `String markdown` (original source), + `List blocks` (unmodifiable), `isEmpty`/`isNotEmpty`, and a lossy + `String get text` (concatenated span text; expensive; tests only). `toString()` + returns the raw source. Parsing is relatively expensive — do it outside build. + +## Node model (`nodes.dart`) + +`sealed class MD$Block` is the base of every block (`@immutable`), with abstract +`String type` and `String text`. Dispatch is a **`map`/`maybeMap` pattern**, not a +visitor object: + +```dart +block.map( + paragraph: (p) => …, heading: (h) => …, quote: (q) => …, code: (c) => …, + list: (l) => …, divider: (d) => …, table: (t) => …, alert: (a) => …, spacer: (s) => …, +); +// maybeMap({... optional, required orElse}) fills unset handlers with orElse. +``` + +Because `MD$Block` is `sealed`, an exhaustive `switch` also works. **There are +nine block kinds — no image block** (`MD$Image` exists only commented-out); images +are inline spans. Adding a block kind means extending the `map`/`maybeMap` +signature — a breaking change to every override. + +| Block | `type` | Key fields | +|---|---|---| +| `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$Code` | `code` | `String? language` (may be `''`), `text` (raw, never span-parsed) | +| `MD$List` | `list` | `text` (raw slice), `List items` | +| `MD$Divider` | `divider` | none; `text == '---'` | +| `MD$Table` | `table` | `MD$TableRow header`, `List rows`, `List alignments` + `alignmentFor(i)` | +| `MD$Spacer` | `spacer` | `int count` (collapsed blank lines); `text == '\n' * count` | + +Supporting types: + +- **`MD$Span`** — `int start`, `int end`, `String text`, `MD$Style style`, + `Map? extra`. `extra` carries link/image metadata: `'type'` + (`link`/`image`), `'href'`/`'src'`, `'url'`, optional `'alt'`. +- **`MD$Style`** — a **bitmask**, `extension type const MD$Style(int value) + implements int` (not an enum). Flags: `none`, `italic`, `bold`, `underline`, + `strikethrough`, `monospace`, `link`, `image`, `highlight`, `spoiler`. Combine + with `|`/`.add`; query with `.contains`. The parser stores combined masks + (e.g. bold|link). +- **`MD$ListItem`** — `int indent` (leading-space **column width**, not a level + ordinal), `bool? checked` (`null` = not a task; `false`/`true` = task state), + `bool isTask`, `String marker` (literal source marker: `-`,`*`,`+`,`1.`,`1)`), + `text`, `spans`, `List children`, `copyWith(...)`. +- **`MD$TableRow`** — `text`, `List> cells` (each cell its own span list). +- **`MD$TableColumnAlign`** — `none, left, center, right`. +- **`MD$AlertType`** — `note, tip, important, warning, caution`, each with a + `marker` and a `title` (`Note`, `Tip`, …), plus `static tryParse(keyword)` + (case-insensitive). + +## The span offset invariant + +Every `MD$Span.start/end` are **UTF-16 offsets into its block's `text`**, and +**`spans.map((s) => s.text).join()` reproduces the block's rendered (visible) +text** — markers, escapes, and link/image syntax removed. Locked by +`test/parser/regression_test.dart` ("Span offset invariants"): + +- spans are ordered by ascending `start`; every span has `start <= end`; +- plain text with no markers is a single span `start=0`, `end=text.length`, + `style=none`. + +Selection depends on this to map a visible-text range back to positions/styles. +`start/end` index the **visible** text, so they can diverge from raw-source +offsets: + +- **Escapes:** the backslash is removed from `text`; `end` is reduced by the + number of removed backslashes, so `end - start != text.length`. +- **Inline math** (when enabled): offsets index the already-substituted Unicode + string, not the `$…$` source. +- **Links/images:** offsets span the full `[..](..)` / `![..](..)` source range + while `text` is only the label, so `text.length != end - start` there. + +## GFM support & intentional/nonstandard choices + +Emphasis (CommonMark-inspired flanking rules; stray/unterminated markers stay +literal and never leak style to end of line): + +- `*x*` italic, `**x**` bold; `_x_` italic, **`__x__` = UNDERLINE, not bold**. +- `~~x~~` strikethrough, `==x==` highlight, `||x||` spoiler — all require the + **double** marker; a single one is literal. +- `` `x` `` monospace, single backtick only (double backtick unsupported); inside + a code span all markdown is literal. +- `_` cannot open/close intra-word (snake_case, Cyrillic preserved); `*` may + emphasize intra-word (`a*b*c`). + +Inline: + +- **Soft line breaks preserved** — consecutive non-blank lines join into one + paragraph with embedded `\n`; a blank line splits paragraphs and emits a + `MD$Spacer`. +- **Escapes** for `` ! # $ ( ) * + - . [ \ ] _ ` { } ``; `\\` → `\`. +- **Links/images** `[text](url)`, `![alt](src)`; angle-bracket URLs, quoted/paren + titles, balanced parens in bare URLs. **No reference-style links, no + autolinks.** Emphasis wrapping a link merges masks (`**[x](u)**` → bold|link). +- **Inline math `$…$` is opt-in** (`inlineMath: false` by default, so `$5`, + `$HOME` are untouched). When on, only `$…$` runs containing a recognized + `\command` or `^`/`_` script convert to Unicode; currency and unmappable runs + stay literal; inline/fenced code is protected; `\$` opts out. Command table is + `kMarkdownMathCommands` (public, extensible via the `mathReplacements` ctor arg). + +Block-level: + +- **Task lists** `- [ ]` / `- [x]` / `- [X]` (also ordered); empty `[]` is not a task. +- **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. +- **Thematic breaks** `---`/`***`/`___` (3+ markers); **ATX headings** `#`–`######` + (`#hashtag` and 7+ `#` are paragraphs; trailing `#` stripped); **fenced code** + ` ``` ` / `~~~` with a language label; unterminated fences run to EOF. + +## Gotchas for contributors + +- `convert` gates on the first code unit; perf-critical scans (`_isBlank`, + `_parseListLine`, `_parseLinkTarget`, escape range-copy, inline-math bails) were + rewritten from regexes — `regression_test.dart` locks their exact semantics. +- The **inline fast path** returns a single unstyled full-width span when the text + contains none of `` * _ ~ = | \ [ ` ``. Any new inline syntax must extend that + special-character set or it becomes invisible to the parser. +- List `indent` is a **column width**, capped at 8; 9+ leading spaces end the list. + 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')`. +- `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. +- `blocks` and most nested lists (`items`, `rows`, `cells`, `alignments`) are + unmodifiable — don't mutate in place. +- Empty input → `const Markdown.empty()`; a doc ending in blank lines still emits a + trailing `MD$Spacer`. + +## Public API (via `flutter_md.dart`) + +All of `markdown.dart` (`Markdown`), `nodes.dart` (every `MD$*`, `MD$Style`, +`MD$AlertType`, `MD$TableColumnAlign`, `MD$ListItem`, `MD$TableRow`, `MD$Span`), +and `parser.dart` (`MarkdownDecoder`, `markdownDecoder`, `kMarkdownMathCommands`). diff --git a/docs/rendering.md b/docs/rendering.md new file mode 100644 index 0000000..f6f61e3 --- /dev/null +++ b/docs/rendering.md @@ -0,0 +1,220 @@ +# Rendering layer + +Files: `lib/src/render.dart` (a re-export barrel), `lib/src/render/**`, +`lib/src/theme.dart`, `lib/src/widget.dart`. The layer paints the model onto a +canvas via one `RenderBox` and a list of `BlockPainter`s — **no widget per block**. + +## File map (`lib/src/render/`) + +`render.dart` is a **barrel** (`library;` + `export` lines) so legacy +`import 'src/render.dart'` keeps resolving; it has no code. + +| File | Contents | +|---|---| +| `block_painter.dart` | The framework: `BlockPainter`, `SelectableBlockPainter`, mixins `SelectableTextBlock` / `MultiPainterSelectable` / `ParagraphGestureHandler`, class `SelectableFragment`, private `_distanceToRect`. | +| `span_builder.dart` | `paragraphFromMarkdownSpans({spans, theme, textStyle})` → `TextSpan`; private `_buildTapRecognizer` (link taps from `span.extra['url']`). | +| `markdown_painter.dart` | `MarkdownPainter` (`@meta.internal`) — the orchestrator. | +| `markdown_render_object.dart` | `MarkdownRenderObject` (`@meta.internal`) — the `RenderBox`, also a `MarkdownSelectionSurface`; plus `_paintNothing`. | +| `blocks/*.dart` | `BlockPainter$Paragraph, $Heading, $Quote, $Alert, $Code, $List` (+ private `_ListItemMetrics`), `$Table`, `$Divider`, `$Spacer`. | + +## Render flow + +`MarkdownWidget` (`LeafRenderObjectWidget`) → `createRenderObject` builds a +`MarkdownRenderObject` then `updateSelection(controller, documentId)`; +`updateRenderObject` calls `update(markdown, theme)` + `updateSelection(...)`. +Theme resolution: explicit `theme` → `MarkdownTheme.maybeOf(context)` → a default +from `DefaultTextStyle`/`Directionality`/`MediaQuery.textScaler`. + +`MarkdownRenderObject` (a `RenderBox`, not `sizedByParent`) owns one +`MarkdownPainter`. `computeDryLayout`/`performLayout` both do +`constraints.constrain(_painter.layout(maxWidth: constraints.maxWidth))`. + +`MarkdownPainter`: + +- **Build** (`_rebuild`): reads `theme.blockFilter` and `theme.builder ?? + _defaultBlockBuilder`; for each non-filtered block appends + `builder(block, theme) ?? _defaultBlockBuilder(block, theme)` to + `_blockPainters`, records the true `Markdown.blocks` index in `_sourceIndices`, + sizes `_blockOffsets`. `_defaultBlockBuilder` is a `block.map(...)` to the nine + `BlockPainter$*` constructors. +- **Layout:** per-block, top-to-bottom. Writes each block's top-`y` into + `_blockOffsets[i]`, calls `block.layout(maxWidth)`, accumulates height, tracks + max width. **No inter-block spacing** is added — a `MD$Spacer` block supplies gaps. +- **Paint:** guarded by `!_needsLayout`. **Glyphs are cached in a `ui.Picture` + keyed by size** — if `_lastSize == size`, the picture is replayed via + `drawPicture`; otherwise it re-records (walking blocks, `painter.paint($canvas, + size, offset)`, advancing `offset += painter.size.height`). The cache is nulled + only by `update` (model/theme change) and `invalidateLayout` (system fonts). An + overflow guard stops emitting blocks past the viewport height. +- **Hit-testing** (`_blockIndexForDy`): **binary search** over `_blockOffsets`. + `positionForLocal` maps a content-local offset → `(sourceBlockIndex, textOffset)` + (null if the hit block isn't a `SelectableBlockPainter`). `handleEvent` routes + tap-down/up to the block, re-basing the pointer into block-local space (a manual + `PointerEvent` clone, since `PointerEvent` has no `copyWith`). +- **System fonts:** `MarkdownRenderObject.attach` listens on + `PaintingBinding.instance.systemFonts`; a change calls `invalidateLayout` (nulls + the cache, disposes+rebuilds every block painter so `TextPainter`s re-layout) + + `markNeedsLayout`. + +## The `BlockPainter` framework + +`BlockPainter` — every painter implements this: + +```dart +abstract final Size size; // valid only after layout() +void handleTapDown(PointerDownEvent event); // block-LOCAL coords +void handleTapUp(PointerUpEvent event); +Size layout(double width); // measure at width; sets size +void paint(Canvas canvas, Size size, double offset); // size = full content size; offset = block top-y +void dispose(); +``` + +Coordinate contract: taps arrive in **block-local** space; `paint`'s `offset` is +the block's top-`y` in the content, and `size` is the full content size (blocks +use `size.width` for full-width backgrounds/rules and to bail when too narrow). + +`SelectableBlockPainter implements BlockPainter` adds selection: + +```dart +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) +``` + +Two mixins implement it for you: + +- **`SelectableTextBlock`** — a block backed by a **single `TextPainter`**. Supply + `TextPainter get selectionPainter` and optionally override `Offset get + selectionOrigin` (default `Offset.zero`) when glyphs aren't at the block origin. + It derives `renderedText`, `offsetForLocalPosition`, `boxesForRange` for you. +- **`MultiPainterSelectable`** — a block whose text spans **several + `TextPainter`s** at different origins (lists, tables). Supply + `List get fragments` (in rendered-text order, each + `textStart` matching the linearization; the gaps are the `\n`/`\t` separators) + and `renderedText`. `SelectableFragment` is `(TextPainter painter, Offset + origin, int textStart)`. + +`ParagraphGestureHandler` — mix in for link taps: `hitTestInlineSpanWithPointerEvent(event, +painter)` resolves the `InlineSpan` under a pointer so `handleTapDown`/`handleTapUp` +can match down and up on the same span before firing its recognizer. + +`paragraphFromMarkdownSpans({spans, theme, textStyle})` → `TextSpan` — the public +helper that applies `theme.spanFilter`, maps each `MD$Span` via +`theme.textStyleFor(span.style)` (merged under `textStyle` if given), and attaches +a link recognizer (from `theme.onLinkTap` + `span.extra['url']`). **Use it** for +any custom text block so filters/styles/link-taps stay consistent. + +### Recipe: a custom block painter + +```dart +class MyBlock with ParagraphGestureHandler, SelectableTextBlock implements BlockPainter { + MyBlock({required List spans, required this.theme}) + : painter = TextPainter( + text: paragraphFromMarkdownSpans(spans: spans, theme: theme), + textDirection: theme.textDirection, textScaler: theme.textScaler); + + final MarkdownThemeData theme; + final TextPainter painter; + @override TextPainter get selectionPainter => painter; // SelectableTextBlock hook + + Size _size = Size.zero; + @override Size get size => _size; + + @override Size layout(double width) { painter.layout(maxWidth: width); return _size = painter.size; } + @override void paint(Canvas c, Size s, double dy) { /* decorate */ painter.paint(c, Offset(0, dy)); } + @override void handleTapDown(PointerDownEvent e) {/* see BlockPainter$Paragraph */} + @override void handleTapUp(PointerUpEvent e) {/* ... */} + @override void dispose() => painter.dispose(); +} +``` + +Wire it in via the theme: + +```dart +MarkdownThemeData( + builder: (block, theme) => block is MD$Paragraph ? MyBlock(spans: block.spans, theme: theme) : null, +); +``` + +Returning `null` falls back to the default painter for that block. For +many-painter blocks (custom lists/tables), mix in `MultiPainterSelectable` instead +and expose `fragments` + `renderedText`. + +> **Offset-space rule:** if your block is selectable, `renderedText` (and each +> fragment's `textStart`) must match `markdownBlockRenderedText(block)` (see +> [selection](selection.md)), or hit-testing, highlight, and copied text will +> disagree. + +## Default block painters + +- **`$Paragraph`** — one `TextPainter` from spans, painted at `(0, offset)`; + selectable + link taps; no decoration. +- **`$Heading`** — like `$Paragraph`, styled by `theme.headingStyleFor(level)`. +- **`$Quote`** — body styled `theme.quoteStyle ?? textStyle`; one vertical accent + bar per `indent` level (`lineIndent = 10`, `dividerColor`); text shifted right + by `lineIndent + indent*lineIndent` (also its `selectionOrigin`). +- **`$Alert`** — GitHub admonition: tinted rounded background (accent α 0.10), + left accent bar (width 4), bold colored title above body (`alert.title`, + `alertColorFor(type)`); body selectable via `selectionOrigin`. +- **`$Code`** — monospace `TextPainter` (raw text, no span parsing); rounded + `surfaceColor` background (radius = padding 8); taps are no-ops; **no link taps**. +- **`$List`** — recursive nested items (`_baseIndent = 8`, `_levelIndent = 16` per + depth); bullet glyph `☑`/`☐` for task items, else `•`/ordered marker; each item + is a bullet + content `TextPainter`; `MultiPainterSelectable`, items joined `\n`. +- **`$Table`** — per-cell `TextPainter` (bold header), per-column alignment; + column widths via `_distributeWidths` (natural if they fit, else shrink toward + per-column min = longest-word width, else overflow); zebra rows, cached inner + grid + outer border; `MultiPainterSelectable`, cells joined `\t`, rows `\n`. +- **`$Divider`** — one horizontal line across `size.width`; not selectable. +- **`$Spacer`** — blank vertical gap `Size(0, fontSize * count)`; `paint` is a + no-op; not selectable. + +`$Divider`/`$Spacer` are not selectable; `$Code` is selectable but has no link +taps; only `$List`/`$Table` use `MultiPainterSelectable`. + +## Theme customization (`MarkdownThemeData`) + +`MarkdownThemeData implements ThemeExtension` — put it in +`ThemeData.extensions` (it `lerp`s; non-lerpable fields switch at `t < 0.5`) or +provide it through the `MarkdownTheme` inherited widget (`MarkdownTheme.of/maybeOf`). +`MarkdownThemeData.mergeTheme(ThemeData, ...)` derives one from a Material theme. + +Render-override hooks: + +- **`builder`** `BlockPainter? Function(MD$Block, MarkdownThemeData)` — custom + painter per block; `null` ⇒ default. +- **`blockFilter`** `bool Function(MD$Block)` — drop a whole block before painters + are built; `_sourceIndices` keeps selection anchored to the real model index. +- **`spanFilter`** `bool Function(MD$Span)` — filter inline spans. **Caveat: + 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.** + +Styling: `textStyle`, per-level `h1Style..h6Style` (+ cached `headingStyleFor`), +`textStyleFor(MD$Style)` (cached mapping of the bitmask → bold/italic/underline/ +strikethrough/monospace + highlight/monospace backgrounds + link color), +`linkColor`/`linkStyle`, `surfaceColor` (code/table/quote backgrounds), +`highlightBackgroundColor`, `monospaceBackgroundColor`, `dividerColor`, +`alertColors` (+ built-in GitHub palette fallback via `alertColorFor`), +`textDirection`, `textScaler`, and `onLinkTap`. + +## Public vs internal + +Public (in `flutter_md.dart`'s `show` list): the framework +(`BlockPainter`, `SelectableBlockPainter`, `SelectableTextBlock`, +`MultiPainterSelectable`, `SelectableFragment`, `ParagraphGestureHandler`, +`paragraphFromMarkdownSpans`) and the nine defaults (`BlockPainter$Paragraph … +$Spacer`). Internal (`@meta.internal`, only via `src/render.dart`): +`MarkdownPainter`, `MarkdownRenderObject`. + +## Invariants (repeated from [architecture](architecture.md), owned here) + +- Glyphs cached in a `ui.Picture` keyed by size; reused on repaint; nulled only on + `update`/`invalidateLayout`. +- **Selection highlight is painted before/outside that cache** + (`MarkdownRenderObject.paint` calls `_painter.paintHighlight(...)` then + `_painter.paint(...)`), so drags/streaming never rebuild the glyph cache. 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. diff --git a/docs/selection.md b/docs/selection.md new file mode 100644 index 0000000..936f4ab --- /dev/null +++ b/docs/selection.md @@ -0,0 +1,180 @@ +# Text selection + +Files: `lib/src/selection.dart`, `lib/src/selection_scope.dart`, +`lib/src/widget.dart`, and the render side in +`lib/src/render/markdown_render_object.dart`. Selection spans **across blocks and +across multiple `MarkdownWidget`s** (a whole chat), including messages whose +widgets are scrolled off and disposed (issue #25). + +## Controller-anchored architecture + +`MarkdownSelectionController extends ChangeNotifier` is the single source of +truth. State is held as **logical anchors over immutable models**, never over +render objects. + +- **`MarkdownPosition`** `{Object documentId, int blockIndex, int offset}` — + `documentId` is app-defined (e.g. a chat message id); `blockIndex` indexes + `Markdown.blocks` (the source list, not painter fragments); `offset` indexes the + block's **rendered text** (§ linearization). +- **`MarkdownSelection`** `{MarkdownPosition base, extent}` (+ `.collapsed(at)`, + `isCollapsed`). Direction is stored as authored; reading order is resolved + lazily by the controller (`_ordered`/`_compare`), never normalized. + +**Why anchor to the model:** text is derived from an app-supplied registry of +immutable `Markdown` models, so `getText()`/`selectedContent()` work even when a +widget is unmounted. A selection may span documents whose widgets a `ListView` has +disposed; only mounted docs contribute *geometry*, while *all* in-range docs +contribute *text*. Flutter's `SelectableRegion`/custom delegates were rejected in +spikes (they glue without separators and drop disposed items) — see +`benchmark/experiments/FINDINGS.md`. + +## Document registry + +The app registers each document's immutable model so text extraction is +mount-independent: + +- `setDocuments(Iterable)` — replace the whole registry + (bulk/initial load); clamps a still-valid selection into the new docs, else + drops it. +- `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 (§). +- `removeDocument(id)` — removes and drops the selection if either endpoint was in + that doc. +- `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. + +Ordering is O(1): `_sort()` sorts `_docs` by `order` then `_reindex()` rebuilds a +`Map _indexById`, so `_orderIndex(id)` is a map lookup. This matters +because `_orderIndex` runs several times per selectable block on **every highlight +repaint** (via `rangeFor`) — a linear scan would scale with chat size and stall +drags. `_reindex` runs on set/order changes only, not on model-only updates. + +## Surfaces (mounted geometry bridge) + +`abstract interface class MarkdownSelectionSurface` is the controller's window +onto a live render object, **implemented by `MarkdownRenderObject`**: + +- `documentId`, `Rect globalBounds` +- `MarkdownPosition? positionForGlobal(Offset)` — global point → logical position +- `List globalSelectionRects()` / `localSelectionRects()` — highlight rects + (screen / content-local); used for handles, magnifier, toolbar anchor +- `setSelectionHandleLayers({startLink, startLocal, endLink, endLocal})` — the + handle `LayerLink`s the surface paints so the overlay's handles follow content +- `repaintSelection()` — repaint just the highlight; safe during build + +The controller keeps a `Map` keyed by +`documentId`; render objects `attachSurface`/`detachSurface` on attach/detach. +`rangeFor(documentId, blockIndex)` returns the selected `TextRange` within a block +(null if outside the selection) — the per-block highlight lookup during paint. + +## Reconciliation (streaming) + +When `putDocument` replaces a model, `MarkdownReconciliationPolicy.remap(anchor, +old, new)` relocates each endpoint in the changed doc (returning null drops the +whole selection). **No stable block id exists — remapping is content-based.** + +- `appendFastPath()` — keep the block index verbatim when everything before the + anchor is unchanged and the anchor block's new text is a prefix-superset; else + clamp. Cheapest; correct for appends, drifts on front/mid inserts. +- **`contentAnchored()` — the default.** Append fast path, else relocate by + matching the anchor block's rendered text in the new model, else clamp. Robust + to inserts/reorders without an id. +- `clearOnChange()` — drop the selection on any change to the anchor's doc. + +Set via `MarkdownSelectionController(reconciliation: …)`. + +## Extraction & formatting + +`selectedContent()` → `MarkdownSelectedContent { List +documents; isEmpty; toPlainText() }`, built from the models in reading order, +independent of what's mounted (empty slices skipped). + +- `MarkdownSelectedDocument { documentId, List blocks }` +- `MarkdownSelectedBlock { int blockIndex, String type, String text, TextRange + renderedRange, TextRange? sourceRange /* currently always null */, MD$Block block }` + +`getText([formatter])` = `(formatter ?? controller.formatter).format(selectedContent())`. + +- `MarkdownSelectionFormatter` — `String format(MarkdownSelectedContent)`. +- `MarkdownPlainTextFormatter` (default) — joins block slices within a doc by + `blockSeparator` (`'\n'`), docs by `documentSeparator` (`'\n\n'`); empty blocks + skipped. Implement your own for "copy as Markdown" etc. + +### `markdownBlockRenderedText(block)` — the linearization + +This is the **single source of truth** shared by extraction and pointer +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.** +- code → raw `text`. +- **list** → items depth-first, joined by `'\n'` unconditionally (one line per + item, even empty ones); checkbox glyphs contribute nothing. +- **table** → cells joined by `'\t'`, rows by `'\n'`. +- divider / spacer → `''` (structural blocks contribute no text). + +## `MarkdownSelectionScope` — gestures, keyboard, handles, toolbar + +`MarkdownSelectionScope` (a `StatefulWidget`) owns interaction for its subtree and +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`. +Statics: `MarkdownSelectionScope.of/maybeOf` (→ controller), `stateOf` (→ state). + +- **Gestures:** a `PanGestureRecognizer` restricted to + mouse/stylus/trackpad with `DragStartBehavior.down` (immediate drag-select on + pointer devices); a `LongPressGestureRecognizer` for **touch** (long-press then + drag) so a plain touch swipe still scrolls an enclosing `ListView`; a + `TapGestureRecognizer` (tap hides toolbar; right-click shows it at the point). +- **Keyboard:** an `Actions` map bound to the ambient `DefaultTextEditingShortcuts` + Intents (installed by `WidgetsApp`/`MaterialApp`): Ctrl/Cmd+C copy, Ctrl/Cmd+A + 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. +- **Toolbar:** `ContextMenuController` + `AdaptiveTextSelectionToolbar`; default + items are Copy (non-collapsed selection) and Select-all (`hasDocuments`). + Public ops: `copySelection`, `selectAll`, `clearSelection`, `showToolbar`, + `hideToolbar`. +- **`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`/ + `SelectionArea` starts its own selection). + +## Opting a `MarkdownWidget` into selection + +`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`. + +## Gotchas / known limitations + +- `selectionColor` setter repaints surfaces directly and must **not** + `notifyListeners` — it's applied during build (`didChangeDependencies`), where + notifying would trigger `setState`. (The `formatter` setter *does* notify.) +- Alert **title** is not selectable (body only). +- Keyboard word/line extension is **block-approximate** (a whitespace scan / jump + 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). +- `MarkdownSelectedBlock.sourceRange` is documented as best-effort but is currently + always null. From f8271f4c44a0d530bca6e60e8c8061b8e012f1a3 Mon Sep 17 00:00:00 2001 From: Mike Matiunin Date: Mon, 3 Aug 2026 15:50:34 +0400 Subject: [PATCH 17/30] test: add cases for space-flanked closers and ensure proper emphasis handling --- lib/src/parser.dart | 13 ++++++++-- test/parser/inline_test.dart | 46 ++++++++++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 2 deletions(-) diff --git a/lib/src/parser.dart b/lib/src/parser.dart index 312190a..09851e7 100644 --- a/lib/src/parser.dart +++ b/lib/src/parser.dart @@ -787,6 +787,14 @@ bool _hasClosingBacktick(List codes, int length, int from) { /// [markerLen]) exists at or after [from]. A closer must be right-flanking /// (preceded by a non-space); underscore closers must also be at a word /// boundary. Escaped characters are skipped. +/// +/// Right-flanking is a property of the whole delimiter *run*, not the +/// individual marker character: the deciding character is the one immediately +/// before the run, skipping consecutive same-marker delimiters. We therefore +/// only accept a closer at the run boundary (`codes[j - 1] != ch`). A run +/// whose boundary is not right-flanking cannot close from any of its inner +/// delimiters either, so e.g. `a **bold ** x` (the closing `**` follows a +/// space) stays literal instead of degrading into a lopsided italic. bool _hasEmphasisCloser( List codes, int length, int from, int ch, int markerLen) { for (var j = from; j < length; j++) { @@ -797,10 +805,11 @@ bool _hasEmphasisCloser( if (codes[j] != ch) continue; if (markerLen == 2) { if (j + 1 < length && codes[j + 1] == ch) { - if (j > 0 && !_isInlineSpace(codes[j - 1])) return true; + if (j > 0 && !_isInlineSpace(codes[j - 1]) && codes[j - 1] != ch) + return true; j++; // Consume the delimiter pair. } - } else if (j > 0 && !_isInlineSpace(codes[j - 1])) { + } else if (j > 0 && !_isInlineSpace(codes[j - 1]) && codes[j - 1] != ch) { if (ch != 0x5F /* _ */) return true; // Underscore: also require a word boundary after the closer. final after = j + 1; diff --git a/test/parser/inline_test.dart b/test/parser/inline_test.dart index 04abf79..ea73e3e 100644 --- a/test/parser/inline_test.dart +++ b/test/parser/inline_test.dart @@ -111,6 +111,52 @@ void main() => group('Inline parsing', () { expect(_spans('a ~~ b').every((s) => s.style.isEmpty), isTrue); expect(_spans('a == b').every((s) => s.style.isEmpty), isTrue); }); + + test('double marker with a space-flanked closer stays literal', () { + // The closing `**` is preceded by a space, so it is not + // right-flanking and cannot close: the whole run is literal. The + // inner `*` of the run must not degrade into a stray italic. + expect(_visible('a **bold ** x'), 'a **bold ** x'); + expect(_spans('a **bold ** x').every((s) => s.style.isEmpty), isTrue); + }); + + test('double marker with a closer after a soft break stays literal', + () { + // Same rule across a soft line break: the closing `**` follows a + // newline (whitespace), so it cannot close and stays literal. + expect(_visible('a **bold\n** x'), 'a **bold\n** x'); + expect( + _spans('a **bold\n** x').every((s) => s.style.isEmpty), isTrue); + }); + + test('double underline with a space-flanked closer stays literal', () { + expect(_visible('a __bold __ x'), 'a __bold __ x'); + expect(_spans('a __bold __ x').every((s) => s.style.isEmpty), isTrue); + }); + }); + + group('Double markers still emphasize when properly flanked', () { + // Regression guards: the fix for space-flanked closers must not break + // legitimate double/triple/nested emphasis. + test('triple *** is bold + italic', () { + final spans = _spans('***both***'); + expect(spans.single.text, 'both'); + expect(spans.single.style.contains(MD$Style.bold), isTrue); + expect(spans.single.style.contains(MD$Style.italic), isTrue); + }); + + test('italic nested inside bold', () { + final spans = _spans('**b *bi* b**'); + expect(_styleOf(spans, 'bi').contains(MD$Style.bold), isTrue); + expect(_styleOf(spans, 'bi').contains(MD$Style.italic), isTrue); + expect(_styleOf(spans, 'b '), MD$Style.bold); + }); + + test('bold spanning a soft line break is closed', () { + final spans = _spans('x **a\nb** y'); + expect(_styleOf(spans, 'a\nb'), MD$Style.bold); + expect(_visible('x **a\nb** y'), 'x a\nb y'); + }); }); group('Intraword underscores (snake_case)', () { From 2dd01b65a60451fbfc3df5e73cc8a787a64ac75e Mon Sep 17 00:00:00 2001 From: Mike Matiunin Date: Mon, 3 Aug 2026 17:10:49 +0400 Subject: [PATCH 18/30] Add benchmark package for comparing Markdown libraries - Introduced `benchmark_compare` package to evaluate parsing and rendering performance of `flutter_md`, `flutter_markdown`, and `gpt_markdown`. - Implemented parser benchmark in `parser_benchmark.dart` to measure parsing times. - Created render benchmark in `render_benchmark_test.dart` to assess end-to-end rendering performance. - Added integration test for scroll performance in `scroll_perf_test.dart`. - Developed utility scripts for summarizing benchmark results and formatting output. - Established shared corpus and styles for consistent benchmarking across libraries. - Configured analysis and devtools options for the benchmark package. --- .pubignore | 7 +- README.md | 34 ++- benchmark_compare/.gitignore | 12 + benchmark_compare/README.md | 126 ++++++++++ benchmark_compare/RESULTS.md | 131 ++++++++++ benchmark_compare/analysis_options.yaml | 8 + .../benchmark/parser_benchmark.dart | 118 +++++++++ benchmark_compare/devtools_options.yaml | 3 + .../integration_test/scroll_perf_test.dart | 126 ++++++++++ benchmark_compare/lib/corpus.dart | 197 +++++++++++++++ benchmark_compare/lib/styles.dart | 112 +++++++++ benchmark_compare/pubspec.yaml | 50 ++++ .../test/render_benchmark_test.dart | 229 ++++++++++++++++++ .../test_driver/perf_driver.dart | 15 ++ .../tool/summarize_timeline.dart | 114 +++++++++ 15 files changed, 1272 insertions(+), 10 deletions(-) create mode 100644 benchmark_compare/.gitignore create mode 100644 benchmark_compare/README.md create mode 100644 benchmark_compare/RESULTS.md create mode 100644 benchmark_compare/analysis_options.yaml create mode 100644 benchmark_compare/benchmark/parser_benchmark.dart create mode 100644 benchmark_compare/devtools_options.yaml create mode 100644 benchmark_compare/integration_test/scroll_perf_test.dart create mode 100644 benchmark_compare/lib/corpus.dart create mode 100644 benchmark_compare/lib/styles.dart create mode 100644 benchmark_compare/pubspec.yaml create mode 100644 benchmark_compare/test/render_benchmark_test.dart create mode 100644 benchmark_compare/test_driver/perf_driver.dart create mode 100644 benchmark_compare/tool/summarize_timeline.dart diff --git a/.pubignore b/.pubignore index 80bfe48..b6c2d30 100644 --- a/.pubignore +++ b/.pubignore @@ -1,5 +1,10 @@ +.dart_tool/ +pubspec.lock +benchmark/.baseline.txt +benchmark/.render_baseline.txt .vscode/ build/ coverage/ credentials.json -*.exe \ No newline at end of file +*.exe +benchmark_compare/ \ No newline at end of file diff --git a/README.md b/README.md index 29f2fb4..efffc11 100644 --- a/README.md +++ b/README.md @@ -375,18 +375,34 @@ class _MyWidgetState extends State { ## 📊 Performance -- **Parsing**: single-pass, lookup-table driven parser with a plain-text fast - path and hand-rolled (regex-free) block/inline scanning. The hot path was - rewritten for a ~45% speedup, and it parses typical AI responses roughly - **10× faster** than the `markdown` package. -- **Rendering**: 120 FPS smooth scrolling for chat-like interfaces -- **Memory**: Minimal memory footprint with efficient span filtering - -Benchmarks live in `benchmark/`: +`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 +whole document into one cached `ui.Picture` instead of a deep tree of per-block +widgets. + +Head-to-head against `flutter_markdown` and `gpt_markdown` on the same machine +(i7-13700K) with identical styles — full tables and methodology in +[`benchmark_compare/RESULTS.md`](benchmark_compare/RESULTS.md): + +- **Parsing** — **~18× faster** than the `markdown` package that backs + `flutter_markdown` (`gpt_markdown` has no standalone parser), sustaining + ~60 MB/s on mixed documents. +- **Rendering** (string → painted pixels) — **~6.6× faster than + `flutter_markdown` and ~15.6× faster than `gpt_markdown`** on a large document + (1.2–1.6× on small chat bubbles). +- **Scrolling** — 60 fps with **zero dropped frames** in profile mode, and the + lowest UI-thread frame-build cost of the three (99th-percentile frame build + **1.6 ms** vs 4.8 / 9.4 ms). ```bash -dart run benchmark/parser_benchmark.dart # multi-scenario, vs. `markdown` +# In-repo parser micro-benchmarks: +dart run benchmark/parser_benchmark.dart # multi-scenario, vs. `markdown` dart run benchmark/compare.dart --save # low-noise before/after tool + +# Head-to-head vs flutter_markdown & gpt_markdown (parser + render): +cd benchmark_compare && flutter pub get +dart run benchmark/parser_benchmark.dart +flutter test test/render_benchmark_test.dart ``` ## 🔧 Advanced Features diff --git a/benchmark_compare/.gitignore b/benchmark_compare/.gitignore new file mode 100644 index 0000000..615e140 --- /dev/null +++ b/benchmark_compare/.gitignore @@ -0,0 +1,12 @@ +.dart_tool/ +build/ +pubspec.lock +*.exe + +# Generated platform scaffolding (regenerate with: +# flutter create --platforms=linux --project-name md_benchmark_compare .) +linux/ +.metadata +.flutter-plugins-dependencies +*.iml +.idea/ diff --git a/benchmark_compare/README.md b/benchmark_compare/README.md new file mode 100644 index 0000000..da58e41 --- /dev/null +++ b/benchmark_compare/README.md @@ -0,0 +1,126 @@ +# Markdown benchmark comparison + +A standalone, non-published package that pits **flutter_md** against two popular +alternatives — [`flutter_markdown`](https://pub.dev/packages/flutter_markdown) +and [`gpt_markdown`](https://pub.dev/packages/gpt_markdown) — on both **parsing** +and **rendering**. + +It lives inside the repo (path dependency on `../`) but is excluded from +`flutter_md` publishing via the root `.pubignore`. + +## Results at a glance + +Measured on an i7-13700K (Flutter 3.41.6), all three libraries given identical +normalized styles. Full tables in **[`RESULTS.md`](RESULTS.md)**. + +- **Parser:** flutter_md is **~18× faster** than the `markdown` package that + backs flutter_markdown (gpt_markdown has no separable parser). +- **Render (string → painted pixels):** flutter_md is **1.4–15.6× faster** + end-to-end — ~6.6× vs flutter_markdown and ~15.6× vs gpt_markdown on the large + document. +- **Profile-mode scroll:** all three hold 60 fps with **0 dropped frames** on + desktop; flutter_md has the lowest UI-thread build cost (99th-percentile frame + build **1.6 ms** vs 4.8 / 9.4 ms) and, at equal on-screen content, the lowest + raster time too. + +## Layout + +| File | What it measures | How to run | +| ---- | ---------------- | ---------- | +| `lib/corpus.dart` | Shared benchmark documents (simple → complex/large), common-denominator GFM only. | — | +| `lib/styles.dart` | Normalized styles shared by all three libraries: one base text style + explicit line height, matched heading sizes and block spacing. | — | +| `benchmark/parser_benchmark.dart` | **Parser** cost via `benchmark_harness`: `flutter_md` vs the `markdown` package (which `flutter_markdown` uses internally). | `dart run benchmark/parser_benchmark.dart` | +| `test/render_benchmark_test.dart` | **Render** cost via widget tests: end-to-end string → painted pixels for all three libraries (headless). | `flutter test test/render_benchmark_test.dart` | +| `integration_test/scroll_perf_test.dart` + `test_driver/perf_driver.dart` | **Profile-mode** confirmation: real frame build/raster times while scrolling a feed, captured as a `TimelineSummary`. | see below | +| `tool/summarize_timeline.dart` | Formats the profile-mode JSON into a table. | `dart run tool/summarize_timeline.dart` | + +## Running + +```shell +cd benchmark_compare +flutter pub get + +# Parser — JIT is fine, AOT is the most stable: +dart run benchmark/parser_benchmark.dart +dart compile exe benchmark/parser_benchmark.dart -o /tmp/pb && /tmp/pb + +# Render — must run under the Flutter test engine (needs real text layout): +flutter test test/render_benchmark_test.dart + +# Profile-mode confirmation — real frame timings on a device (here: Linux +# desktop). Needs the platform runner: regenerate it once with +# flutter create --platforms=linux --project-name md_benchmark_compare . +flutter drive --driver=test_driver/perf_driver.dart \ + --target=integration_test/scroll_perf_test.dart --profile -d linux +dart run tool/summarize_timeline.dart # -> reads build/integration_response_data.json + +# Controlled variants for an apples-to-apples raster comparison: +# FEED=prose one prose doc repeated -> identical content per frame +# ITEM_MODE=fixed every item clipped to a constant height +flutter drive --driver=test_driver/perf_driver.dart \ + --target=integration_test/scroll_perf_test.dart --profile -d linux \ + --dart-define=FEED=prose +``` + +The parser and render entry points print ready-to-paste Markdown tables; the +profile-mode run writes `build/integration_response_data.json`, which +`tool/summarize_timeline.dart` turns into a table. A captured run of all three is +in [`RESULTS.md`](RESULTS.md). + +> The generated `linux/` runner (and `.metadata`, `*.iml`, …) are gitignored as +> throwaway scaffolding — regenerate with the `flutter create` line above. Swap +> `-d linux` for another device (`-d chrome`, a mobile device/emulator) if you +> prefer; the harness is platform-agnostic. + +## Methodology & fairness + +**Corpus.** Documents stick to common-denominator GFM (headings, emphasis, +inline code, links, blockquotes, fenced code, ordered/unordered/task lists, +tables) so all three libraries render them without diverging on dialect-specific +extensions. Images are omitted so render timings measure text layout, not +network image stubs. + +**Normalized styles.** All three libraries are configured from `lib/styles.dart` +with the same base text style (font size 14, explicit line `height` 1.4, same +color), the same heading sizes (base + {10,8,6,4,2,0}, bold) and matched block +spacing. An explicit line height makes every line box `fontSize × height`, +independent of font metrics, so text lays out at the same vertical rhythm for +every library and in both the headless-test and profile-desktop environments. +This removes layout *density* as a confound in the render/raster comparison — a +more compact renderer would otherwise rasterize more content per frame. Purely +structural chrome (code-block frames, table borders, list bullets/indents, +gpt_markdown's code header) is not unifiable through public style APIs and +remains; the render benchmark prints a height table that quantifies the residual. + +**Parser.** `flutter_markdown` does no parsing of its own — on every build it +delegates to the `markdown` package (`md.Document(...).parse(source)`). So the +apples-to-apples parser comparison is `flutter_md`'s `Markdown.fromString` +against that same `markdown` package (a fresh single-use `Document` per parse, +exactly as `flutter_markdown` uses it). `gpt_markdown` has **no separable +parser** — it parses inline while building its widget tree — so it does not +appear in the parser table; its parse cost is folded into the render numbers +instead. Each `benchmark_harness` benchmark overrides `exercise()` to run once, +so the reported figure is microseconds **per single parse**. + +**Render.** Measures the *end-to-end* cost of turning a Markdown **string** into +painted pixels — parse + build + layout + paint — which is what every library +does on the frame that first shows a message. A changing `ValueKey` forces a +full subtree teardown + rebuild each iteration, and wall-clock time is taken +around `tester.pump()` (min-of-batches, the most stable estimator). To keep it +fair: + +- Every iteration reconstructs from the source string. For `flutter_md` that + means `Markdown.fromString(src)` runs inside the builder too, so its parse + cost is included on equal footing. +- All three render into the same fixed-width (400px) viewport inside a + `SingleChildScrollView`, so layout covers the whole document while paint is + clipped to the same visible region for every library. + +**Interpreting the numbers.** Render microseconds (headless widget test) include +a fixed `flutter_test` per-frame overhead (~1 ms floor), so they are meaningful +only *relative to each other on the same machine* — use the ratios, and note +that the floor compresses the small-document ratios while the large/complex +documents show the true gap. The **profile-mode scroll benchmark** removes that +floor and confirms the ordering on a real device with true GPU rasterization +(same data you'd read off the DevTools Performance timeline, but automated). +Parser microseconds have no such floor and are directly comparable. diff --git a/benchmark_compare/RESULTS.md b/benchmark_compare/RESULTS.md new file mode 100644 index 0000000..f672237 --- /dev/null +++ b/benchmark_compare/RESULTS.md @@ -0,0 +1,131 @@ +# Benchmark results + +Head-to-head numbers for **flutter_md** vs **flutter_markdown** vs +**gpt_markdown**. Machine-specific — regenerate with the commands in +[`README.md`](README.md). All three libraries use the same normalized styles +([`lib/styles.dart`](lib/styles.dart)). **Lower is better** throughout. + +## Environment + +| | | +| ----------------- | ------------------------------------------ | +| CPU | 13th Gen Intel Core i7-13700K (24 threads) | +| OS | Linux 7.1.3 (CachyOS) | +| Flutter | 3.41.6 (stable) | +| Dart | 3.11.4 | +| flutter_md | 0.2.0 (path `../`) | +| flutter_markdown | 0.7.7+1 | +| gpt_markdown | 1.1.8 | +| markdown (engine) | 7.3.1 | + +## Parser (µs per parse) + +Parse only, AOT-compiled: `flutter_md.Markdown.fromString` vs the `markdown` +package (flutter_markdown's engine). gpt_markdown has no separable parser. +`speedup = markdown-pkg / flutter_md`. + +| scenario | bytes | flutter_md | flutter_md MB/s | markdown-pkg | speedup | +| ------------- | ----: | ---------: | --------------: | -----------: | ---------: | +| simple | 340 | 3.08 | 110.2 | 87.01 | 28.21x | +| inline | 388 | 4.52 | 85.9 | 100.71 | 22.29x | +| lists | 344 | 5.72 | 60.1 | 100.09 | 17.49x | +| table | 471 | 7.94 | 59.4 | 105.08 | 13.24x | +| code | 290 | 1.39 | 208.0 | 23.99 | 17.21x | +| quotes | 315 | 3.22 | 97.8 | 71.15 | 22.08x | +| complex | 1030 | 16.76 | 61.4 | 275.78 | 16.45x | +| complex_large | 8256 | 130.53 | 63.3 | 2318.79 | 17.76x | +| **TOTAL** | | **173.17** | | **3082.62** | **17.80x** | + +## Render — end-to-end (µs per string → painted pixels) + +Parse + build + layout + paint, headless widget test at 400 px width. Absolute +µs include a fixed `flutter_test` per-frame overhead — compare via the ratios. + +| document | flutter_md | flutter_markdown | gpt_markdown | +| ------------- | ---------: | ---------------: | -----------: | +| simple | 1147.3 | 1717.3 | 1335.3 | +| inline | 1142.0 | 1619.3 | 1475.8 | +| lists | 1321.5 | 3991.3 | 7077.5 | +| table | 1521.5 | 3804.5 | 4452.0 | +| code | 818.5 | 2498.8 | 4014.8 | +| quotes | 818.8 | 1292.5 | 1316.3 | +| complex | 1447.3 | 5087.0 | 11339.8 | +| complex_large | 2678.0 | 17696.8 | 41847.5 | + +Relative to flutter_md (× = library / flutter_md): + +| document | flutter_md | flutter_markdown | gpt_markdown | +| ------------- | ---------: | ---------------: | -----------: | +| simple | 1.00x | 1.50x | 1.16x | +| inline | 1.00x | 1.42x | 1.29x | +| lists | 1.00x | 3.02x | 5.36x | +| table | 1.00x | 2.50x | 2.93x | +| code | 1.00x | 3.05x | 4.91x | +| quotes | 1.00x | 1.58x | 1.61x | +| complex | 1.00x | 3.51x | 7.84x | +| complex_large | 1.00x | 6.61x | 15.63x | + +## Rendered height (px) + +At 376 px width. Confirms the normalized styles give the same vertical rhythm; +the remaining spread is structural chrome (list indent, table borders, +gpt_markdown's code-block header). + +| document | flutter_md | flutter_markdown | gpt_markdown | +| ------------- | ---------: | ---------------: | -----------: | +| simple | 294 | 254 | 296 | +| inline | 294 | 294 | 296 | +| quotes | 274 | 280 | 294 | +| lists | 348 | 582 | 424 | +| table | 382 | 582 | 250 | +| code | 380 | 340 | 514 | +| complex | 1352 | 1548 | 1525 | +| complex_large | 5520 | 6234 | 6148 | + +## Profile mode — scroll frame timings (ms) + +Profile build on the Linux desktop device, flinging the feed up and down; +`TimelineSummary`, mean of 2 runs. 60 Hz frame budget = 16.7 ms. + +**Mixed feed** (60 messages cycling through the corpus): + +| metric | flutter_md | flutter_markdown | gpt_markdown | +| -------------------- | ---------: | ---------------: | -----------: | +| frame build avg | 0.31 | 0.49 | 0.67 | +| frame build 90th pct | 0.70 | 0.70 | 0.94 | +| frame build 99th pct | 1.59 | 4.78 | 9.38 | +| frame build worst | 3.80 | 6.41 | 13.11 | +| raster avg | 1.10 | 0.89 | 0.95 | +| raster 90th pct | 1.85 | 1.54 | 1.53 | +| raster 99th pct | 2.79 | 2.18 | 2.57 | +| missed frames | 0 | 0 | 0 | + +**Prose feed** (one prose document repeated → identical content per frame in all +three, so no density or chrome differences): + +| metric | flutter_md | flutter_markdown | gpt_markdown | +| -------------------- | ---------: | ---------------: | -----------: | +| frame build avg | 0.36 | 1.00 | 0.62 | +| frame build 90th pct | 0.89 | 2.51 | 2.16 | +| frame build 99th pct | 1.98 | 6.03 | 4.08 | +| frame build worst | 2.84 | 7.56 | 6.61 | +| raster avg | 1.45 | 1.98 | 1.75 | +| raster 90th pct | 2.35 | 2.97 | 2.77 | +| raster 99th pct | 3.62 | 4.61 | 4.33 | +| missed frames | 0 | 0 | 0 | + +## Regenerate + +```shell +dart compile exe benchmark/parser_benchmark.dart -o /tmp/pb && /tmp/pb +flutter test test/render_benchmark_test.dart + +flutter drive --driver=test_driver/perf_driver.dart \ + --target=integration_test/scroll_perf_test.dart --profile -d linux +dart run tool/summarize_timeline.dart + +# identical content per frame: +flutter drive --driver=test_driver/perf_driver.dart \ + --target=integration_test/scroll_perf_test.dart --profile -d linux \ + --dart-define=FEED=prose +``` diff --git a/benchmark_compare/analysis_options.yaml b/benchmark_compare/analysis_options.yaml new file mode 100644 index 0000000..e56f5a1 --- /dev/null +++ b/benchmark_compare/analysis_options.yaml @@ -0,0 +1,8 @@ +# Inherit the repo's analysis rules, but relax two that are noise for a +# throwaway benchmark package full of public constants and wide table strings. +include: ../analysis_options.yaml + +linter: + rules: + public_member_api_docs: false + lines_longer_than_80_chars: false diff --git a/benchmark_compare/benchmark/parser_benchmark.dart b/benchmark_compare/benchmark/parser_benchmark.dart new file mode 100644 index 0000000..7c6de6e --- /dev/null +++ b/benchmark_compare/benchmark/parser_benchmark.dart @@ -0,0 +1,118 @@ +// ignore_for_file: avoid_print +// +// PARSER BENCHMARK — flutter_md vs the `markdown` package (flutter_markdown's +// engine), using benchmark_harness. +// +// flutter_markdown does no parsing of its own; on every build it delegates to +// the `markdown` package (`md.Document(...).parse(source)`). So the fair +// parser-to-parser comparison is: +// +// flutter_md : Markdown.fromString(source) -> block model +// flutter_markdown engine : md.Document(gfm).parse(source) -> AST nodes +// +// gpt_markdown has **no separable parser** — it parses inline while building +// its widget tree — so it does not appear here; its parsing cost is folded into +// the render benchmark (test/render_benchmark_test.dart) instead. +// +// Run (JIT): dart run benchmark/parser_benchmark.dart +// Run (AOT, best) dart compile exe benchmark/parser_benchmark.dart -o /tmp/pb && /tmp/pb +// +// Each benchmark overrides `exercise()` to call `run()` once, so the number +// reported by `measure()` is microseconds per single parse (per-op), not the +// benchmark_harness default of 10 runs per exercise. + +import 'package:benchmark_harness/benchmark_harness.dart'; +// Import the pure-Dart parser entry point directly (not the `flutter_md.dart` +// barrel, which pulls in Flutter widgets) so this runs under `dart`/AOT with no +// Flutter engine. +import 'package:flutter_md/src/markdown.dart' show Markdown; +import 'package:markdown/markdown.dart' as md; +import 'package:md_benchmark_compare/corpus.dart'; + +/// DCE guard — accumulates a byte of each result so the AOT compiler cannot +/// eliminate the parse as dead code. +int _sink = 0; + +void main() { + final rows = <_Row>[]; + for (final entry in corpus.entries) { + final bytes = entry.value.length; + final fmd = _FlutterMdBenchmark(entry.value).measure(); + final pkg = _MarkdownPkgBenchmark(entry.value).measure(); + rows.add(_Row(entry.key, bytes, fmd, pkg)); + } + + if (_sink == 0x7fffffff) print('(unreachable sink marker)'); + + _printTable(rows); +} + +void _printTable(List<_Row> rows) { + print(''); + print('Parser: flutter_md vs `markdown` pkg (flutter_markdown engine)'); + print('Lower us/op is better; speedup = markdown-pkg / flutter_md.'); + print(''); + print('| scenario | bytes | flutter_md us/op | flutter_md MB/s ' + '| markdown-pkg us/op | speedup |'); + print('| -------------- | ------: | ---------------: | --------------: ' + '| -----------------: | ------: |'); + + var fmdTotal = 0.0; + var pkgTotal = 0.0; + for (final r in rows) { + fmdTotal += r.fmd; + pkgTotal += r.pkg; + final mbps = r.bytes / r.fmd; // bytes/us == MB/s + final speedup = r.pkg / r.fmd; + print('| ${r.name.padRight(14)} ' + '| ${r.bytes.toString().padLeft(7)} ' + '| ${r.fmd.toStringAsFixed(2).padLeft(16)} ' + '| ${mbps.toStringAsFixed(1).padLeft(15)} ' + '| ${r.pkg.toStringAsFixed(2).padLeft(18)} ' + '| ${'${speedup.toStringAsFixed(2)}x'.padLeft(7)} |'); + } + final totalSpeedup = pkgTotal / fmdTotal; + print('| ${'TOTAL'.padRight(14)} ' + '| ${''.padLeft(7)} ' + '| ${fmdTotal.toStringAsFixed(2).padLeft(16)} ' + '| ${''.padLeft(15)} ' + '| ${pkgTotal.toStringAsFixed(2).padLeft(18)} ' + '| ${'${totalSpeedup.toStringAsFixed(2)}x'.padLeft(7)} |'); + print(''); +} + +class _Row { + _Row(this.name, this.bytes, this.fmd, this.pkg); + final String name; + final int bytes; + final double fmd; + final double pkg; +} + +class _FlutterMdBenchmark extends BenchmarkBase { + _FlutterMdBenchmark(this.input) : super('flutter_md'); + final String input; + + @override + void exercise() => run(); // one parse per exercise -> measure() is per-op. + + @override + void run() => _sink ^= Markdown.fromString(input).blocks.length; +} + +class _MarkdownPkgBenchmark extends BenchmarkBase { + _MarkdownPkgBenchmark(this.input) : super('markdown'); + final String input; + + @override + void exercise() => run(); + + @override + 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); + _sink ^= nodes.length; + } +} diff --git a/benchmark_compare/devtools_options.yaml b/benchmark_compare/devtools_options.yaml new file mode 100644 index 0000000..fa0b357 --- /dev/null +++ b/benchmark_compare/devtools_options.yaml @@ -0,0 +1,3 @@ +description: This file stores settings for Dart & Flutter DevTools. +documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states +extensions: diff --git a/benchmark_compare/integration_test/scroll_perf_test.dart b/benchmark_compare/integration_test/scroll_perf_test.dart new file mode 100644 index 0000000..eabb9ac --- /dev/null +++ b/benchmark_compare/integration_test/scroll_perf_test.dart @@ -0,0 +1,126 @@ +// PROFILE-MODE SCROLL BENCHMARK +// flutter_md vs flutter_markdown vs gpt_markdown. +// +// The widget-test render benchmark (test/render_benchmark_test.dart) measures +// wall-clock around `tester.pump()` in the *headless* flutter_tester engine, +// which carries a fixed ~1 ms per-frame floor and does no real GPU raster. +// This test confirms those results on a **real device in profile mode**: it +// scrolls a feed of Markdown "messages" per library, capturing a +// TimelineSummary +// (the same data DevTools' Performance timeline shows) — real frame *build* and +// *rasterizer* times, with 90th/99th percentiles and missed-frame counts. +// +// Run: +// flutter drive \ +// --driver=test_driver/perf_driver.dart \ +// --target=integration_test/scroll_perf_test.dart \ +// --profile -d linux +// +// The per-library summaries are written to build/integration_response_data.json +// by the driver; tool/summarize_timeline.dart formats them into a table. + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:md_benchmark_compare/corpus.dart'; +import 'package:md_benchmark_compare/styles.dart'; + +// All three libraries use the shared normalized styles from lib/styles.dart, so +// the feed renders at the same layout density for every library. +final Map _libraries = styledLibraries; + +// Feed mode: 'mixed' (representative chat feed) or 'prose' (one prose doc +// repeated). The 'prose' feed is a control: with normalized styles the `inline` +// doc renders to near-identical height in all three libraries (294/294/296 px) +// 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'); + +/// A realistic chat feed: representative documents, cycled. Excludes the huge +/// `complex_large` tier so many messages fit in a scrollable list. +final List _feed = _feedMode == 'prose' + ? [corpus['inline']!] + : [ + corpus['simple']!, + corpus['inline']!, + corpus['quotes']!, + corpus['lists']!, + corpus['code']!, + corpus['table']!, + corpus['complex']!, + ]; + +const int _feedLength = 60; + +const Key _feedKey = ValueKey('feed'); + +// Item sizing mode: 'natural' (each item its own height) or 'fixed' (every item +// clipped to a constant height, so a fling of the same distance crosses the +// same number of items for every library — this controls for the fact that a +// more compact renderer packs more items per screen and therefore rasterizes +// more of them per unit scroll). +const String _itemMode = + String.fromEnvironment('ITEM_MODE', defaultValue: 'natural'); +const double _kFixedItemHeight = 320; + +Widget _sizeItem(Widget child) { + if (_itemMode != 'fixed') return child; + return SizedBox( + height: _kFixedItemHeight, + child: ClipRect( + child: OverflowBox( + alignment: Alignment.topLeft, + minHeight: 0, + maxHeight: double.infinity, + child: child, + ), + ), + ); +} + +void main() { + final binding = IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + for (final entry in _libraries.entries) { + final lib = entry.key; + final factory = entry.value; + + testWidgets('scroll perf: $lib', (tester) async { + await tester.pumpWidget( + MaterialApp( + debugShowCheckedModeBanner: false, + home: Scaffold( + body: ListView.builder( + key: _feedKey, + itemCount: _feedLength, + itemBuilder: (context, i) => Padding( + padding: + const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + child: _sizeItem(factory(context, _feed[i % _feed.length])), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + final listFinder = find.byKey(_feedKey); + + // Trace a series of flings (down then back up) — this builds, lays out + // and rasterizes many items, exercising the real frame pipeline. + await binding.watchPerformance( + () async { + for (var i = 0; i < 6; i++) { + await tester.fling(listFinder, const Offset(0, -600), 2000); + await tester.pumpAndSettle(); + await tester.fling(listFinder, const Offset(0, 600), 2000); + await tester.pumpAndSettle(); + } + }, + reportKey: 'scroll_$lib', + ); + }); + } +} diff --git a/benchmark_compare/lib/corpus.dart b/benchmark_compare/lib/corpus.dart new file mode 100644 index 0000000..6bf494f --- /dev/null +++ b/benchmark_compare/lib/corpus.dart @@ -0,0 +1,197 @@ +/// Shared benchmark corpus used by both the parser benchmark +/// (`benchmark/parser_benchmark.dart`) and the render benchmark +/// (`test/render_benchmark_test.dart`). +/// +/// The documents deliberately stick to **common-denominator GFM** (headings, +/// emphasis, inline code, links, blockquotes, fenced code, ordered/unordered/ +/// task lists, tables) so that all three libraries — flutter_md, +/// flutter_markdown and gpt_markdown — render them without diverging on +/// dialect-specific extensions. Images are intentionally omitted so the render +/// numbers measure text layout, not network image stubs. +library; + +/// The benchmark documents, keyed by a short name and ordered from the smallest +/// / simplest to the largest / most complex. +final Map corpus = { + 'simple': _simple, + 'inline': _inline, + 'lists': _lists, + 'table': _table, + 'code': _code, + 'quotes': _quotes, + 'complex': _complex, + // A large document: the kitchen-sink `complex` doc repeated so per-op timings + // are stable and the O(n) cost of each library is visible. + 'complex_large': _repeat(_complex, 8), +}; + +/// The subset used for the render benchmark. Rendering a very large document +/// through three widget trees is slow, so we cap the large tier at a smaller +/// repeat count than the parser benchmark uses. +final Map renderCorpus = { + 'simple': _simple, + 'inline': _inline, + 'lists': _lists, + 'table': _table, + 'code': _code, + 'quotes': _quotes, + 'complex': _complex, + 'complex_large': _repeat(_complex, 4), +}; + +String _repeat(String block, int times) { + final buffer = StringBuffer(); + for (var i = 0; i < times; i++) { + buffer + ..write(block) + ..write('\n\n'); + } + return buffer.toString(); +} + +/// Simple rich text: a couple of paragraphs with inline emphasis, code and a +/// link — the "chat bubble" case. +const String _simple = ''' +Hello **world**, this is a *simple* rich-text paragraph with some `inline code` +and [a link](https://example.com) mixed into ordinary prose to exercise the +common inline formatting path. + +A second paragraph follows with a bit more **bold**, some _italic_ and a final +~~struck-through~~ span so the inline parser has a little of everything. +'''; + +/// Emphasis-heavy inline stress: many markers on every line. +const String _inline = ''' +This line has **bold**, *italic*, ***bold italic***, `code`, ~~strike~~ and a +[link](https://example.com) all packed together to stress the inline scanner. + +Nested **bold with *italic* and `code` inside** it, then *italic with **bold** +inside* and a trailing `code span` — repeated markers keep the closer lookahead +busy across the whole paragraph without producing much block structure. +'''; + +/// Nested and task lists. +const String _lists = ''' +- First item with **bold** +- Second item with [a link](https://example.com) + - Nested item one + - Nested item two with `code` + - Deep item with *italic* +- Back to the top level + +1. Ordered one +2. Ordered two + 1. Sub one + 2. Sub two +3. Ordered three + +- [x] Completed task +- [ ] Pending task +- [ ] Another pending task with **emphasis** +'''; + +/// GFM tables with alignment and inline formatting inside cells. +const String _table = ''' +| Name | Age | Role | Notes | +| :------ | --: | :--------: | ---------------- | +| Alice | 25 | Developer | Likes **bold** | +| Bob | 30 | Designer | Uses `tools` | +| Charlie | 35 | Manager | ~~former~~ lead | + +| Metric | Q1 | Q2 | Q3 | Q4 | +| ----------- | ---- | ---- | ---- | ---- | +| Revenue | 100 | 120 | 140 | 180 | +| Growth | 10% | 20% | 17% | 29% | +| Active users| 1.2k | 1.5k | 1.9k | 2.4k | +'''; + +/// Fenced code blocks in a couple of languages. +const String _code = ''' +```dart +void main() { + final greeting = 'Hello, world!'; + for (var i = 0; i < 10; i++) { + print(greeting); + } +} +``` + +Some prose between two code blocks to break them apart cleanly. + +```python +def fib(n): + a, b = 0, 1 + for _ in range(n): + a, b = b, a + b + return a +``` +'''; + +/// Blockquotes, including nested formatting. +const String _quotes = ''' +> This is a blockquote that spans several lines and contains **bold** text, +> some `inline code` and [a link](https://example.com) inside the body. + +> A second quote paragraph. +> +> It has multiple paragraphs and even a bit of *emphasis* to keep the inline +> parser working while inside a block-level quote context. +'''; + +/// The kitchen-sink document: every common block type, in one place. Used both +/// on its own (`complex`) and repeated to form the large tier. +const String _complex = ''' +# Markdown rendering benchmark + +This is a **bold** paragraph with *italic*, `monospace`, ~~strike~~ and +[a link](https://example.com) mixed into a normal sentence. + +## Multiline paragraph + +Lorem ipsum dolor sit amet, +consectetur adipiscing elit. +Sed do eiusmod **tempor** incididunt +*ut labore* et dolore `magna aliqua`. + +### Quote + +> This is a simple quote. +> +> It may contain **several lines**, and even nested formatting like `code` +> or [links](https://example.com). + +### Code + +```javascript +function helloWorld() { + console.log("Hello, world!"); +} +``` + +Inline code works like this: `let x = 42;` + +### Lists + +- First item +- Second item with *italic* + - Subitem with **bold** + - Third level ~~strike~~ +- [x] Done task +- [ ] Pending task + +1. First step +2. Second step + 1. Substep 2.1 + 2. Substep 2.2 +3. Final step + +### Table + +| Name | Age | Role | +| :------ | --: | :--------: | +| Alice | 25 | Developer | +| Bob | 30 | Designer | +| Charlie | 35 | Manager | + +That is all for the *test* document. +'''; diff --git a/benchmark_compare/lib/styles.dart b/benchmark_compare/lib/styles.dart new file mode 100644 index 0000000..5676e70 --- /dev/null +++ b/benchmark_compare/lib/styles.dart @@ -0,0 +1,112 @@ +/// Shared, normalized styling so all three libraries render the corpus with the +/// same base text style, the same heading sizes and matched block spacing. This +/// removes layout *density* as a variable from the render/raster comparison — +/// the whole point being that a more compact renderer otherwise rasterizes more +/// content per frame (see RESULTS.md). +/// +/// The key lever is an explicit line `height`: when set, every line box is +/// `fontSize * height`, independent of the font's own metrics — so the three +/// libraries (and the headless-test vs profile-desktop environments) all lay +/// text out at the same vertical rhythm. +/// +/// Structural chrome that each library draws its own way — code-block frames, +/// table borders, list bullets/indents, gpt_markdown's code header — cannot be +/// unified through public style APIs and is left as-is; it is called out in the +/// height report. +library; + +import 'package:flutter/material.dart'; +import 'package:flutter_markdown/flutter_markdown.dart' as fm; +import 'package:flutter_md/flutter_md.dart' as fmd; +import 'package:gpt_markdown/gpt_markdown.dart'; +import 'package:markdown/markdown.dart' as md; + +/// Base body text: identical for all three libraries. +const double kFontSize = 14; +const double kLineHeight = 1.4; +const Color kTextColor = Color(0xFF000000); + +const TextStyle kBaseTextStyle = TextStyle( + fontSize: kFontSize, + height: kLineHeight, + color: kTextColor, +); + +/// Heading sizes mirror flutter_md's scheme: base + {10, 8, 6, 4, 2, 0}, bold. +/// Index 0 == h1 … index 5 == h6. +const List _headingSizes = [24, 22, 20, 18, 16, 14]; + +TextStyle _heading(int levelIndex) => kBaseTextStyle.copyWith( + fontSize: _headingSizes[levelIndex], + fontWeight: FontWeight.bold, + ); + +/// Monospace body used for code, sized like the base text (no 0.85 shrink). +final TextStyle _monospace = kBaseTextStyle.copyWith(fontFamily: 'monospace'); + +/// Block gap: flutter_md inserts a spacer `fontSize` px tall per blank line, so +/// we match that for the libraries that expose a block-spacing knob. +const double kBlockSpacing = kFontSize; + +/// flutter_md theme. +fmd.MarkdownThemeData flutterMdTheme() => fmd.MarkdownThemeData( + textStyle: kBaseTextStyle, + textDirection: TextDirection.ltr, + textScaler: TextScaler.noScaling, + ); + +/// flutter_markdown style sheet. Starts from `fromTheme` (so every derived +/// style — em/strong/del/a/checkbox — exists and stays a delta merged onto the +/// base) and overrides the size/spacing-driving fields to match. +fm.MarkdownStyleSheet flutterMarkdownStyle(BuildContext context) => + fm.MarkdownStyleSheet.fromTheme(Theme.of(context)).copyWith( + p: kBaseTextStyle, + h1: _heading(0), + h2: _heading(1), + h3: _heading(2), + h4: _heading(3), + h5: _heading(4), + h6: _heading(5), + code: _monospace, + blockquote: kBaseTextStyle, + listBullet: kBaseTextStyle, + tableBody: kBaseTextStyle, + tableHead: kBaseTextStyle.copyWith(fontWeight: FontWeight.bold), + blockSpacing: kBlockSpacing, + textScaler: TextScaler.noScaling, + ); + +/// gpt_markdown theme override (headings matched to the shared scheme). The base +/// body style is passed to `GptMarkdown(style: kBaseTextStyle)` separately. +GptMarkdownThemeData gptMarkdownTheme(BuildContext context) => + GptMarkdownThemeData( + brightness: Theme.of(context).brightness, + h1: _heading(0), + h2: _heading(1), + h3: _heading(2), + h4: _heading(3), + h5: _heading(4), + h6: _heading(5), + ); + +/// The three library widgets, each built from `source` with the normalized +/// styles above. gpt_markdown needs a [BuildContext] for its inherited theme, +/// so every factory takes one (the others ignore it). +typedef StyledFactory = Widget Function(BuildContext context, String source); + +final Map styledLibraries = { + 'flutter_md': (context, src) => fmd.MarkdownWidget( + markdown: fmd.Markdown.fromString(src), + theme: flutterMdTheme(), + ), + 'flutter_markdown': (context, src) => fm.MarkdownBody( + data: src, + styleSheet: flutterMarkdownStyle(context), + extensionSet: md.ExtensionSet.gitHubFlavored, + shrinkWrap: true, + ), + 'gpt_markdown': (context, src) => GptMarkdownTheme( + gptThemeData: gptMarkdownTheme(context), + child: GptMarkdown(src, style: kBaseTextStyle), + ), +}; diff --git a/benchmark_compare/pubspec.yaml b/benchmark_compare/pubspec.yaml new file mode 100644 index 0000000..7fbc725 --- /dev/null +++ b/benchmark_compare/pubspec.yaml @@ -0,0 +1,50 @@ +# Head-to-head benchmark package: flutter_md vs flutter_markdown vs gpt_markdown. +# +# This is a standalone, non-published package that lives inside the repo purely +# to compare parsing and rendering performance across the three libraries. It is +# excluded from `flutter_md` publishing via the root `.pubignore`. +# +# flutter pub get +# dart run benchmark/parser_benchmark.dart # parser (benchmark_harness) +# flutter test test/render_benchmark_test.dart # rendering (widget tests) +name: md_benchmark_compare +description: "Head-to-head parse & render benchmarks: flutter_md vs flutter_markdown vs gpt_markdown." + +publish_to: "none" + +version: 0.0.1 + +environment: + sdk: ">=3.6.0 <4.0.0" + flutter: ">=3.29.0" + +dependencies: + flutter: + sdk: flutter + + # The library under test — path dependency on the repo root. + flutter_md: + path: ../ + + # The two libraries we compare against. + flutter_markdown: ^0.7.7+1 # discontinued; the last published release + gpt_markdown: ^1.1.8 + + # The parser engine that flutter_markdown uses under the hood. We benchmark it + # directly so the parser comparison is apples-to-apples (flutter_markdown does + # no parsing of its own — it delegates to this package). + markdown: ^7.3.0 + +dev_dependencies: + flutter_test: + sdk: flutter + benchmark_harness: ^2.3.1 + flutter_lints: ">=5.0.0 <7.0.0" + # Real profile-mode frame timing (flutter drive + TimelineSummary). + integration_test: + sdk: flutter + flutter_driver: + sdk: flutter + +flutter: + uses-material-design: true diff --git a/benchmark_compare/test/render_benchmark_test.dart b/benchmark_compare/test/render_benchmark_test.dart new file mode 100644 index 0000000..a11f2e5 --- /dev/null +++ b/benchmark_compare/test/render_benchmark_test.dart @@ -0,0 +1,229 @@ +// RENDER BENCHMARK — flutter_md vs flutter_markdown vs gpt_markdown. +// +// This measures the *end-to-end* cost of turning a Markdown **string** into +// painted pixels: parse + build + layout + paint, which is what every library +// actually does on the frame that first shows a message. It is measured as +// wall-clock time around `tester.pump()` after forcing a full subtree rebuild +// (a changing `ValueKey` unmounts the old tree and builds a fresh one). +// +// flutter test test/render_benchmark_test.dart +// +// All three libraries are given the SAME normalized styles (see lib/styles.dart) +// — identical base text style + explicit line height, matched heading sizes and +// matched block spacing — so layout *density* is no longer a variable. The test +// also prints a rendered-height table so the density match can be verified. +// +// Notes on fairness & interpretation: +// * Each iteration reconstructs from the source string. For flutter_md that +// means `Markdown.fromString(src)` is called inside the builder too, so its +// parse cost is included on equal footing with the other two (which parse +// inside build()). +// * All three render into the same fixed viewport (width 400) inside a +// SingleChildScrollView, so layout covers the whole document while paint is +// clipped to the same visible region for every library. +// * Absolute microseconds include fixed flutter_test frame overhead and are +// only meaningful *relative to each other* on the same machine — use the +// ratios. Confirm real on-device frame time with the profile-mode scroll +// benchmark (integration_test/scroll_perf_test.dart). +// +// The reported number per (library, document) is the min-of-batches per-op time +// (the minimum is the most stable estimator, least perturbed by GC / scheduler). + +// ignore_for_file: avoid_print + +import 'dart:math' as math; + +import 'package:flutter/foundation.dart' show ValueListenable; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:md_benchmark_compare/corpus.dart'; +import 'package:md_benchmark_compare/styles.dart'; + +/// width the documents are laid out at. +const double _kWidth = 400; + +/// results[library][document] = us/op. +final Map> _results = >{ + for (final name in styledLibraries.keys) name: {}, +}; + +/// heights[library][document] = rendered px height (density check). +final Map> _heights = >{ + for (final name in styledLibraries.keys) name: {}, +}; + +const Key _probeKey = ValueKey('probe'); + +/// A host that rebuilds its subtree from scratch whenever [tick] changes, so a +/// pump measures the full parse+build+layout+paint of one library on one doc. +class _Host extends StatelessWidget { + const _Host({ + required this.tick, + required this.factory, + required this.source, + }); + + final ValueListenable tick; + final StyledFactory factory; + final String source; + + @override + Widget build(BuildContext context) => MaterialApp( + debugShowCheckedModeBanner: false, + home: Scaffold( + body: ValueListenableBuilder( + valueListenable: tick, + builder: (context, value, _) => SingleChildScrollView( + child: SizedBox( + width: _kWidth, + // A fresh key each tick forces a full teardown + rebuild. + child: KeyedSubtree( + key: ValueKey(value), + child: Builder( + builder: (ctx) => KeyedSubtree( + key: _probeKey, + child: factory(ctx, source), + ), + ), + ), + ), + ), + ), + ), + ); +} + +Future _benchBuild( + WidgetTester tester, + ValueNotifier tick, { + int warmup = 4, + int batches = 10, + int itersPerBatch = 4, +}) async { + for (var i = 0; i < warmup; i++) { + tick.value++; + await tester.pump(); + } + var best = double.infinity; + for (var b = 0; b < batches; b++) { + final sw = Stopwatch()..start(); + for (var i = 0; i < itersPerBatch; i++) { + tick.value++; + await tester.pump(); + } + sw.stop(); + best = math.min(best, sw.elapsedMicroseconds / itersPerBatch); + } + return best; +} + +void main() { + for (final libEntry in styledLibraries.entries) { + final lib = libEntry.key; + final factory = libEntry.value; + + group('render: $lib', () { + for (final docEntry in renderCorpus.entries) { + final doc = docEntry.key; + final source = docEntry.value; + + testWidgets('$lib / $doc', (tester) async { + final tick = ValueNotifier(0); + await tester.pumpWidget( + _Host(tick: tick, factory: factory, source: source), + ); + // Surface any build-time error (e.g. an unsupported construct) loudly + // rather than recording a bogus timing. + expect(tester.takeException(), isNull, + reason: '$lib failed to render "$doc"'); + + _heights[lib]![doc] = tester.getSize(find.byKey(_probeKey)).height; + _results[lib]![doc] = await _benchBuild(tester, tick); + tick.dispose(); + }); + } + }); + } + + tearDownAll(_printTables); +} + +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)); + + // Relative-to-flutter_md view (how many times slower each library is). + const base = 'flutter_md'; + print(''); + print('Render, relative to $base (x = library / $base; ' + 'lower is better, 1.00x = same speed)'); + _relative(docs, libs, _results, base); + + // Density check: rendered height per library, and its spread vs flutter_md. + _matrix('Rendered height (px) — density check (styles normalized)', docs, + libs, _heights, (v) => v.toStringAsFixed(0)); + print(''); + print('Height relative to $base (x = library / $base; 1.00x = same height)'); + _relative(docs, libs, _heights, base); +} + +void _matrix( + String title, + List docs, + List libs, + Map> data, + String Function(double) fmt, +) { + print(''); + print(title); + print(''); + final header = StringBuffer('| document |'); + final sep = StringBuffer('| -------------- |'); + for (final lib in libs) { + header.write(' ${lib.padLeft(16)} |'); + sep.write(' ---------------: |'); + } + print(header); + print(sep); + for (final doc in docs) { + final row = StringBuffer('| ${doc.padRight(14)} |'); + for (final lib in libs) { + final v = data[lib]![doc]; + row.write(' ${(v == null ? '-' : fmt(v)).padLeft(16)} |'); + } + print(row); + } +} + +void _relative( + List docs, + List libs, + Map> data, + String base, +) { + print(''); + final header = StringBuffer('| document |'); + final sep = StringBuffer('| -------------- |'); + for (final lib in libs) { + header.write(' ${lib.padLeft(16)} |'); + sep.write(' ---------------: |'); + } + print(header); + print(sep); + for (final doc in docs) { + final baseVal = data[base]![doc]; + final row = StringBuffer('| ${doc.padRight(14)} |'); + for (final lib in libs) { + final v = data[lib]![doc]; + final cell = (baseVal == null || v == null || baseVal == 0) + ? '-' + : '${(v / baseVal).toStringAsFixed(2)}x'; + row.write(' ${cell.padLeft(16)} |'); + } + print(row); + } + print(''); +} diff --git a/benchmark_compare/test_driver/perf_driver.dart b/benchmark_compare/test_driver/perf_driver.dart new file mode 100644 index 0000000..6640eaa --- /dev/null +++ b/benchmark_compare/test_driver/perf_driver.dart @@ -0,0 +1,15 @@ +// Driver for the profile-mode scroll benchmark. +// +// `integrationDriver()` connects to the app launched by `flutter drive`, +// collects the `reportData` that `scroll_perf_test.dart` stored (one +// TimelineSummary per library) and writes the whole map to +// `build/integration_response_data.json`. +// +// Run: +// flutter drive \ +// --driver=test_driver/perf_driver.dart \ +// --target=integration_test/scroll_perf_test.dart \ +// --profile -d linux +import 'package:integration_test/integration_test_driver.dart'; + +Future main() => integrationDriver(); diff --git a/benchmark_compare/tool/summarize_timeline.dart b/benchmark_compare/tool/summarize_timeline.dart new file mode 100644 index 0000000..43b36a2 --- /dev/null +++ b/benchmark_compare/tool/summarize_timeline.dart @@ -0,0 +1,114 @@ +// ignore_for_file: avoid_print +// +// Formats the profile-mode scroll benchmark output into a comparison table. +// +// Reads build/integration_response_data.json (written by the flutter drive run, +// one TimelineSummary per library) and prints Markdown tables of frame build & +// rasterizer times (average / 90th / 99th percentile) plus missed-frame counts. +// +// dart run tool/summarize_timeline.dart + +import 'dart:convert'; +import 'dart:io'; + +const String _kInput = 'build/integration_response_data.json'; + +// Report keys are 'scroll_' in feed order. +const List _libs = [ + 'flutter_md', + 'flutter_markdown', + 'gpt_markdown', +]; + +double? _num(Map m, String key) { + final v = m[key]; + if (v is num) return v.toDouble(); + return null; +} + +String _cell(double? v) => v == null ? '-' : v.toStringAsFixed(2); + +void main() { + final file = File(_kInput); + if (!file.existsSync()) { + stderr.writeln('Not found: $_kInput\n' + 'Run the profile-mode benchmark first:\n' + ' flutter drive --driver=test_driver/perf_driver.dart ' + '--target=integration_test/scroll_perf_test.dart --profile -d linux'); + exitCode = 1; + return; + } + + final data = json.decode(file.readAsStringSync()) as Map; + final summaries = >{}; + for (final lib in _libs) { + final raw = data['scroll_$lib']; + if (raw is Map) { + summaries[lib] = raw; + } else if (raw is String) { + // Some driver versions store the summary JSON as a string. + summaries[lib] = json.decode(raw) as Map; + } + } + + // Metrics to show: (label, json key, lower-is-better). + const metrics = >[ + ['frame build avg (ms)', 'average_frame_build_time_millis'], + ['frame build 90th (ms)', '90th_percentile_frame_build_time_millis'], + ['frame build 99th (ms)', '99th_percentile_frame_build_time_millis'], + ['frame build worst (ms)', 'worst_frame_build_time_millis'], + ['raster avg (ms)', 'average_frame_rasterizer_time_millis'], + ['raster 90th (ms)', '90th_percentile_frame_rasterizer_time_millis'], + ['raster 99th (ms)', '99th_percentile_frame_rasterizer_time_millis'], + ['raster worst (ms)', 'worst_frame_rasterizer_time_millis'], + ['missed build frames', 'missed_frame_build_budget_count'], + ['missed raster frames', 'missed_frame_rasterizer_budget_count'], + ['frame count', 'frame_count'], + ]; + + print(''); + print('Profile-mode scroll benchmark — real frame timings (lower is better)'); + print(''); + final header = StringBuffer('| metric |'); + final sep = StringBuffer('| ----------------------- |'); + for (final lib in _libs) { + header.write(' ${lib.padLeft(16)} |'); + sep.write(' ---------------: |'); + } + print(header); + print(sep); + for (final metric in metrics) { + final row = StringBuffer('| ${metric[0].padRight(23)} |'); + for (final lib in _libs) { + final s = summaries[lib]; + final v = s == null ? null : _num(s, metric[1]); + row.write(' ${_cell(v).padLeft(16)} |'); + } + print(row); + } + print(''); + + // Relative build-time (the frame work each library asks of the UI thread), + // normalized to flutter_md. + final base = summaries['flutter_md']; + final baseBuild = + base == null ? null : _num(base, 'average_frame_build_time_millis'); + if (baseBuild != null && baseBuild > 0) { + print('Average frame build time relative to flutter_md ' + '(x = library / flutter_md):'); + print(''); + print('| ${'flutter_md'.padLeft(16)} | ' + '${'flutter_markdown'.padLeft(16)} | ${'gpt_markdown'.padLeft(16)} |'); + print('| ---------------: | ---------------: | ---------------: |'); + 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 cell = v == null ? '-' : '${(v / baseBuild).toStringAsFixed(2)}x'; + row.write(' ${cell.padLeft(16)} |'); + } + print(row); + print(''); + } +} From b4991c04367ecfdeb06a9346bb16de3d11822ed9 Mon Sep 17 00:00:00 2001 From: Mike Matiunin Date: Mon, 3 Aug 2026 18:18:34 +0400 Subject: [PATCH 19/30] feat: enhance selection functionality with granular gestures and mouse feedback - Added word- and block-granular selection gestures: double-click selects a word, triple-click selects a block, single click clears selection, and Shift-click extends it. - Implemented dragging after double/triple clicks to maintain word/block granularity; touch long-press selects a word and extends by word. - Introduced mouse cursor feedback: hand cursor over actionable links, I-beam cursor during selection, and default cursor otherwise. - Updated selection highlight rendering to paint on top of glyphs for better visibility over opaque backgrounds. - Reworked selection gestures to support multi-tap and extend selection with Shift-click. - Added tests for word boundaries and selection gestures to ensure expected behavior. --- CHANGELOG.md | 19 +- docs/architecture.md | 3 +- docs/rendering.md | 8 +- docs/selection.md | 23 +- lib/src/render/block_painter.dart | 91 ++++++- lib/src/render/markdown_painter.dart | 30 +++ lib/src/render/markdown_render_object.dart | 58 ++++- lib/src/selection.dart | 152 ++++++++++- lib/src/selection_scope.dart | 117 ++++++++- test/selection/selection_test.dart | 33 +++ test/selection/selection_widget_test.dart | 284 +++++++++++++++++++++ 11 files changed, 774 insertions(+), 44 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ab5e977..19aebd1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -41,10 +41,27 @@ `localSelectionRects`, `setSelectionHandleLayers`, `repaintSelection`, and `MarkdownSelectionController.selectionHandleEndpoints` / `MarkdownHandleEndpoints`. +- **ADDED**: Word- and block-granular selection gestures. Double-click/tap + selects the word under the pointer, triple-click/tap selects the whole block, + a single click collapses (clears) the selection, and `Shift`-click extends it. + Dragging after a double/triple click keeps word/block granularity; a touch + long-press grabs the whole word (then extends by word), and a touch + double-tap selects the word and pops the toolbar. Word boundaries use the + platform word segmentation (`TextPainter.getWordBoundary`), so double-click + keeps intra-word punctuation like apostrophes (`can't`). New controller ops: + `selectWordAtGlobal`, `selectBlockAtGlobal`, `wordSelectionAt`, + `blockSelectionAt`, `extendSelectionGranular`, and `wordRangeIn`; new surface + geometry `MarkdownSelectionSurface.wordBoundaryForGlobal`. +- **ADDED**: Mouse cursor feedback — a `MarkdownWidget` shows the click (hand) + cursor over actionable links, the text (I-beam) cursor while it participates + in a selection controller, and otherwise the default cursor. - **CHANGED**: `MarkdownWidget`'s render object now draws the selection highlight outside the cached content `Picture` and becomes a repaint boundary when selectable, so selection/drag repaints do not rebuild the glyph cache. - The highlight color is now customizable via the controller / scope. + The highlight color is now customizable via the controller / scope. The + highlight is painted on top of (rather than beneath) the glyphs, so a + translucent selection stays visible over opaque backgrounds — code fences, + `inline code`, and `==marked==` spans. - **EXAMPLE**: Reworked the demo tabs — a longer, richer chat (tables, code, nested/task lists, alerts, math, token-by-token streaming with a typing indicator, Select-all/Clear) and a Selection tab that spans every block type. diff --git a/docs/architecture.md b/docs/architecture.md index 5babe14..da89f67 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -43,7 +43,8 @@ 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) beneath the glyphs. See [selection](selection.md). +(looked up from the controller) on top of the glyphs, so it stays visible over +opaque backgrounds. See [selection](selection.md). ## Why the model is immutable and selection anchors to it diff --git a/docs/rendering.md b/docs/rendering.md index f6f61e3..9a3869b 100644 --- a/docs/rendering.md +++ b/docs/rendering.md @@ -211,9 +211,11 @@ $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 before/outside that cache** - (`MarkdownRenderObject.paint` calls `_painter.paintHighlight(...)` then - `_painter.paint(...)`), so drags/streaming never rebuild the glyph cache. Color = +- **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 diff --git a/docs/selection.md b/docs/selection.md index 936f4ab..822150c 100644 --- a/docs/selection.md +++ b/docs/selection.md @@ -130,11 +130,24 @@ but still exposes the controller), `selectionColor`, `contextMenuBuilder` (null no toolbar), `magnifierConfiguration`, `selectionControls`, `onSelectionChanged`. Statics: `MarkdownSelectionScope.of/maybeOf` (→ controller), `stateOf` (→ state). -- **Gestures:** a `PanGestureRecognizer` restricted to - mouse/stylus/trackpad with `DragStartBehavior.down` (immediate drag-select on - pointer devices); a `LongPressGestureRecognizer` for **touch** (long-press then - drag) so a plain touch swipe still scrolls an enclosing `ListView`; a - `TapGestureRecognizer` (tap hides toolbar; right-click shows it at the point). +- **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 + `wordRangeIn` heuristic as an unmounted-document fallback. +- **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 + `MouseTracker` re-reads the cursor), the text (I-beam) cursor while selectable, + and otherwise `MouseCursor.defer`. - **Keyboard:** an `Actions` map bound to the ambient `DefaultTextEditingShortcuts` Intents (installed by `WidgetsApp`/`MaterialApp`): Ctrl/Cmd+C copy, Ctrl/Cmd+A select-all, Shift+arrows extend (char/word/line/doc), Esc clear. Extension diff --git a/lib/src/render/block_painter.dart b/lib/src/render/block_painter.dart index cd2d931..8ab89c0 100644 --- a/lib/src/render/block_painter.dart +++ b/lib/src/render/block_painter.dart @@ -55,6 +55,16 @@ abstract interface class SelectableBlockPainter implements BlockPainter { /// Highlight rectangles (block-local) for the rendered range `[start, end)`. List boxesForRange(int start, int end); + + /// The word range (in rendered-text space) at a block-local [local] point, + /// using the platform's word segmentation (`TextPainter.getWordBoundary`) so + /// double-click/tap selects real words (keeping intra-word punctuation such as + /// apostrophes, matching native text fields). + TextRange wordBoundaryForLocal(Offset local); + + /// 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); } /// Provides [SelectableBlockPainter] for a block backed by a single @@ -79,6 +89,17 @@ mixin SelectableTextBlock implements SelectableBlockPainter { .getBoxesForSelection(TextSelection(baseOffset: start, extentOffset: end)) .map((box) => box.toRect().shift(selectionOrigin)) .toList(growable: false); + + @override + TextRange wordBoundaryForLocal(Offset local) { + final offset = + selectionPainter.getPositionForOffset(local - selectionOrigin).offset; + return selectionPainter.getWordBoundary(TextPosition(offset: offset)); + } + + @override + bool isLinkAtLocal(Offset local) => + _spanHasRecognizerAt(selectionPainter, local - selectionOrigin); } /// One selectable text run inside a multi-painter block (a list item or a table @@ -120,20 +141,8 @@ mixin MultiPainterSelectable implements SelectableBlockPainter { @override int offsetForLocalPosition(Offset local) { - final frags = fragments; - if (frags.isEmpty) return 0; - SelectableFragment? best; - var bestDistance = double.infinity; - for (final fragment in frags) { - final rect = fragment.origin & fragment.painter.size; - final distance = _distanceToRect(local, rect); - if (distance < bestDistance) { - bestDistance = distance; - best = fragment; - if (distance == 0) break; - } - } - final fragment = best!; + 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); @@ -156,6 +165,60 @@ mixin MultiPainterSelectable implements SelectableBlockPainter { } return out; } + + @override + TextRange wordBoundaryForLocal(Offset local) { + final fragment = _nearestFragment(local); + if (fragment == null) return const TextRange(start: 0, end: 0); + final inner = + fragment.painter.getPositionForOffset(local - fragment.origin).offset; + final wb = fragment.painter.getWordBoundary(TextPosition(offset: inner)); + // Keep the word within its own fragment (never cross a cell/item boundary). + return TextRange( + start: fragment.textStart + wb.start.clamp(0, fragment.length), + end: fragment.textStart + wb.end.clamp(0, fragment.length), + ); + } + + @override + bool isLinkAtLocal(Offset local) { + for (final fragment in fragments) { + if ((fragment.origin & fragment.painter.size).contains(local)) { + return _spanHasRecognizerAt(fragment.painter, local - fragment.origin); + } + } + return false; + } + + SelectableFragment? _nearestFragment(Offset local) { + SelectableFragment? best; + var bestDistance = double.infinity; + for (final fragment in fragments) { + final distance = + _distanceToRect(local, fragment.origin & fragment.painter.size); + if (distance < bestDistance) { + bestDistance = distance; + best = fragment; + if (distance == 0) break; + } + } + return best; + } +} + +/// 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) { + final size = painter.size; + if (local.dx < 0 || + local.dy < 0 || + local.dx > size.width || + local.dy > size.height) { + return false; + } + final span = + painter.text?.getSpanForPosition(painter.getPositionForOffset(local)); + return span is TextSpan && span.recognizer != null; } /// Shortest distance from [point] to [rect] (0 when the point is inside). diff --git a/lib/src/render/markdown_painter.dart b/lib/src/render/markdown_painter.dart index fdcb946..266aa15 100644 --- a/lib/src/render/markdown_painter.dart +++ b/lib/src/render/markdown_painter.dart @@ -164,6 +164,36 @@ class MarkdownPainter { return (_sourceIndices[idx], offset); } + /// Maps a content-local [local] point to the word range at it, as + /// `(sourceBlockIndex, TextRange)`, or null if the hit block is not + /// selectable. Uses the platform word segmentation of the underlying painter. + (int, TextRange)? wordBoundaryForLocal(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 range = painter.wordBoundaryForLocal(blockLocal); + final len = painter.renderedText.length; + return ( + _sourceIndices[idx], + TextRange( + start: range.start.clamp(0, len), + end: range.end.clamp(0, len), + ), + ); + } + + /// Whether an actionable link sits under a content-local [local] point. + bool isLinkAtLocal(Offset local) { + if (_blockPainters.isEmpty) return false; + final idx = _blockIndexForDy(local.dy); + final painter = _blockPainters[idx]; + if (painter is! SelectableBlockPainter) return false; + final blockLocal = Offset(local.dx, local.dy - _blockOffsets[idx]); + return painter.isLinkAtLocal(blockLocal); + } + /// Paints the selection highlight of every selectable block, using [rangeOf] /// to look up the selected rendered range for a source block index. void paintHighlight( diff --git a/lib/src/render/markdown_render_object.dart b/lib/src/render/markdown_render_object.dart index 883b758..0dd1ab9 100644 --- a/lib/src/render/markdown_render_object.dart +++ b/lib/src/render/markdown_render_object.dart @@ -7,6 +7,7 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart' show MouseTrackerAnnotation; import 'package:meta/meta.dart' as meta show internal; import '../markdown.dart'; @@ -14,12 +15,12 @@ import '../selection.dart'; import '../theme.dart'; import 'markdown_painter.dart'; -/// Default color used to paint the selection highlight beneath the glyphs. +/// Default color used to paint the selection highlight over the glyphs. const Color _kSelectionColor = Color(0x552196F3); @meta.internal class MarkdownRenderObject extends RenderBox - implements MarkdownSelectionSurface { + implements MarkdownSelectionSurface, MouseTrackerAnnotation { MarkdownRenderObject({ required Markdown markdown, required MarkdownThemeData theme, @@ -106,6 +107,14 @@ class MarkdownRenderObject extends RenderBox ); } + @override + (int, int, int)? wordBoundaryForGlobal(Offset globalPosition) { + if (_documentId == null) return null; + final wb = _painter.wordBoundaryForLocal(globalToLocal(globalPosition)); + if (wb == null) return null; + return (wb.$1, wb.$2.start, wb.$2.end); + } + @override List globalSelectionRects() { final local = localSelectionRects(); @@ -149,6 +158,30 @@ class MarkdownRenderObject extends RenderBox if (!_disposed && attached) markNeedsPaint(); } + // --- MouseTrackerAnnotation (I-beam cursor over selectable text) --- + + /// Whether the pointer is currently hovering an actionable link (updated in + /// [handleEvent]); drives the click (hand) cursor. + bool _hoverLink = false; + + /// 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; + } + + @override + void Function(PointerEnterEvent)? get onEnter => null; + + @override + void Function(PointerExitEvent)? get onExit => null; + + @override + bool get validForMouseTracker => !_disposed && attached; + /// Current size of the render box. @override Size get size => _size; @@ -217,6 +250,16 @@ 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. + if (event is PointerHoverEvent) { + final link = _painter.isLinkAtLocal(event.localPosition); + if (link != _hoverLink) { + _hoverLink = link; + if (!_disposed && attached) markNeedsPaint(); + } + } _painter.handleEvent(event); } @@ -280,8 +323,13 @@ class MarkdownRenderObject extends RenderBox ..translate(offset.dx, offset.dy); //..clipRect(Rect.fromLTWH(0, 0, size.width, size.height)); - // Paint the selection highlight OUTSIDE the cached content Picture, beneath - // the glyphs, so drag/streaming repaints never rebuild the glyph cache. + _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). final controller = _controller; final id = _documentId; if (controller != null && id != null) { @@ -293,8 +341,6 @@ class MarkdownRenderObject extends RenderBox ); } - _painter.paint(canvas, size); - canvas.restore(); // Push handle leader layers (empty layers) so the scope's SelectionOverlay diff --git a/lib/src/selection.dart b/lib/src/selection.dart index 0bda1ed..a1b20d6 100644 --- a/lib/src/selection.dart +++ b/lib/src/selection.dart @@ -385,6 +385,11 @@ abstract interface class MarkdownSelectionSurface { /// Maps a global point to a logical position, or null if outside any text. MarkdownPosition? positionForGlobal(Offset globalPosition); + /// 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. + (int, int, int)? wordBoundaryForGlobal(Offset globalPosition); + /// Global (screen-space) rectangles covering the selected part of this /// surface's document, in reading order. Empty when nothing here is /// selected. Used to place selection handles, the magnifier and the toolbar @@ -665,6 +670,15 @@ class MarkdownSelectionController extends ChangeNotifier { /// 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); + } + + /// 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) { MarkdownSelectionSurface? nearest; var bestDistance = double.infinity; for (final surface in _surfaces.values) { @@ -672,9 +686,7 @@ class MarkdownSelectionController extends ChangeNotifier { // 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.positionForGlobal(globalPosition); - } + if (bounds.contains(globalPosition)) return (surface, globalPosition); final dy = globalPosition.dy < bounds.top ? bounds.top - globalPosition.dy : (globalPosition.dy > bounds.bottom @@ -697,7 +709,7 @@ class MarkdownSelectionController extends ChangeNotifier { globalPosition.dy .clamp(bounds.top, maxY < bounds.top ? bounds.top : maxY), ); - return nearest.positionForGlobal(clamped); + return (nearest, clamped); } // --- mutation ------------------------------------------------------------ @@ -746,6 +758,138 @@ class MarkdownSelectionController extends ChangeNotifier { ); } + // --- word / block selection (multi-tap, long-press) ---------------------- + + /// The word range around [offset] in a block's rendered [text], as + /// `(start, end)`. A "word" is the maximal run of same-class characters + /// (letters/digits vs. punctuation vs. whitespace) touching the caret, + /// preferring an adjacent word character so clicking a word's edge grabs it. + /// + /// Exposed for testing; mirrors what double-click / long-press select. + @visibleForTesting + static (int, int) wordRangeIn(String text, int offset) { + final len = text.length; + if (len == 0) return (0, 0); + final o = offset.clamp(0, len); + // Reference character: a word char adjacent to the caret (right first, then + // left), else whichever side has a character at all. + final int idx; + if (o < len && _charClass(text.codeUnitAt(o)) == _clsWord) { + idx = o; + } else if (o > 0 && _charClass(text.codeUnitAt(o - 1)) == _clsWord) { + idx = o - 1; + } else if (o < len) { + idx = o; + } else { + idx = o - 1; + } + final cls = _charClass(text.codeUnitAt(idx)); + var start = idx, end = idx + 1; + while (start > 0 && _charClass(text.codeUnitAt(start - 1)) == cls) start--; + while (end < len && _charClass(text.codeUnitAt(end)) == cls) end++; + return (start, end); + } + + static const int _clsSpace = 0; + static const int _clsWord = 1; + static const int _clsPunct = 2; + + static int _charClass(int c) { + if (c == 0x20 || c == 0x09 || c == 0x0A || c == 0x0D) return _clsSpace; + final isAsciiWord = (c >= 0x30 && c <= 0x39) || // 0-9 + (c >= 0x41 && c <= 0x5A) || // A-Z + (c >= 0x61 && c <= 0x7A) || // a-z + c == 0x5F; // _ + if (isAsciiWord) return _clsWord; + if (c < 0x80) return _clsPunct; // other ASCII => punctuation + return _clsWord; // non-ASCII (accents, CJK, emoji) => word-ish + } + + /// The ordered selection of the word at a global point, without applying it. + /// + /// Prefers the mounted painter's platform word segmentation + /// (`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); + if (resolved == null) return null; + final (surface, point) = resolved; + final id = surface.documentId; + final wb = surface.wordBoundaryForGlobal(point); + if (wb != null) { + return MarkdownSelection( + base: + MarkdownPosition(documentId: id, blockIndex: wb.$1, offset: wb.$2), + extent: + MarkdownPosition(documentId: id, blockIndex: wb.$1, offset: wb.$3), + ); + } + // Fallback: text-heuristic boundary. + final p = surface.positionForGlobal(point); + if (p == null) return null; + final di = _orderIndex(p.documentId); + if (di < 0) return null; + final (s, e) = wordRangeIn(_blockTextAt(di, p.blockIndex), p.offset); + return MarkdownSelection( + base: p.copyWith(offset: s), + extent: p.copyWith(offset: e), + ); + } + + /// The ordered selection of the whole block at a global point, without + /// applying it. + MarkdownSelection? blockSelectionAt(Offset globalPosition) { + final p = positionForGlobal(globalPosition); + if (p == null) return null; + final di = _orderIndex(p.documentId); + if (di < 0) return null; + final text = _blockTextAt(di, p.blockIndex); + return MarkdownSelection( + base: p.copyWith(offset: 0), + extent: p.copyWith(offset: text.length), + ); + } + + /// 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; + 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; + return sel; + } + + /// Extends a word/block-granular drag: grows the selection from the fixed + /// [anchor] (an ordered word/block range) to include the word (or block) at + /// [globalPosition], so double/triple-click-drag snaps to whole units. + void extendSelectionGranular( + MarkdownSelection anchor, + Offset globalPosition, { + required bool word, + }) { + final target = word + ? wordSelectionAt(globalPosition) + : blockSelectionAt(globalPosition); + if (target == null) return; + // 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); + } else if (_compare(target.extent, anchor.extent) > 0) { + selection = MarkdownSelection(base: anchor.base, extent: target.extent); + } else { + selection = MarkdownSelection(base: anchor.base, extent: anchor.extent); + } + } + // --- keyboard extension -------------------------------------------------- void _extendExtent(MarkdownPosition Function(MarkdownPosition) step) { diff --git a/lib/src/selection_scope.dart b/lib/src/selection_scope.dart index 5f528d1..5ab2a5d 100644 --- a/lib/src/selection_scope.dart +++ b/lib/src/selection_scope.dart @@ -139,8 +139,15 @@ class MarkdownSelectionScopeState extends State { SelectionOverlay? _selectionOverlay; FocusNode? _internalFocusNode; Offset? _lastSecondaryTapDown; + Offset? _lastDoubleTapDown; MarkdownSelection? _lastSelection; + // Multi-tap / granular selection state. [_granularity] is 1 = character, + // 2 = word, 3 = block; [_granularAnchor] is the ordered word/block range a + // word/block-granular drag grows from. + int _granularity = 1; + MarkdownSelection? _granularAnchor; + FocusNode get _focusNode => widget.focusNode ?? (_internalFocusNode ??= FocusNode(debugLabel: 'MarkdownSelectionScope')); @@ -469,17 +476,77 @@ class MarkdownSelectionScopeState extends State { // --- gestures ------------------------------------------------------------ - void _onDragDown(Offset globalPosition) { + /// 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); + switch (_granularity) { + case 2: + _granularAnchor = controller.selectWordAtGlobal(global); + case 3: + _granularAnchor = controller.selectBlockAtGlobal(global); + default: + controller.startAtGlobal(global); + _granularAnchor = null; + } + } + + /// Extends the active selection to [global] at the current granularity. + void _updateSelection(Offset global) { + final anchor = _granularAnchor; + if (_granularity == 1 || anchor == null) { + controller.extendToGlobal(global); + } else { + controller.extendSelectionGranular(anchor, global, + word: _granularity == 2); + } + } + + /// Whether a Shift-click should extend (rather than replace) the selection. + bool get _shiftHeld => HardwareKeyboard.instance.isShiftPressed; + + void _handleTapDown(TapDragDownDetails d) { _focusNode.requestFocus(); hideToolbar(); - controller.startAtGlobal(globalPosition); + } + + void _handleTapUp(TapDragUpDetails d) { + // 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); + return; + } + _beginSelection(d.globalPosition, d.consecutiveTapCount); + } + + void _handleDragStart(TapDragStartDetails d) { + _focusNode.requestFocus(); + hideToolbar(); + if (_shiftHeld && + d.consecutiveTapCount <= 1 && + controller.selection != null) { + _granularity = 1; + _granularAnchor = null; + controller.extendToGlobal(d.globalPosition); + return; + } + _beginSelection(d.globalPosition, d.consecutiveTapCount); } Map get _gestures => { - PanGestureRecognizer: - GestureRecognizerFactoryWithHandlers( - () => PanGestureRecognizer( + // 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, @@ -489,23 +556,53 @@ class MarkdownSelectionScopeState extends State { ), (recognizer) => recognizer ..dragStartBehavior = DragStartBehavior.down - ..onStart = ((d) => _onDragDown(d.globalPosition)) - ..onUpdate = ((d) => controller.extendToGlobal(d.globalPosition)), + ..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) => _onDragDown(d.globalPosition)) + ..onLongPressStart = ((d) { + _focusNode.requestFocus(); + hideToolbar(); + _beginSelection(d.globalPosition, 2); + }) ..onLongPressMoveUpdate = - ((d) => controller.extendToGlobal(d.globalPosition)) + ((d) => _updateSelection(d.globalPosition)) ..onLongPressEnd = ((_) => showToolbar()), ), + // Secondary-only: right-click opens the context toolbar. With no + // primary callbacks this recognizer ignores primary taps, so it never + // competes with the TapAndPan recognizer above. TapGestureRecognizer: GestureRecognizerFactoryWithHandlers( () => TapGestureRecognizer(), (recognizer) => recognizer - ..onTapDown = ((_) => hideToolbar()) ..onSecondaryTapDown = ((d) => _lastSecondaryTapDown = d.globalPosition) ..onSecondaryTapUp = ((d) { diff --git a/test/selection/selection_test.dart b/test/selection/selection_test.dart index 08d2a9f..b592025 100644 --- a/test/selection/selection_test.dart +++ b/test/selection/selection_test.dart @@ -164,4 +164,37 @@ void main() { expect(n, 2); }); }); + + group('word boundaries (double-click / long-press)', () { + (int, int) word(String text, int offset) => + MarkdownSelectionController.wordRangeIn(text, offset); + + test('empty text yields an empty range', () { + expect(word('', 0), (0, 0)); + }); + + test('caret inside a word grabs the whole word', () { + expect(word('Hello world', 2), (0, 5)); + expect(word('Hello world', 8), (6, 11)); + }); + + test('caret at a word edge prefers the adjacent word', () { + expect(word('Hello world', 5), (0, 5)); // end of "Hello" + expect(word('Hello world', 6), (6, 11)); // start of "world" + expect(word('Hello world', 11), (6, 11)); // very end + }); + + test('underscore and digits are part of a word', () { + expect(word('foo_bar2 baz', 1), (0, 8)); + }); + + test('non-ASCII letters stay in the word', () { + expect(word('café crème', 1), (0, 4)); + }); + + test('punctuation forms its own run', () { + // "a===b": clicking on the punctuation run selects just the "===". + expect(word('a===b', 2), (1, 4)); + }); + }); } diff --git a/test/selection/selection_widget_test.dart b/test/selection/selection_widget_test.dart index b2ff0f5..43dfbe9 100644 --- a/test/selection/selection_widget_test.dart +++ b/test/selection/selection_widget_test.dart @@ -1,5 +1,7 @@ import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_md/flutter_md.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -12,6 +14,17 @@ Future _mouseDrag(WidgetTester tester, Offset from, Offset to) async { await tester.pumpAndSettle(); } +/// Taps [times] times in quick succession at [pos] (a mouse click, then a +/// double- or triple-click when times > 1). +Future _clicks(WidgetTester tester, Offset pos, int times) async { + for (var i = 0; i < times; i++) { + final g = await tester.startGesture(pos, kind: PointerDeviceKind.mouse); + await g.up(); + if (i < times - 1) await tester.pump(const Duration(milliseconds: 40)); + } + await tester.pumpAndSettle(); +} + Widget _wrap(MarkdownSelectionController controller, Widget child) => MaterialApp( home: Scaffold( @@ -342,6 +355,277 @@ void main() { expect(tester.takeException(), isNull); }); }); + + group('selection gestures', () { + Future pumpParagraph( + WidgetTester tester, + MarkdownSelectionController controller, + ) async { + await tester.pumpWidget(_wrap( + controller, + const Align( + alignment: Alignment.topLeft, + child: SizedBox(width: 400, child: _Doc('d')), + ), + )); + await tester.pumpAndSettle(); + return tester.getTopLeft(find.byType(MarkdownWidget)); + } + + testWidgets('double-click selects the word under the pointer', + (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(), 'Hello'); + expect(tester.takeException(), isNull); + }); + + testWidgets('double-click keeps intra-word punctuation (apostrophe)', + (tester) async { + final controller = MarkdownSelectionController() + ..setDocuments([ + MarkdownDocumentRef( + id: 'd', model: Markdown.fromString("can't stop here")), + ]); + final tl = await pumpParagraph(tester, controller); + + await _clicks(tester, tl + const Offset(8, 8), 2); + // The platform word segmentation keeps the apostrophe inside the word, + // unlike the plain punctuation-splitting heuristic. + expect(controller.getText(), "can't"); + expect(tester.takeException(), isNull); + }); + + testWidgets('touch double-tap selects a word and shows the toolbar', + (tester) async { + final controller = MarkdownSelectionController() + ..setDocuments([ + MarkdownDocumentRef( + id: 'd', model: Markdown.fromString('Hello selectable world')), + ]); + final tl = await pumpParagraph(tester, controller); + + final pos = tl + const Offset(8, 8); + await tester.tapAt(pos); // default gesture kind is touch + 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, + reason: 'a mobile double-tap pops the selection toolbar'); + 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('shift-click extends the selection', (tester) async { + final controller = MarkdownSelectionController() + ..setDocuments([ + MarkdownDocumentRef( + id: 'd', model: Markdown.fromString('Hello selectable world')), + ]); + final tl = await pumpParagraph(tester, controller); + + // Place a caret near the start, then Shift-click further along. + await _clicks(tester, tl + const Offset(4, 8), 1); + await tester.sendKeyDownEvent(LogicalKeyboardKey.shiftLeft); + await _clicks(tester, tl + const Offset(60, 8), 1); + await tester.sendKeyUpEvent(LogicalKeyboardKey.shiftLeft); + + expect(controller.getText(), isNotEmpty, + reason: 'shift-click should grow a selection from the caret'); + expect(tester.takeException(), isNull); + }); + }); + + 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```'); + 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); + }); + }); + + group('selection cursor', () { + testWidgets('selectable content shows the text (I-beam) cursor', + (tester) async { + final controller = MarkdownSelectionController() + ..setDocuments([ + MarkdownDocumentRef( + id: 'd', model: Markdown.fromString('Selectable text here')), + ]); + await tester.pumpWidget(_wrap( + controller, + const Align( + alignment: Alignment.topLeft, + child: SizedBox(width: 400, child: _Doc('d')), + ), + )); + 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.text, + ); + }); + + testWidgets('an actionable link shows the click (hand) cursor', + (tester) async { + await tester.pumpWidget(MaterialApp( + home: Scaffold( + body: MarkdownTheme( + data: MarkdownThemeData( + textStyle: const TextStyle(fontSize: 14), + onLinkTap: (_, __) {}, + ), + 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); + // Hover over the link glyphs (near the start of the line). + await gesture.moveTo( + tester.getTopLeft(find.byType(MarkdownWidget)) + const Offset(8, 8)); + await tester.pumpAndSettle(); + + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + SystemMouseCursors.click, + ); + }); + + testWidgets('inert content keeps the default cursor', (tester) async { + final md = Markdown.fromString('Not selectable'); + await tester.pumpWidget(MaterialApp( + home: Scaffold( + body: Align( + alignment: Alignment.topLeft, + child: SizedBox(width: 400, child: MarkdownWidget(markdown: md)), + ), + ), + )); + 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.basic, + ); + }); + }); } /// A MarkdownWidget that resolves its controller from the ambient scope. From 84de57d3fa4e5b846c9d84a11ead1897dc28d7c6 Mon Sep 17 00:00:00 2001 From: Mike Matiunin Date: Mon, 3 Aug 2026 18:26:52 +0400 Subject: [PATCH 20/30] docs: add migration guide for upgrading from 0.0.x to 0.2.x --- CHANGELOG.md | 5 + README.md | 4 + docs/migration/0.0.x-to-0.2.x.md | 381 +++++++++++++++++++++++++++++++ 3 files changed, 390 insertions(+) create mode 100644 docs/migration/0.0.x-to-0.2.x.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 19aebd1..3ea0d6a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ ## 0.2.0 +> **Upgrading from 0.0.x?** See the +> [migration guide](docs/migration/0.0.x-to-0.2.x.md). 0.2.x is almost entirely +> backward compatible — the only required code change is a new `alert` branch +> for direct `MD$Block.map` / `switch` callers. + - **ADDED**: Cross-block and cross-widget text selection. A `MarkdownSelectionController` anchors the selection on the immutable model, so it spans multiple blocks and multiple `MarkdownWidget`s and survives list diff --git a/README.md b/README.md index efffc11..e83eb9a 100644 --- a/README.md +++ b/README.md @@ -192,6 +192,10 @@ Then run: flutter pub get ``` +> **Upgrading from 0.0.x?** See the +> [Migration guide: 0.0.x → 0.2.x](docs/migration/0.0.x-to-0.2.x.md). The upgrade +> is almost entirely backward compatible — most apps need no code changes. + ## ✂️ Text Selection Selection is anchored on the immutable Markdown model, not on the render diff --git a/docs/migration/0.0.x-to-0.2.x.md b/docs/migration/0.0.x-to-0.2.x.md new file mode 100644 index 0000000..74bba58 --- /dev/null +++ b/docs/migration/0.0.x-to-0.2.x.md @@ -0,0 +1,381 @@ +# Migrating `flutter_md` from 0.0.x to 0.2.x + +This guide walks you from the **0.0.x** line (last release: `0.0.8`) to the +**0.2.x** line (`0.2.0`). It covers everything that landed across `0.1.0` and +`0.2.0` so you can upgrade in a single step. + +> **TL;DR** — 0.2.x is **almost entirely backward compatible**. Most apps +> upgrade with **zero code changes**. There is exactly **one** compile-time +> breaking change (a new `MD$Alert` branch in the block model) and a handful of +> parser bug-fixes that change output only for previously-malformed edge cases. +> No SDK, Flutter, or dependency version changes are required. + +--- + +## Contents + +- [Is this a hard migration?](#is-this-a-hard-migration) +- [Step 1 — Bump the version](#step-1--bump-the-version) +- [Step 2 — Handle the new `MD$Alert` block *(the only required change)*](#step-2--handle-the-new-mdalert-block-the-only-required-change) +- [Step 3 — Review parser behaviour changes](#step-3--review-parser-behaviour-changes) +- [Step 4 — Adopt the new features *(optional)*](#step-4--adopt-the-new-features-optional) +- [New public API reference](#new-public-api-reference) +- [Compatibility matrix](#compatibility-matrix) +- [FAQ / Troubleshooting](#faq--troubleshooting) + +--- + +## Is this a hard migration? + +No. The public surface from 0.0.8 is **fully additive** in 0.2.x with a single +exception. Here is the whole story in one table: + +| Concern | Changed in 0.2.x? | Action needed | +| --- | --- | --- | +| Dart / Flutter SDK constraints | ❌ No (`sdk >=3.6.0 <4.0.0`, `flutter >=3.29.0`) | None | +| `dependencies` in `pubspec.yaml` | ❌ No | None | +| `MarkdownWidget(markdown:, theme:)` | ❌ No — same constructor | None | +| `Markdown.fromString(String)` | ❌ No — same call | None | +| `MarkdownThemeData(...)` fields | ➕ Additive (new optional fields) | None | +| `MD$Block.map(...)` exhaustive calls | ⚠️ **Breaking** — new `alert` branch | [Step 2](#step-2--handle-the-new-mdalert-block-the-only-required-change) | +| `switch (block)` over the sealed model | ⚠️ **Breaking** — new `MD$Alert` case | [Step 2](#step-2--handle-the-new-mdalert-block-the-only-required-change) | +| `MD$Block.maybeMap(...)` | ✅ Safe (new branch is optional, falls to `orElse`) | Optional | +| Parser output for edge cases | ⚠️ A few bug-fixes | [Step 3](#step-3--review-parser-behaviour-changes) | +| Everything else (theme, spans, painters) | ➕ Additive | None | + +If your app only ever calls `MarkdownWidget(markdown: Markdown.fromString(...))` +and configures a `MarkdownThemeData`, you can upgrade by bumping the version and +you are done. Read [Step 3](#step-3--review-parser-behaviour-changes) anyway — +it is short and explains subtle rendering differences you might notice. + +--- + +## Step 1 — Bump the version + +```yaml +dependencies: + flutter_md: ^0.2.0 +``` + +```bash +flutter pub get +``` + +Nothing else in `pubspec.yaml` needs to change — the SDK/Flutter constraints and +the (empty) runtime dependency set are identical to 0.0.8. + +--- + +## Step 2 — Handle the new `MD$Alert` block *(the only required change)* + +0.1.0 introduced GitHub-style **alert** blocks (`> [!NOTE]`, `> [!TIP]`, +`> [!IMPORTANT]`, `> [!WARNING]`, `> [!CAUTION]`). They are a new subtype of the +sealed `MD$Block` model: + +```dart +final class MD$Alert extends MD$Block { ... } +``` + +Because `MD$Block` is a **sealed** class and `MD$Block.map()` takes a +**required** callback per subtype, adding `MD$Alert` is a breaking change for +any code that visits blocks exhaustively. You will hit this only if you traverse +the model yourself (custom rendering, block filtering that inspects types, +serialization, analytics, etc.). Rendering through `MarkdownWidget` needs no +change. + +### 2a. If you call `MD$Block.map(...)` + +Add an `alert:` branch. This is a **compile error** until you do. + +```dart +// Before (0.0.8) — compiles. +block.map( + paragraph: (p) => ..., + heading: (h) => ..., + quote: (q) => ..., + code: (c) => ..., + list: (l) => ..., + divider: (d) => ..., + table: (t) => ..., + spacer: (s) => ..., +); +``` + +```dart +// After (0.2.0) — add the `alert` branch. +block.map( + paragraph: (p) => ..., + heading: (h) => ..., + quote: (q) => ..., + code: (c) => ..., + list: (l) => ..., + divider: (d) => ..., + table: (t) => ..., + alert: (a) => ..., // 👈 new + spacer: (s) => ..., +); +``` + +### 2b. If you `switch` over the sealed type + +Add a `case MD$Alert`. The analyzer flags the switch as non-exhaustive until you +do. + +```dart +switch (block) { + case MD$Paragraph p: ... + case MD$Heading h: ... + case MD$Quote q: ... + case MD$Alert a: ... // 👈 new + case MD$Code c: ... + case MD$List l: ... + case MD$Divider d: ... + case MD$Table t: ... + case MD$Spacer s: ... + case MD$Image i: ... +} +``` + +### 2c. If you use `MD$Block.maybeMap(...)` + +**No change required.** `maybeMap`'s new `alert` parameter is optional and +routes to your `orElse` when omitted, so existing calls keep compiling and +behaving as before. Add an explicit `alert:` only if you want to handle alerts +specially. + +### 2d. Note: `> [!NOTE]` blockquotes are now `MD$Alert`, not `MD$Quote` + +Previously a blockquote whose first line was `> [!NOTE]` parsed as an ordinary +`MD$Quote` (with the literal `[!NOTE]` text inside). It now parses as an +`MD$Alert` with `alert.alert == MD$AlertType.note` and the marker line removed +from the body. If you special-cased those quotes by inspecting their text, move +that logic to the `MD$Alert` branch. + +--- + +## Step 3 — Review parser behaviour changes + +0.1.0 tightened the parser to follow CommonMark-inspired flanking rules and fix +long-standing edge cases. These change **output**, but only for input that was +previously mis-parsed. Skim the table; if none of these patterns appear in your +content, there is nothing to do. + +| Input | 0.0.8 result | 0.2.x result | Why | +| --- | --- | --- | --- | +| `5 * 6 = 30` | `6 ` italicised (stray `*`) | Literal text | Emphasis requires proper flanking | +| `**bold never closed` | Leaked bold to line end | Literal `**bold never closed` | Unterminated markers stay literal | +| `snake_case`, `object_id` | `_` treated as emphasis | Preserved verbatim | Intraword `_` is not emphasis | +| `#hashtag` | Treated as heading | Literal paragraph text | ATX headings need a space after `#` | +| `####### too many` (7+ `#`) | Heading | Literal text | Max heading level is 6 | +| `## Title ##` | Trailing `##` kept | Trailing `#` stripped | ATX closing sequence removed | +| `***` / `___` (and `- - -`) | Not always a rule | Thematic break (`MD$Divider`) | New rule variants | +| ` ~~~ ` fenced block | Not recognized | Fenced code block (like ` ``` `) | New fence marker | +| `\$` | Backslash + `$` | Literal `$` | `\$` is now a recognized escape | +| `*[link](url)*` (emphasis around a link) | Split spans | Emphasis merged onto the link span | Cleaner span model | +| `[t]()`, `[t](url 'title')` | Not parsed | Angle-bracket URLs & single-quoted titles supported | Wider link syntax | + +There is also a small fix worth knowing about: **`MarkdownThemeData.copyWith` +now preserves `builder` and `onLinkTap`.** In 0.0.8 those two were silently +dropped by `copyWith`; if you relied on that (accidental) behaviour to clear +them, pass them explicitly instead. + +> **Inline math is opt-in and off by default**, so the default parse of `$5`, +> `$HOME`, or `$x$` is unchanged. See [Step 4](#inline-math-opt-in) to enable +> it. + +--- + +## Step 4 — Adopt the new features *(optional)* + +None of these are required, but they are the reason to upgrade. + +### Text selection (headline feature of 0.2.0) + +Cross-block **and** cross-widget (chat) text selection, anchored on the +immutable model so it survives list scrolling/disposal and streaming updates. +Opt in per group of widgets: + +```dart +final controller = MarkdownSelectionController(); + +// Register documents in reading order (a chat feeds this from its list). +controller.setDocuments([ + for (final (i, m) in messages.indexed) + MarkdownDocumentRef(id: m.id, model: m.markdown, order: i), +]); + +MarkdownSelectionScope( + controller: controller, + child: ListView.builder( + itemCount: messages.length, + itemBuilder: (context, i) => MarkdownWidget( + documentId: messages[i].id, // 👈 opt in + markdown: messages[i].markdown, + ), + ), +); + +final String text = controller.getText(); // default formatter +final structured = controller.selectedContent(); // per-document/-block +``` + +A `MarkdownWidget` with **no** `documentId`/`controller` is completely inert, so +this is fully opt-in. Selection ships with keyboard shortcuts, native handles + +magnifier on touch, a context toolbar, and word/block granularity. See the +[Text Selection section of the README](../../README.md#-text-selection) and the +runnable **Selection** / **Chat** tabs in `example/`. + +### GitHub alerts + +```markdown +> [!WARNING] +> Critical content demanding immediate user attention. +``` + +Style per-type accent colours through the theme: + +```dart +MarkdownThemeData( + alertColors: const { + MD$AlertType.warning: Color(0xFF9A6700), + }, +); +``` + +Missing entries fall back to the GitHub default palette via `alertColorFor`. + +### Task lists + +`- [x]` / `- [ ]` items render with a checkbox and expose their state on the +model: + +```dart +if (item.isTask) { + final done = item.checked; // true / false +} +``` + +### Table column alignment + +Delimiter-row alignment (`:---`, `:--:`, `---:`) is captured on the model and +applied when rendering: + +```dart +final align = table.alignmentFor(columnIndex); // MD$TableColumnAlign.left/center/right/none +``` + +### Link styling + +Merge a custom `TextStyle` on top of the default link styling: + +```dart +MarkdownThemeData( + linkColor: Colors.indigo, + linkStyle: const TextStyle(decoration: TextDecoration.underline), +); +``` + +### Inline math (opt-in) + +Off by default. Enable per parse or via a reusable decoder: + +```dart +// Per parse: +final md = Markdown.fromString(r'The angle $\alpha$ and $x^2$.', inlineMath: true); + +// Reusable decoder, optionally extending the command table: +const decoder = MarkdownDecoder( + inlineMath: true, + mathReplacements: {...kMarkdownMathCommands, r'\R': 'ℝ'}, +); +``` + +It converts common LaTeX commands and super/subscripts to Unicode, is +code-span/code-block safe, and treats `\$` as a literal dollar. + +--- + +## New public API reference + +Everything below is **new in 0.2.x** and purely additive (except the `MD$Alert` +branch noted in [Step 2](#step-2--handle-the-new-mdalert-block-the-only-required-change)). + +**Node model (`nodes.dart`)** +- `MD$Alert` block, `MD$AlertType` enum (`marker`, `title`, `tryParse`) +- `MD$TableColumnAlign` enum; `MD$Table.alignments`, `MD$Table.alignmentFor` +- `MD$ListItem.checked`, `MD$ListItem.isTask` +- `MD$Block.map` / `maybeMap` gain an `alert` branch + +**Theme (`theme.dart`)** +- `MarkdownThemeData.linkStyle` +- `MarkdownThemeData.alertColors`, `alertColorFor` +- `copyWith` now preserves `builder` and `onLinkTap` + +**Decoder (`markdown.dart` / `parser.dart`)** +- `MarkdownDecoder({inlineMath, mathReplacements})` +- `Markdown.fromString(text, {inlineMath})` +- `kMarkdownMathCommands` + +**Widget (`widget.dart`)** +- `MarkdownWidget.controller`, `MarkdownWidget.documentId` + +**Selection (`selection.dart` / `selection_scope.dart`)** +- `MarkdownSelectionController`, `MarkdownSelectionScope` (+ `MarkdownSelectionScopeState`) +- `MarkdownSelectionGroup`, `MarkdownDocumentRef` +- `MarkdownPosition`, `MarkdownSelection` +- `MarkdownSelectedContent` (+ document/block variants) +- `MarkdownSelectionFormatter`, `MarkdownPlainTextFormatter` +- `MarkdownReconciliationPolicy`, `MarkdownSelectionSurface` +- `MarkdownHandleEndpoints`, `markdownBlockRenderedText` + +**Render framework (`render.dart`, additive exports)** +- `SelectableBlockPainter`, `SelectableTextBlock` +- `MultiPainterSelectable`, `SelectableFragment`, `ParagraphGestureHandler` +- `paragraphFromMarkdownSpans` +- Default painters `BlockPainter$Paragraph … BlockPainter$Spacer` + +--- + +## Compatibility matrix + +| Version | `MarkdownWidget` API | Node model | Requires code change from 0.0.8? | +| --- | --- | --- | --- | +| 0.0.8 | `markdown`, `theme` | 9 block types | — | +| 0.1.0 | same | +`MD$Alert`, task lists, table alignment | Only exhaustive `map`/`switch` callers | +| 0.2.0 | +`controller`, `documentId` (optional) | same as 0.1.0 | Same as 0.1.0 | + +--- + +## FAQ / Troubleshooting + +**Q: My build fails with "missing `alert` argument" / "switch is not +exhaustive" after upgrading.** +You call `MD$Block.map(...)` or `switch` over `MD$Block` directly. Add the +`alert` branch / `case MD$Alert`. See +[Step 2](#step-2--handle-the-new-mdalert-block-the-only-required-change). + +**Q: A blockquote that used to render as a quote now looks different.** +If its first line is `> [!NOTE]` (or TIP/IMPORTANT/WARNING/CAUTION) it is now an +alert block. This is intentional — see +[Step 2](#step-2--handle-the-new-mdalert-block-the-only-required-change). + +**Q: Some emphasis/headings render as literal text now.** +That is the parser correctness pass from +[Step 3](#step-3--review-parser-behaviour-changes). The old output was a bug; +the new output matches CommonMark-style rules. + +**Q: `$`-prefixed text (prices, shell vars) — will inline math eat it?** +No. Inline math is **disabled by default**. Nothing changes unless you pass +`inlineMath: true`. + +**Q: Do I have to use text selection?** +No. It is opt-in. A `MarkdownWidget` without a `documentId`/`controller` behaves +exactly as in 0.0.8. + +**Q: Did the SDK / dependency requirements change?** +No. `sdk: >=3.6.0 <4.0.0`, `flutter: >=3.29.0`, and no added runtime +dependencies. + +--- + +For the full, dated list of changes see the [CHANGELOG](../../CHANGELOG.md). From 08bf02b5f5de2d3daa25b745a8706451f6d97637 Mon Sep 17 00:00:00 2001 From: Mike Matiunin Date: Mon, 3 Aug 2026 19:54:33 +0400 Subject: [PATCH 21/30] docs: update table formatting and improve rule syntax examples in README --- README.md | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index e83eb9a..4999ec0 100644 --- a/README.md +++ b/README.md @@ -150,10 +150,10 @@ Column alignment is supported via the delimiter row (`:---` left, `:--:` center, `---:` right): ```markdown -| Left | Center | Right | -| :------- | :------: | -------: | -| Cell 1 | Cell 2 | Cell 3 | -| **Bold** | _Italic_ | `Code` | +| Left | Center | Right | +| :------- | :------: | -----: | +| Cell 1 | Cell 2 | Cell 3 | +| **Bold** | _Italic_ | `Code` | ``` ### Links and Images @@ -171,8 +171,9 @@ Any of `---`, `***`, or `___` (optionally spaced, e.g. `- - -`) produce a rule: ```markdown --- -*** -___ +--- + +--- ``` ## 🚀 Quick Start From 5c79e752da85fae60cb5273201459eb02a9ec94e Mon Sep 17 00:00:00 2001 From: Mike Matiunin Date: Tue, 4 Aug 2026 12:36:40 +0400 Subject: [PATCH 22/30] feat: enhance text selection functionality with example tests and documentation updates --- .github/actions/setup/action.yaml | 1 + .github/workflows/checkout.yml | 8 + .pubignore | 4 +- README.md | 7 +- docs/development.md | 10 +- docs/migration/0.0.x-to-0.2.x.md | 1 - docs/selection.md | 11 ++ lib/src/render/block_painter.dart | 9 +- lib/src/render/markdown_render_object.dart | 10 +- lib/src/selection.dart | 10 + test/selection/selection_handles_test.dart | 173 +++++++++++++++-- test/selection/selection_keyboard_test.dart | 203 ++++++++++++++++++++ test/selection/selection_widget_test.dart | 114 ++++++++++- 13 files changed, 516 insertions(+), 45 deletions(-) diff --git a/.github/actions/setup/action.yaml b/.github/actions/setup/action.yaml index 02f7865..ae4de23 100644 --- a/.github/actions/setup/action.yaml +++ b/.github/actions/setup/action.yaml @@ -25,6 +25,7 @@ runs: sparse-checkout: | lib/ test/ + example/ analysis_options.yaml pubspec.yaml README.md diff --git a/.github/workflows/checkout.yml b/.github/workflows/checkout.yml index 337807f..d840591 100644 --- a/.github/workflows/checkout.yml +++ b/.github/workflows/checkout.yml @@ -104,6 +104,14 @@ jobs: run: | flutter test --coverage --concurrency=40 test/unit_test.dart + - name: 🧪 Run example tests + id: run-example-tests + timeout-minutes: 5 + working-directory: example + run: | + flutter pub get + flutter test + # - name: 📊 Upload coverage to Codecov # id: upload-coverage # timeout-minutes: 2 diff --git a/.pubignore b/.pubignore index b6c2d30..2a73998 100644 --- a/.pubignore +++ b/.pubignore @@ -7,4 +7,6 @@ build/ coverage/ credentials.json *.exe -benchmark_compare/ \ No newline at end of file +benchmark_compare/ +benchmark/experiments/ +example/lib/experiments/ \ No newline at end of file diff --git a/README.md b/README.md index 4999ec0..9ecbd49 100644 --- a/README.md +++ b/README.md @@ -171,9 +171,10 @@ Any of `---`, `***`, or `___` (optionally spaced, e.g. `- - -`) produce a rule: ```markdown --- ---- ---- +*** + +___ ``` ## 🚀 Quick Start @@ -266,7 +267,7 @@ final MarkdownSelectedContent structured = controller.selectedContent(); ```dart MarkdownSelectionScope( controller: controller, - selectionColor: Colors.amber.withOpacity(0.3), + selectionColor: Colors.amber.withValues(alpha: 0.3), onSelectionChanged: (sel) => debugPrint('selection: $sel'), contextMenuBuilder: (context, state) => AdaptiveTextSelectionToolbar.buttonItems( anchors: state.contextMenuAnchors, diff --git a/docs/development.md b/docs/development.md index e2688df..3f9c419 100644 --- a/docs/development.md +++ b/docs/development.md @@ -50,8 +50,9 @@ Parser scenarios live in `benchmark/scenarios.dart` (`prose, inline, links, list table, code, quotes, escapes, currency, pathological, mixed`). `compare.dart` uses warmup + auto-calibrated iterations + min-of-batches for low-noise deltas. -Selection spikes S1–S7 (`benchmark/experiments/`, findings in `FINDINGS.md`) are -throwaway and not in CI. +Selection spikes S1–S5 and S7 (`benchmark/experiments/`, findings in +`FINDINGS.md`) are throwaway and not in CI; S6 lives at +`example/lib/experiments/s6_platforms.dart`. ## CI pipeline (`.github/workflows/`) @@ -131,5 +132,6 @@ example/ md_example app: lib/main.dart (Editor/Selection/Ch AGENTS.md docs/ this documentation set ``` -`build/`, `coverage/`, `.dart_tool/` are gitignored; `.baseline.txt` is -uncommitted (the render baseline `.render_baseline.txt` is committed). +`build/`, `coverage/`, `.dart_tool/` are gitignored; both benchmark baselines +(`.baseline.txt` and `.render_baseline.txt`) are gitignored and uncommitted — +they are machine-specific and generated locally. diff --git a/docs/migration/0.0.x-to-0.2.x.md b/docs/migration/0.0.x-to-0.2.x.md index 74bba58..870f6c5 100644 --- a/docs/migration/0.0.x-to-0.2.x.md +++ b/docs/migration/0.0.x-to-0.2.x.md @@ -132,7 +132,6 @@ switch (block) { case MD$Divider d: ... case MD$Table t: ... case MD$Spacer s: ... - case MD$Image i: ... } ``` diff --git a/docs/selection.md b/docs/selection.md index 822150c..6e67b27 100644 --- a/docs/selection.md +++ b/docs/selection.md @@ -191,3 +191,14 @@ Typical pattern: keep models in a list, feed them to the controller, and give ea avoid disposing the overlay mid-gesture). - `MarkdownSelectedBlock.sourceRange` is documented as best-effort but is currently always null. +- `MarkdownThemeData.blockFilter` drops whole blocks at render time, but selection + **extraction is model-based** and does not see that filtering. A selection spanning + *across* a dropped block therefore copies the hidden block's text even though the + block is never highlighted on screen. Avoid `blockFilter` when selection is enabled + (same guidance as `spanFilter`, which shifts the painter's offset space). +- Keyboard **word** navigation indexes the block's rendered text by UTF-16 code unit, + so it may split a surrogate pair (an emoji / other non-BMP character). Accepted v1 + limitation. +- 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). diff --git a/lib/src/render/block_painter.dart b/lib/src/render/block_painter.dart index 8ab89c0..c261e09 100644 --- a/lib/src/render/block_painter.dart +++ b/lib/src/render/block_painter.dart @@ -46,6 +46,12 @@ abstract interface class BlockPainter { /// shifts the painter's offset space relative to the (unfiltered) model, so the /// on-screen highlight stays correct but copied text may be misaligned. Avoid /// dropping text-bearing spans when selection is enabled. +/// +/// Caveat: a [MarkdownThemeData.blockFilter] that drops whole blocks is never +/// highlighted on screen, but a selection spanning *across* a dropped block +/// still copies that hidden block's text — selection extraction is model-based +/// and does not see render-time block filtering. Avoid `blockFilter` when +/// selection is enabled. abstract interface class SelectableBlockPainter implements BlockPainter { /// The block's rendered plain text. String get renderedText; @@ -244,10 +250,7 @@ mixin ParagraphGestureHandler { InlineSpan? hitTestInlineSpanWithPointerEvent( PointerEvent event, TextPainter painter) { final pos = painter.getPositionForOffset(event.localPosition); - //final int index = pos.offset; final span = painter.text?.getSpanForPosition(pos); - //final plainText = span?.toPlainText(); - //print('[${pos.offset}] $plainText'); return span; } } diff --git a/lib/src/render/markdown_render_object.dart b/lib/src/render/markdown_render_object.dart index 0dd1ab9..7f4cad2 100644 --- a/lib/src/render/markdown_render_object.dart +++ b/lib/src/render/markdown_render_object.dart @@ -211,6 +211,10 @@ class MarkdownRenderObject extends RenderBox _size = super.size; } + // Measuring the content requires laying out the block [TextPainter]s, so this + // delegates to `_painter.layout(...)` which populates the painter's cached + // layout as a side effect (not a "pure" dry layout). [performLayout] re-runs + // the same layout, so the cached state is always finalized before [paint]. @override Size computeDryLayout(BoxConstraints constraints) => constraints.constrain(_painter.layout(maxWidth: constraints.maxWidth)); @@ -222,12 +226,6 @@ class MarkdownRenderObject extends RenderBox constraints.constrain(_painter.layout(maxWidth: constraints.maxWidth)); } - @override - // ignore: unnecessary_overrides - void performResize() { - size = computeDryLayout(constraints); - } - @override bool hitTestSelf(Offset position) => true; diff --git a/lib/src/selection.dart b/lib/src/selection.dart index a1b20d6..0f64260 100644 --- a/lib/src/selection.dart +++ b/lib/src/selection.dart @@ -604,6 +604,9 @@ class MarkdownSelectionController extends ChangeNotifier { notifyListeners(); } + // Documents are ordered by their `order` key. `List.sort` is not stable, so + // 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)); _reindex(); @@ -766,6 +769,10 @@ class MarkdownSelectionController extends ChangeNotifier { /// preferring an adjacent word character so clicking a word's edge grabs it. /// /// Exposed for testing; mirrors what double-click / long-press select. + /// + /// Limitation (v1): the block's rendered text is indexed by UTF-16 code unit, + /// so word segmentation may split a surrogate pair (e.g. an emoji or other + /// non-BMP character). Accepted for v1. @visibleForTesting static (int, int) wordRangeIn(String text, int offset) { final len = text.length; @@ -1175,6 +1182,9 @@ class MarkdownSelectionController extends ChangeNotifier { offset: _blockTextAt(adj.$1, adj.$2).length); } + // Limitation (v1): stepping indexes the block's rendered text by UTF-16 code + // unit, so keyboard word navigation may land inside a surrogate pair (e.g. an + // emoji or other non-BMP character). Accepted for v1. MarkdownPosition _stepWord(MarkdownPosition p, {required bool forward}) { final di = _orderIndex(p.documentId); if (di < 0) return p; diff --git a/test/selection/selection_handles_test.dart b/test/selection/selection_handles_test.dart index 6414c66..2989a9d 100644 --- a/test/selection/selection_handles_test.dart +++ b/test/selection/selection_handles_test.dart @@ -65,9 +65,15 @@ void main() { expect('Hello selectable world', startsWith(text)); }); - testWidgets('touch platform shows draggable handles for a selection', + // Ordered (start, end) rendered-text offsets of a single-block selection. + (int, int) endpoints(MarkdownSelectionController c) { + final sel = c.selection!; + final a = sel.base.offset, b = sel.extent.offset; + return a <= b ? (a, b) : (b, a); + } + + testWidgets('moveSelectionEdgeToGlobal(isStart: true) moves only the start', (tester) async { - debugDefaultTargetPlatformOverride = TargetPlatform.android; final md = Markdown.fromString('Hello selectable world'); final controller = MarkdownSelectionController() ..setDocuments( @@ -82,20 +88,65 @@ void main() { )); await tester.pumpAndSettle(); + controller.selectAll(); // d#0@0 -> d#0@22 + await tester.pump(); + const full = 'Hello selectable world'; + expect(controller.getText(), full); + + // Drag the reading-order START edge rightwards into the line. 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.getText(), isNotEmpty); - // Two handles are composited to follow the content. - expect(find.byType(CompositedTransformFollower), findsWidgets); - expect(tester.takeException(), isNull); - debugDefaultTargetPlatformOverride = null; + controller.moveSelectionEdgeToGlobal(tl + const Offset(120, 8), + isStart: true); + await tester.pump(); + + final (start, end) = endpoints(controller); + expect(end, 22, reason: 'the end edge stayed fixed at the line end'); + expect(start, greaterThan(0), reason: 'the start edge moved inward'); + expect(start, lessThan(22)); + final text = controller.getText(); + expect(text.length, lessThan(full.length)); + expect(full.endsWith(text), isTrue, + reason: 'moving the start keeps a suffix of the line'); }); - testWidgets('desktop platform shows no selection handles', (tester) async { - debugDefaultTargetPlatformOverride = TargetPlatform.linux; + testWidgets('moveSelectionEdgeToGlobal(isStart: false) moves only the end', + (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(); + + controller.selectAll(); // d#0@0 -> d#0@22 + await tester.pump(); + const full = 'Hello selectable world'; + + // Drag the reading-order END edge leftwards into the middle of the line. + final tl = tester.getTopLeft(find.byType(MarkdownWidget)); + controller.moveSelectionEdgeToGlobal(tl + const Offset(120, 8), + isStart: false); + await tester.pump(); + + final (start, end) = endpoints(controller); + expect(start, 0, reason: 'the start edge stayed fixed at the line start'); + expect(end, greaterThan(0), reason: 'the end edge moved inward'); + expect(end, lessThan(22)); + final text = controller.getText(); + expect(text.length, lessThan(full.length)); + expect(full.startsWith(text), isTrue, + reason: 'moving the end keeps a prefix of the line'); + }); + + testWidgets('a handle drag past the other edge never collapses', + (tester) async { final md = Markdown.fromString('Hello selectable world'); final controller = MarkdownSelectionController() ..setDocuments( @@ -110,15 +161,95 @@ void main() { )); await tester.pumpAndSettle(); + controller.selectAll(); + await tester.pump(); + const full = 'Hello selectable world'; + final before = controller.selection; + 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.getText(), isNotEmpty); - expect(find.byType(CompositedTransformFollower), findsNothing); - expect(tester.takeException(), isNull); - debugDefaultTargetPlatformOverride = null; + // 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); + await tester.pump(); + expect(controller.selection, before, + reason: 'a collapsing move is a no-op'); + expect(controller.getText(), full); + expect(controller.selection!.isCollapsed, isFalse); + + // Symmetric: drag the END edge past the START (far left) — also dropped. + controller.moveSelectionEdgeToGlobal(tl + const Offset(1, 8), + isStart: false); + await tester.pump(); + expect(controller.selection, before); + expect(controller.getText(), full); + expect(controller.selection!.isCollapsed, isFalse); + }); + + testWidgets('touch platform shows draggable handles for a selection', + (tester) async { + debugDefaultTargetPlatformOverride = TargetPlatform.android; + // NOTE: reset via try/finally rather than addTearDown — in this Flutter + // version the framework's debugAssertAllFoundationVarsUnset check runs + // before user tearDowns, so a tearDown reset arrives too late and fails. + 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 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.getText(), isNotEmpty); + // Exactly two handles (start + end) are composited to follow the + // content — assert the pair specifically, not merely "some widgets". + expect(find.byType(CompositedTransformFollower), findsNWidgets(2)); + expect(tester.takeException(), isNull); + } finally { + debugDefaultTargetPlatformOverride = null; + } + }); + + testWidgets('desktop platform shows no selection handles', (tester) async { + debugDefaultTargetPlatformOverride = TargetPlatform.linux; + 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 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.getText(), isNotEmpty); + expect(find.byType(CompositedTransformFollower), findsNothing); + expect(tester.takeException(), isNull); + } finally { + debugDefaultTargetPlatformOverride = null; + } }); }); } diff --git a/test/selection/selection_keyboard_test.dart b/test/selection/selection_keyboard_test.dart index 00551d2..a7e5ca1 100644 --- a/test/selection/selection_keyboard_test.dart +++ b/test/selection/selection_keyboard_test.dart @@ -119,6 +119,51 @@ void main() { expect(controller.getText(), 'H'); }); + testWidgets('Shift+ArrowRight extends across a block boundary', + (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 { + // '# Title\nBody text here' → block 0 heading "Title" (len 5), block 1 + // paragraph "Body text here" — adjacent blocks with no spacer between. + final md = Markdown.fromString('# Title\nBody text here'); + final controller = MarkdownSelectionController() + ..setDocuments( + [MarkdownDocumentRef(id: 'd', model: md)]) + ..selection = const MarkdownSelection( + base: MarkdownPosition(documentId: 'd', blockIndex: 0, offset: 0), + extent: MarkdownPosition(documentId: 'd', blockIndex: 0, offset: 5), + ); + final focus = FocusNode(); + addTearDown(focus.dispose); + + await tester.pumpWidget(_wrap( + controller, + const SizedBox(width: 400, child: _Doc('d')), + focusNode: focus, + )); + await tester.pumpAndSettle(); + focus.requestFocus(); + await tester.pump(); + + await tester.sendKeyDownEvent(LogicalKeyboardKey.shiftLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); + await tester.sendKeyUpEvent(LogicalKeyboardKey.shiftLeft); + // pump (not pumpAndSettle) to avoid overlay/magnifier animation loops. + await tester.pump(); + + final extent = controller.selection!.extent; + expect(extent.documentId, 'd'); + expect(extent.blockIndex, 1, + reason: 'the extent crossed from block 0 into block 1'); + expect(extent.offset, 0, reason: 'offset resets to block start'); + expect(controller.getText(), 'Title'); + } finally { + debugDefaultTargetPlatformOverride = null; + } + }); + testWidgets('Ctrl+C copies the selection to the clipboard', (tester) async { debugDefaultTargetPlatformOverride = TargetPlatform.linux; final md = Markdown.fromString('Copy this text'); @@ -217,4 +262,162 @@ void main() { expect(state.toolbarIsVisible, isFalse); }); }); + + // Deterministic, geometry-free coverage of the controller's keyboard-driven + // extension primitives (what the Shift+arrow Actions delegate to). + group('keyboard extension within a block', () { + MarkdownSelectionController single() => MarkdownSelectionController() + ..setDocuments([ + MarkdownDocumentRef( + id: 'd', model: Markdown.fromString('Hello selectable world')), + ]); // one paragraph block, rendered text length 22 + + test('extendSelectionByCharacter(forward: false) shrinks by one char', () { + final c = single() + ..selection = const MarkdownSelection( + base: MarkdownPosition(documentId: 'd', blockIndex: 0, offset: 0), + extent: MarkdownPosition(documentId: 'd', blockIndex: 0, offset: 5), + ); + c.extendSelectionByCharacter(forward: false); + expect(c.selection!.extent.offset, 4); + expect(c.getText(), 'Hell'); + }); + + test('extendSelectionByWord(forward: true) grows one word at a time', () { + final c = single() + ..selection = const MarkdownSelection.collapsed( + MarkdownPosition(documentId: 'd', blockIndex: 0, offset: 0)); + c.extendSelectionByWord(forward: true); + expect(c.getText(), 'Hello'); + c.extendSelectionByWord(forward: true); + expect(c.getText(), 'Hello selectable'); + expect(c.selection!.extent.offset, 16); + }); + + test('extendSelectionByWord(forward: false) grabs the previous word', () { + final c = single() + ..selection = const MarkdownSelection.collapsed( + MarkdownPosition(documentId: 'd', blockIndex: 0, offset: 22)); + c.extendSelectionByWord(forward: false); + expect(c.selection!.extent.offset, 17); + expect(c.getText(), 'world'); + }); + + test('extendSelectionToLineBreak reaches the block start/end', () { + final c = single() + ..selection = const MarkdownSelection( + base: MarkdownPosition(documentId: 'd', blockIndex: 0, offset: 0), + extent: MarkdownPosition(documentId: 'd', blockIndex: 0, offset: 5), + ); + c.extendSelectionToLineBreak(forward: true); // End + expect(c.selection!.extent.offset, 22); + expect(c.getText(), 'Hello selectable world'); + + c.selection = const MarkdownSelection( + base: MarkdownPosition(documentId: 'd', blockIndex: 0, offset: 10), + extent: MarkdownPosition(documentId: 'd', blockIndex: 0, offset: 22), + ); + c.extendSelectionToLineBreak(forward: false); // Home + expect(c.selection!.extent.offset, 0); + expect(c.getText(), 'Hello sele'); + }); + }); + + group('keyboard extension across blocks and documents', () { + // '# Title\nBody text here' → block 0 heading "Title" (len 5), block 1 + // paragraph "Body text here" (len 14): adjacent, no spacer between them. + MarkdownSelectionController headingDoc() => MarkdownSelectionController() + ..setDocuments([ + MarkdownDocumentRef( + id: 'd', model: Markdown.fromString('# Title\nBody text here')), + ]); + + // Two paragraph blocks per doc (index 0 = paragraph, 1 = spacer, 2 = para). + MarkdownSelectionController twoDocs() => MarkdownSelectionController() + ..setDocuments([ + MarkdownDocumentRef( + id: 'a', + model: Markdown.fromString('Alpha one\n\nAlpha two'), + order: 0), + MarkdownDocumentRef( + id: 'b', + model: Markdown.fromString('Bravo one\n\nBravo two'), + order: 1), + ]); + + test('stepping forward off a block end moves into the next block', () { + final c = headingDoc() + ..selection = const MarkdownSelection( + base: MarkdownPosition(documentId: 'd', blockIndex: 0, offset: 0), + extent: MarkdownPosition(documentId: 'd', blockIndex: 0, offset: 5), + ); + c.extendSelectionByCharacter(forward: true); + final e = c.selection!.extent; + expect(e.blockIndex, 1, reason: 'blockIndex incremented'); + expect(e.offset, 0, reason: 'offset reset to the block start'); + expect(e.documentId, 'd'); + expect(c.getText(), 'Title'); + }); + + test('stepping backward off a block start moves into the previous block', + () { + final c = headingDoc() + ..selection = const MarkdownSelection( + base: MarkdownPosition(documentId: 'd', blockIndex: 1, offset: 14), + extent: MarkdownPosition(documentId: 'd', blockIndex: 1, offset: 0), + ); + c.extendSelectionByCharacter(forward: false); + final e = c.selection!.extent; + expect(e.blockIndex, 0, reason: 'blockIndex decremented into block 0'); + expect(e.offset, 5, reason: 'landed at the end of the previous block'); + expect(c.getText(), 'Body text here'); + }); + + test('stepping forward off the last block of doc A lands in doc B', () { + final c = twoDocs() + ..selection = const MarkdownSelection( + base: MarkdownPosition(documentId: 'a', blockIndex: 0, offset: 0), + extent: MarkdownPosition(documentId: 'a', blockIndex: 2, offset: 9), + ); + c.extendSelectionByCharacter(forward: true); + final e = c.selection!.extent; + expect(e.documentId, 'b', reason: 'crossed into the next document'); + expect(e.blockIndex, 0); + expect(e.offset, 0); + }); + + test('stepping backward off the first block of doc B lands in doc A', () { + final c = twoDocs() + ..selection = const MarkdownSelection( + base: MarkdownPosition(documentId: 'b', blockIndex: 0, offset: 5), + extent: MarkdownPosition(documentId: 'b', blockIndex: 0, offset: 0), + ); + c.extendSelectionByCharacter(forward: false); + final e = c.selection!.extent; + expect(e.documentId, 'a'); + expect(e.blockIndex, 2, reason: 'end of doc A (last non-empty block)'); + expect(e.offset, 9, reason: 'end of "Alpha two"'); + }); + + test('extendSelectionToDocumentBoundary reaches the first/last position', + () { + final c = twoDocs() + ..selection = const MarkdownSelection.collapsed( + MarkdownPosition(documentId: 'a', blockIndex: 0, offset: 0)); + c.extendSelectionToDocumentBoundary(forward: true); + final end = c.selection!.extent; + expect(end.documentId, 'b'); + expect(end.blockIndex, 2); + expect(end.offset, 9, reason: 'the very last position across all docs'); + expect(c.getText(), 'Alpha one\nAlpha two\n\nBravo one\nBravo two'); + + c.selection = const MarkdownSelection.collapsed( + MarkdownPosition(documentId: 'b', blockIndex: 2, offset: 9)); + c.extendSelectionToDocumentBoundary(forward: false); + final start = c.selection!.extent; + expect(start.documentId, 'a'); + expect(start.blockIndex, 0); + expect(start.offset, 0, reason: 'the first position across all docs'); + }); + }); } diff --git a/test/selection/selection_widget_test.dart b/test/selection/selection_widget_test.dart index 43dfbe9..e14d939 100644 --- a/test/selection/selection_widget_test.dart +++ b/test/selection/selection_widget_test.dart @@ -1,3 +1,4 @@ +import 'package:flutter/foundation.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; @@ -455,7 +456,8 @@ void main() { expect(tester.takeException(), isNull); }); - testWidgets('shift-click extends the selection', (tester) async { + testWidgets('shift-click grows the selection toward the clicked point', + (tester) async { final controller = MarkdownSelectionController() ..setDocuments([ MarkdownDocumentRef( @@ -463,18 +465,118 @@ void main() { ]); final tl = await pumpParagraph(tester, controller); - // Place a caret near the start, then Shift-click further along. - await _clicks(tester, tl + const Offset(4, 8), 1); + // 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(60, 8), 1); + 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)); - expect(controller.getText(), isNotEmpty, - reason: 'shift-click should grow a selection from the caret'); + // 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; + } + }); + }); + 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 From 8a0855b76ac03c600a69558478c793c3b7bd58e4 Mon Sep 17 00:00:00 2001 From: Mike Matiunin Date: Tue, 4 Aug 2026 13:15:17 +0400 Subject: [PATCH 23/30] feat: add MarkdownMarkupFormatter for structured Markdown copying and enhance related tests --- CHANGELOG.md | 12 +- README.md | 11 +- example/lib/tabs/lorem_tab.dart | 170 +++++++++++++++--- example/test/smoke_test.dart | 5 + lib/src/selection.dart | 90 ++++++++++ test/selection/markup_formatter_test.dart | 199 ++++++++++++++++++++++ test/unit_test.dart | 2 + 7 files changed, 462 insertions(+), 27 deletions(-) create mode 100644 test/selection/markup_formatter_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ea0d6a..9581bfd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,9 +12,17 @@ `MarkdownSelectionScope`, `MarkdownSelectionGroup`, `MarkdownPosition`, `MarkdownSelection`, `MarkdownDocumentRef`, `MarkdownSelectedContent` (+ document/block), `MarkdownSelectionFormatter` / - `MarkdownPlainTextFormatter`, `MarkdownReconciliationPolicy`, - `MarkdownSelectionSurface`, `markdownBlockRenderedText`, and + `MarkdownPlainTextFormatter` / `MarkdownMarkupFormatter`, + `MarkdownReconciliationPolicy`, `MarkdownSelectionSurface`, + `markdownBlockRenderedText`, and `SelectableBlockPainter` / `SelectableTextBlock`. +- **ADDED**: `MarkdownMarkupFormatter`, a built-in "Copy as Markdown" formatter. + Pass it to `getText()` (or set `controller.formatter`) to reconstruct Markdown + structure on copy — heading `#`s, nested list markers with task checkboxes, + blockquote/alert `>` prefixes, fenced code and pipe tables — for blocks the + selection covers in full; partially-selected boundary blocks fall back to the + plain sliced text so nothing outside the selection is emitted. The default + copy behaviour is unchanged (`MarkdownPlainTextFormatter`). - **ADDED**: `MarkdownWidget` gains optional `documentId` and `controller` parameters (resolved from the ambient scope). Backward compatible: a widget with no `documentId` is inert. diff --git a/README.md b/README.md index 9ecbd49..4546057 100644 --- a/README.md +++ b/README.md @@ -229,6 +229,7 @@ MarkdownSelectionScope( // Any time — even for messages scrolled off-screen: final String text = controller.getText(); // default formatter +final String md = controller.getText(const MarkdownMarkupFormatter()); // as Markdown final MarkdownSelectedContent structured = controller.selectedContent(); ``` @@ -236,9 +237,13 @@ final MarkdownSelectedContent structured = controller.selectedContent(); lists and tables — a drag can start or end inside a list item or table cell, and copied text preserves the list `\n` / table `\t` separators. - **Get the text your way.** `getText()` uses the default - `MarkdownPlainTextFormatter` (configurable block/document separators); pass a - custom `MarkdownSelectionFormatter` for e.g. "Copy as Markdown". - `selectedContent()` returns the structured per-document / per-block result. + `MarkdownPlainTextFormatter` (configurable block/document separators). For + richer output pass the built-in `MarkdownMarkupFormatter` ("Copy as + Markdown"): it re-emits heading `#`s, nested list markers with task + checkboxes, blockquote/alert `>` prefixes, fenced code and pipe tables for + fully-selected blocks (partially-selected edges fall back to plain text). Or + implement your own `MarkdownSelectionFormatter`. `selectedContent()` returns + the structured per-document / per-block result each formatter consumes. - **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). diff --git a/example/lib/tabs/lorem_tab.dart b/example/lib/tabs/lorem_tab.dart index 3e3f78b..ab034de 100644 --- a/example/lib/tabs/lorem_tab.dart +++ b/example/lib/tabs/lorem_tab.dart @@ -51,6 +51,11 @@ const String _loremPlain = 'This is a plain SelectableText (not Markdown). Selecting here clears the ' 'Markdown selections above — and selecting Markdown clears this one.'; +/// Reconstructs Markdown structure (heading levels, nested list markers, task +/// checkboxes, blockquotes, fenced code, pipe tables) on copy, instead of the +/// flattened plain text the default [MarkdownPlainTextFormatter] produces. +const MarkdownMarkupFormatter _markupFormatter = MarkdownMarkupFormatter(); + /// Demonstrates cross-block selection within a single [MarkdownWidget], several /// independent controllers that reset one another via a shared /// [MarkdownSelectionGroup], and coordination with a plain `SelectableText`. @@ -109,15 +114,22 @@ class _LoremTabState extends State { super.dispose(); } - Future _copy() async { - final text = - _isActive(_a) ? _a.getText() : (_isActive(_b) ? _b.getText() : ''); + MarkdownSelectionController? get _activeController => + _isActive(_a) ? _a : (_isActive(_b) ? _b : null); + + Future _copy({MarkdownSelectionFormatter? formatter}) async { + final text = _activeController?.getText(formatter) ?? ''; if (text.isEmpty) return; await Clipboard.setData(ClipboardData(text: text)); if (!mounted) return; + final how = formatter == null ? 'plain text' : 'Markdown'; ScaffoldMessenger.of(context) ..clearSnackBars() - ..showSnackBar(SnackBar(content: Text('Copied:\n$text'))); + ..showSnackBar(SnackBar( + behavior: SnackBarBehavior.floating, + duration: const Duration(seconds: 2), + content: Text('Copied ${text.length} chars as $how'), + )); } Widget _label(String text) => Padding( @@ -125,8 +137,8 @@ class _LoremTabState extends State { child: Text(text, style: Theme.of(context).textTheme.labelLarge), ); - /// A custom [contextMenuBuilder] that appends a "Copy LOUD" action to the - /// default Copy / Select-all buttons. + /// 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, @@ -135,6 +147,16 @@ class _LoremTabState extends State { 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: () { @@ -146,6 +168,93 @@ class _LoremTabState extends State { ], ); + /// 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, + ); + + /// A live, side-by-side preview of what the two Copy buttons produce for the + /// current selection — the whole point of [MarkdownMarkupFormatter] is that + /// the right column keeps the structure the left column flattens away. + Widget _preview() { + final c = _activeController; + final plain = c?.getText() ?? ''; + 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); + if (constraints.maxWidth > 620) { + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded(child: plainPanel), + const SizedBox(width: 10), + Expanded(child: mdPanel), + ], + ); + } + return Column( + children: [ + plainPanel, + const SizedBox(height: 8), + mdPanel, + ], + ); + }, + ); + } + + Widget _previewPanel(String title, String body, {required bool accent}) { + final scheme = Theme.of(context).colorScheme; + return Container( + decoration: BoxDecoration( + color: accent + ? scheme.primaryContainer.withValues(alpha: 0.35) + : scheme.surfaceContainerHighest.withValues(alpha: 0.5), + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: accent + ? scheme.primary.withValues(alpha: 0.5) + : scheme.outlineVariant, + ), + ), + padding: const EdgeInsets.all(10), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + title, + style: Theme.of(context).textTheme.labelSmall?.copyWith( + fontWeight: FontWeight.bold, + color: accent ? scheme.primary : scheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 6), + ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 108), + child: SingleChildScrollView( + child: Text( + body.isEmpty ? '—' : body, + style: const TextStyle( + fontFamily: 'monospace', + fontFamilyFallback: ['Courier'], + fontSize: 12, + height: 1.35, + ), + ), + ), + ), + ], + ), + ); + } + @override Widget build(BuildContext context) => Column( children: [ @@ -156,7 +265,7 @@ class _LoremTabState extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ _label('Markdown A — custom toolbar (right-click / ' - 'long-press for a "Copy LOUD" action)'), + 'long-press for "Copy as Markdown" & "Copy LOUD")'), MarkdownSelectionScope( controller: _a, contextMenuBuilder: _loudContextMenu, @@ -192,23 +301,40 @@ class _LoremTabState extends State { child: SafeArea( top: false, child: Padding( - padding: const EdgeInsets.all(8), - child: Row( + padding: const EdgeInsets.all(12), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - Expanded( - child: Text( - _mdActive - ? 'Selection active — Ctrl/Cmd+C to copy, ' - 'right-click for the toolbar, Esc to clear' - : 'Drag to select · Ctrl/Cmd+A all · ' - 'Shift+arrows extend · right-click toolbar', - style: Theme.of(context).textTheme.bodySmall, - ), + 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, ), - FilledButton.icon( - onPressed: _copy, - icon: const Icon(Icons.copy), - label: const Text('Copy'), + 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'), + ), + ], ), ], ), diff --git a/example/test/smoke_test.dart b/example/test/smoke_test.dart index 424ae2d..fd2d3fb 100644 --- a/example/test/smoke_test.dart +++ b/example/test/smoke_test.dart @@ -31,6 +31,11 @@ void main() { 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(); diff --git a/lib/src/selection.dart b/lib/src/selection.dart index 0f64260..8422f8a 100644 --- a/lib/src/selection.dart +++ b/lib/src/selection.dart @@ -261,6 +261,96 @@ final class MarkdownPlainTextFormatter implements MarkdownSelectionFormatter { } } +/// A formatter that reconstructs Markdown-flavored text from a selection, +/// preserving structure that [MarkdownPlainTextFormatter] flattens away: +/// heading levels (`#`), blockquote and alert prefixes (`>`), fenced code +/// (```` ``` ````), nested list markers with task checkboxes, and pipe tables. +/// +/// Fidelity is best-effort. A block is re-rendered from its model only when the +/// selection covers it **in full**; a partially selected boundary block falls +/// back to its plain sliced [MarkdownSelectedBlock.text], so the copied output +/// never leaks text from outside the selection (at the cost of losing markup on +/// just those edge blocks). This matches the common case — selecting whole +/// lists, sections, or messages — while staying safe on ragged edges. +@immutable +final class MarkdownMarkupFormatter implements MarkdownSelectionFormatter { + /// Creates a Markdown-reconstructing formatter. + const MarkdownMarkupFormatter({ + this.blockSeparator = '\n\n', + this.documentSeparator = '\n\n', + this.listIndent = ' ', + }); + + /// Inserted between blocks within a document (a blank line by default, the + /// idiomatic Markdown block separator). + final String blockSeparator; + + /// Inserted between documents. + final String documentSeparator; + + /// Whitespace prepended per nesting level of a list. Two spaces by default. + final String listIndent; + + @override + String format(MarkdownSelectedContent content) { + final docs = []; + for (final doc in content.documents) { + final blocks = []; + for (final block in doc.blocks) { + final rendered = _block(block); + if (rendered.isNotEmpty) blocks.add(rendered); + } + if (blocks.isNotEmpty) docs.add(blocks.join(blockSeparator)); + } + return docs.join(documentSeparator); + } + + String _block(MarkdownSelectedBlock seg) { + // Only reconstruct rich markup when the whole block is selected; a partial + // boundary block falls back to its plain sliced text so we never emit text + // outside the selection. + final full = markdownBlockRenderedText(seg.block); + final whole = + seg.renderedRange.start <= 0 && seg.renderedRange.end >= full.length; + if (!whole) return seg.text; + 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), '> '), + alert: (a) => + '> [!${a.alert.marker}]\n${_prefixLines(_spans(a.spans), '> ')}', + code: (c) => '```${c.language ?? ''}\n${c.text}\n```', + list: (l) => _list(l.items, 0), + table: _table, + divider: (_) => '---', + spacer: (_) => '', + ); + } + + String _list(List items, int depth) { + final out = []; + for (final item in items) { + final box = item.isTask ? (item.checked! ? '[x] ' : '[ ] ') : ''; + out.add('${listIndent * depth}${item.marker} $box${_spans(item.spans)}'); + if (item.children.isNotEmpty) out.add(_list(item.children, depth + 1)); + } + return out.join('\n'); + } + + String _table(MD$Table t) { + String row(MD$TableRow r) => '| ${r.cells.map(_spans).join(' | ')} |'; + final cols = t.header.cells.length; + return [ + row(t.header), + '| ${List.filled(cols, '---').join(' | ')} |', + for (final r in t.rows) row(r), + ].join('\n'); + } + + String _prefixLines(String text, String prefix) => + text.split('\n').map((line) => '$prefix$line').join('\n'); +} + /// Decides how a selection anchor is remapped when a document's model is /// replaced (e.g. streaming). Returning null drops the anchor (collapsing the /// selection). No stable block id is required — remapping is content-based. diff --git a/test/selection/markup_formatter_test.dart b/test/selection/markup_formatter_test.dart new file mode 100644 index 0000000..314f8d4 --- /dev/null +++ b/test/selection/markup_formatter_test.dart @@ -0,0 +1,199 @@ +import 'package:flutter/painting.dart' show TextRange; +import 'package:flutter_md/flutter_md.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// Full-block selection of a single-document source, formatted as Markdown. +String _markup(String source, + [MarkdownMarkupFormatter formatter = const MarkdownMarkupFormatter()]) { + final c = MarkdownSelectionController() + ..setDocuments([ + MarkdownDocumentRef(id: 'doc', model: Markdown.fromString(source)), + ]) + ..selectAll(); + return c.getText(formatter); +} + +void main() { + group('MarkdownMarkupFormatter — whole-block reconstruction', () { + test('nested unordered list keeps indentation and markers', () { + expect( + _markup('- one\n- two\n - nested\n- three'), + '- one\n- two\n - nested\n- three', + ); + }); + + test('ordered list keeps numbers, nested indented one level', () { + expect( + _markup('1. first\n2. second\n 1. sub'), + '1. first\n2. second\n 1. sub', + ); + }); + + test('task list keeps checkboxes', () { + expect(_markup('- [x] done\n- [ ] todo'), '- [x] done\n- [ ] todo'); + }); + + test('headings keep their level', () { + expect(_markup('# Title'), '# Title'); + expect(_markup('### Deep'), '### Deep'); + }); + + test('blockquote is prefixed', () { + expect(_markup('> quoted line'), '> quoted line'); + }); + + test('alert emits its marker and prefixed body', () { + expect( + _markup('> [!NOTE]\n> Body one\n> Body two'), + '> [!NOTE]\n> Body one\n> Body two', + ); + }); + + test('code block is fenced with its language', () { + expect(_markup('```dart\nvar x = 1;\n```'), '```dart\nvar x = 1;\n```'); + }); + + test('code block without a language uses a bare fence', () { + expect(_markup('```\nplain\n```'), '```\nplain\n```'); + }); + + test('table becomes a pipe table with a delimiter row', () { + expect( + _markup('| a | b |\n|---|---|\n| 1 | 2 |'), + '| a | b |\n| --- | --- |\n| 1 | 2 |', + ); + }); + + test('custom listIndent controls nesting width', () { + expect( + _markup( + '- a\n - b', + const MarkdownMarkupFormatter(listIndent: ' '), + ), + '- a\n - b', + ); + }); + }); + + group('MarkdownMarkupFormatter — separators', () { + // Blocks: 0 = heading, 1 = spacer, 2 = paragraph. + final doc = Markdown.fromString('# Heading\n\nA paragraph.'); + + MarkdownSelectionController one() => MarkdownSelectionController() + ..setDocuments([ + MarkdownDocumentRef(id: 'doc', model: doc), + ]); + + test('blocks within a document join with a blank line by default', () { + final c = one()..selectAll(); + expect(c.getText(const MarkdownMarkupFormatter()), + '# Heading\n\nA paragraph.'); + }); + + test('documents join with documentSeparator', () { + final c = MarkdownSelectionController() + ..setDocuments([ + MarkdownDocumentRef(id: 'a', model: Markdown.fromString('# One')), + MarkdownDocumentRef(id: 'b', model: Markdown.fromString('- x\n- y')), + ]) + ..selectAll(); + expect(c.getText(const MarkdownMarkupFormatter()), '# One\n\n- x\n- y'); + }); + + test('separators are configurable', () { + final c = one()..selectAll(); + expect( + c.getText(const MarkdownMarkupFormatter(blockSeparator: '\n')), + '# Heading\nA paragraph.', + ); + }); + }); + + group('MarkdownMarkupFormatter — partial boundary fallback', () { + test('a partially selected list falls back to plain sliced text', () { + final c = MarkdownSelectionController() + ..setDocuments([ + MarkdownDocumentRef( + id: 'doc', + model: Markdown.fromString('- one\n- two\n - nested\n- three')), + ]); + // Rendered text is 'one\ntwo\nnested\nthree'; select only 'one\ntwo'. + c.selection = const MarkdownSelection( + base: MarkdownPosition(documentId: 'doc', blockIndex: 0, offset: 0), + extent: MarkdownPosition(documentId: 'doc', blockIndex: 0, offset: 7), + ); + // No markers reconstructed — the plain slice is copied verbatim. + expect(c.getText(const MarkdownMarkupFormatter()), 'one\ntwo'); + }); + + test('fully selected boundary block still reconstructs', () { + // A selection that ends exactly at the block length is "whole". + final model = Markdown.fromString('- a\n- b'); + final c = MarkdownSelectionController() + ..setDocuments([ + MarkdownDocumentRef(id: 'doc', model: model), + ]); + final len = markdownBlockRenderedText(model.blocks.first).length; + c.selection = MarkdownSelection( + base: const MarkdownPosition( + documentId: 'doc', blockIndex: 0, offset: 0), + extent: + MarkdownPosition(documentId: 'doc', blockIndex: 0, offset: len), + ); + expect(c.getText(const MarkdownMarkupFormatter()), '- a\n- b'); + }); + }); + + group('MarkdownMarkupFormatter — wiring', () { + test('can be installed as the controller default formatter', () { + final c = MarkdownSelectionController() + ..formatter = const MarkdownMarkupFormatter() + ..setDocuments([ + MarkdownDocumentRef(id: 'doc', model: Markdown.fromString('# Hi')), + ]) + ..selectAll(); + // getText() with no argument now uses the markup formatter. + expect(c.getText(), '# Hi'); + }); + + test('differs from the default plain formatter', () { + final markup = _markup('# Heading\n\n- a\n- b'); + final plain = MarkdownSelectionController() + ..setDocuments([ + MarkdownDocumentRef( + id: 'doc', model: Markdown.fromString('# Heading\n\n- a\n- b')), + ]); + plain.selectAll(); + expect(markup, isNot(equals(plain.getText()))); + expect(markup.contains('# Heading'), isTrue); + expect(markup.contains('- a'), isTrue); + }); + + test('implements the MarkdownSelectionFormatter interface', () { + const MarkdownSelectionFormatter formatter = MarkdownMarkupFormatter(); + const content = MarkdownSelectedContent( + documents: []); + expect(formatter.format(content), isEmpty); + }); + + test('an empty selection formats to an empty string', () { + final c = MarkdownSelectionController() + ..setDocuments([ + MarkdownDocumentRef(id: 'doc', model: Markdown.fromString('# Hi')), + ]); + expect(c.getText(const MarkdownMarkupFormatter()), isEmpty); + }); + }); + + group('MarkdownMarkupFormatter — sanity of renderedRange assumptions', () { + test('TextRange is exposed on selected blocks', () { + final c = MarkdownSelectionController() + ..setDocuments([ + MarkdownDocumentRef(id: 'doc', model: Markdown.fromString('hello')), + ]) + ..selectAll(); + final seg = c.selectedContent().documents.single.blocks.single; + expect(seg.renderedRange, const TextRange(start: 0, end: 5)); + }); + }); +} diff --git a/test/unit_test.dart b/test/unit_test.dart index 3a6036e..e7529f6 100644 --- a/test/unit_test.dart +++ b/test/unit_test.dart @@ -9,6 +9,7 @@ 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 'selection/markup_formatter_test.dart' as markup_formatter_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; @@ -29,6 +30,7 @@ void main() => group('Unit', () { nodes_test.main(); theme_test.main(); selection_test.main(); + markup_formatter_test.main(); selection_widget_test.main(); selection_keyboard_test.main(); selection_handles_test.main(); From 4e917563814dcee47d2538fa0f0061d50d03e7ef Mon Sep 17 00:00:00 2001 From: Mike Matiunin Date: Tue, 4 Aug 2026 16:27:12 +0400 Subject: [PATCH 24/30] style: format two selection test files with dart format (line-length 80) CI's "Check code format" step failed on feat/text-selection because test/selection/selection_widget_test.dart and test/selection/markup_formatter_test.dart were not formatted to the 80-column limit, which short-circuited the analyzer, unit-test and example-test steps. Reflow both files with `dart format --line-length 80` (whitespace only, no logic changes) so the pipeline goes green again. Co-Authored-By: Claude Opus 4.8 (1M context) --- test/selection/markup_formatter_test.dart | 13 ++++++------- test/selection/selection_widget_test.dart | 8 ++++---- 2 files changed, 10 insertions(+), 11 deletions(-) diff --git a/test/selection/markup_formatter_test.dart b/test/selection/markup_formatter_test.dart index 314f8d4..4fa2ad9 100644 --- a/test/selection/markup_formatter_test.dart +++ b/test/selection/markup_formatter_test.dart @@ -4,7 +4,7 @@ import 'package:flutter_test/flutter_test.dart'; /// Full-block selection of a single-document source, formatted as Markdown. String _markup(String source, - [MarkdownMarkupFormatter formatter = const MarkdownMarkupFormatter()]) { + [MarkdownMarkupFormatter formatter = const MarkdownMarkupFormatter()]) { final c = MarkdownSelectionController() ..setDocuments([ MarkdownDocumentRef(id: 'doc', model: Markdown.fromString(source)), @@ -135,10 +135,9 @@ void main() { ]); final len = markdownBlockRenderedText(model.blocks.first).length; c.selection = MarkdownSelection( - base: const MarkdownPosition( - documentId: 'doc', blockIndex: 0, offset: 0), - extent: - MarkdownPosition(documentId: 'doc', blockIndex: 0, offset: len), + base: + const MarkdownPosition(documentId: 'doc', blockIndex: 0, offset: 0), + extent: MarkdownPosition(documentId: 'doc', blockIndex: 0, offset: len), ); expect(c.getText(const MarkdownMarkupFormatter()), '- a\n- b'); }); @@ -171,8 +170,8 @@ void main() { test('implements the MarkdownSelectionFormatter interface', () { const MarkdownSelectionFormatter formatter = MarkdownMarkupFormatter(); - const content = MarkdownSelectedContent( - documents: []); + const content = + MarkdownSelectedContent(documents: []); expect(formatter.format(content), isEmpty); }); diff --git a/test/selection/selection_widget_test.dart b/test/selection/selection_widget_test.dart index e14d939..197e16b 100644 --- a/test/selection/selection_widget_test.dart +++ b/test/selection/selection_widget_test.dart @@ -516,8 +516,8 @@ void main() { addTearDown(() => tester.binding.defaultBinaryMessenger .setMockMethodCallHandler(SystemChannels.platform, null)); - await tester.pumpWidget(_wrap( - controller, const SizedBox(width: 400, child: _Doc('d')))); + await tester.pumpWidget( + _wrap(controller, const SizedBox(width: 400, child: _Doc('d')))); await tester.pumpAndSettle(); final state = tester.state( @@ -551,8 +551,8 @@ void main() { extent: MarkdownPosition(documentId: 'd', blockIndex: 0, offset: 7), ); - await tester.pumpWidget(_wrap( - controller, const SizedBox(width: 400, child: _Doc('d')))); + await tester.pumpWidget( + _wrap(controller, const SizedBox(width: 400, child: _Doc('d')))); await tester.pumpAndSettle(); expect(controller.getText(), 'One two'); From 2d32dfa4437c220d5216a6389453ef324d7f197a Mon Sep 17 00:00:00 2001 From: Mike Matiunin Date: Tue, 4 Aug 2026 16:30:23 +0400 Subject: [PATCH 25/30] test: drop deprecated ListView.cacheExtent from disposal test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI runs on a newer Flutter stable where ListView.cacheExtent was renamed to scrollCacheExtent (deprecated after v3.41.0-0.0.pre), so `dart analyze --fatal-infos` failed on the one remaining reference. The replacement property does not exist on the package's supported floor (flutter: >=3.29.0), so renaming would break older SDKs. Remove the `cacheExtent: 0` instead: after jumping the ListView to offset 560, item m0 (px 0-80) sits ~480px from the viewport — well beyond the default 250px cache extent — so it is still disposed and the `findsNothing` assertion that the test relies on continues to hold. Co-Authored-By: Claude Opus 4.8 (1M context) --- test/selection/selection_widget_test.dart | 1 - 1 file changed, 1 deletion(-) diff --git a/test/selection/selection_widget_test.dart b/test/selection/selection_widget_test.dart index 197e16b..f23d061 100644 --- a/test/selection/selection_widget_test.dart +++ b/test/selection/selection_widget_test.dart @@ -115,7 +115,6 @@ void main() { height: 200, child: ListView.builder( controller: scroll, - cacheExtent: 0, itemCount: 10, itemBuilder: (_, i) => SizedBox( height: 80, From 198d2f9b323397a7eea49ab9ce49fc03211db211 Mon Sep 17 00:00:00 2001 From: Mike Matiunin Date: Wed, 5 Aug 2026 16:15:47 +0400 Subject: [PATCH 26/30] Compress pub size --- .pubignore | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/.pubignore b/.pubignore index 2a73998..c29f738 100644 --- a/.pubignore +++ b/.pubignore @@ -7,6 +7,12 @@ build/ coverage/ credentials.json *.exe +benchmark/ benchmark_compare/ -benchmark/experiments/ -example/lib/experiments/ \ No newline at end of file +example/lib/experiments/ +example/android/ +example/ios/ +example/windows/ +example/macos/ +example/linux/ +docs/ \ No newline at end of file From 95d742f73a7b9e2fad160df486c464542fd32fb0 Mon Sep 17 00:00:00 2001 From: Mike Matiunin Date: Wed, 5 Aug 2026 16:55:32 +0400 Subject: [PATCH 27/30] feat: add StreamingMarkdownParser for incremental Markdown parsing and related benchmarks --- CHANGELOG.md | 11 + README.md | 53 ++++- benchmark/streaming_benchmark.dart | 138 +++++++++++ example/lib/tabs/chat_tab.dart | 43 +++- lib/src/parser.dart | 229 ++++++++++++++++++ test/parser/streaming_test.dart | 359 +++++++++++++++++++++++++++++ test/unit_test.dart | 2 + 7 files changed, 821 insertions(+), 14 deletions(-) create mode 100644 benchmark/streaming_benchmark.dart create mode 100644 test/parser/streaming_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 9581bfd..fd259c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,17 @@ `MarkdownReconciliationPolicy`, `MarkdownSelectionSurface`, `markdownBlockRenderedText`, and `SelectableBlockPainter` / `SelectableTextBlock`. +- **ADDED**: `StreamingMarkdownParser`, an incremental parser for streaming + sources such as LLM token output. It freezes completed blocks (a block ends at + a blank line, outside any open code fence) so only the still-growing tail is + re-parsed as tokens arrive — turning the `O(N²)` cost of re-parsing the whole + buffer on every token into roughly `O(tail)` (3–14× faster on a full message + stream in `benchmark/streaming_benchmark.dart`). `parser.add(chunk)` returns + the growing `Markdown`, always identical block-for-block to + `Markdown.fromString(everythingSoFar)`, and a `Stream.toMarkdown()` + extension wires it into a stream transform. Pass a configured `MarkdownDecoder` + (e.g. `inlineMath: true`) to match `Markdown.fromString`. The batch + `MarkdownDecoder` hot path is byte-for-byte unchanged. - **ADDED**: `MarkdownMarkupFormatter`, a built-in "Copy as Markdown" formatter. Pass it to `getText()` (or set `controller.formatter`) to reconstruct Markdown structure on copy — heading `#`s, nested list markers with task checkboxes, diff --git a/README.md b/README.md index 4546057..85fa299 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,8 @@ A high-performance, lightweight Markdown parser and renderer specifically design - **🔗 Interactive Elements**: Clickable links with customizable tap handlers - **✂️ Text Selection**: Cross-block and cross-widget (chat) selection via a controller that survives list disposal and streaming updates +- **🌊 Streaming Parser**: `StreamingMarkdownParser` parses LLM token output + incrementally — completed blocks are frozen, only the live tail re-parses - **🌐 Cross Platform**: Works on all Flutter-supported platforms - **📝 GitHub Flavored**: Alerts (`> [!NOTE]`), task lists (`- [x]`), tables with column alignment, thematic breaks, strikethrough, and more @@ -171,10 +173,9 @@ Any of `---`, `***`, or `___` (optionally spaced, e.g. `- - -`) produce a rule: ```markdown --- +--- -*** - -___ +--- ``` ## 🚀 Quick Start @@ -291,6 +292,52 @@ MarkdownSelectionScope( See the runnable **Selection** and **Chat** tabs in `example/`. +## 🌊 Streaming (LLM output) + +LLM replies arrive token by token. Re-parsing the whole accumulated buffer on +every token is `O(N²)` and janks long messages. `StreamingMarkdownParser` keeps +the accumulated source and only re-parses the still-growing **tail**: once a +block is provably complete (terminated by a blank line, and not inside an open +code fence) it is _frozen_ and never parsed again. + +```dart +final parser = StreamingMarkdownParser(); + +llmTokenStream.listen((token) { + final Markdown md = parser.add(token); // cheap, incremental + setState(() => _message = md); +}); +``` + +Or transform a `Stream` directly — each event emits the grown document: + +```dart +llmTokenStream + .toMarkdown() // Stream → Stream + .listen((md) => setState(() => _message = md)); +``` + +- **Exact, never approximate.** The result of `add()` / `current` is always + identical, block for block, to `Markdown.fromString(everythingReceivedSoFar)`. + Blocks whose type still depends on input that hasn't arrived — an unterminated + code fence, a table header still missing its delimiter row, a list that may + continue — stay in the live tail and are re-evaluated, so they never freeze + into the wrong shape. +- **Same options as batch.** Pass a configured decoder to match + `Markdown.fromString`: + `StreamingMarkdownParser(decoder: const MarkdownDecoder(inlineMath: true))` + (or `.toMarkdown(decoder: ...)`). +- **Fast.** Replaying a whole message token-by-token is **3–14× faster** than + the full-reparse approach, and the gap grows with message length + (`benchmark/streaming_benchmark.dart`). +- **Reusable.** `parser.reset()` clears state for the next message; + `parser.source` is the raw text accumulated so far, `parser.current` the + parsed model without adding anything. + +Pair it with selection: feed each grown model to `controller.putDocument(id, md)` +and the active selection stays anchored as the message streams in (see the +**Chat** tab in `example/`). + ## 🎨 Customization ### Theme Configuration diff --git a/benchmark/streaming_benchmark.dart b/benchmark/streaming_benchmark.dart new file mode 100644 index 0000000..f2a3625 --- /dev/null +++ b/benchmark/streaming_benchmark.dart @@ -0,0 +1,138 @@ +// ignore_for_file: avoid_print + +import 'package:benchmark_harness/benchmark_harness.dart'; +import 'package:flutter_md/src/markdown.dart' show Markdown; +import 'package:flutter_md/src/parser.dart' show StreamingMarkdownParser; + +import 'scenarios.dart'; + +/// Streaming benchmark: replays a document token-by-token and compares the two +/// ways of keeping a parsed [Markdown] up to date on every token. +/// +/// - **full** — what a naive chat UI does today: re-parse the *entire* +/// accumulated buffer on every token (`Markdown.fromString(buffer)`), which +/// is `O(N²)` over a stream. +/// - **stream** — feed each token to a [StreamingMarkdownParser], which freezes +/// completed blocks and only re-parses the live tail. +/// +/// Each `measure()` replays the whole stream once, so the reported µs is the +/// cost of rendering one full message start-to-finish. The batch decoder is +/// byte-identical to `master` (this file adds no code to the hot path), so the +/// separate `parser_benchmark.dart` numbers are the regression guard; this file +/// shows the incremental win. +/// +/// Run: +/// ```shell +/// dart run benchmark/streaming_benchmark.dart +/// # or, for stable numbers: +/// dart compile exe benchmark/streaming_benchmark.dart -o /tmp/sb && /tmp/sb +/// ``` +void main() { + final docs = { + 'prose': scenarios['prose']!, + 'lists': scenarios['lists']!, + 'table': scenarios['table']!, + 'code': scenarios['code']!, + 'quotes': scenarios['quotes']!, + 'mixed': scenarios['mixed']!, + 'big-chat': _bigChat(80), + }; + + print('Replaying each document token-by-token (one full stream per op).'); + print(''); + print('scenario tokens chars full ms stream ms speedup'); + print('--------- ------ ------ -------- ---------- --------'); + for (final entry in docs.entries) { + final doc = entry.value; + final tokens = _tokenize(doc); + final full = _FullReparse(tokens).measure(); // us per full replay + final stream = _Streaming(tokens).measure(); // us per full replay + final speedup = full / stream; + print('${entry.key.padRight(9)} ' + '${tokens.length.toString().padLeft(6)} ' + '${doc.length.toString().padLeft(6)} ' + '${(full / 1000).toStringAsFixed(2).padLeft(8)} ' + '${(stream / 1000).toStringAsFixed(2).padLeft(10)} ' + '${'${speedup.toStringAsFixed(2)}x'.padLeft(8)}'); + } + print(''); + print('Equivalence is proven in test/parser/streaming_test.dart — the stream ' + 'result matches Markdown.fromString at every prefix.'); +} + +/// Splits [doc] into word-ish tokens that reconstruct it exactly, approximating +/// how an LLM streams sub-word/word tokens (whitespace rides along). +List _tokenize(String doc) { + final parts = doc.split(' '); + return [ + for (var i = 0; i < parts.length; i++) i == 0 ? parts[i] : ' ${parts[i]}', + ]; +} + +/// Re-parses the whole accumulated buffer on every token (the `O(N²)` path). +class _FullReparse extends BenchmarkBase { + _FullReparse(this.tokens) : super('full'); + + final List tokens; + Markdown? _result; + + @override + void run() { + final buffer = StringBuffer(); + for (final token in tokens) { + buffer.write(token); + _result = Markdown.fromString(buffer.toString()); + } + } + + @override + void teardown() { + super.teardown(); + if (_result == null) throw StateError('result is null'); + } +} + +/// Feeds each token to a [StreamingMarkdownParser] (the incremental path). +class _Streaming extends BenchmarkBase { + _Streaming(this.tokens) : super('stream'); + + final List tokens; + Markdown? _result; + + @override + void run() { + final parser = StreamingMarkdownParser(); + for (final token in tokens) { + _result = parser.add(token); + } + } + + @override + void teardown() { + super.teardown(); + if (_result == null) throw StateError('result is null'); + } +} + +/// Builds a long, blank-separated chat message with [blocks] varied blocks, so +/// the incremental parser has plenty of completed blocks to freeze. +String _bigChat(int blocks) { + final buffer = StringBuffer(); + for (var i = 0; i < blocks; i++) { + switch (i % 5) { + case 0: + buffer.write('## Section $i\n\n'); + case 1: + buffer.write('A paragraph with **bold**, _italic_ and `code` number ' + '$i to give the inline parser some real work to do.\n\n'); + case 2: + buffer.write('- item ${i}a\n- item ${i}b\n- item ${i}c\n\n'); + case 3: + buffer.write('| Col | Val |\n| --- | --: |\n' + '| a | $i |\n| b | ${i + 1} |\n\n'); + case 4: + buffer.write('```dart\nfinal x$i = $i;\nprint(x$i);\n```\n\n'); + } + } + return buffer.toString(); +} diff --git a/example/lib/tabs/chat_tab.dart b/example/lib/tabs/chat_tab.dart index d122152..b7a99f1 100644 --- a/example/lib/tabs/chat_tab.dart +++ b/example/lib/tabs/chat_tab.dart @@ -13,8 +13,10 @@ import 'package:flutter_md/flutter_md.dart'; /// The conversation is intentionally long and varied — headings, tables, code, /// nested and task lists, block quotes, GitHub alerts and inline math — to /// exercise selection across every block type. The "Stream" button appends a -/// new assistant reply and grows it token-by-token to show that streaming -/// updates keep any active selection anchored (content-based reconciliation). +/// new assistant reply and grows it token-by-token via a +/// [StreamingMarkdownParser], so completed blocks are parsed once and only the +/// live tail is re-parsed as tokens arrive — while any active selection stays +/// anchored (content-based reconciliation). class ChatTab extends StatefulWidget { /// Creates the chat demo tab. const ChatTab({super.key}); @@ -31,7 +33,11 @@ class _ChatTabState extends State { Timer? _streamTimer; List _streamTokens = const []; int _streamCursor = 0; - String _streamBuffer = ''; + + /// Incremental parser for the reply currently streaming in. Reused (via + /// [StreamingMarkdownParser.reset]) for each new streamed message so closed + /// blocks are never re-parsed. + final StreamingMarkdownParser _streamParser = StreamingMarkdownParser(); bool get _isStreaming => _streamTimer != null; @@ -80,7 +86,7 @@ class _ChatTabState extends State { final id = 'stream-${DateTime.now().microsecondsSinceEpoch}'; _streamTokens = _streamAnswer.split(' '); _streamCursor = 0; - _streamBuffer = ''; + _streamParser.reset(); setState(() => _messages.add(_Msg(id, false, const Markdown.empty()))); _controller.putDocument(id, const Markdown.empty(), order: _messages.length - 1); @@ -95,8 +101,10 @@ class _ChatTabState extends State { return; } final token = _streamTokens[_streamCursor++]; - _streamBuffer = _streamBuffer.isEmpty ? token : '$_streamBuffer $token'; - final grown = Markdown.fromString(_streamBuffer); + // Feed only the newly-arrived delta; the parser reuses the already-parsed + // prefix and re-parses just the live tail — no full re-parse per token. + final delta = _streamParser.source.isEmpty ? token : ' $token'; + final grown = _streamParser.add(delta); final idx = _messages.indexWhere((m) => m.id == id); if (idx < 0) { _stopStream(); @@ -365,11 +373,24 @@ class _Msg { } /// The answer streamed in token-by-token when the "Stream" button is pressed. -const String _streamAnswer = - 'Absolutely — here is a streamed reply. Because the selection is anchored ' - 'on the **immutable model**, it stays put while these words arrive one ' - 'at a time, and the parser re-runs on every token. Try selecting an ' - 'earlier message first, then press Stream and watch the highlight hold.'; +/// +/// It is deliberately multi-block (blank-line separated) so the +/// [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' + '\n' + 'Because the selection is anchored on the **immutable model**, it stays ' + 'put while these words arrive one at a time.\n' + '\n' + 'And this reply is parsed **incrementally**:\n' + '\n' + '- completed blocks are parsed once, then frozen\n' + '- only the live tail re-parses on each token\n' + '- so long messages stay cheap to grow\n' + '\n' + 'Try selecting an earlier message first, then press Stream and watch the ' + 'highlight hold while these blocks stream in.'; List<_Msg> _seed() { final data = <(bool, String)>[ diff --git a/lib/src/parser.dart b/lib/src/parser.dart index 09851e7..129d44e 100644 --- a/lib/src/parser.dart +++ b/lib/src/parser.dart @@ -1148,3 +1148,232 @@ List _parseInlineSpans(String text, {Map? math}) { // For now, it returns an empty list as a placeholder. return spans; } + +// ============================================================================= +// Streaming (incremental) parser +// ============================================================================= + +/// {@template streaming_markdown_parser} +/// Incremental Markdown parser for streaming sources such as LLM token output. +/// +/// A plain [MarkdownDecoder] re-parses the whole document on every call, so +/// feeding it an `N`-line message one token at a time costs `O(N²)`. This +/// parser keeps the accumulated source and only re-parses the still-growing +/// *tail* of the document: once a run of blocks is provably complete — +/// terminated by a blank line and not sitting inside an open code fence — it is +/// *frozen* and never parsed again. +/// +/// The result of [add] / [current] is always identical, block for block, to +/// `Markdown.fromString(everythingAddedSoFar)` (using the same [decoder]) — the +/// incremental path is a pure performance optimization, never a behavioral one. +/// Blocks whose type still depends on input that has not arrived yet — an +/// unterminated code fence, a table header still missing its delimiter row, a +/// list or block quote that might continue — deliberately stay in the live tail +/// and are re-evaluated on every [add], so they never freeze into the wrong +/// shape. +/// +/// ```dart +/// final parser = StreamingMarkdownParser(); +/// llmTokenStream.listen((token) { +/// final markdown = parser.add(token); // cheap, incremental +/// setState(() => _message = markdown); +/// }); +/// ``` +/// +/// For a [Stream] of chunks, prefer the [MarkdownStreamParsing.toMarkdown] +/// extension, which wires an instance of this class into a transform. +/// +/// The parser is stateful and single-conversation: call [reset] to reuse the +/// instance for a new document. +/// {@endtemplate} +class StreamingMarkdownParser { + /// Creates a streaming parser. + /// + /// [decoder] performs the actual parsing of each tail; pass a configured + /// [MarkdownDecoder] (e.g. `MarkdownDecoder(inlineMath: true)`) to match the + /// options you would use with [Markdown.fromString]. Defaults to a plain + /// [MarkdownDecoder]. + /// {@macro streaming_markdown_parser} + StreamingMarkdownParser({MarkdownDecoder decoder = const MarkdownDecoder()}) + : _decoder = decoder; + + /// The decoder used to (re)parse frozen prefixes and the live tail. + final MarkdownDecoder _decoder; + + /// Source of the frozen prefix (`== ` everything up to [_stableOffset]). Kept + /// as a materialized string so [current] can expose the full source without + /// re-joining lines; only grows when a prefix is frozen. + String _frozen = ''; + + /// The still-mutable remainder of the source (everything after the frozen + /// prefix). Re-parsed on every [add]. + String _tail = ''; + + /// Blocks parsed from [_frozen]. Never re-parsed once appended. + final List _stableBlocks = []; + + /// Blocks parsed from the current [_tail]. Rebuilt on every [add]. + List _tailBlocks = const []; + + /// The full Markdown source accumulated so far. + String get source => _tail.isEmpty ? _frozen : '$_frozen$_tail'; + + /// Number of blocks already frozen (exposed for diagnostics / benchmarks). + int get stableBlockCount => _stableBlocks.length; + + /// Appends a chunk of streamed Markdown text and returns the updated + /// [Markdown]. The chunk may end mid-line; the partial line is buffered and + /// completed by later chunks. + Markdown add(String chunk) { + if (chunk.isNotEmpty) { + _tail = _tail.isEmpty ? chunk : '$_tail$chunk'; + _freezeCompletedPrefix(); + _tailBlocks = _tail.isEmpty + ? const [] + : _decoder.convert(_tail).blocks; + } + return current; + } + + /// The current parsed [Markdown] (frozen prefix + live tail) without adding + /// anything. Equivalent, block for block, to `Markdown.fromString(source)`. + Markdown get current => _build(); + + /// Freezes any newly-completed leading blocks so they are never re-parsed. + /// + /// Finds the largest safe boundary in [_tail] (via [_safeCutOffset]), + /// converts the prefix up to it once, moves it into [_frozen] / + /// [_stableBlocks], and leaves the remainder as the live tail. + void _freezeCompletedPrefix() { + final cut = _safeCutOffset(_tail); + if (cut <= 0) return; + final prefix = _tail.substring(0, cut); + // Safe by construction: [cut] sits at a hard block boundary outside any + // open fence, so converting the prefix in isolation yields exactly the + // blocks it would contribute to a full-document parse. + _stableBlocks.addAll(_decoder.convert(prefix).blocks); + _frozen = _frozen.isEmpty ? prefix : '$_frozen$prefix'; + _tail = _tail.substring(cut); + } + + /// Resets the parser to its initial empty state so the instance can be reused + /// for a new document. + void reset() { + _frozen = ''; + _tail = ''; + _stableBlocks.clear(); + _tailBlocks = const []; + } + + /// Builds the combined [Markdown] view (frozen prefix + live tail). + Markdown _build() { + final src = source; + if (src.isEmpty) return const Markdown.empty(); + final blocks = [..._stableBlocks, ..._tailBlocks]; + return Markdown( + markdown: src, + blocks: List.unmodifiable(blocks), + ); + } +} + +/// Returns the offset within [tail] at which the still-mutable remainder of the +/// document begins: everything before it forms complete blocks that no future +/// input can change and may be frozen. Returns `0` when nothing new can be +/// frozen yet. +/// +/// A cut is placed at the start of a **complete, non-blank line whose preceding +/// complete line is blank**, provided that position is not inside an open code +/// fence. Because this block grammar has no lazy continuation across a blank +/// line (a blank line terminates paragraphs, quotes, lists and tables, and only +/// an open code fence swallows blanks), such a position is a hard boundary: +/// parsing the prefix and the suffix separately yields exactly the same blocks +/// as parsing the whole. The last such boundary in [tail] is returned so as +/// much as possible is frozen. +int _safeCutOffset(String tail) { + final len = tail.length; + var cut = 0; + var inFence = false; + var fence = 0; // active fence marker code unit (0x60 ``` / 0x7E ~~~), 0 = none + var prevBlank = false; // the previous *complete* line was blank + var havePrev = false; // there is a previous complete line + var start = 0; + + for (var i = 0; i <= len; i++) { + // A line ends at each '\n' and at end-of-string. The final segment + // (i == len) is the still-growing pending line and is never itself frozen. + if (i != len && tail.codeUnitAt(i) != 0x0A /* \n */) continue; + final complete = i < len; + + // Evaluate a cut at this line's start, using the fence/blank state that + // holds *before* this line is folded in. + if (havePrev && + prevBlank && + !inFence && + !_segIsBlank(tail, start, i)) { + cut = start; + } + + if (complete) { + final marker = _fenceMarker(tail, start, i); + if (!inFence) { + if (marker != 0) { + inFence = true; + fence = marker; + } + } else if (marker == fence) { + inFence = false; + fence = 0; + } + prevBlank = _segIsBlank(tail, start, i); + havePrev = true; + start = i + 1; + } + } + return cut; +} + +/// Whether the line segment `s[start..end)` is blank — only spaces, tabs, or a +/// trailing carriage return (so CRLF blank lines are recognized). Mirrors +/// [MarkdownDecoder]'s blank-line rule for the streaming boundary scan. +bool _segIsBlank(String s, int start, int end) { + for (var k = start; k < end; k++) { + final c = s.codeUnitAt(k); + if (c != 0x20 /* space */ && c != 0x09 /* tab */ && c != 0x0D /* CR */) { + return false; + } + } + return true; +} + +/// Returns the fence marker code unit (0x60 for ```` ``` ````, 0x7E for `~~~`) +/// when `s[start..end)` opens or closes a fenced code block, else `0`. Matches +/// the `startsWith('```') || startsWith('~~~')` test in [MarkdownDecoder]. +int _fenceMarker(String s, int start, int end) { + if (end - start < 3) return 0; + final c = s.codeUnitAt(start); + if (c != 0x60 /* ` */ && c != 0x7E /* ~ */) return 0; + return (s.codeUnitAt(start + 1) == c && s.codeUnitAt(start + 2) == c) ? c : 0; +} + +/// Incremental Markdown parsing for a stream of text chunks. +extension MarkdownStreamParsing on Stream { + /// Parses this stream of Markdown text chunks incrementally, emitting the + /// growing [Markdown] after each chunk. + /// + /// A single [StreamingMarkdownParser] is threaded through the stream, so + /// closed blocks are parsed once and only the live tail is re-parsed as + /// tokens arrive. Pass a configured [decoder] (e.g. for `inlineMath`) to + /// match [Markdown.fromString]. The last emitted value equals + /// `Markdown.fromString(concatenationOfAllChunks)`. + /// + /// ```dart + /// llmTokenStream.toMarkdown().listen((md) => setState(() => _message = md)); + /// ``` + Stream toMarkdown({ + MarkdownDecoder decoder = const MarkdownDecoder(), + }) { + final parser = StreamingMarkdownParser(decoder: decoder); + return map(parser.add); + } +} diff --git a/test/parser/streaming_test.dart b/test/parser/streaming_test.dart new file mode 100644 index 0000000..64797ac --- /dev/null +++ b/test/parser/streaming_test.dart @@ -0,0 +1,359 @@ +import 'dart:math'; + +import 'package:flutter_md/flutter_md.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// Tests for [StreamingMarkdownParser]. +/// +/// The governing invariant is simple and strong: for *any* split of a document +/// into chunks, after feeding a prefix of those chunks the parser's [current] +/// must be identical — block for block, span for span, offsets and all — to +/// `Markdown.fromString(thatPrefix)`. The incremental path is only ever an +/// optimization, so it must never diverge from the batch decoder at any step. +/// +/// Most cases therefore run a document through many chunkings (whole, per-line, +/// per-word, fixed sizes, char-by-char, and seeded-random) and assert the +/// invariant after *every* chunk. That turns a handful of documents into +/// thousands of prefix comparisons and exercises every mid-construct boundary +/// (a fence opened but not closed, a table header before its delimiter row, a +/// blank run split across chunks, a surrogate pair split down the middle, …). +void main() => group('StreamingMarkdownParser', () { + // --------------------------------------------------------------------- + // The corpus: each entry is a document that exercises a specific corner. + // --------------------------------------------------------------------- + const corpus = { + 'empty': '', + 'blank-only': '\n\n\n', + 'spaces-only': ' \n \t \n', + 'single-paragraph': 'Just a single line of prose.', + 'paragraph-no-newline': 'No trailing newline here', + 'two-paragraphs': 'First paragraph.\n\nSecond paragraph.', + 'paragraphs-trailing-blank': 'P1\n\nP2\n\nP3\n\n', + 'wide-blank-run': 'A\n\n\n\n\nB', + 'leading-blanks': '\n\n\nAfter some blanks.', + 'soft-wrapped-paragraph': 'Line one\nline two\nline three.', + 'headings': '# H1\n\n## H2\n\n### H3 ###\n\ntext after', + 'heading-then-para': '# Title\nA paragraph right after.', + 'not-a-heading': '#hashtag is not a heading\n\n####### seven hashes', + 'thematic-breaks': 'a\n\n---\n\nb\n\n***\n\nc\n\n___\n\nd', + 'divider-dashes': 'text\n---\nmore', + 'quote': '> quote line one\n> quote line two\n\nafter', + '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.', + 'code-closed': '```dart\nvoid main() {}\n```\n\nafter code', + 'code-tilde': '~~~\nplain\n~~~\n\nafter', + 'code-unclosed': '```dart\nline 1\nline 2\nstill going', + 'code-blank-inside': '```\n\n\ncode after blanks\n\n```\n\nafter', + 'code-then-code': '```\na\n```\n\n```\nb\n```\n\ntail', + 'code-fence-lookalike': '```\nnot ``` a real close\n```\n\nx', + 'list-unordered': '- one\n- two\n- three\n\nafter', + 'list-ordered': '1. one\n2. two\n3. three', + 'list-nested': '- a\n - a1\n - a2\n- b\n\nafter', + 'list-tasks': '- [x] done\n- [ ] todo\n- [X] also done', + 'list-then-para': '- item\n\nnot a list anymore', + 'table-full': '| A | B |\n| - | - |\n| 1 | 2 |\n| 3 | 4 |\n\nafter', + 'table-aligned': '| L | C | R |\n| :- | :-: | -: |\n| a | b | c |', + 'table-header-first': '| Col1 | Col2 |\n| ---- | ---- |\n| v1 | v2 |', + 'table-malformed': '| looks | like |\nbut no delimiter row', + 'pipes-not-table': 'a | b | c is just prose with pipes', + 'inline-styles': + 'Some **bold**, _italic_, `code`, ~~strike~~ and ==mark==.', + 'emoji-and-unicode': '# Welcome 👋\n\nUnicode: café, naïve, 日本語, 🚀.', + 'crlf-doc': 'para one\r\n\r\n## Heading\r\n\r\npara two\r\n', + 'crlf-code': '```\r\ncode\r\n```\r\n\r\nafter\r\n', + 'mixed': _mixed, + }; + + // Chunkings that all reconstruct the original exactly. + List whole(String s) => [if (s.isNotEmpty) s]; + + List chars(String s) => + [for (var i = 0; i < s.length; i++) s[i]]; + + List byWord(String s) { + final parts = s.split(' '); + return [ + for (var i = 0; i < parts.length; i++) + i == 0 ? parts[i] : ' ${parts[i]}', + ]; + } + + List byLine(String s) { + final parts = s.split('\n'); + return [ + for (var i = 0; i < parts.length; i++) + i < parts.length - 1 ? '${parts[i]}\n' : parts[i], + ]; + } + + List bySize(String s, int n) => [ + for (var i = 0; i < s.length; i += n) + s.substring(i, min(i + n, s.length)), + ]; + + List byRandom(String s, int seed) { + final rng = Random(seed); + final out = []; + var i = 0; + while (i < s.length) { + final take = 1 + rng.nextInt(7); + out.add(s.substring(i, min(i + take, s.length))); + i += take; + } + return out; + } + + /// Feeds [chunks] into a fresh parser and asserts the invariant after + /// every chunk against the batch decoder. Returns the final parser so + /// callers can make extra assertions (e.g. on [stableBlockCount]). + StreamingMarkdownParser feed( + List chunks, { + bool inlineMath = false, + String reason = '', + }) { + // The chunking must reconstruct the source, or the test is meaningless. + final doc = chunks.join(); + final decoder = MarkdownDecoder(inlineMath: inlineMath); + final parser = StreamingMarkdownParser(decoder: decoder); + final acc = StringBuffer(); + for (var i = 0; i < chunks.length; i++) { + parser.add(chunks[i]); + acc.write(chunks[i]); + final expected = decoder.convert(acc.toString()); + expect( + _sig(parser.current), + _sig(expected), + reason: '$reason after chunk ${i + 1}/${chunks.length}', + ); + expect(parser.source, acc.toString(), reason: '$reason source'); + } + // Final result matches a single-shot parse of the whole document. + expect(_sig(parser.current), _sig(decoder.convert(doc)), + reason: '$reason final'); + return parser; + } + + // --------------------------------------------------------------------- + // Equivalence across the whole corpus and every chunking. + // --------------------------------------------------------------------- + corpus.forEach((name, doc) { + group(name, () { + test('whole', () => feed(whole(doc), reason: name)); + test('by-line', () => feed(byLine(doc), reason: name)); + test('by-word', () => feed(byWord(doc), reason: name)); + test('char-by-char', () => feed(chars(doc), reason: name)); + for (final n in const [2, 3, 5, 7, 13]) { + test('by-size-$n', () => feed(bySize(doc, n), reason: name)); + } + for (final seed in const [1, 42, 1337]) { + test('random-$seed', () => feed(byRandom(doc, seed), reason: name)); + } + }); + }); + + // --------------------------------------------------------------------- + // Inline math must flow through the injected decoder. + // --------------------------------------------------------------------- + group('inlineMath decoder', () { + const doc = r'Euler: $e^{i\pi} + 1 = 0$.' + '\n\n' + r'Roots: $x = \frac{-b \pm \sqrt{b^2-4ac}}{2a}$ and $H_2O$.'; + test('char-by-char matches batch(inlineMath)', () { + feed(chars(doc), inlineMath: true, reason: 'math'); + }); + test(r'literal $ preserved without inlineMath', () { + feed(chars(r'Price is $5 and $HOME is a var.'), reason: 'no-math'); + }); + }); + + // --------------------------------------------------------------------- + // Targeted: freezing behaviour (the whole point of the optimization). + // --------------------------------------------------------------------- + group('freezing', () { + test('completed blank-separated blocks are frozen', () { + final p = StreamingMarkdownParser(); + for (final t in const ['P1\n\n', 'P2\n\n', 'P3\n\n']) { + p.add(t); + } + // Nothing after the last blank run yet — the trailing spacer stays + // live because more blanks could still arrive. + final beforeTail = p.stableBlockCount; + p.add('P4'); // real content proves the previous run is complete + expect(p.stableBlockCount, greaterThan(beforeTail)); + expect(p.stableBlockCount, greaterThanOrEqualTo(6), + reason: 'P1,Spacer,P2,Spacer,P3,Spacer should be frozen'); + expect(_sig(p.current), + _sig(Markdown.fromString('P1\n\nP2\n\nP3\n\nP4'))); + }); + + test('an open code fence never freezes', () { + final p = StreamingMarkdownParser(); + p.add('```dart\n'); + p.add('line 1\n'); + p.add('line 2\n'); + p.add('\n'); // a blank line *inside* the fence must not freeze + p.add('line 3\n'); + expect(p.stableBlockCount, 0, + reason: 'blocks are unstable while the fence is open'); + // Closing the fence and starting a new block freezes the code. + p.add('```\n\nAfter.'); + expect(p.stableBlockCount, greaterThan(0)); + expect( + _sig(p.current), + _sig(Markdown.fromString( + '```dart\nline 1\nline 2\n\nline 3\n```\n\nAfter.')), + ); + }); + + test('a table header does not freeze before its delimiter row', () { + // The PR-breaking case: the header line looks like a malformed table + // (→ paragraph) until the delimiter row arrives. It must never freeze + // as a paragraph, or the table could never form. + final p = StreamingMarkdownParser(); + p.add('| A | B |\n'); + expect(p.stableBlockCount, 0); + p.add('| - | - |\n'); + expect(p.stableBlockCount, 0); + p.add('| 1 | 2 |\n'); + expect(p.stableBlockCount, 0); + expect(_sig(p.current), + _sig(Markdown.fromString('| A | B |\n| - | - |\n| 1 | 2 |\n'))); + final blocks = p.current.blocks; + expect(blocks.length, 1); + expect(blocks.single.type, 'table'); + }); + }); + + // --------------------------------------------------------------------- + // API surface: reset, empty adds, current/source, stream extension. + // --------------------------------------------------------------------- + group('api', () { + test('starts empty', () { + final p = StreamingMarkdownParser(); + expect(p.current.isEmpty, isTrue); + expect(p.source, isEmpty); + expect(p.stableBlockCount, 0); + }); + + test('empty chunks are no-ops', () { + final p = StreamingMarkdownParser(); + expect(p.add('').isEmpty, isTrue); + p.add('# Hi'); + final before = _sig(p.current); + expect(_sig(p.add('')), before); + expect(p.source, '# Hi'); + }); + + test('reset reuses the instance', () { + final p = StreamingMarkdownParser(); + p.add('# First doc\n\nbody\n\nmore'); + p.reset(); + expect(p.current.isEmpty, isTrue); + expect(p.source, isEmpty); + expect(p.stableBlockCount, 0); + p.add('# Second doc'); + expect(_sig(p.current), _sig(Markdown.fromString('# Second doc'))); + }); + + test('add returns the same as current', () { + final p = StreamingMarkdownParser(); + final returned = p.add('# Hello\n\nworld'); + expect(_sig(returned), _sig(p.current)); + }); + + test('Stream.toMarkdown emits growing, batch-equivalent results', + () async { + const doc = 'para one\n\n## Heading\n\n- a\n- b\n\ndone'; + final chunks = [ + for (var i = 0; i < doc.length; i += 4) + doc.substring(i, min(i + 4, doc.length)), + ]; + final results = + await Stream.fromIterable(chunks).toMarkdown().toList(); + expect(results, isNotEmpty); + expect(_sig(results.last), _sig(Markdown.fromString(doc))); + // Every intermediate emission matches the batch parse of its prefix. + final acc = StringBuffer(); + for (var i = 0; i < chunks.length; i++) { + acc.write(chunks[i]); + expect( + _sig(results[i]), _sig(Markdown.fromString(acc.toString()))); + } + }); + + test('Stream.toMarkdown threads inlineMath through', () async { + const doc = r'$\alpha$ and $x^2$'; + final results = await Stream.value(doc) + .toMarkdown(decoder: const MarkdownDecoder(inlineMath: true)) + .toList(); + expect(_sig(results.last), + _sig(Markdown.fromString(doc, inlineMath: true))); + }); + }); + }); + +/// A structural signature of a [Markdown] value: source + a deep dump of every +/// block (type, level/marker/checked/alignment, inline spans with their exact +/// offsets and styles). Two [Markdown]s with equal signatures are equivalent +/// for every observable purpose, which node identity equality cannot express. +String _sig(Markdown md) { + final b = StringBuffer()..writeln('SRC<<${md.markdown}>>'); + for (final block in md.blocks) { + b.writeln(_blockSig(block)); + } + return b.toString(); +} + +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)}', + code: (c) => 'C[${c.language}]<<${c.text}>>', + list: (l) => 'L|${l.items.map(_itemSig).join(';')}', + divider: (_) => 'DIV', + table: (t) => 'T|${_rowSig(t.header)}|' + '${t.alignments.join(',')}|' + '${t.rows.map(_rowSig).join(';')}', + spacer: (s) => 'S${s.count}', + ); + +String _itemSig(MD$ListItem it) => + '{${it.indent}/${it.marker}/${it.checked}/${_spans(it.spans)}' + '[${it.children.map(_itemSig).join(',')}]}'; + +String _rowSig(MD$TableRow row) => row.cells.map(_spans).join('¦'); + +String _spans(List spans) => spans + .map((s) => '${s.start}:${s.end}:${s.style.value}:${s.text}') + .join('§'); + +/// A varied document that hits most block types in one parse. +const String _mixed = ''' +# Streaming demo + +A short intro paragraph with **bold**, _italic_ and `code`. + +> [!TIP] +> Blocks freeze once a blank line proves they are complete. + +- one +- two + - nested +- [x] a task + +| Feature | Incremental | +| ------- | :---------: | +| Parse | yes | +| Paint | n/a | + +```dart +final md = StreamingMarkdownParser(); +md.add('# Hello'); +``` + +--- + +The end. +'''; diff --git a/test/unit_test.dart b/test/unit_test.dart index e7529f6..295af46 100644 --- a/test/unit_test.dart +++ b/test/unit_test.dart @@ -9,6 +9,7 @@ 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_handles_test.dart' as selection_handles_test; import 'selection/selection_keyboard_test.dart' as selection_keyboard_test; @@ -26,6 +27,7 @@ void main() => group('Unit', () { edge_cases_test.main(); math_test.main(); regression_test.main(); + streaming_test.main(); golden_test.main(); nodes_test.main(); theme_test.main(); From 5b12b49bb8a23d84fb80aa03fad130714f0770bd Mon Sep 17 00:00:00 2001 From: Mike Matiunin Date: Wed, 5 Aug 2026 18:41:59 +0400 Subject: [PATCH 28/30] feat: add syntax highlighting support using Prism.js grammars - Implemented a new syntax highlighting engine in `lib/src/highlight/engine.dart` that utilizes Prism.js language definitions. - Enhanced `MarkdownThemeData` to include an optional `highlighter` for fenced code blocks. - Updated `BlockPainter$Code` to support rendering highlighted code using the new highlighter. - Added tests for the syntax highlighter in `test/highlight/highlight_test.dart` to ensure lossless text rendering and correct token coloring. - Introduced a code generation tool in `tool/prism_codegen` to convert Prism.js grammars into Dart code, allowing for tree-shakeable imports. - Added support for multiple languages and their aliases in the grammar generation process. - Updated the main test suite to include highlight tests. --- .gitignore | 6 + .pubignore | 6 +- CHANGELOG.md | 18 + README.md | 76 ++ benchmark/experiments/FINDINGS.md | 51 - .../experiments/s1_stock_baseline_test.dart | 128 --- .../experiments/s2_custom_delegate_test.dart | 226 ---- .../s3_selectable_renderobject_test.dart | 390 ------- .../s4_logical_controller_test.dart | 255 ----- .../s5_cross_widget_topology_test.dart | 260 ----- benchmark/experiments/s7_caching_test.dart | 213 ---- docs/architecture.md | 7 +- docs/development.md | 6 +- docs/selection.md | 13 +- example/lib/experiments/s6_platforms.dart | 367 ------- example/lib/main.dart | 5 +- example/lib/tabs/highlight_tab.dart | 257 +++++ example/test/smoke_test.dart | 8 + lib/flutter_md.dart | 1 + lib/highlight.dart | 22 + lib/highlight/all.dart | 239 +++++ lib/highlight/apacheconf.dart | 69 ++ lib/highlight/bash.dart | 285 +++++ lib/highlight/batch.dart | 151 +++ lib/highlight/c.dart | 106 ++ lib/highlight/clike.dart | 53 + lib/highlight/clojure.dart | 41 + lib/highlight/coffeescript.dart | 244 +++++ lib/highlight/cpp.dart | 363 +++++++ lib/highlight/csharp.dart | 292 +++++ lib/highlight/css.dart | 78 ++ lib/highlight/dart.dart | 109 ++ lib/highlight/diff.dart | 101 ++ lib/highlight/docker.dart | 88 ++ lib/highlight/elixir.dart | 109 ++ lib/highlight/elm.dart | 62 ++ lib/highlight/erlang.dart | 55 + lib/highlight/fsharp.dart | 87 ++ lib/highlight/git.dart | 32 + lib/highlight/go.dart | 61 ++ lib/highlight/graphql.dart | 85 ++ lib/highlight/groovy.dart | 86 ++ lib/highlight/handlebars.dart | 44 + lib/highlight/haskell.dart | 81 ++ lib/highlight/html.dart | 197 ++++ lib/highlight/http.dart | 137 +++ lib/highlight/ini.dart | 58 + lib/highlight/java.dart | 155 +++ lib/highlight/js.dart | 155 +++ lib/highlight/json.dart | 40 + lib/highlight/json5.dart | 43 + lib/highlight/jsx.dart | 826 +++++++++++++++ lib/highlight/julia.dart | 53 + lib/highlight/kotlin.dart | 93 ++ lib/highlight/latex.dart | 63 ++ lib/highlight/less.dart | 82 ++ lib/highlight/lua.dart | 43 + lib/highlight/makefile.dart | 56 + lib/highlight/markdown.dart | 801 ++++++++++++++ lib/highlight/markup_templating.dart | 16 + lib/highlight/nginx.dart | 60 ++ lib/highlight/objectivec.dart | 101 ++ lib/highlight/ocaml.dart | 67 ++ lib/highlight/perl.dart | 80 ++ lib/highlight/php.dart | 429 ++++++++ lib/highlight/plain.dart | 16 + lib/highlight/powershell.dart | 67 ++ lib/highlight/protobuf.dart | 91 ++ lib/highlight/python.dart | 87 ++ lib/highlight/r.dart | 39 + lib/highlight/regex.dart | 96 ++ lib/highlight/ruby.dart | 233 ++++ lib/highlight/rust.dart | 121 +++ lib/highlight/sass.dart | 101 ++ lib/highlight/scala.dart | 159 +++ lib/highlight/scss.dart | 94 ++ lib/highlight/solidity.dart | 55 + lib/highlight/sql.dart | 64 ++ lib/highlight/swift.dart | 113 ++ lib/highlight/themes.dart | 136 +++ lib/highlight/toml.dart | 54 + lib/highlight/tsx.dart | 996 ++++++++++++++++++ lib/highlight/typescript.dart | 417 ++++++++ lib/highlight/vim.dart | 40 + lib/highlight/wasm.dart | 48 + lib/highlight/xml.dart | 89 ++ lib/highlight/yaml.dart | 78 ++ lib/src/highlight/engine.dart | 288 +++++ lib/src/render/blocks/code.dart | 39 +- lib/src/theme.dart | 14 + test/highlight/highlight_test.dart | 452 ++++++++ test/unit_test.dart | 2 + tool/highlight_codegen/README.md | 61 ++ tool/highlight_codegen/codegen.cjs | 150 +++ tool/highlight_codegen/dump.cjs | 95 ++ tool/highlight_codegen/languages.json | 64 ++ tool/highlight_codegen/package.json | 7 + 97 files changed, 11240 insertions(+), 1917 deletions(-) delete mode 100644 benchmark/experiments/FINDINGS.md delete mode 100644 benchmark/experiments/s1_stock_baseline_test.dart delete mode 100644 benchmark/experiments/s2_custom_delegate_test.dart delete mode 100644 benchmark/experiments/s3_selectable_renderobject_test.dart delete mode 100644 benchmark/experiments/s4_logical_controller_test.dart delete mode 100644 benchmark/experiments/s5_cross_widget_topology_test.dart delete mode 100644 benchmark/experiments/s7_caching_test.dart delete mode 100644 example/lib/experiments/s6_platforms.dart create mode 100644 example/lib/tabs/highlight_tab.dart create mode 100644 lib/highlight.dart create mode 100644 lib/highlight/all.dart create mode 100644 lib/highlight/apacheconf.dart create mode 100644 lib/highlight/bash.dart create mode 100644 lib/highlight/batch.dart create mode 100644 lib/highlight/c.dart create mode 100644 lib/highlight/clike.dart create mode 100644 lib/highlight/clojure.dart create mode 100644 lib/highlight/coffeescript.dart create mode 100644 lib/highlight/cpp.dart create mode 100644 lib/highlight/csharp.dart create mode 100644 lib/highlight/css.dart create mode 100644 lib/highlight/dart.dart create mode 100644 lib/highlight/diff.dart create mode 100644 lib/highlight/docker.dart create mode 100644 lib/highlight/elixir.dart create mode 100644 lib/highlight/elm.dart create mode 100644 lib/highlight/erlang.dart create mode 100644 lib/highlight/fsharp.dart create mode 100644 lib/highlight/git.dart create mode 100644 lib/highlight/go.dart create mode 100644 lib/highlight/graphql.dart create mode 100644 lib/highlight/groovy.dart create mode 100644 lib/highlight/handlebars.dart create mode 100644 lib/highlight/haskell.dart create mode 100644 lib/highlight/html.dart create mode 100644 lib/highlight/http.dart create mode 100644 lib/highlight/ini.dart create mode 100644 lib/highlight/java.dart create mode 100644 lib/highlight/js.dart create mode 100644 lib/highlight/json.dart create mode 100644 lib/highlight/json5.dart create mode 100644 lib/highlight/jsx.dart create mode 100644 lib/highlight/julia.dart create mode 100644 lib/highlight/kotlin.dart create mode 100644 lib/highlight/latex.dart create mode 100644 lib/highlight/less.dart create mode 100644 lib/highlight/lua.dart create mode 100644 lib/highlight/makefile.dart create mode 100644 lib/highlight/markdown.dart create mode 100644 lib/highlight/markup_templating.dart create mode 100644 lib/highlight/nginx.dart create mode 100644 lib/highlight/objectivec.dart create mode 100644 lib/highlight/ocaml.dart create mode 100644 lib/highlight/perl.dart create mode 100644 lib/highlight/php.dart create mode 100644 lib/highlight/plain.dart create mode 100644 lib/highlight/powershell.dart create mode 100644 lib/highlight/protobuf.dart create mode 100644 lib/highlight/python.dart create mode 100644 lib/highlight/r.dart create mode 100644 lib/highlight/regex.dart create mode 100644 lib/highlight/ruby.dart create mode 100644 lib/highlight/rust.dart create mode 100644 lib/highlight/sass.dart create mode 100644 lib/highlight/scala.dart create mode 100644 lib/highlight/scss.dart create mode 100644 lib/highlight/solidity.dart create mode 100644 lib/highlight/sql.dart create mode 100644 lib/highlight/swift.dart create mode 100644 lib/highlight/themes.dart create mode 100644 lib/highlight/toml.dart create mode 100644 lib/highlight/tsx.dart create mode 100644 lib/highlight/typescript.dart create mode 100644 lib/highlight/vim.dart create mode 100644 lib/highlight/wasm.dart create mode 100644 lib/highlight/xml.dart create mode 100644 lib/highlight/yaml.dart create mode 100644 lib/src/highlight/engine.dart create mode 100644 test/highlight/highlight_test.dart create mode 100644 tool/highlight_codegen/README.md create mode 100644 tool/highlight_codegen/codegen.cjs create mode 100644 tool/highlight_codegen/dump.cjs create mode 100644 tool/highlight_codegen/languages.json create mode 100644 tool/highlight_codegen/package.json diff --git a/.gitignore b/.gitignore index e526c1f..39aec15 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,9 @@ pubspec.lock # Benchmark comparison baseline (machine-specific, generated by benchmark/compare.dart --save) benchmark/.baseline.txt benchmark/.render_baseline.txt +# Node tooling for the syntax-highlight codegen (tool/highlight_codegen); +# grammars.json is a regenerable snapshot, languages.json is the source of truth. +node_modules/ +tool/highlight_codegen/package-lock.json +tool/highlight_codegen/grammars.json +*.err diff --git a/.pubignore b/.pubignore index c29f738..11527de 100644 --- a/.pubignore +++ b/.pubignore @@ -15,4 +15,8 @@ example/ios/ example/windows/ example/macos/ example/linux/ -docs/ \ No newline at end of file +docs/ +# Prism codegen tooling — not part of the published package. (.pubignore does +# not inherit .gitignore, so node_modules must be excluded explicitly here.) +tool/ +node_modules/ \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index fd259c8..0402849 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,24 @@ > backward compatible — the only required code change is a new `alert` branch > for direct `MD$Block.map` / `switch` callers. +- **ADDED**: Opt-in, dependency-free syntax highlighting for fenced code blocks + (65+ languages, GitHub light/dark themes). Assign a `SyntaxHighlighter` to the + new `MarkdownThemeData.highlighter` field; the default (unset) renders code as + plain monospace, so existing usage is unchanged. New public API on + `package:flutter_md/highlight.dart`: `SyntaxHighlighter`, `MarkdownHighlighter`, + `CodeHighlightTheme`, `Grammar`, `GrammarToken`, `compileHighlightPattern` + (`SyntaxHighlighter` / `CodeHighlightTheme` are also re-exported from the main + entrypoint). Each language is its own library + (`package:flutter_md/highlight/.dart`, e.g. `HighlightDart.grammar`) with + no central registry, so importing one never references the others and unused + grammars tree-shake away — a Dart-only app adds ~0 beyond the engine; all 65 + add ~62 KB gzipped. `HighlightThemes.githubDark` / `githubLight` + (`highlight/themes.dart`) provide ready themes; `allHighlightLanguages` + (`highlight/all.dart`) is a convenience registry of every grammar for + demos/tooling (it references all languages, so unused ones can no longer + tree-shake away). The highlighter only partitions text — never edits it — so + selection and copy stay aligned. Grammars are generated by + `tool/highlight_codegen` (adapted from Prism, MIT). - **ADDED**: Cross-block and cross-widget text selection. A `MarkdownSelectionController` anchors the selection on the immutable model, so it spans multiple blocks and multiple `MarkdownWidget`s and survives list diff --git a/README.md b/README.md index 85fa299..1ede646 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,9 @@ A high-performance, lightweight Markdown parser and renderer specifically design - **📝 GitHub Flavored**: Alerts (`> [!NOTE]`), task lists (`- [x]`), tables with column alignment, thematic breaks, strikethrough, and more - **🧮 Inline Math**: Opt-in `$...$` LaTeX → Unicode (commands + super/subscripts) +- **🌈 Syntax Highlighting**: Opt-in, dependency-free code-block highlighting — + 65+ languages and GitHub light/dark themes; only the languages you import are + bundled (the rest tree-shake away) - **🎯 AI-Optimized**: Specifically designed for AI-generated content display - **🔧 Extensible**: Easy to extend with custom block and span renderers - **✅ Well Tested**: 370+ tests; parser and node model at ~100% line coverage @@ -146,6 +149,9 @@ void main() { ``` ```` +Code blocks can be syntax-highlighted — see +[Syntax Highlighting](#-syntax-highlighting). + ### Tables Column alignment is supported via the delimiter row (`:---` left, `:--:` @@ -338,6 +344,76 @@ Pair it with selection: feed each grown model to `controller.putDocument(id, md) and the active selection stays anchored as the message streams in (see the **Chat** tab in `example/`). +## 🌈 Syntax Highlighting + +Fenced code blocks can be syntax-highlighted with a **tree-shakeable, +dependency-free** highlighter — 65+ languages and ready-made GitHub light/dark +themes. Highlighting is **opt-in** and off by default (code renders as plain +monospace): assign a `SyntaxHighlighter` to `MarkdownThemeData.highlighter`. + +```dart +import 'package:flutter_md/highlight.dart'; // engine + MarkdownHighlighter +import 'package:flutter_md/highlight/dart.dart'; // one import per language +import 'package:flutter_md/highlight/sql.dart'; +import 'package:flutter_md/highlight/themes.dart'; // HighlightThemes + +final theme = MarkdownThemeData.mergeTheme( + Theme.of(context), + highlighter: MarkdownHighlighter( + // You assemble the map, so only these grammars are compiled in. + languages: { + 'dart': HighlightDart.grammar, + 'sql': HighlightSql.grammar, + }, + theme: isDark ? HighlightThemes.githubDark : HighlightThemes.githubLight, + ), +); + +MarkdownWidget(markdown: doc, theme: theme); +``` + +- **Only the languages you import ship.** Each language is its own library + exposing a single grammar, referenced through lazy thunks with **no central + registry/`Map`/`enum`**. Importing `highlight/dart.dart` never references any + other language, so unused grammars are removed by tree-shaking — a Dart-only + app adds ~0 beyond the engine, and all 65 languages together add ~62 KB + (gzipped) only if you deliberately bundle them all. +- **Selection- and copy-safe.** The highlighter only *partitions* the source + into colored spans; the concatenated text is byte-identical, so cross-block + selection, offsets and "Copy as Markdown" stay aligned. +- **Themes.** Use `HighlightThemes.githubDark` / `githubLight`, or implement + `CodeHighlightTheme` (a `switch` from token type → `TextStyle`) for your own + palette. Its `background` is applied to the code-block surface. +- **Demos & tooling.** `import 'package:flutter_md/highlight/all.dart';` exposes + `allHighlightLanguages`, a ready map of every grammar keyed by tag and alias + (`js`, `ts`, `sh`, …). It references everything, so unused languages can no + longer be tree-shaken away — use it for demos, not production. +- **Robust.** Unknown languages fall back to plain text; a pattern Dart's regex + engine rejects disables just that one rule instead of breaking the language. + +The grammars are generated by `tool/highlight_codegen` (see its README to +regenerate or add languages). See the runnable **Highlight** tab in `example/`. + +> Syntax grammars are adapted from [Prism](https://prismjs.com) (MIT License). + +```dart +// Custom theme: map token types to styles. +final class MyCodeTheme implements CodeHighlightTheme { + const MyCodeTheme(); + @override + Color? get background => const Color(0xFF1E1E1E); + @override + Color? get foreground => const Color(0xFFD4D4D4); + @override + TextStyle? styleFor(String tokenType) => switch (tokenType) { + 'comment' => const TextStyle(color: Color(0xFF6A9955)), + 'keyword' => const TextStyle(color: Color(0xFF569CD6)), + 'string' || 'string-literal' => const TextStyle(color: Color(0xFFCE9178)), + _ => null, + }; +} +``` + ## 🎨 Customization ### Theme Configuration diff --git a/benchmark/experiments/FINDINGS.md b/benchmark/experiments/FINDINGS.md deleted file mode 100644 index d2bf5b3..0000000 --- a/benchmark/experiments/FINDINGS.md +++ /dev/null @@ -1,51 +0,0 @@ -# Selection spikes — Phase 1 findings - -Throwaway experiments for issue #25 (cross-block + cross-widget text selection). -All headless spikes are `flutter test`-driven and pass (16 tests). S6 is an -interactive app to run on-device. Nothing here is in `lib/` or `test/`, so CI -(format / analyze / `unit_test.dart`) is untouched and still green (371 tests). - -Run: `flutter test benchmark/experiments/` · `flutter test benchmark/render_benchmark.dart` - -## What each spike established - -| Spike | Result | -|-------|--------| -| **S1** stock `SelectionArea` | GLUE confirmed — adjacent selectables concatenate with **no separator** (`AlphaBravoCharlie`). On disposal, `onSelectionChanged` does **not** re-fire → the app's selection value goes stale with no retrieval path. | -| **S2** custom delegate | Separators work **only** for selectables that register directly (a `Column`). A **`ListView` interposes its own private `_ScrollableSelectionContainerDelegate`** (scrollable.dart:1157) → a delegate above it sees one pre-glued child and is powerless. `Text` also wraps itself in a `SelectionContainer`, and a dying child yields `null`, so you must **cache content while alive**. Screen-Y snapshot keys **collide on reflow** (remove-middle → wrong text). ⇒ pure-delegate route is a dead end for chat. | -| **S3** canvas `Selectable` | A single `RenderBox` with `Selectable`+`SelectionRegistrant` maps a drag to **rendered-text** offsets across internal blocks, paints the highlight under glyphs, extracts text **with separators natively** (`Heading\nBody paragraph\nThird line`), and stays one `RenderBox`. Gotcha: guard `markNeedsPaint` against post-dispose callbacks. | -| **S4** logical controller | Selection as logical anchors `(docId, blockIndex, renderedOffset)` over the immutable model → extraction is **mount-independent** (disposal survival is free). Append-only streaming keeps the anchor via a prefix fast-path; **index-only anchors break on front/mid insert** (needs a stable id or content-anchored remap). Screen-order comparator handles vertical + horizontal + **RTL**. Non-text blocks (spacer/divider) occupy indices and must be skipped. | -| **S5** cross-widget topology | Recommended topology: `MarkdownSelectionScope(controller)` → a normal `ListView.builder` of message widgets that register as **surfaces**. Selection spans multiple widgets and **survives disposal** (`before == after` after scroll-off). **No `SelectableRegion`** → the single-child `add` assert is a non-issue. | -| **S7** caching | A 30-frame selection drag rebuilt the content `ui.Picture` **exactly once** (overlay drawn outside it). Content/size changes do rebuild. `isRepaintBoundary => true` **isolates** repaint to the changed widget (neighbour did not repaint). | -| **S6** platforms | Interactive app (`example/lib/experiments/s6_platforms.dart`) — **run on device** to judge touch handles, magnifier, native menus, keyboard, and link-tap-vs-drag arena. Headless mechanics already covered by S1–S5,S7. | - -## Render benchmark (relative, headless — `benchmark/.render_baseline.txt`) - -| tier | µs/op | note | -|------|-------|------| -| layout_large | ~6400 | full layout of a 50-block doc | -| paint_miss | ~6800 | fresh painter: layout + records Picture | -| **paint_hit** | **~41** | same painter+size → reuses Picture (**~160× cheaper**) | -| stream_append | ~5700 | `update()` + relayout | -| scroll_frame | ~2900 | wall time per drag+pump (noisy) | - -The ~160× cache payoff is why the highlight **must** be drawn outside the cached -Picture. (A `selection_drag` tier asserting the S7 zero-rebuild invariant is -added once the overlay lands in `lib/`.) - -## Decisions for Phase 2 - -1. **Substrate → keep canvas + custom `Selectable`/controller.** S3+S5+S7 confirm - it meets every requirement while preserving the Picture cache and keeping - `MarkdownWidget` a `LeafRenderObjectWidget` (so the single-RenderBox tests - survive). Stock widgets+`SelectionArea` fail glue + disposal (S1/S2). -2. **Cross-widget → scope-owned controller, not `SelectableRegion`.** (S2/S5.) -3. **Repaint → `isRepaintBoundary => true` + highlight overlay outside the - Picture; `alwaysNeedsCompositing` when handle `LeaderLayer`s are pushed.** (S7.) -4. **Model identity → OPEN.** index + append-fast-path covers streaming append - (dominant chat case) but breaks on front/mid inserts (S4). Options: (a) accept - index+clamp for v1; (b) add a stable id to `MD$Block` (`nodes.dart`); (c) - content-anchored remap. **Needs a decision.** -5. **Separator / spacer policy → OPEN.** block separator (`\n`?), document - separator (`\n\n`?), table cell separator (`\t`?), and whether spacer/divider - contribute to copied text. **Needs a decision.** diff --git a/benchmark/experiments/s1_stock_baseline_test.dart b/benchmark/experiments/s1_stock_baseline_test.dart deleted file mode 100644 index 3564f86..0000000 --- a/benchmark/experiments/s1_stock_baseline_test.dart +++ /dev/null @@ -1,128 +0,0 @@ -// SPIKE S1 — Stock SelectionArea + one Text-per-block baseline. -// -// Question: on Flutter 3.41.6, do the two documented defects actually reproduce -// with the naive "one Text widget per Markdown block in a scroll view" approach? -// (1) GLUE: text of adjacent selectables is concatenated with NO separator. -// (2) DISPOSAL: a scrolled-off (disposed) item's selected text vanishes. -// -// This is throwaway spike code. It lives OUTSIDE lib/ and test/ so it never -// enters the CI format/analyze/test gates. -// -// Run: flutter test benchmark/experiments/s1_stock_baseline_test.dart -import 'package:flutter/gestures.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; - -void main() { - testWidgets('S1.1 GLUE: adjacent selectables concatenate WITHOUT separators', - (tester) async { - String? captured; - await tester.pumpWidget( - MaterialApp( - home: Scaffold( - body: SelectionArea( - onSelectionChanged: (c) => captured = c?.plainText, - child: const Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text('Alpha'), - Text('Bravo'), - Text('Charlie'), - ], - ), - ), - ), - ), - ); - await tester.pumpAndSettle(); - - // Mouse-drag select from the very start of "Alpha" to the very end of - // "Charlie" — i.e. everything. - final start = tester.getTopLeft(find.text('Alpha')) + const Offset(1, 3); - final end = - tester.getBottomRight(find.text('Charlie')) - const Offset(1, 3); - final gesture = - await tester.startGesture(start, kind: PointerDeviceKind.mouse); - await tester.pump(const Duration(milliseconds: 200)); - await gesture.moveTo(end); - await tester.pump(const Duration(milliseconds: 200)); - await gesture.up(); - await tester.pumpAndSettle(); - - debugPrint( - 'S1.1 captured plainText = ${captured!.replaceAll('\n', r'\n')}'); - - // The defect: the three fragments are glued with no separator between them. - expect(captured, isNotNull); - expect(captured, contains('Bravo')); - expect(captured, isNot(contains('\n')), - reason: - 'DEFECT CONFIRMED if this passes: no separators inserted between ' - 'selectables — "Alpha", "Bravo", "Charlie" are glued.'); - // Concretely, the whole selection is the bare concatenation. - expect(captured, 'AlphaBravoCharlie'); - }); - - testWidgets('S1.2 DISPOSAL: scrolled-off item text disappears from selection', - (tester) async { - String? captured; - const itemExtent = 120.0; - final controller = ScrollController(); - - await tester.pumpWidget( - MaterialApp( - home: Scaffold( - body: SizedBox( - height: 300, // viewport shows ~2.5 items - child: SelectionArea( - onSelectionChanged: (c) => captured = c?.plainText, - child: ListView.builder( - controller: controller, - cacheExtent: 0, // force disposal of off-screen items - itemCount: 50, - itemBuilder: (_, i) => SizedBox( - height: itemExtent, - child: Text('Item$i'), - ), - ), - ), - ), - ), - ), - ); - await tester.pumpAndSettle(); - - // Select across Item0 and Item1 (both on screen). - final start = tester.getTopLeft(find.text('Item0')) + const Offset(1, 3); - final end = tester.getBottomRight(find.text('Item1')) - const Offset(1, 3); - final gesture = - await tester.startGesture(start, kind: PointerDeviceKind.mouse); - await tester.pump(const Duration(milliseconds: 200)); - await gesture.moveTo(end); - await tester.pump(const Duration(milliseconds: 200)); - await gesture.up(); - await tester.pumpAndSettle(); - - final beforeScroll = captured; - debugPrint('S1.2 before scroll = ${beforeScroll?.replaceAll('\n', r'\n')}'); - expect(beforeScroll, contains('Item0')); - - // Scroll far so Item0 (and Item1) are disposed. - controller.jumpTo(itemExtent * 20); - await tester.pumpAndSettle(); - expect(find.text('Item0'), findsNothing, - reason: 'Item0 should be disposed'); - - debugPrint('S1.2 after scroll = ${captured?.replaceAll('\n', r'\n')}'); - // FINDING: disposing the selectable does NOT re-fire onSelectionChanged, so - // the app's only selection signal goes STALE — it still reads "Item0Item1" - // even though Item0's RenderParagraph (and its selectable) are gone. There - // is no public API to pull the fresh, now-reduced live selection. That - // staleness + the lack of a retrieval path IS the disposal defect from the - // app's perspective. (S2 proves at the delegate level that the LIVE - // getSelectedContent() actually drops the disposed text.) - expect(captured, equals(beforeScroll), - reason: - 'onSelectionChanged did not re-fire on disposal → stale value.'); - }); -} diff --git a/benchmark/experiments/s2_custom_delegate_test.dart b/benchmark/experiments/s2_custom_delegate_test.dart deleted file mode 100644 index ec9401d..0000000 --- a/benchmark/experiments/s2_custom_delegate_test.dart +++ /dev/null @@ -1,226 +0,0 @@ -// SPIKE S2 — Custom MultiSelectable delegate: separators + snapshot-on-remove, -// and the STRUCTURAL LIMIT of the delegate approach for scrollables. -// -// Findings this file establishes: -// S2.1 A StaticSelectionContainerDelegate subclass CAN insert block -// separators between the selectables that register directly into it -// (fixing the S1 glue defect) — in a NON-scrolling subtree. -// S2.2 A stock Scrollable (ListView) interposes its OWN private -// `_ScrollableSelectionContainerDelegate` (scrollable.dart:1157), so a -// custom delegate placed ABOVE the ListView sees a single, already-glued -// child and is powerless over per-item separators / ordering / disposal. -// => the pure-delegate route is a dead end for the chat/lazy-list case. -// S2.3 The snapshot-on-remove mechanism DOES retain a disposed child's text -// (where the child registers directly into our delegate), but relies on -// screen-Y keys that are not scroll-invariant — motivating the -// controller's explicit registry order (S4). -// -// Throwaway spike; outside lib/ and test/. -// Run: flutter test benchmark/experiments/s2_custom_delegate_test.dart - -import 'package:flutter/gestures.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/rendering.dart'; -import 'package:flutter_test/flutter_test.dart'; - -class _Entry { - _Entry(this.key, this.text); - final double key; - final String text; -} - -class MdDelegate extends StaticSelectionContainerDelegate { - MdDelegate({this.separator = '\n'}); - - final String separator; - final Map _lastTop = {}; - // Proactively cache each live child's content: at remove() time a dying - // nested SelectionContainer already yields null, so we snapshot from here. - final Map _lastContent = {}; - final List<_Entry> _snaps = <_Entry>[]; - - int get liveChildCount => selectables.length; - int get snapshotCount => _snaps.length; - - double _topOf(Selectable s) => - MatrixUtils.transformPoint(s.getTransformTo(null), Offset.zero).dy; - - @override - void remove(Selectable selectable) { - final cached = _lastContent[selectable]; - if (cached != null && cached.isNotEmpty) { - _snaps.add(_Entry(_lastTop[selectable] ?? 0.0, cached)); - } - _lastTop.remove(selectable); - _lastContent.remove(selectable); - super.remove(selectable); - } - - @override - SelectedContent? getSelectedContent() { - final live = <_Entry>[]; - for (final s in selectables) { - final content = s.getSelectedContent()?.plainText; - if (content == null || content.isEmpty) continue; - final top = _topOf(s); - _lastTop[s] = top; - _lastContent[s] = content; - live.add(_Entry(top, content)); - } - final entries = <_Entry>[...live]; - const eps = 1.0; - for (final s in _snaps) { - final coveredByLive = live.any((e) => (e.key - s.key).abs() < eps); - if (!coveredByLive) entries.add(s); - } - if (entries.isEmpty) return null; - entries.sort((a, b) => a.key.compareTo(b.key)); - return SelectedContent( - plainText: entries.map((e) => e.text).join(separator), - ); - } -} - -Future _dragSelect(WidgetTester tester, Finder from, Finder to) async { - final start = tester.getTopLeft(from) + const Offset(1, 3); - final end = tester.getBottomRight(to) - const Offset(1, 3); - final g = await tester.startGesture(start, kind: PointerDeviceKind.mouse); - await tester.pump(const Duration(milliseconds: 200)); - await g.moveTo(end); - await tester.pump(const Duration(milliseconds: 200)); - await g.up(); - await tester.pumpAndSettle(); -} - -void main() { - testWidgets('S2.1 separators work in a NON-scrolling subtree', - (tester) async { - final delegate = MdDelegate(); - await tester.pumpWidget(MaterialApp( - home: Scaffold( - body: SelectionArea( - child: SelectionContainer( - delegate: delegate, - child: const Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [Text('Item0'), Text('Item1'), Text('Item2')], - ), - ), - ), - ), - )); - await tester.pumpAndSettle(); - - await _dragSelect(tester, find.text('Item0'), find.text('Item2')); - final text = delegate.getSelectedContent()?.plainText; - debugPrint('S2.1 liveChildren=${delegate.liveChildCount} ' - 'text=${text?.replaceAll('\n', r'\n')}'); - expect(delegate.liveChildCount, 3, - reason: 'each Text registers directly into our delegate'); - expect(text, 'Item0\nItem1\nItem2'); // separators inserted - }); - - testWidgets( - 'S2.2 BLOCKER: a ListView hides its items behind its own container', - (tester) async { - final delegate = MdDelegate(); - await tester.pumpWidget(MaterialApp( - home: Scaffold( - body: SizedBox( - height: 300, - child: SelectionArea( - child: SelectionContainer( - delegate: delegate, - child: ListView( - children: const [ - SizedBox(height: 120, child: Text('Item0')), - SizedBox(height: 120, child: Text('Item1')), - ], - ), - ), - ), - ), - ), - )); - await tester.pumpAndSettle(); - - await _dragSelect(tester, find.text('Item0'), find.text('Item1')); - final text = delegate.getSelectedContent()?.plainText; - debugPrint('S2.2 liveChildren=${delegate.liveChildCount} ' - 'text=${text?.replaceAll('\n', r'\n')}'); - // The Scrollable interposes ONE aggregated child; our delegate can't split. - expect(delegate.liveChildCount, 1, - reason: - 'Scrollable._ScrollableSelectionContainerDelegate is the child'); - expect(text, 'Item0Item1', - reason: 'gluing happened inside the private scrollable delegate, ' - 'below us — the delegate route cannot fix the chat case'); - }); - - testWidgets('S2.3 snapshot MECHANISM works when layout does not reflow', - (tester) async { - final delegate = MdDelegate(); - final result = await _removeWhileSelected(tester, delegate, removeIndex: 2); - debugPrint('S2.3 snapshots=${delegate.snapshotCount} ' - 'after=${result?.replaceAll('\n', r'\n')}'); - // Removing the LAST item: remaining items keep their Y, snapshot key - // (>liveMax) splices cleanly. The mechanism retains the text. - expect(delegate.snapshotCount, greaterThanOrEqualTo(1)); - expect(result, 'Item0\nItem1\nItem2'); - }); - - testWidgets('S2.4 LIMIT: screen-Y snapshot key collides on reflow', - (tester) async { - final delegate = MdDelegate(); - final result = await _removeWhileSelected(tester, delegate, removeIndex: 1); - debugPrint('S2.4 snapshots=${delegate.snapshotCount} ' - 'after=${result?.replaceAll('\n', r'\n')}'); - // Removing the MIDDLE item: Item2 reflows UP into Item1's old Y, so the - // snapshot's screen-Y key collides with a live child and is dropped — - // Item1's retained text is LOST. Screen geometry is not a stable identity; - // this is precisely why the controller (S4) anchors on an explicit - // registry order over the immutable model instead. - expect(result, 'Item0\nItem2', reason: 'DEFECT of the delegate approach'); - expect(result, isNot(contains('Item1'))); - }); -} - -/// Selects all three keyed Texts, then removes [removeIndex] while selected, -/// returning the delegate's assembled text afterwards. -Future _removeWhileSelected( - WidgetTester tester, - MdDelegate delegate, { - required int removeIndex, -}) async { - var items = ['Item0', 'Item1', 'Item2']; - late StateSetter setOuter; - await tester.pumpWidget(MaterialApp( - home: Scaffold( - body: SelectionArea( - child: SelectionContainer( - delegate: delegate, - child: StatefulBuilder(builder: (_, setState) { - setOuter = setState; - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - for (final s in items) Text(s, key: ValueKey(s)), - ], - ); - }), - ), - ), - ), - )); - await tester.pumpAndSettle(); - - await _dragSelect(tester, find.text('Item0'), find.text('Item2')); - expect(delegate.getSelectedContent()!.plainText, 'Item0\nItem1\nItem2'); - - final removed = items[removeIndex]; - setOuter(() => items = List.of(items)..removeAt(removeIndex)); - await tester.pumpAndSettle(); - expect(find.text(removed), findsNothing); - - return delegate.getSelectedContent()?.plainText; -} diff --git a/benchmark/experiments/s3_selectable_renderobject_test.dart b/benchmark/experiments/s3_selectable_renderobject_test.dart deleted file mode 100644 index 287b8c2..0000000 --- a/benchmark/experiments/s3_selectable_renderobject_test.dart +++ /dev/null @@ -1,390 +0,0 @@ -// SPIKE S3 — Custom Selectable RenderBox drawing highlight on canvas. -// -// Question: can ONE RenderBox (a LeafRenderObjectWidget, mirroring -// MarkdownRenderObject) implement Selectable + SelectionRegistrant, map a -// pointer drag to RENDERED-text offsets across multiple internal "blocks", -// paint the highlight under its glyphs, and return the selected text WITH -// block separators (natively solving the S1 glue problem, since it is a single -// selectable that controls its own getSelectedContent)? -// -// Each "block" here is a TextPainter, mirroring how MarkdownPainter holds one -// (or more) TextPainter per MD$Block. Throwaway spike; outside lib/ and test/. -// -// Run: flutter test benchmark/experiments/s3_selectable_renderobject_test.dart -import 'dart:math' as math; - -import 'package:flutter/gestures.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/rendering.dart'; -import 'package:flutter_test/flutter_test.dart'; - -/// A logical position inside the render box: which block + rendered-char offset. -class _Pos implements Comparable<_Pos> { - const _Pos(this.block, this.offset); - final int block; - final int offset; - @override - int compareTo(_Pos o) => - block != o.block ? block.compareTo(o.block) : offset.compareTo(o.offset); -} - -class _Block { - _Block(this.text, TextStyle style) - : painter = TextPainter( - text: TextSpan(text: text, style: style), - textDirection: TextDirection.ltr, - ); - final String text; - final TextPainter painter; - double top = 0; - double get height => painter.height; -} - -const String _blockSeparator = '\n'; - -class MdSelectableRenderBox extends RenderBox - with Selectable, SelectionRegistrant { - MdSelectableRenderBox(List blocks, TextStyle style) - : _blocks = [for (final b in blocks) _Block(b, style)]; - - final List<_Block> _blocks; - - final List _listeners = []; - _Pos? _start; - _Pos? _end; - LayerLink? _startHandle; - LayerLink? _endHandle; - bool _disposed = false; - - @override - void dispose() { - _disposed = true; - _listeners.clear(); - super.dispose(); // SelectionRegistrant.dispose -> unregister -> RenderBox - } - - void _safeMarkNeedsPaint() { - if (!_disposed) markNeedsPaint(); - } - - SelectionGeometry _geometry = - const SelectionGeometry(status: SelectionStatus.none, hasContent: true); - - // ---- ValueListenable ---- - @override - SelectionGeometry get value => _geometry; - @override - void addListener(VoidCallback l) => _listeners.add(l); - @override - void removeListener(VoidCallback l) => _listeners.remove(l); - void _notify() { - for (final l in List.of(_listeners)) l(); - } - - // ---- full-text helpers (rendered text joined with block separators) ---- - (String, List) _fullTextAndBases() { - final bases = []; - final buf = StringBuffer(); - var acc = 0; - for (var i = 0; i < _blocks.length; i++) { - if (i > 0) { - buf.write(_blockSeparator); - acc += _blockSeparator.length; - } - bases.add(acc); - buf.write(_blocks[i].text); - acc += _blocks[i].text.length; - } - return (buf.toString(), bases); - } - - int _globalOffset(_Pos p, List bases) => bases[p.block] + p.offset; - - @override - int get contentLength => _fullTextAndBases().$1.length; - - // ---- hit-testing: local offset -> logical position ---- - _Pos _positionForLocal(Offset local) { - var blockIndex = 0; - for (var i = 0; i < _blocks.length; i++) { - if (local.dy >= _blocks[i].top) blockIndex = i; - } - final b = _blocks[blockIndex]; - final tp = b.painter.getPositionForOffset(local - Offset(0, b.top)); - final off = tp.offset.clamp(0, b.text.length); - return _Pos(blockIndex, off); - } - - // ---- event handling ---- - @override - SelectionResult dispatchSelectionEvent(SelectionEvent event) { - switch (event) { - case final SelectionEdgeUpdateEvent e: - final local = globalToLocal(e.globalPosition); - final pos = _positionForLocal(local); - if (e.type == SelectionEventType.startEdgeUpdate) { - _start = pos; - } else { - _end = pos; - } - _start ??= pos; - _end ??= pos; - _recompute(); - return SelectionResult.end; - case ClearSelectionEvent(): - _start = _end = null; - _recompute(); - return SelectionResult.none; - case SelectAllSelectionEvent(): - _start = const _Pos(0, 0); - _end = _Pos(_blocks.length - 1, _blocks.last.text.length); - _recompute(); - return SelectionResult.end; - case final SelectWordSelectionEvent e: - final pos = _positionForLocal(globalToLocal(e.globalPosition)); - final range = _blocks[pos.block] - .painter - .getWordBoundary(TextPosition(offset: pos.offset)); - _start = _Pos(pos.block, range.start); - _end = _Pos(pos.block, range.end); - _recompute(); - return SelectionResult.end; - default: - return SelectionResult.none; - } - } - - (_Pos, _Pos) get _ordered => - _start!.compareTo(_end!) <= 0 ? (_start!, _end!) : (_end!, _start!); - - // Local selection range within a given block, in that block's char space. - TextRange? _rangeInBlock(int block, _Pos s, _Pos e) { - if (block < s.block || block > e.block) return null; - final start = block == s.block ? s.offset : 0; - final end = block == e.block ? e.offset : _blocks[block].text.length; - if (start == end) return null; - return TextRange(start: start, end: end); - } - - List _selectionRects() { - if (_start == null || _end == null) return const []; - final (s, e) = _ordered; - final rects = []; - for (var i = s.block; i <= e.block; i++) { - final r = _rangeInBlock(i, s, e); - if (r == null) continue; - final boxes = _blocks[i].painter.getBoxesForSelection( - TextSelection(baseOffset: r.start, extentOffset: r.end), - ); - for (final box in boxes) { - rects.add(box.toRect().shift(Offset(0, _blocks[i].top))); - } - } - return rects; - } - - SelectionPoint _pointFor(_Pos p, TextSelectionHandleType type) { - final b = _blocks[p.block]; - final caret = b.painter.getOffsetForCaret( - TextPosition(offset: p.offset), - Rect.zero, - ); - return SelectionPoint( - localPosition: caret + Offset(0, b.top + b.painter.preferredLineHeight), - lineHeight: b.painter.preferredLineHeight, - handleType: type, - ); - } - - void _recompute() { - if (_start == null || _end == null) { - _geometry = const SelectionGeometry( - status: SelectionStatus.none, hasContent: true); - } else { - final rects = _selectionRects(); - final collapsed = _start!.compareTo(_end!) == 0; - _geometry = SelectionGeometry( - startSelectionPoint: _pointFor(_start!, TextSelectionHandleType.left), - endSelectionPoint: _pointFor(_end!, TextSelectionHandleType.right), - selectionRects: rects, - status: - collapsed ? SelectionStatus.collapsed : SelectionStatus.uncollapsed, - hasContent: true, - ); - } - _safeMarkNeedsPaint(); - _notify(); - } - - // ---- content extraction ---- - @override - SelectedContent? getSelectedContent() { - if (_start == null || _end == null) return null; - final (full, bases) = _fullTextAndBases(); - final (s, e) = _ordered; - final a = _globalOffset(s, bases); - final b = _globalOffset(e, bases); - if (a == b) return null; - return SelectedContent(plainText: full.substring(a, b)); - } - - @override - SelectedContentRange? getSelection() { - if (_start == null || _end == null) return null; - final (_, bases) = _fullTextAndBases(); - return SelectedContentRange( - startOffset: _globalOffset(_start!, bases), - endOffset: _globalOffset(_end!, bases), - ); - } - - // ---- geometry required by the delegate ---- - @override - List get boundingBoxes => [ - for (final b in _blocks) Rect.fromLTWH(0, b.top, size.width, b.height) - ]; - - @override - void pushHandleLayers(LayerLink? startHandle, LayerLink? endHandle) { - // Real impl pushes LeaderLayers in paint(); the mouse-drag spike doesn't - // need visible handles, so we just record + repaint. (Handles are S6.) - _startHandle = startHandle; - _endHandle = endHandle; - _safeMarkNeedsPaint(); - } - - // ---- layout / paint ---- - @override - void performLayout() { - var y = 0.0, w = 0.0; - for (final b in _blocks) { - b.painter.layout(maxWidth: constraints.maxWidth); - b.top = y; - y += b.painter.height; - w = math.max(w, b.painter.width); - } - size = constraints.constrain(Size(w, y)); - } - - @override - void paint(PaintingContext context, Offset offset) { - // 1) highlight UNDER the glyphs - final rects = _geometry.selectionRects; - if (rects.isNotEmpty) { - final paint = Paint()..color = const Color(0x552196F3); - for (final r in rects) { - context.canvas.drawRect(r.shift(offset), paint); - } - } - // 2) glyphs - for (final b in _blocks) { - b.painter.paint(context.canvas, offset + Offset(0, b.top)); - } - } -} - -class MdSelectableWidget extends LeafRenderObjectWidget { - const MdSelectableWidget( - {required this.blocks, required this.style, super.key}); - final List blocks; - final TextStyle style; - - @override - MdSelectableRenderBox createRenderObject(BuildContext context) => - MdSelectableRenderBox(blocks, style) - ..registrar = SelectionContainer.maybeOf(context); - - @override - void updateRenderObject(BuildContext context, MdSelectableRenderBox ro) { - ro.registrar = SelectionContainer.maybeOf(context); - } -} - -void main() { - const style = TextStyle(fontSize: 20, color: Color(0xFF000000)); - - testWidgets('S3 single selectable box spans blocks with separators', - (tester) async { - String? captured; - await tester.pumpWidget(MaterialApp( - home: Scaffold( - body: SelectionArea( - onSelectionChanged: (c) => captured = c?.plainText, - child: const Align( - alignment: Alignment.topLeft, - child: SizedBox( - width: 400, - child: MdSelectableWidget( - blocks: ['Heading', 'Body paragraph', 'Third line'], - style: style, - ), - ), - ), - ), - ), - )); - await tester.pumpAndSettle(); - - final box = tester.renderObject(find.byType(MdSelectableWidget)); - // Exactly one RenderBox for the widget (leaf) — the S1/render tests invariant. - expect(box, isA()); - - // Drag-select from the very top-left to the bottom-right (everything). - final topLeft = tester.getTopLeft(find.byType(MdSelectableWidget)); - final bottomRight = tester.getBottomRight(find.byType(MdSelectableWidget)); - final g = await tester.startGesture(topLeft + const Offset(1, 3), - kind: PointerDeviceKind.mouse); - await tester.pump(const Duration(milliseconds: 200)); - await g.moveTo(bottomRight - const Offset(1, 3)); - await tester.pump(const Duration(milliseconds: 200)); - await g.up(); - await tester.pumpAndSettle(); - - debugPrint('S3 captured = ${captured?.replaceAll('\n', r'\n')}'); - // Glue solved natively: ONE selectable inserts its own separators. - expect(captured, 'Heading\nBody paragraph\nThird line'); - }); - - testWidgets('S3 partial cross-block selection', (tester) async { - String? captured; - await tester.pumpWidget(MaterialApp( - home: Scaffold( - body: SelectionArea( - onSelectionChanged: (c) => captured = c?.plainText, - child: const Align( - alignment: Alignment.topLeft, - child: SizedBox( - width: 400, - child: MdSelectableWidget( - blocks: ['ABCDEF', 'GHIJKL'], - style: style, - ), - ), - ), - ), - ), - )); - await tester.pumpAndSettle(); - - // Start mid-first-block, end mid-second-block. - final box = tester.renderObject(find.byType(MdSelectableWidget)); - final origin = tester.getTopLeft(find.byType(MdSelectableWidget)); - final half = box.size.height / 2; - final g = await tester.startGesture( - origin + Offset(box.size.width * 0.45, half * 0.5), - kind: PointerDeviceKind.mouse); - await tester.pump(const Duration(milliseconds: 200)); - await g.moveTo(origin + Offset(box.size.width * 0.55, half * 1.5)); - await tester.pump(const Duration(milliseconds: 200)); - await g.up(); - await tester.pumpAndSettle(); - - debugPrint('S3 partial captured = ${captured?.replaceAll('\n', r'\n')}'); - // Whatever the exact chars, the block boundary must carry a separator. - expect(captured, isNotNull); - expect(captured, contains('\n'), - reason: 'cross-block selection carries the block separator'); - // And the selected text is drawn from RENDERED text (letters we laid out). - expect(captured!.replaceAll('\n', ''), matches(RegExp(r'^[A-L]+$'))); - }); -} diff --git a/benchmark/experiments/s4_logical_controller_test.dart b/benchmark/experiments/s4_logical_controller_test.dart deleted file mode 100644 index f3ad397..0000000 --- a/benchmark/experiments/s4_logical_controller_test.dart +++ /dev/null @@ -1,255 +0,0 @@ -// SPIKE S4 — Controller-anchored LOGICAL selection over the immutable model. -// -// The core idea: selection is a pair of logical anchors (docId, blockIndex, -// renderedOffset) into the immutable Markdown model. Text is derived from the -// MODEL (always retained by the app), so extraction is completely independent -// of which widgets are currently mounted. This is what makes disposal survival -// and streaming reconciliation fall out. -// -// Proves: -// T1 cross-document extraction from the model (with block/doc separators). -// T2 DISPOSAL SURVIVAL: extraction is identical before/after scrolling items -// out of a ListView (i.e. after their widgets are disposed). -// T3 STREAMING: append-only reconcile keeps the anchor; and index-only anchors -// BREAK on a front-insert — the evidence for the index-vs-stable-id call. -// T4 SCREEN ORDER: a geometry comparator (vertical, then horizontal flipped -// for RTL) orders MOUNTED docs; registry order governs unmounted ones. -// -// Uses the REAL flutter_md model. Throwaway spike; outside lib/ and test/. -// Run: flutter test benchmark/experiments/s4_logical_controller_test.dart -import 'package:flutter/material.dart'; -import 'package:flutter_md/flutter_md.dart'; -import 'package:flutter_test/flutter_test.dart'; - -// --- shared block linearization (seed for the real MarkdownBlockText helper) -- -String renderedBlockText(MD$Block b) => b.map( - paragraph: (p) => p.spans.map((s) => s.text).join(), - heading: (h) => h.spans.map((s) => s.text).join(), - quote: (q) => q.spans.map((s) => s.text).join(), - alert: (a) => a.spans.map((s) => s.text).join(), - code: (c) => c.text, - list: (l) => - l.items.map((i) => i.spans.map((s) => s.text).join()).join('\n'), - table: (t) => [ - t.header.cells.map((c) => c.map((s) => s.text).join()).join('\t'), - for (final r in t.rows) - r.cells.map((c) => c.map((s) => s.text).join()).join('\t'), - ].join('\n'), - divider: (_) => '', // structural, no selectable text - spacer: (_) => '', // structural, no selectable text (policy: Phase 2) - ); - -// --- logical model --- -@immutable -class MdPos { - const MdPos(this.doc, this.block, this.offset); - final Object doc; - final int block; - final int offset; -} - -@immutable -class MdSel { - const MdSel(this.base, this.extent); - final MdPos base; - final MdPos extent; -} - -class MdDoc { - MdDoc(this.id, this.model); - final Object id; - Markdown model; -} - -/// Append-only fast path, else clamp. Returns null to drop an anchor. -MdPos reconcile(MdPos anchor, Markdown oldM, Markdown newM) { - final oldB = oldM.blocks, newB = newM.blocks; - bool prefixUnchanged() { - if (anchor.block >= newB.length) return false; - for (var i = 0; i < anchor.block; i++) { - if (i >= newB.length || - renderedBlockText(oldB[i]) != renderedBlockText(newB[i])) { - return false; - } - } - // anchor block itself: old rendered text must be a prefix of the new one. - final oldT = renderedBlockText(oldB[anchor.block]); - final newT = renderedBlockText(newB[anchor.block]); - return newT.startsWith(oldT) || oldT.startsWith(newT); - } - - if (anchor.block < oldB.length && prefixUnchanged()) return anchor; // keep - // clamp - final block = anchor.block.clamp(0, newB.length - 1); - final len = renderedBlockText(newB[block]).length; - return MdPos(anchor.doc, block, anchor.offset.clamp(0, len)); -} - -class MdController extends ChangeNotifier { - final List docs = []; // registry order == reading order - MdSel? selection; - - int _docIndex(Object id) => docs.indexWhere((d) => d.id == id); - Markdown _model(Object id) => docs[_docIndex(id)].model; - - void updateDocument(Object id, Markdown next) { - final d = docs[_docIndex(id)]; - final old = d.model; - d.model = next; - final sel = selection; - if (sel != null) { - selection = MdSel( - sel.base.doc == id ? reconcile(sel.base, old, next) : sel.base, - sel.extent.doc == id ? reconcile(sel.extent, old, next) : sel.extent, - ); - } - notifyListeners(); - } - - int _cmp(MdPos a, MdPos b) { - final ai = _docIndex(a.doc), bi = _docIndex(b.doc); - if (ai != bi) return ai.compareTo(bi); - if (a.block != b.block) return a.block.compareTo(b.block); - return a.offset.compareTo(b.offset); - } - - String getPlainText({String blockSep = '\n', String docSep = '\n\n'}) { - final sel = selection; - if (sel == null) return ''; - var a = sel.base, b = sel.extent; - if (_cmp(a, b) > 0) { - final t = a; - a = b; - b = t; - } - final startDoc = _docIndex(a.doc), endDoc = _docIndex(b.doc); - final docChunks = []; - for (var d = startDoc; d <= endDoc; d++) { - final blocks = docs[d].model.blocks; - final fromBlock = d == startDoc ? a.block : 0; - final toBlock = d == endDoc ? b.block : blocks.length - 1; - final blockChunks = []; - for (var bi = fromBlock; bi <= toBlock; bi++) { - final text = renderedBlockText(blocks[bi]); - if (text.isEmpty) continue; // skip structural blocks (spacer/divider) - final from = (d == startDoc && bi == a.block) ? a.offset : 0; - final to = (d == endDoc && bi == b.block) ? b.offset : text.length; - blockChunks.add(text.substring( - from.clamp(0, text.length), to.clamp(0, text.length))); - } - docChunks.add(blockChunks.join(blockSep)); - } - return docChunks.join(docSep); - } -} - -// Screen-order comparator for MOUNTED surfaces (mirrors Flutter's -// _compareScreenOrder, with an RTL horizontal flip). -int compareScreenOrder(Rect a, Rect b, TextDirection dir) { - const threshold = 4.0; - if ((a.top - b.top).abs() > threshold) return a.top.compareTo(b.top); - return dir == TextDirection.rtl - ? b.left.compareTo(a.left) - : a.left.compareTo(b.left); -} - -void main() { - final docA = Markdown.fromString('Alpha one\n\nAlpha two'); - final docB = Markdown.fromString('Bravo one\n\nBravo two'); - - MdController freshController() => - MdController()..docs.addAll([MdDoc('a', docA), MdDoc('b', docB)]); - - // Block indices: 0 = paragraph, 1 = spacer (blank line), 2 = paragraph. - test('T1 cross-document extraction with separators', () { - final c = freshController(); - c.selection = const MdSel(MdPos('a', 0, 0), MdPos('b', 2, 9)); - expect(c.getPlainText(), 'Alpha one\nAlpha two\n\nBravo one\nBravo two'); - - c.selection = const MdSel(MdPos('a', 2, 6), MdPos('b', 0, 5)); - expect(c.getPlainText(), 'two\n\nBravo'); // 'Alpha two'[6:]='two' - }); - - testWidgets('T2 DISPOSAL SURVIVAL: extraction unchanged after scroll-off', - (tester) async { - final c = MdController(); - for (var i = 0; i < 8; i++) { - c.docs.add(MdDoc('d$i', Markdown.fromString('Message number $i'))); - } - // Select from d0 through d7 (whole conversation). - c.selection = const MdSel(MdPos('d0', 0, 0), MdPos('d7', 0, 16)); - final before = c.getPlainText(); - expect(before, contains('Message number 0')); - expect(before, contains('Message number 7')); - - final scroll = ScrollController(); - await tester.pumpWidget(MaterialApp( - home: Scaffold( - body: SizedBox( - height: 200, - child: ListView.builder( - controller: scroll, - cacheExtent: 0, - itemCount: c.docs.length, - itemBuilder: (_, i) => SizedBox( - height: 80, - child: Text(c.docs[i].model.blocks.map(renderedBlockText).join()), - ), - ), - ), - ), - )); - await tester.pumpAndSettle(); - - scroll.jumpTo(80.0 * 6); // dispose the first several messages - await tester.pumpAndSettle(); - expect(find.text('Message number 0'), findsNothing); // truly disposed - - // The controller reads the MODEL, not live widgets → identical text. - expect(c.getPlainText(), before); - expect(c.getPlainText(), contains('Message number 0')); - }); - - test('T3 STREAMING: append keeps anchor; front-insert breaks index-only', () { - // Anchor extent inside docB block 2 ("Bravo two"), offset 9 = end. - final c = freshController(); - c.selection = const MdSel(MdPos('a', 0, 0), MdPos('b', 2, 9)); - final base = c.getPlainText(); - - // (a) Append-only streaming: grow the last block + add a new block. - c.updateDocument('b', - Markdown.fromString('Bravo one\n\nBravo two three\n\nBravo appended')); - // block 2 grew as a prefix ("Bravo two" -> "Bravo two three"), so the fast - // path keeps the anchor; the originally-selected text is unchanged. - expect(c.selection!.extent.block, 2); - expect(c.selection!.extent.offset, 9); - expect(c.getPlainText(), base); // selection content preserved verbatim - - // (b) Front-insert: prepend a new first block. With INDEX-only anchors the - // fast path fails (block 0 changed) and clamp keeps block index 1 — which - // now points at a DIFFERENT block. The selected text changes => WRONG. - final c2 = freshController(); - c2.selection = const MdSel(MdPos('b', 0, 0), MdPos('b', 0, 9)); - final beforeInsert = c2.getPlainText(); // "Bravo one" - c2.updateDocument( - 'b', Markdown.fromString('INSERTED HEADER\n\nBravo one\n\nBravo two')); - final afterInsert = c2.getPlainText(); - expect(beforeInsert, 'Bravo one'); - expect(afterInsert, isNot('Bravo one'), - reason: - 'index-only anchors mis-track a front-insert → needs stable id'); - }); - - test('T4 SCREEN ORDER: vertical, then horizontal with RTL flip', () { - const vTop = Rect.fromLTWH(0, 0, 100, 40); - const vBot = Rect.fromLTWH(0, 60, 100, 40); - expect(compareScreenOrder(vTop, vBot, TextDirection.ltr) < 0, isTrue); - - const left = Rect.fromLTWH(0, 0, 100, 40); - const right = Rect.fromLTWH(120, 1, 100, 40); // same row (±threshold) - expect(compareScreenOrder(left, right, TextDirection.ltr) < 0, isTrue, - reason: 'LTR: left comes first'); - expect(compareScreenOrder(left, right, TextDirection.rtl) > 0, isTrue, - reason: 'RTL: right comes first'); - }); -} diff --git a/benchmark/experiments/s5_cross_widget_topology_test.dart b/benchmark/experiments/s5_cross_widget_topology_test.dart deleted file mode 100644 index 0392938..0000000 --- a/benchmark/experiments/s5_cross_widget_topology_test.dart +++ /dev/null @@ -1,260 +0,0 @@ -// SPIKE S5 — Cross-widget topology. -// -// Question: what topology lets ONE selection span N MarkdownWidgets in a -// ListView.builder AND survive item disposal? -// -// Given S2's finding (a stock Scrollable interposes its own private -// SelectionContainer, so stock SelectableRegion coordinates only LIVE items and -// drops disposed ones), the answer is: DON'T route cross-widget selection -// through SelectableRegion/Scrollable at all. Instead a scope-owned controller -// holds logical anchors + an app-supplied model registry (survives disposal), -// while mounted render objects register as "surfaces" that map a global point -// to a logical position. The scope's gesture layer drives the controller. -// -// This spike wires that end to end (one paragraph per message, since cross-BLOCK -// was already proven in S3 and cross-doc extraction in S4) and proves: -// * no SelectableRegion single-child assert is involved (we don't use it); -// * selection spans multiple mounted MarkdownWidgets; -// * it SURVIVES disposal — text still retrievable after scroll-off. -// -// Throwaway spike; outside lib/ and test/. -// Run: flutter test benchmark/experiments/s5_cross_widget_topology_test.dart -import 'package:flutter/gestures.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/rendering.dart'; -import 'package:flutter_test/flutter_test.dart'; - -@immutable -class MdPos { - const MdPos(this.doc, this.offset); - final Object doc; - final int offset; -} - -abstract interface class MdSurface { - Object get docId; - Rect get globalBounds; - int offsetForGlobal(Offset global); -} - -class MdController extends ChangeNotifier { - MdController( - this.docs); // app-supplied, ordered, ALL messages (mounted or not) - final List<(Object id, String text)> docs; - - final Map _surfaces = {}; - MdPos? base; - MdPos? extent; - - void registerSurface(MdSurface s) => _surfaces[s.docId] = s; - void unregisterSurface(MdSurface s) { - if (_surfaces[s.docId] == s) _surfaces.remove(s.docId); - } - - Iterable get mountedDocIds => _surfaces.keys; - - int _docIndex(Object id) => docs.indexWhere((d) => d.$1 == id); - String _text(Object id) => docs[_docIndex(id)].$2; - - /// Map a global point to a logical position by asking mounted surfaces. - MdPos? hitTest(Offset global) { - for (final s in _surfaces.values) { - if (s.globalBounds.contains(global)) { - return MdPos(s.docId, s.offsetForGlobal(global)); - } - } - return null; - } - - void startAt(Offset global) { - final p = hitTest(global); - if (p == null) return; - base = extent = p; - notifyListeners(); - } - - void extendTo(Offset global) { - final p = hitTest(global); - if (p == null) return; - extent = p; - notifyListeners(); - } - - int _cmp(MdPos a, MdPos b) { - final ai = _docIndex(a.doc), bi = _docIndex(b.doc); - return ai != bi ? ai.compareTo(bi) : a.offset.compareTo(b.offset); - } - - String getPlainText({String docSep = '\n\n'}) { - if (base == null || extent == null) return ''; - var a = base!, b = extent!; - if (_cmp(a, b) > 0) { - final t = a; - a = b; - b = t; - } - final start = _docIndex(a.doc), end = _docIndex(b.doc); - final chunks = []; - for (var d = start; d <= end; d++) { - final text = docs[d].$2; - final from = d == start ? a.offset : 0; - final to = d == end ? b.offset : text.length; - chunks.add( - text.substring(from.clamp(0, text.length), to.clamp(0, text.length))); - } - return chunks.join(docSep); - } -} - -class _Scope extends InheritedWidget { - const _Scope({required this.controller, required super.child}); - final MdController controller; - static MdController of(BuildContext c) => - c.dependOnInheritedWidgetOfExactType<_Scope>()!.controller; - @override - bool updateShouldNotify(_Scope old) => controller != old.controller; -} - -class MarkdownScope extends StatelessWidget { - const MarkdownScope( - {required this.controller, required this.child, super.key}); - final MdController controller; - final Widget child; - - @override - Widget build(BuildContext context) => _Scope( - controller: controller, - // A real scope resolves scroll-vs-select in the gesture arena (S6); - // here the coordinator just forwards global drag points. - child: RawGestureDetector( - gestures: { - PanGestureRecognizer: - GestureRecognizerFactoryWithHandlers( - () => PanGestureRecognizer(), - (r) => r - ..onStart = ((d) => controller.startAt(d.globalPosition)) - ..onUpdate = ((d) => controller.extendTo(d.globalPosition)), - ), - }, - child: child, - ), - ); -} - -class MdMessage extends LeafRenderObjectWidget { - const MdMessage({required this.docId, required this.text, super.key}); - final Object docId; - final String text; - - @override - RenderObject createRenderObject(BuildContext context) => - _MdMessageBox(docId, text, _Scope.of(context)); - @override - void updateRenderObject(BuildContext context, _MdMessageBox ro) => - ro.controller = _Scope.of(context); -} - -class _MdMessageBox extends RenderBox implements MdSurface { - _MdMessageBox(this.docId, String text, this.controller) - : _painter = TextPainter( - text: TextSpan( - text: text, - style: const TextStyle(fontSize: 16, color: Color(0xFF000000)), - ), - textDirection: TextDirection.ltr, - ); - - @override - final Object docId; - final TextPainter _painter; - MdController controller; - - @override - void attach(PipelineOwner owner) { - super.attach(owner); - controller.registerSurface(this); - } - - @override - void detach() { - controller.unregisterSurface(this); - super.detach(); - } - - @override - Rect get globalBounds => localToGlobal(Offset.zero) & size; - - @override - int offsetForGlobal(Offset global) => - _painter.getPositionForOffset(globalToLocal(global)).offset; - - @override - void performLayout() { - _painter.layout(maxWidth: constraints.maxWidth); - size = constraints.constrain(_painter.size); - } - - @override - void paint(PaintingContext context, Offset offset) => - _painter.paint(context.canvas, offset); -} - -void main() { - testWidgets('S5 cross-widget selection survives disposal', (tester) async { - final controller = MdController(<(Object, String)>[ - for (var i = 0; i < 8; i++) ('m$i', 'Message number $i'), - ]); - final scroll = ScrollController(); - - await tester.pumpWidget(MaterialApp( - home: Scaffold( - body: SizedBox( - height: 200, - child: MarkdownScope( - controller: controller, - child: ListView.builder( - controller: scroll, - cacheExtent: 0, - itemCount: 8, - itemBuilder: (_, i) => SizedBox( - height: 80, - child: MdMessage(docId: 'm$i', text: 'Message number $i'), - ), - ), - ), - ), - ), - )); - await tester.pumpAndSettle(); - - // Only the first few messages are mounted (surfaces). Anchor the selection - // across m0..m1 by hit-testing their mounted surfaces — exactly what the - // scope gesture layer does on a real drag. - final p0 = - tester.getTopLeft(find.byType(MdMessage).first) + const Offset(1, 3); - final m1 = find.byWidgetPredicate((w) => w is MdMessage && w.docId == 'm1'); - final p1 = tester.getBottomRight(m1) - const Offset(1, 3); - controller.startAt(p0); - controller.extendTo(p1); - - final before = controller.getPlainText(); - debugPrint('S5 before = ${before.replaceAll('\n', r'\n')}'); - expect(before, contains('Message number 0')); - expect(before, contains('Message number 1')); - - // Scroll so m0 is disposed. - scroll.jumpTo(80.0 * 6); - await tester.pumpAndSettle(); - expect(find.byWidgetPredicate((w) => w is MdMessage && w.docId == 'm0'), - findsNothing); - expect(controller.mountedDocIds, isNot(contains('m0')), - reason: 'm0 surface unregistered on disposal'); - - // Selection text is derived from the app-supplied model registry, so it is - // fully intact even though m0 is gone. - final after = controller.getPlainText(); - debugPrint('S5 after = ${after.replaceAll('\n', r'\n')}'); - expect(after, before, reason: 'cross-widget selection survived disposal'); - expect(after, contains('Message number 0')); - }); -} diff --git a/benchmark/experiments/s7_caching_test.dart b/benchmark/experiments/s7_caching_test.dart deleted file mode 100644 index 4bc8456..0000000 --- a/benchmark/experiments/s7_caching_test.dart +++ /dev/null @@ -1,213 +0,0 @@ -// SPIKE S7 — Caching: static content Picture vs dynamic selection overlay. -// -// Question: can the selection highlight be drawn as an overlay that changes -// every drag-frame WITHOUT rebuilding the cached content ui.Picture, and does a -// RepaintBoundary isolate one widget's selection repaint from its neighbours? -// -// Mirrors MarkdownPainter's cache (one ui.Picture keyed by size). The highlight -// is drawn OUTSIDE that Picture each paint. Instruments rebuild/paint counts. -// -// Throwaway spike; outside lib/ and test/. -// Run: flutter test benchmark/experiments/s7_caching_test.dart -import 'dart:ui' as ui; - -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; - -class CachingBox extends RenderBox { - CachingBox(String text, this._contentRevision) - : _painter = TextPainter( - text: TextSpan( - text: text, - style: const TextStyle(fontSize: 16, color: Color(0xFF000000)), - ), - textDirection: TextDirection.ltr, - ); - - TextPainter _painter; - int _contentRevision; - ui.Picture? _content; - Size? _contentSize; - int? _cachedRevision; - - Rect? _selectionRect; - int contentRebuilds = 0; - int paintCount = 0; - - @override - bool get isRepaintBoundary => true; // candidate decision (Spike 7) - - set selectionRect(Rect? r) { - if (r == _selectionRect) return; - _selectionRect = r; - markNeedsPaint(); // selection change => repaint only, no relayout - } - - void setContent(String text, int revision) { - if (revision == _contentRevision) return; - _painter = TextPainter( - text: TextSpan( - text: text, - style: const TextStyle(fontSize: 16, color: Color(0xFF000000)), - ), - textDirection: TextDirection.ltr, - ); - _contentRevision = revision; - markNeedsLayout(); - } - - @override - void performLayout() { - _painter.layout(maxWidth: constraints.maxWidth); - size = constraints.constrain(Size(_painter.width, _painter.height + 20)); - } - - @override - void paint(PaintingContext context, Offset offset) { - paintCount++; - // (Re)build the content Picture only when size or content changed. - if (_content == null || - _contentSize != size || - _cachedRevision != _contentRevision) { - final recorder = ui.PictureRecorder(); - final canvas = Canvas(recorder); - _painter.paint(canvas, Offset.zero); - _content = recorder.endRecording(); - _contentSize = size; - _cachedRevision = _contentRevision; - contentRebuilds++; - } - final canvas = context.canvas..save(); - canvas.translate(offset.dx, offset.dy); - // Dynamic overlay drawn fresh each paint, OUTSIDE the cached Picture. - if (_selectionRect != null) { - canvas.drawRect( - _selectionRect!, Paint()..color = const Color(0x552196F3)); - } - canvas.drawPicture(_content!); - canvas.restore(); - } -} - -class CacheWidget extends LeafRenderObjectWidget { - const CacheWidget({ - required this.text, - required this.revision, - this.selectionRect, - super.key, - }); - final String text; - final int revision; - final Rect? selectionRect; - - @override - CachingBox createRenderObject(BuildContext context) => - CachingBox(text, revision)..selectionRect = selectionRect; - @override - void updateRenderObject(BuildContext context, CachingBox ro) { - ro - ..setContent(text, revision) - ..selectionRect = selectionRect; - } -} - -void main() { - testWidgets('S7.1 selection drag => ZERO extra content-Picture rebuilds', - (tester) async { - Rect? sel; - late StateSetter setOuter; - await tester.pumpWidget(MaterialApp( - home: Scaffold( - body: Center( - child: StatefulBuilder(builder: (_, setState) { - setOuter = setState; - return CacheWidget( - text: 'Selectable content here', - revision: 0, - selectionRect: sel); - }), - ), - ), - )); - await tester.pumpAndSettle(); - - final box = tester.renderObject(find.byType(CacheWidget)); - expect(box.contentRebuilds, 1); // built once - final paintsAfterFirst = box.paintCount; - - // Simulate a 30-frame selection drag: only selectionRect changes. - for (var i = 0; i < 30; i++) { - setOuter(() => sel = Rect.fromLTWH(0, 0, 4.0 * i, 18)); - await tester.pump(); - } - - debugPrint('S7.1 contentRebuilds=${box.contentRebuilds} ' - 'paints=${box.paintCount}'); - // The overlay redrew every frame (paints grew) but the Picture never rebuilt. - expect(box.contentRebuilds, 1, - reason: 'content Picture reused across drag'); - expect(box.paintCount, greaterThan(paintsAfterFirst)); - }); - - testWidgets('S7.2 content or size change DOES rebuild the Picture', - (tester) async { - var text = 'first'; - var rev = 0; - late StateSetter setOuter; - await tester.pumpWidget(MaterialApp( - home: Scaffold( - body: Center( - child: StatefulBuilder(builder: (_, setState) { - setOuter = setState; - return CacheWidget(text: text, revision: rev); - }), - ), - ), - )); - await tester.pumpAndSettle(); - final box = tester.renderObject(find.byType(CacheWidget)); - expect(box.contentRebuilds, 1); - - setOuter(() { - text = 'second content that is different'; - rev = 1; - }); - await tester.pumpAndSettle(); - debugPrint('S7.2 contentRebuilds=${box.contentRebuilds}'); - expect(box.contentRebuilds, 2, reason: 'content change rebuilds Picture'); - }); - - testWidgets('S7.3 RepaintBoundary isolates per-widget selection repaint', - (tester) async { - Rect? selA; - late StateSetter setOuter; - await tester.pumpWidget(MaterialApp( - home: Scaffold( - body: StatefulBuilder(builder: (_, setState) { - setOuter = setState; - return Column( - children: [ - CacheWidget(text: 'Message A', revision: 0, selectionRect: selA), - const CacheWidget(text: 'Message B', revision: 0), - ], - ); - }), - ), - )); - await tester.pumpAndSettle(); - - final widgets = find.byType(CacheWidget); - final a = tester.renderObject(widgets.at(0)); - final b = tester.renderObject(widgets.at(1)); - final aPaints = a.paintCount, bPaints = b.paintCount; - - // Change ONLY A's selection. - setOuter(() => selA = const Rect.fromLTWH(0, 0, 40, 18)); - await tester.pump(); - - debugPrint('S7.3 A paints $aPaints->${a.paintCount}, ' - 'B paints $bPaints->${b.paintCount}'); - expect(a.paintCount, greaterThan(aPaints), reason: 'A repainted'); - expect(b.paintCount, bPaints, reason: 'B did NOT repaint (isolated)'); - }); -} diff --git a/docs/architecture.md b/docs/architecture.md index da89f67..acff16e 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -55,8 +55,7 @@ renderedOffset)` over an app-supplied registry of immutable `Markdown` models, not over render objects. Text extraction reads the models directly and works even when nothing is mounted; only mounted surfaces contribute geometry (highlight rects, handles). This is the core design decision; the alternatives (Flutter's -`SelectableRegion`, a custom selection delegate) were ruled out in spikes -(`benchmark/experiments/FINDINGS.md`). +`SelectableRegion`, a custom selection delegate). ## Load-bearing invariants @@ -66,8 +65,8 @@ These are cross-cutting; each subsystem doc repeats the ones it owns. it is reused on every repaint and only nulled by `update`/`invalidateLayout` (model/theme change or system-font change). Repaints from selection or scroll must not invalidate it. -- **Highlight outside the cache.** The selection highlight is drawn *before* and - *outside* the cached `Picture`, so drags/streaming repaint only the highlight. +- **Highlight outside the cache.** The selection highlight is drawn _before_ and + _outside_ the cached `Picture`, so drags/streaming repaint only the highlight. `MarkdownRenderObject.isRepaintBoundary` is true whenever a controller is attached, isolating those repaints. - **Offset-space agreement.** A `SelectableBlockPainter`'s `renderedText` and diff --git a/docs/development.md b/docs/development.md index 3f9c419..b14316a 100644 --- a/docs/development.md +++ b/docs/development.md @@ -50,10 +50,6 @@ Parser scenarios live in `benchmark/scenarios.dart` (`prose, inline, links, list table, code, quotes, escapes, currency, pathological, mixed`). `compare.dart` uses warmup + auto-calibrated iterations + min-of-batches for low-noise deltas. -Selection spikes S1–S5 and S7 (`benchmark/experiments/`, findings in -`FINDINGS.md`) are throwaway and not in CI; S6 lives at -`example/lib/experiments/s6_platforms.dart`. - ## CI pipeline (`.github/workflows/`) **`checkout.yml`** (name `Checkout`) — runs on push to `main`/`master` and PRs to @@ -127,7 +123,7 @@ lib/src/ test/ parser/ nodes/ selection/ theme/ widget/ (aggregated by test/unit_test.dart) benchmark/ parser + render benchmarks, compare.dart, scenarios.dart, - .render_baseline.txt, experiments/ (spikes + FINDINGS.md) + .render_baseline.txt example/ md_example app: lib/main.dart (Editor/Selection/Chat tabs), lib/tabs/* AGENTS.md docs/ this documentation set ``` diff --git a/docs/selection.md b/docs/selection.md index 6e67b27..fb9e821 100644 --- a/docs/selection.md +++ b/docs/selection.md @@ -23,10 +23,9 @@ render objects. **Why anchor to the model:** text is derived from an app-supplied registry of immutable `Markdown` models, so `getText()`/`selectedContent()` work even when a widget is unmounted. A selection may span documents whose widgets a `ListView` has -disposed; only mounted docs contribute *geometry*, while *all* in-range docs -contribute *text*. Flutter's `SelectableRegion`/custom delegates were rejected in -spikes (they glue without separators and drop disposed items) — see -`benchmark/experiments/FINDINGS.md`. +disposed; only mounted docs contribute _geometry_, while _all_ in-range docs +contribute _text_. Flutter's `SelectableRegion`/custom delegates were rejected in +spikes (they glue without separators and drop disposed items). ## Document registry @@ -96,7 +95,7 @@ independent of what's mounted (empty slices skipped). - `MarkdownSelectedDocument { documentId, List blocks }` - `MarkdownSelectedBlock { int blockIndex, String type, String text, TextRange - renderedRange, TextRange? sourceRange /* currently always null */, MD$Block block }` +renderedRange, TextRange? sourceRange /* currently always null */, MD$Block block }` `getText([formatter])` = `(formatter ?? controller.formatter).format(selectedContent())`. @@ -178,7 +177,7 @@ Typical pattern: keep models in a list, feed them to the controller, and give ea - `selectionColor` setter repaints surfaces directly and must **not** `notifyListeners` — it's applied during build (`didChangeDependencies`), where - notifying would trigger `setState`. (The `formatter` setter *does* notify.) + notifying would trigger `setState`. (The `formatter` setter _does_ notify.) - Alert **title** is not selectable (body only). - Keyboard word/line extension is **block-approximate** (a whitespace scan / jump to block start-end within the linearized text, not visual lines). Only @@ -193,7 +192,7 @@ Typical pattern: keep models in a list, feed them to the controller, and give ea always null. - `MarkdownThemeData.blockFilter` drops whole blocks at render time, but selection **extraction is model-based** and does not see that filtering. A selection spanning - *across* a dropped block therefore copies the hidden block's text even though the + _across_ a dropped block therefore copies the hidden block's text even though the block is never highlighted on screen. Avoid `blockFilter` when selection is enabled (same guidance as `spanFilter`, which shifts the painter's offset space). - Keyboard **word** navigation indexes the block's rendered text by UTF-16 code unit, diff --git a/example/lib/experiments/s6_platforms.dart b/example/lib/experiments/s6_platforms.dart deleted file mode 100644 index 602951d..0000000 --- a/example/lib/experiments/s6_platforms.dart +++ /dev/null @@ -1,367 +0,0 @@ -// SPIKE S6 — Platform interaction demo (RUN THIS ON EACH PLATFORM). -// -// This wires the recommended architecture end to end at a small scale: -// * a scope-owned controller holds the selection as logical anchors over an -// app-supplied model registry (so it survives disposal); -// * each "message" is a custom Selectable RenderBox that paints its own -// highlight under the glyphs (S3) and registers a surface (S5); -// * a scope gesture layer drives selection from a mouse/touch drag; -// * "Copy" reads controller.getPlainText() (full text WITH separators, even -// for scrolled-off messages); -// * one message is a link, to exercise the tap-vs-drag gesture arena. -// -// It is a manual, interactive spike (touch handles / magnifier / native menus -// can only be judged by a human on device). Headless mechanics are already -// proven by s1..s5,s7 under benchmark/experiments/. -// -// Run (from example/): -// flutter run -t lib/experiments/s6_platforms.dart -d chrome -// flutter run -t lib/experiments/s6_platforms.dart -d linux -// flutter run -t lib/experiments/s6_platforms.dart -d - -import 'package:flutter/gestures.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/rendering.dart'; -import 'package:flutter/services.dart'; - -void main() => runApp(const _App()); - -class _App extends StatefulWidget { - const _App(); - @override - State<_App> createState() => _AppState(); -} - -class _AppState extends State<_App> { - final MdSelectionController controller = MdSelectionController([ - MdDoc('m0', 'Heading of the conversation'), - MdDoc('m1', 'This is the first message. Drag across me and the next ones.'), - MdDoc('m2', 'Second message with a bit more text to select through.'), - MdDoc('m3', 'LINK: tap me to test the tap-vs-drag arena.', isLink: true), - MdDoc('m4', 'Fourth message. Selection should span all of these blocks.'), - MdDoc('m5', 'Fifth and final message in this little transcript.'), - ]); - - @override - Widget build(BuildContext context) { - return MaterialApp( - title: 'S6 selection spike', - home: Scaffold( - appBar: AppBar(title: const Text('flutter_md selection spike (S6)')), - floatingActionButton: FloatingActionButton.extended( - icon: const Icon(Icons.copy), - label: const Text('Copy'), - onPressed: () async { - final text = controller.getPlainText(); - await Clipboard.setData(ClipboardData(text: text)); - if (!context.mounted) return; - ScaffoldMessenger.of(context) - ..clearSnackBars() - ..showSnackBar(SnackBar( - content: Text(text.isEmpty - ? '(no selection)' - : 'Copied ${text.length} chars:\n$text'), - )); - }, - ), - body: MarkdownSelectionScope( - controller: controller, - child: ListView( - padding: const EdgeInsets.all(16), - children: [ - for (final d in controller.docs) - Padding( - padding: const EdgeInsets.symmetric(vertical: 8), - child: MdMessage(docId: d.id), - ), - ], - ), - ), - ), - ); - } -} - -// --------------------------------------------------------------------------- -// Controller + model registry (logical anchors over the immutable text). -// --------------------------------------------------------------------------- -class MdDoc { - MdDoc(this.id, this.text, {this.isLink = false}); - final Object id; - final String text; - final bool isLink; -} - -@immutable -class MdPos { - const MdPos(this.doc, this.offset); - final Object doc; - final int offset; -} - -class MdSelectionController extends ChangeNotifier { - MdSelectionController(this.docs); - final List docs; - final Map _surfaces = {}; - MdPos? base; - MdPos? extent; - - void registerSurface(MdSurface s) => _surfaces[s.docId] = s; - void unregisterSurface(MdSurface s) { - if (_surfaces[s.docId] == s) _surfaces.remove(s.docId); - } - - int docIndex(Object id) => docs.indexWhere((d) => d.id == id); - String _text(Object id) => docs[docIndex(id)].text; - - MdPos? hitTest(Offset global) { - for (final s in _surfaces.values) { - if (s.globalBounds.inflate(6).contains(global)) { - return MdPos(s.docId, s.offsetForGlobal(global)); - } - } - return null; - } - - void startAt(Offset g) { - final p = hitTest(g); - if (p == null) return; - base = extent = p; - notifyListeners(); - } - - void extendTo(Offset g) { - final p = hitTest(g); - if (p == null) return; - extent = p; - notifyListeners(); - } - - void clear() { - base = extent = null; - notifyListeners(); - } - - int _cmp(MdPos a, MdPos b) { - final ai = docIndex(a.doc), bi = docIndex(b.doc); - return ai != bi ? ai.compareTo(bi) : a.offset.compareTo(b.offset); - } - - /// The [start, end] selected range for [docId], or null if not selected. - (int, int)? rangeFor(Object docId) { - if (base == null || extent == null) return null; - var a = base!, b = extent!; - if (_cmp(a, b) > 0) { - final t = a; - a = b; - b = t; - } - final di = docIndex(docId); - if (di < docIndex(a.doc) || di > docIndex(b.doc)) return null; - final len = _text(docId).length; - final from = docId == a.doc ? a.offset : 0; - final to = docId == b.doc ? b.offset : len; - return (from.clamp(0, len), to.clamp(0, len)); - } - - String getPlainText({String docSep = '\n\n'}) { - if (base == null || extent == null) return ''; - var a = base!, b = extent!; - if (_cmp(a, b) > 0) { - final t = a; - a = b; - b = t; - } - final start = docIndex(a.doc), end = docIndex(b.doc); - final chunks = []; - for (var d = start; d <= end; d++) { - final text = docs[d].text; - final from = d == start ? a.offset : 0; - final to = d == end ? b.offset : text.length; - chunks.add( - text.substring(from.clamp(0, text.length), to.clamp(0, text.length))); - } - return chunks.join(docSep); - } -} - -// --------------------------------------------------------------------------- -// Scope: provides the controller + a scope-level drag coordinator. -// --------------------------------------------------------------------------- -abstract interface class MdSurface { - Object get docId; - Rect get globalBounds; - int offsetForGlobal(Offset global); -} - -class _ScopeInherited extends InheritedWidget { - const _ScopeInherited({required this.controller, required super.child}); - final MdSelectionController controller; - static MdSelectionController of(BuildContext c) => - c.dependOnInheritedWidgetOfExactType<_ScopeInherited>()!.controller; - @override - bool updateShouldNotify(_ScopeInherited old) => controller != old.controller; -} - -class MarkdownSelectionScope extends StatelessWidget { - const MarkdownSelectionScope({ - required this.controller, - required this.child, - super.key, - }); - final MdSelectionController controller; - final Widget child; - - @override - Widget build(BuildContext context) { - return _ScopeInherited( - controller: controller, - // Mouse: drag selects. Touch: long-press-then-drag selects (so a plain - // swipe still scrolls the ListView). This is the arena resolution S6 is - // meant to eyeball on each platform. - child: RawGestureDetector( - gestures: { - PanGestureRecognizer: - GestureRecognizerFactoryWithHandlers( - () => PanGestureRecognizer( - supportedDevices: {PointerDeviceKind.mouse}), - (r) => r - ..onStart = ((d) => controller.startAt(d.globalPosition)) - ..onUpdate = ((d) => controller.extendTo(d.globalPosition)), - ), - LongPressGestureRecognizer: - GestureRecognizerFactoryWithHandlers( - () => LongPressGestureRecognizer(), - (r) => r - ..onLongPressStart = ((d) => controller.startAt(d.globalPosition)) - ..onLongPressMoveUpdate = - ((d) => controller.extendTo(d.globalPosition)), - ), - }, - child: child, - ), - ); - } -} - -// --------------------------------------------------------------------------- -// A message = a custom Selectable-ish RenderBox that paints its own highlight. -// --------------------------------------------------------------------------- -class MdMessage extends LeafRenderObjectWidget { - const MdMessage({required this.docId, super.key}); - final Object docId; - - @override - RenderObject createRenderObject(BuildContext context) { - final controller = _ScopeInherited.of(context); - final doc = controller.docs[controller.docIndex(docId)]; - return _MdMessageBox(doc, controller); - } - - @override - void updateRenderObject(BuildContext context, _MdMessageBox ro) { - ro.controller = _ScopeInherited.of(context); - } -} - -class _MdMessageBox extends RenderBox implements MdSurface { - _MdMessageBox(this.doc, this._controller) - : _painter = TextPainter( - text: TextSpan( - text: doc.text, - style: TextStyle( - fontSize: 16, - color: doc.isLink - ? const Color(0xFF1565C0) - : const Color(0xFF111111), - decoration: doc.isLink ? TextDecoration.underline : null, - ), - ), - textDirection: TextDirection.ltr, - ); - - final MdDoc doc; - final TextPainter _painter; - MdSelectionController _controller; - bool _disposed = false; - TapGestureRecognizer? _tap; - - @override - Object get docId => doc.id; - - set controller(MdSelectionController c) { - if (identical(c, _controller)) return; - _controller.removeListener(_onSel); - _controller = c; - _controller.addListener(_onSel); - } - - void _onSel() { - if (!_disposed) markNeedsPaint(); - } - - @override - void attach(PipelineOwner owner) { - super.attach(owner); - _controller - ..registerSurface(this) - ..addListener(_onSel); - if (doc.isLink) { - _tap = TapGestureRecognizer() - ..onTap = () => debugPrint('LINK TAP fired for ${doc.id}'); - } - } - - @override - void detach() { - _controller - ..unregisterSurface(this) - ..removeListener(_onSel); - _tap?.dispose(); - _tap = null; - super.detach(); - } - - @override - void dispose() { - _disposed = true; - _painter.dispose(); - super.dispose(); - } - - // Tap handling for the link (drag is handled at the scope level). - @override - bool hitTestSelf(Offset position) => doc.isLink; - @override - void handleEvent(PointerEvent event, covariant HitTestEntry entry) { - if (doc.isLink && event is PointerDownEvent) _tap?.addPointer(event); - } - - @override - Rect get globalBounds => localToGlobal(Offset.zero) & size; - - @override - int offsetForGlobal(Offset global) => - _painter.getPositionForOffset(globalToLocal(global)).offset; - - @override - void performLayout() { - _painter.layout(maxWidth: constraints.maxWidth); - size = constraints.constrain(Size(constraints.maxWidth, _painter.height)); - } - - @override - void paint(PaintingContext context, Offset offset) { - final range = _controller.rangeFor(doc.id); - if (range != null && range.$1 != range.$2) { - final boxes = _painter.getBoxesForSelection( - TextSelection(baseOffset: range.$1, extentOffset: range.$2), - ); - final paint = Paint()..color = const Color(0x552196F3); - for (final b in boxes) { - context.canvas.drawRect(b.toRect().shift(offset), paint); - } - } - _painter.paint(context.canvas, offset); - } -} diff --git a/example/lib/main.dart b/example/lib/main.dart index cd2a527..ec3ef34 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -6,6 +6,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_md/flutter_md.dart'; import 'tabs/chat_tab.dart'; +import 'tabs/highlight_tab.dart'; import 'tabs/lorem_tab.dart'; void main() => runZonedGuarded( @@ -104,7 +105,7 @@ class HomeScreen extends StatefulWidget { /// State for widget HomeScreen. class _HomeScreenState extends State with SingleTickerProviderStateMixin { - late final TabController _tabs = TabController(length: 3, vsync: this); + late final TabController _tabs = TabController(length: 4, vsync: this); @override void dispose() { @@ -132,6 +133,7 @@ class _HomeScreenState extends State 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)), ], ), ), @@ -142,6 +144,7 @@ class _HomeScreenState extends State EditorTab(), LoremTab(), ChatTab(), + HighlightTab(), ], ), ), diff --git a/example/lib/tabs/highlight_tab.dart b/example/lib/tabs/highlight_tab.dart new file mode 100644 index 0000000..bf5fa0d --- /dev/null +++ b/example/lib/tabs/highlight_tab.dart @@ -0,0 +1,257 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_md/flutter_md.dart'; +import 'package:flutter_md/highlight.dart'; +import 'package:flutter_md/highlight/all.dart'; +import 'package:flutter_md/highlight/themes.dart'; + +/// Showcases syntax highlighting across many popular languages. The whole +/// registry ([allHighlightLanguages]) is used here on purpose — a real app +/// would list only the languages it needs so the rest tree-shakes away. +class HighlightTab extends StatefulWidget { + /// Creates the highlighting showcase tab. + const HighlightTab({super.key}); + + @override + State createState() => _HighlightTabState(); +} + +class _HighlightTabState extends State { + // Built once; the whole registry is shared between both theme variants. + final SyntaxHighlighter _dark = MarkdownHighlighter( + languages: allHighlightLanguages, + theme: HighlightThemes.githubDark, + ); + final SyntaxHighlighter _light = MarkdownHighlighter( + languages: allHighlightLanguages, + theme: HighlightThemes.githubLight, + ); + + final Markdown _doc = Markdown.fromString(_showcase); + + final ScrollController _scrollController = ScrollController(); + + @override + void dispose() { + _scrollController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final isDark = Theme.of(context).brightness == Brightness.dark; + final theme = MarkdownThemeData.mergeTheme( + Theme.of(context), + highlighter: isDark ? _dark : _light, + spanFilter: (span) => !span.style.contains(MD$Style.image), + ); + return Scrollbar( + controller: _scrollController, + child: SingleChildScrollView( + controller: _scrollController, + padding: const EdgeInsets.all(16), + child: MarkdownWidget(markdown: _doc, theme: theme), + ), + ); + } +} + +const String _showcase = r''' +# Syntax highlighting + +Fenced code blocks are tokenized and colored with the GitHub theme (follow the +light/dark switch above). Selection and copy stay aligned with the source — the +highlighter only *partitions* text into spans, it never edits it. + +## Dart + +```dart +import 'dart:math' as math; + +Future roll(int sides) async { + final r = math.Random(); + return r.nextInt(sides) + 1; // 1..sides +} +``` + +## Python + +```python +from dataclasses import dataclass + +@dataclass +class Point: + x: float = 0.0 + y: float = 0.0 + + def dist(self) -> float: + return (self.x ** 2 + self.y ** 2) ** 0.5 # hypot +``` + +## JavaScript + +```js +const memo = new Map(); +export const fib = (n) => + n < 2 ? n : (memo.get(n) ?? memo.set(n, fib(n - 1) + fib(n - 2)).get(n)); +``` + +## TypeScript + +```typescript +interface User { id: number; name: string; } + +async function load(url: string): Promise { + const res = await fetch(`/api/${url}`); + return (await res.json()) as T; +} +``` + +## Rust + +```rust +fn main() { + let nums = vec![1, 2, 3, 4]; + let sum: i32 = nums.iter().filter(|&&x| x % 2 == 0).sum(); + println!("even sum = {sum}"); +} +``` + +## Go + +```go +package main + +import "fmt" + +func main() { + ch := make(chan int, 3) + go func() { ch <- 42 }() + fmt.Println(<-ch) +} +``` + +## Java + +```java +record Point(int x, int y) { + static Point origin() { return new Point(0, 0); } +} +``` + +## Kotlin + +```kotlin +fun main() { + val squares = (1..5).map { it * it } + println(squares.joinToString(prefix = "[", postfix = "]")) +} +``` + +## Swift + +```swift +let names = ["Ada", "Alan", "Grace"] +let greeting = names.map { "Hello, \($0)!" }.joined(separator: "\n") +print(greeting) +``` + +## C++ + +```cpp +#include +#include + +int sum(const std::vector& v) { + return std::accumulate(v.begin(), v.end(), 0); +} +``` + +## C# + +```csharp +public record Money(decimal Amount, string Currency) { + public override string ToString() => $"{Amount:F2} {Currency}"; +} +``` + +## Ruby + +```ruby +def fizzbuzz(n) + (1..n).map { |i| i % 15 == 0 ? "FizzBuzz" : i.to_s } +end +``` + +## PHP + +```php + + + +

Hello & welcome

+ + +``` + +## CSS + +```css +:root { --accent: #0969da; } +.button:hover { + color: var(--accent); + transition: color 120ms ease-in-out; +} +``` + +## SQL + +```sql +SELECT u.name, COUNT(o.id) AS orders +FROM users u +LEFT JOIN orders o ON o.user_id = u.id +WHERE u.active = TRUE +GROUP BY u.name; +``` + +## YAML + +```yaml +service: + name: web + ports: [80, 443] + env: + DEBUG: false # production +``` + +## JSON + +```json +{ + "name": "flutter_md", + "version": "0.2.0", + "keywords": ["markdown", "rendering"], + "stable": true +} +``` + +## Bash + +```bash +#!/usr/bin/env bash +set -euo pipefail +for f in *.log; do + echo "rotating ${f}" + mv -- "$f" "${f}.$(date +%s)" +done +``` +'''; diff --git a/example/test/smoke_test.dart b/example/test/smoke_test.dart index fd2d3fb..31d82c2 100644 --- a/example/test/smoke_test.dart +++ b/example/test/smoke_test.dart @@ -3,6 +3,7 @@ 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', @@ -41,5 +42,12 @@ void main() { 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 09dbed0..a301310 100644 --- a/lib/flutter_md.dart +++ b/lib/flutter_md.dart @@ -23,6 +23,7 @@ export 'src/render.dart' BlockPainter$Table, BlockPainter$Divider, BlockPainter$Spacer; +export 'src/highlight/engine.dart' show CodeHighlightTheme, SyntaxHighlighter; export 'src/selection.dart'; export 'src/selection_scope.dart'; export 'src/theme.dart'; diff --git a/lib/highlight.dart b/lib/highlight.dart new file mode 100644 index 0000000..080558c --- /dev/null +++ b/lib/highlight.dart @@ -0,0 +1,22 @@ +/// Tree-shakeable syntax highlighting for fenced code blocks. +/// +/// Import this entrypoint for the engine and the [MarkdownHighlighter], then +/// one small library per language you use (e.g. `highlight/dart.dart`). +/// Languages you never import are dropped by the compiler. +/// +/// ```dart +/// import 'package:flutter_md/highlight.dart'; +/// import 'package:flutter_md/highlight/dart.dart'; +/// import 'package:flutter_md/highlight/themes.dart'; +/// +/// final theme = MarkdownThemeData( +/// textStyle: const TextStyle(), +/// highlighter: MarkdownHighlighter( +/// languages: {'dart': HighlightDart.grammar}, +/// theme: HighlightThemes.githubDark, +/// ), +/// ); +/// ``` +library; + +export 'src/highlight/engine.dart'; diff --git a/lib/highlight/all.dart b/lib/highlight/all.dart new file mode 100644 index 0000000..48a478a --- /dev/null +++ b/lib/highlight/all.dart @@ -0,0 +1,239 @@ +// GENERATED CODE — do not modify by hand. Regenerate with tool/highlight_codegen. +// ignore_for_file: lines_longer_than_80_chars, public_member_api_docs +// ignore_for_file: prefer_single_quotes, require_trailing_commas, directives_ordering + +import '../highlight.dart'; +import 'apacheconf.dart'; +import 'bash.dart'; +import 'batch.dart'; +import 'c.dart'; +import 'clike.dart'; +import 'clojure.dart'; +import 'coffeescript.dart'; +import 'cpp.dart'; +import 'csharp.dart'; +import 'css.dart'; +import 'dart.dart'; +import 'diff.dart'; +import 'docker.dart'; +import 'elixir.dart'; +import 'elm.dart'; +import 'erlang.dart'; +import 'fsharp.dart'; +import 'git.dart'; +import 'go.dart'; +import 'graphql.dart'; +import 'groovy.dart'; +import 'handlebars.dart'; +import 'haskell.dart'; +import 'html.dart'; +import 'http.dart'; +import 'ini.dart'; +import 'java.dart'; +import 'js.dart'; +import 'json.dart'; +import 'json5.dart'; +import 'jsx.dart'; +import 'julia.dart'; +import 'kotlin.dart'; +import 'latex.dart'; +import 'less.dart'; +import 'lua.dart'; +import 'makefile.dart'; +import 'markdown.dart'; +import 'markup_templating.dart'; +import 'nginx.dart'; +import 'objectivec.dart'; +import 'ocaml.dart'; +import 'perl.dart'; +import 'php.dart'; +import 'plain.dart'; +import 'powershell.dart'; +import 'protobuf.dart'; +import 'python.dart'; +import 'r.dart'; +import 'regex.dart'; +import 'ruby.dart'; +import 'rust.dart'; +import 'sass.dart'; +import 'scala.dart'; +import 'scss.dart'; +import 'solidity.dart'; +import 'sql.dart'; +import 'swift.dart'; +import 'toml.dart'; +import 'tsx.dart'; +import 'typescript.dart'; +import 'vim.dart'; +import 'wasm.dart'; +import 'xml.dart'; +import 'yaml.dart'; +export 'apacheconf.dart'; +export 'bash.dart'; +export 'batch.dart'; +export 'c.dart'; +export 'clike.dart'; +export 'clojure.dart'; +export 'coffeescript.dart'; +export 'cpp.dart'; +export 'csharp.dart'; +export 'css.dart'; +export 'dart.dart'; +export 'diff.dart'; +export 'docker.dart'; +export 'elixir.dart'; +export 'elm.dart'; +export 'erlang.dart'; +export 'fsharp.dart'; +export 'git.dart'; +export 'go.dart'; +export 'graphql.dart'; +export 'groovy.dart'; +export 'handlebars.dart'; +export 'haskell.dart'; +export 'html.dart'; +export 'http.dart'; +export 'ini.dart'; +export 'java.dart'; +export 'js.dart'; +export 'json.dart'; +export 'json5.dart'; +export 'jsx.dart'; +export 'julia.dart'; +export 'kotlin.dart'; +export 'latex.dart'; +export 'less.dart'; +export 'lua.dart'; +export 'makefile.dart'; +export 'markdown.dart'; +export 'markup_templating.dart'; +export 'nginx.dart'; +export 'objectivec.dart'; +export 'ocaml.dart'; +export 'perl.dart'; +export 'php.dart'; +export 'plain.dart'; +export 'powershell.dart'; +export 'protobuf.dart'; +export 'python.dart'; +export 'r.dart'; +export 'regex.dart'; +export 'ruby.dart'; +export 'rust.dart'; +export 'sass.dart'; +export 'scala.dart'; +export 'scss.dart'; +export 'solidity.dart'; +export 'sql.dart'; +export 'swift.dart'; +export 'toml.dart'; +export 'tsx.dart'; +export 'typescript.dart'; +export 'vim.dart'; +export 'wasm.dart'; +export 'xml.dart'; +export 'yaml.dart'; + +/// Every bundled syntax grammar, keyed by language tag and common aliases. +/// +/// Referencing this map pulls in ALL grammars, so unused languages can no longer +/// be removed by tree-shaking — use it for demos or tooling. Production code +/// should assemble a map with only the languages it needs. +final Map allHighlightLanguages = { + 'apacheconf': HighlightApacheconf.grammar, + 'bash': HighlightBash.grammar, + 'batch': HighlightBatch.grammar, + 'c': HighlightC.grammar, + 'clike': HighlightClike.grammar, + 'clojure': HighlightClojure.grammar, + 'coffeescript': HighlightCoffeescript.grammar, + 'cpp': HighlightCpp.grammar, + 'csharp': HighlightCsharp.grammar, + 'css': HighlightCss.grammar, + 'dart': HighlightDart.grammar, + 'diff': HighlightDiff.grammar, + 'docker': HighlightDocker.grammar, + 'elixir': HighlightElixir.grammar, + 'elm': HighlightElm.grammar, + 'erlang': HighlightErlang.grammar, + 'fsharp': HighlightFsharp.grammar, + 'git': HighlightGit.grammar, + 'go': HighlightGo.grammar, + 'graphql': HighlightGraphql.grammar, + 'groovy': HighlightGroovy.grammar, + 'handlebars': HighlightHandlebars.grammar, + 'haskell': HighlightHaskell.grammar, + 'html': HighlightHtml.grammar, + 'http': HighlightHttp.grammar, + 'ini': HighlightIni.grammar, + 'java': HighlightJava.grammar, + 'js': HighlightJs.grammar, + 'json': HighlightJson.grammar, + 'json5': HighlightJson5.grammar, + 'jsx': HighlightJsx.grammar, + 'julia': HighlightJulia.grammar, + 'kotlin': HighlightKotlin.grammar, + 'latex': HighlightLatex.grammar, + 'less': HighlightLess.grammar, + 'lua': HighlightLua.grammar, + 'makefile': HighlightMakefile.grammar, + 'markdown': HighlightMarkdown.grammar, + 'markup-templating': HighlightMarkupTemplating.grammar, + 'nginx': HighlightNginx.grammar, + 'objectivec': HighlightObjectivec.grammar, + 'ocaml': HighlightOcaml.grammar, + 'perl': HighlightPerl.grammar, + 'php': HighlightPhp.grammar, + 'plain': HighlightPlain.grammar, + 'powershell': HighlightPowershell.grammar, + 'protobuf': HighlightProtobuf.grammar, + 'python': HighlightPython.grammar, + 'r': HighlightR.grammar, + 'regex': HighlightRegex.grammar, + 'ruby': HighlightRuby.grammar, + 'rust': HighlightRust.grammar, + 'sass': HighlightSass.grammar, + 'scala': HighlightScala.grammar, + 'scss': HighlightScss.grammar, + 'solidity': HighlightSolidity.grammar, + 'sql': HighlightSql.grammar, + 'swift': HighlightSwift.grammar, + 'toml': HighlightToml.grammar, + 'tsx': HighlightTsx.grammar, + 'typescript': HighlightTypescript.grammar, + 'vim': HighlightVim.grammar, + 'wasm': HighlightWasm.grammar, + 'xml': HighlightXml.grammar, + 'yaml': HighlightYaml.grammar, + 'atom': HighlightXml.grammar, + 'coffee': HighlightCoffeescript.grammar, + 'context': HighlightLatex.grammar, + 'cs': HighlightCsharp.grammar, + 'dockerfile': HighlightDocker.grammar, + 'dotnet': HighlightCsharp.grammar, + 'hbs': HighlightHandlebars.grammar, + 'hs': HighlightHaskell.grammar, + 'javascript': HighlightJs.grammar, + 'kt': HighlightKotlin.grammar, + 'kts': HighlightKotlin.grammar, + 'markup': HighlightHtml.grammar, + 'mathml': HighlightHtml.grammar, + 'md': HighlightMarkdown.grammar, + 'mustache': HighlightHandlebars.grammar, + 'objc': HighlightObjectivec.grammar, + 'plaintext': HighlightPlain.grammar, + 'py': HighlightPython.grammar, + 'rb': HighlightRuby.grammar, + 'rss': HighlightXml.grammar, + 'sh': HighlightBash.grammar, + 'shell': HighlightBash.grammar, + 'sol': HighlightSolidity.grammar, + 'ssml': HighlightXml.grammar, + 'svg': HighlightHtml.grammar, + 'tex': HighlightLatex.grammar, + 'text': HighlightPlain.grammar, + 'ts': HighlightTypescript.grammar, + 'txt': HighlightPlain.grammar, + 'webmanifest': HighlightJson.grammar, + 'yml': HighlightYaml.grammar, +}; diff --git a/lib/highlight/apacheconf.dart b/lib/highlight/apacheconf.dart new file mode 100644 index 0000000..1cd3dc1 --- /dev/null +++ b/lib/highlight/apacheconf.dart @@ -0,0 +1,69 @@ +// GENERATED CODE — do not modify by hand. Regenerate with tool/highlight_codegen. +// ignore_for_file: lines_longer_than_80_chars, public_member_api_docs +// ignore_for_file: prefer_single_quotes, require_trailing_commas, directives_ordering + +import '../highlight.dart'; + +/// Syntax grammar for `apacheconf`. +/// +/// Import this library only when you need `apacheconf` highlighting; unused +/// languages are dropped from the build. +abstract final class HighlightApacheconf { + /// The grammar for `apacheconf`. + static final Grammar grammar = _g0; +} + +final Grammar _g0 = Grammar([ + GrammarToken("comment", compileHighlightPattern("#.*")), + GrammarToken( + "directive-inline", + compileHighlightPattern( + "(^[\\t ]*)\\b(?:AcceptFilter|AcceptPathInfo|AccessFileName|Action|Add(?:Alt|AltByEncoding|AltByType|Charset|DefaultCharset|Description|Encoding|Handler|Icon|IconByEncoding|IconByType|InputFilter|Language|ModuleInfo|OutputFilter|OutputFilterByType|Type)|Alias|AliasMatch|Allow(?:CONNECT|EncodedSlashes|Methods|Override|OverrideList)?|Anonymous(?:_LogEmail|_MustGiveEmail|_NoUserID|_VerifyEmail)?|AsyncRequestWorkerFactor|Auth(?:BasicAuthoritative|BasicFake|BasicProvider|BasicUseDigestAlgorithm|DBDUserPWQuery|DBDUserRealmQuery|DBMGroupFile|DBMType|DBMUserFile|Digest(?:Algorithm|Domain|NonceLifetime|Provider|Qop|ShmemSize)|Form(?:Authoritative|Body|DisableNoStore|FakeBasicAuth|Location|LoginRequiredLocation|LoginSuccessLocation|LogoutLocation|Method|Mimetype|Password|Provider|SitePassphrase|Size|Username)|GroupFile|LDAP(?:AuthorizePrefix|BindAuthoritative|BindDN|BindPassword|CharsetConfig|CompareAsUser|CompareDNOnServer|DereferenceAliases|GroupAttribute|GroupAttributeIsDN|InitialBindAsUser|InitialBindPattern|MaxSubGroupDepth|RemoteUserAttribute|RemoteUserIsDN|SearchAsUser|SubGroupAttribute|SubGroupClass|Url)|Merging|Name|nCache(?:Context|Enable|ProvideFor|SOCache|Timeout)|nzFcgiCheckAuthnProvider|nzFcgiDefineProvider|Type|UserFile|zDBDLoginToReferer|zDBDQuery|zDBDRedirectQuery|zDBMType|zSendForbiddenOnFailure)|BalancerGrowth|BalancerInherit|BalancerMember|BalancerPersist|BrowserMatch|BrowserMatchNoCase|BufferedLogs|BufferSize|Cache(?:DefaultExpire|DetailHeader|DirLength|DirLevels|Disable|Enable|File|Header|IgnoreCacheControl|IgnoreHeaders|IgnoreNoLastMod|IgnoreQueryString|IgnoreURLSessionIdentifiers|KeyBaseURL|LastModifiedFactor|Lock|LockMaxAge|LockPath|MaxExpire|MaxFileSize|MinExpire|MinFileSize|NegotiatedDocs|QuickHandler|ReadSize|ReadTime|Root|Socache(?:MaxSize|MaxTime|MinTime|ReadSize|ReadTime)?|StaleOnError|StoreExpired|StoreNoStore|StorePrivate)|CGIDScriptTimeout|CGIMapExtension|CharsetDefault|CharsetOptions|CharsetSourceEnc|CheckCaseOnly|CheckSpelling|ChrootDir|ContentDigest|CookieDomain|CookieExpires|CookieName|CookieStyle|CookieTracking|CoreDumpDirectory|CustomLog|Dav|DavDepthInfinity|DavGenericLockDB|DavLockDB|DavMinTimeout|DBDExptime|DBDInitSQL|DBDKeep|DBDMax|DBDMin|DBDParams|DBDPersist|DBDPrepareSQL|DBDriver|DefaultIcon|DefaultLanguage|DefaultRuntimeDir|DefaultType|Define|Deflate(?:BufferSize|CompressionLevel|FilterNote|InflateLimitRequestBody|InflateRatio(?:Burst|Limit)|MemLevel|WindowSize)|Deny|DirectoryCheckHandler|DirectoryIndex|DirectoryIndexRedirect|DirectorySlash|DocumentRoot|DTracePrivileges|DumpIOInput|DumpIOOutput|EnableExceptionHook|EnableMMAP|EnableSendfile|Error|ErrorDocument|ErrorLog|ErrorLogFormat|Example|ExpiresActive|ExpiresByType|ExpiresDefault|ExtendedStatus|ExtFilterDefine|ExtFilterOptions|FallbackResource|FileETag|FilterChain|FilterDeclare|FilterProtocol|FilterProvider|FilterTrace|ForceLanguagePriority|ForceType|ForensicLog|GprofDir|GracefulShutdownTimeout|Group|Header|HeaderName|Heartbeat(?:Address|Listen|MaxServers|Storage)|HostnameLookups|IdentityCheck|IdentityCheckTimeout|ImapBase|ImapDefault|ImapMenu|Include|IncludeOptional|Index(?:HeadInsert|Ignore|IgnoreReset|Options|OrderDefault|StyleSheet)|InputSed|ISAPI(?:AppendLogToErrors|AppendLogToQuery|CacheFile|FakeAsync|LogNotSupported|ReadAheadBuffer)|KeepAlive|KeepAliveTimeout|KeptBodySize|LanguagePriority|LDAP(?:CacheEntries|CacheTTL|ConnectionPoolTTL|ConnectionTimeout|LibraryDebug|OpCacheEntries|OpCacheTTL|ReferralHopLimit|Referrals|Retries|RetryDelay|SharedCacheFile|SharedCacheSize|Timeout|TrustedClientCert|TrustedGlobalCert|TrustedMode|VerifyServerCert)|Limit(?:InternalRecursion|Request(?:Body|Fields|FieldSize|Line)|XMLRequestBody)|Listen|ListenBackLog|LoadFile|LoadModule|LogFormat|LogLevel|LogMessage|LuaAuthzProvider|LuaCodeCache|Lua(?:Hook(?:AccessChecker|AuthChecker|CheckUserID|Fixups|InsertFilter|Log|MapToStorage|TranslateName|TypeChecker)|Inherit|InputFilter|MapHandler|OutputFilter|PackageCPath|PackagePath|QuickHandler|Root|Scope)|Max(?:ConnectionsPerChild|KeepAliveRequests|MemFree|RangeOverlaps|RangeReversals|Ranges|RequestWorkers|SpareServers|SpareThreads|Threads)|MergeTrailers|MetaDir|MetaFiles|MetaSuffix|MimeMagicFile|MinSpareServers|MinSpareThreads|MMapFile|ModemStandard|ModMimeUsePathInfo|MultiviewsMatch|Mutex|NameVirtualHost|NoProxy|NWSSLTrustedCerts|NWSSLUpgradeable|Options|Order|OutputSed|PassEnv|PidFile|PrivilegesMode|Protocol|ProtocolEcho|Proxy(?:AddHeaders|BadHeader|Block|Domain|ErrorOverride|ExpressDBMFile|ExpressDBMType|ExpressEnable|FtpDirCharset|FtpEscapeWildcards|FtpListOnWildcard|HTML(?:BufSize|CharsetOut|DocType|Enable|Events|Extended|Fixups|Interp|Links|Meta|StripComments|URLMap)|IOBufferSize|MaxForwards|Pass(?:Inherit|InterpolateEnv|Match|Reverse|ReverseCookieDomain|ReverseCookiePath)?|PreserveHost|ReceiveBufferSize|Remote|RemoteMatch|Requests|SCGIInternalRedirect|SCGISendfile|Set|SourceAddress|Status|Timeout|Via)|ReadmeName|ReceiveBufferSize|Redirect|RedirectMatch|RedirectPermanent|RedirectTemp|ReflectorHeader|RemoteIP(?:Header|InternalProxy|InternalProxyList|ProxiesHeader|TrustedProxy|TrustedProxyList)|RemoveCharset|RemoveEncoding|RemoveHandler|RemoveInputFilter|RemoveLanguage|RemoveOutputFilter|RemoveType|RequestHeader|RequestReadTimeout|Require|Rewrite(?:Base|Cond|Engine|Map|Options|Rule)|RLimitCPU|RLimitMEM|RLimitNPROC|Satisfy|ScoreBoardFile|Script(?:Alias|AliasMatch|InterpreterSource|Log|LogBuffer|LogLength|Sock)?|SecureListen|SeeRequestTail|SendBufferSize|Server(?:Admin|Alias|Limit|Name|Path|Root|Signature|Tokens)|Session(?:Cookie(?:Name|Name2|Remove)|Crypto(?:Cipher|Driver|Passphrase|PassphraseFile)|DBD(?:CookieName|CookieName2|CookieRemove|DeleteLabel|InsertLabel|PerUser|SelectLabel|UpdateLabel)|Env|Exclude|Header|Include|MaxAge)?|SetEnv|SetEnvIf|SetEnvIfExpr|SetEnvIfNoCase|SetHandler|SetInputFilter|SetOutputFilter|SSIEndTag|SSIErrorMsg|SSIETag|SSILastModified|SSILegacyExprParser|SSIStartTag|SSITimeFormat|SSIUndefinedEcho|SSL(?:CACertificateFile|CACertificatePath|CADNRequestFile|CADNRequestPath|CARevocationCheck|CARevocationFile|CARevocationPath|CertificateChainFile|CertificateFile|CertificateKeyFile|CipherSuite|Compression|CryptoDevice|Engine|FIPS|HonorCipherOrder|InsecureRenegotiation|OCSP(?:DefaultResponder|Enable|OverrideResponder|ResponderTimeout|ResponseMaxAge|ResponseTimeSkew|UseRequestNonce)|OpenSSLConfCmd|Options|PassPhraseDialog|Protocol|Proxy(?:CACertificateFile|CACertificatePath|CARevocation(?:Check|File|Path)|CheckPeer(?:CN|Expire|Name)|CipherSuite|Engine|MachineCertificate(?:ChainFile|File|Path)|Protocol|Verify|VerifyDepth)|RandomSeed|RenegBufferSize|Require|RequireSSL|Session(?:Cache|CacheTimeout|TicketKeyFile|Tickets)|SRPUnknownUserSeed|SRPVerifierFile|Stapling(?:Cache|ErrorCacheTimeout|FakeTryLater|ForceURL|ResponderTimeout|ResponseMaxAge|ResponseTimeSkew|ReturnResponderErrors|StandardCacheTimeout)|StrictSNIVHostCheck|UserName|UseStapling|VerifyClient|VerifyDepth)|StartServers|StartThreads|Substitute|Suexec|SuexecUserGroup|ThreadLimit|ThreadsPerChild|ThreadStackSize|TimeOut|TraceEnable|TransferLog|TypesConfig|UnDefine|UndefMacro|UnsetEnv|Use|UseCanonicalName|UseCanonicalPhysicalPort|User|UserDir|VHostCGIMode|VHostCGIPrivs|VHostGroup|VHostPrivs|VHostSecure|VHostUser|Virtual(?:DocumentRoot|ScriptAlias)(?:IP)?|WatchdogInterval|XBitHack|xml2EncAlias|xml2EncDefault|xml2StartParse)\\b", + caseSensitive: false, + multiLine: true), + lookbehind: true, + alias: "property"), + GrammarToken( + "directive-block", + compileHighlightPattern( + "<\\/?\\b(?:Auth[nz]ProviderAlias|Directory|DirectoryMatch|Else|ElseIf|Files|FilesMatch|If|IfDefine|IfModule|IfVersion|Limit|LimitExcept|Location|LocationMatch|Macro|Proxy|Require(?:All|Any|None)|VirtualHost)\\b.*>", + caseSensitive: false), + alias: "tag", + inside: () => _g1), + GrammarToken( + "directive-flags", compileHighlightPattern("\\[(?:[\\w=],?)+\\]"), + alias: "keyword"), + GrammarToken("string", compileHighlightPattern("(\"|').*\\1"), + inside: () => _g5), + GrammarToken( + "variable", compileHighlightPattern("[\$%]\\{?(?:\\w\\.?[-+:]?)+\\}?")), + GrammarToken("regex", compileHighlightPattern("\\^?.*\\\$|\\^.*\\\$?")), +]); + +final Grammar _g1 = Grammar([ + GrammarToken("directive-block", compileHighlightPattern("^<\\/?\\w+"), + alias: "tag", inside: () => _g2), + GrammarToken("directive-block-parameter", compileHighlightPattern(".*[^>]"), + alias: "attr-value", inside: () => _g3), + GrammarToken("punctuation", compileHighlightPattern(">")), +]); + +final Grammar _g2 = Grammar([ + GrammarToken("punctuation", compileHighlightPattern("^<\\/?")), +]); + +final Grammar _g3 = Grammar([ + GrammarToken("punctuation", compileHighlightPattern(":")), + GrammarToken("string", compileHighlightPattern("(\"|').*\\1"), + inside: () => _g4), +]); + +final Grammar _g4 = Grammar([ + GrammarToken( + "variable", compileHighlightPattern("[\$%]\\{?(?:\\w\\.?[-+:]?)+\\}?")), +]); + +final Grammar _g5 = Grammar([ + GrammarToken( + "variable", compileHighlightPattern("[\$%]\\{?(?:\\w\\.?[-+:]?)+\\}?")), +]); diff --git a/lib/highlight/bash.dart b/lib/highlight/bash.dart new file mode 100644 index 0000000..c28878e --- /dev/null +++ b/lib/highlight/bash.dart @@ -0,0 +1,285 @@ +// GENERATED CODE — do not modify by hand. Regenerate with tool/highlight_codegen. +// ignore_for_file: lines_longer_than_80_chars, public_member_api_docs +// ignore_for_file: prefer_single_quotes, require_trailing_commas, directives_ordering + +import '../highlight.dart'; + +/// Syntax grammar for `bash`. +/// +/// Import this library only when you need `bash` highlighting; unused +/// languages are dropped from the build. +abstract final class HighlightBash { + /// The grammar for `bash`. + static final Grammar grammar = _g0; +} + +final Grammar _g0 = Grammar([ + GrammarToken("shebang", compileHighlightPattern("^#!\\s*\\/.*"), + alias: "important"), + GrammarToken("comment", compileHighlightPattern("(^|[^\"{\\\\\$])#.*"), + lookbehind: true), + GrammarToken( + "function-name", + compileHighlightPattern( + "(\\bfunction\\s+)[\\w-]+(?=(?:\\s*\\(?:\\s*\\))?\\s*\\{)"), + lookbehind: true, + alias: "function"), + GrammarToken("function-name", + compileHighlightPattern("\\b[\\w-]+(?=\\s*\\(\\s*\\)\\s*\\{)"), + alias: "function"), + GrammarToken("for-or-select", + compileHighlightPattern("(\\b(?:for|select)\\s+)\\w+(?=\\s+in\\s)"), + lookbehind: true, alias: "variable"), + GrammarToken("assign-left", + compileHighlightPattern("(^|[\\s;|&]|[<>]\\()\\w+(?:\\.\\w+)*(?=\\+?=)"), + lookbehind: true, alias: "variable", inside: () => _g1), + GrammarToken( + "parameter", + compileHighlightPattern( + "(^|\\s)-{1,2}(?:\\w+:[+-]?)?\\w+(?:\\.\\w+)*(?=[=\\s]|\$)"), + lookbehind: true, + alias: "variable"), + GrammarToken( + "string", + compileHighlightPattern( + "((?:^|[^<])<<-?\\s*)(\\w+)\\s[\\s\\S]*?(?:\\r?\\n|\\r)\\2"), + lookbehind: true, + greedy: true, + inside: () => _g2), + GrammarToken( + "string", + compileHighlightPattern( + "((?:^|[^<])<<-?\\s*)([\"'])(\\w+)\\2\\s[\\s\\S]*?(?:\\r?\\n|\\r)\\3"), + lookbehind: true, + greedy: true, + inside: () => _g5), + GrammarToken( + "string", + compileHighlightPattern( + "(^|[^\\\\](?:\\\\\\\\)*)\"(?:\\\\[\\s\\S]|\\\$\\([^)]+\\)|\\\$(?!\\()|`[^`]+`|[^\"\\\\`\$])*\""), + lookbehind: true, + greedy: true, + inside: () => _g2), + GrammarToken("string", compileHighlightPattern("(^|[^\$\\\\])'[^']*'"), + lookbehind: true, greedy: true), + GrammarToken( + "string", compileHighlightPattern("\\\$'(?:[^'\\\\]|\\\\[\\s\\S])*'"), + greedy: true, inside: () => _g6), + GrammarToken( + "environment", + compileHighlightPattern( + "\\\$?\\b(?:BASH|BASHOPTS|BASH_ALIASES|BASH_ARGC|BASH_ARGV|BASH_CMDS|BASH_COMPLETION_COMPAT_DIR|BASH_LINENO|BASH_REMATCH|BASH_SOURCE|BASH_VERSINFO|BASH_VERSION|COLORTERM|COLUMNS|COMP_WORDBREAKS|DBUS_SESSION_BUS_ADDRESS|DEFAULTS_PATH|DESKTOP_SESSION|DIRSTACK|DISPLAY|EUID|GDMSESSION|GDM_LANG|GNOME_KEYRING_CONTROL|GNOME_KEYRING_PID|GPG_AGENT_INFO|GROUPS|HISTCONTROL|HISTFILE|HISTFILESIZE|HISTSIZE|HOME|HOSTNAME|HOSTTYPE|IFS|INSTANCE|JOB|LANG|LANGUAGE|LC_ADDRESS|LC_ALL|LC_IDENTIFICATION|LC_MEASUREMENT|LC_MONETARY|LC_NAME|LC_NUMERIC|LC_PAPER|LC_TELEPHONE|LC_TIME|LESSCLOSE|LESSOPEN|LINES|LOGNAME|LS_COLORS|MACHTYPE|MAILCHECK|MANDATORY_PATH|NO_AT_BRIDGE|OLDPWD|OPTERR|OPTIND|ORBIT_SOCKETDIR|OSTYPE|PAPERSIZE|PATH|PIPESTATUS|PPID|PS1|PS2|PS3|PS4|PWD|RANDOM|REPLY|SECONDS|SELINUX_INIT|SESSION|SESSIONTYPE|SESSION_MANAGER|SHELL|SHELLOPTS|SHLVL|SSH_AUTH_SOCK|TERM|UID|UPSTART_EVENTS|UPSTART_INSTANCE|UPSTART_JOB|UPSTART_SESSION|USER|WINDOWID|XAUTHORITY|XDG_CONFIG_DIRS|XDG_CURRENT_DESKTOP|XDG_DATA_DIRS|XDG_GREETER_DATA_DIR|XDG_MENU_PREFIX|XDG_RUNTIME_DIR|XDG_SEAT|XDG_SEAT_PATH|XDG_SESSION_DESKTOP|XDG_SESSION_ID|XDG_SESSION_PATH|XDG_SESSION_TYPE|XDG_VTNR|XMODIFIERS)\\b"), + alias: "constant"), + GrammarToken( + "variable", compileHighlightPattern("\\\$?\\(\\([\\s\\S]+?\\)\\)"), + greedy: true, inside: () => _g3), + GrammarToken("variable", + compileHighlightPattern("\\\$\\((?:\\([^)]+\\)|[^()])+\\)|`[^`]+`"), + greedy: true, inside: () => _g4), + GrammarToken("variable", compileHighlightPattern("\\\$\\{[^}]+\\}"), + greedy: true, inside: () => _g8), + GrammarToken("variable", compileHighlightPattern("\\\$(?:\\w+|[#?*!@\$])")), + GrammarToken( + "function", + compileHighlightPattern( + "(^|[\\s;|&]|[<>]\\()(?:add|apropos|apt|apt-cache|apt-get|aptitude|aspell|automysqlbackup|awk|basename|bash|bc|bconsole|bg|bzip2|cal|cargo|cat|cfdisk|chgrp|chkconfig|chmod|chown|chroot|cksum|clear|cmp|column|comm|composer|cp|cron|crontab|csplit|curl|cut|date|dc|dd|ddrescue|debootstrap|df|diff|diff3|dig|dir|dircolors|dirname|dirs|dmesg|docker|docker-compose|du|egrep|eject|env|ethtool|expand|expect|expr|fdformat|fdisk|fg|fgrep|file|find|fmt|fold|format|free|fsck|ftp|fuser|gawk|git|gparted|grep|groupadd|groupdel|groupmod|groups|grub-mkconfig|gzip|halt|head|hg|history|host|hostname|htop|iconv|id|ifconfig|ifdown|ifup|import|install|ip|java|jobs|join|kill|killall|less|link|ln|locate|logname|logrotate|look|lpc|lpr|lprint|lprintd|lprintq|lprm|ls|lsof|lynx|make|man|mc|mdadm|mkconfig|mkdir|mke2fs|mkfifo|mkfs|mkisofs|mknod|mkswap|mmv|more|most|mount|mtools|mtr|mutt|mv|nano|nc|netstat|nice|nl|node|nohup|notify-send|npm|nslookup|op|open|parted|passwd|paste|pathchk|ping|pkill|pnpm|podman|podman-compose|popd|pr|printcap|printenv|ps|pushd|pv|quota|quotacheck|quotactl|ram|rar|rcp|reboot|remsync|rename|renice|rev|rm|rmdir|rpm|rsync|scp|screen|sdiff|sed|sendmail|seq|service|sftp|sh|shellcheck|shuf|shutdown|sleep|slocate|sort|split|ssh|stat|strace|su|sudo|sum|suspend|swapon|sync|sysctl|tac|tail|tar|tee|time|timeout|top|touch|tr|traceroute|tsort|tty|umount|uname|unexpand|uniq|units|unrar|unshar|unzip|update-grub|uptime|useradd|userdel|usermod|users|uudecode|uuencode|v|vcpkg|vdir|vi|vim|virsh|vmstat|wait|watch|wc|wget|whereis|which|who|whoami|write|xargs|xdg-open|yarn|yes|zenity|zip|zsh|zypper)(?=\$|[)\\s;|&])"), + lookbehind: true), + GrammarToken( + "keyword", + compileHighlightPattern( + "(^|[\\s;|&]|[<>]\\()(?:case|do|done|elif|else|esac|fi|for|function|if|in|select|then|until|while)(?=\$|[)\\s;|&])"), + lookbehind: true), + GrammarToken( + "builtin", + compileHighlightPattern( + "(^|[\\s;|&]|[<>]\\()(?:\\.|:|alias|bind|break|builtin|caller|cd|command|continue|declare|echo|enable|eval|exec|exit|export|getopts|hash|help|let|local|logout|mapfile|printf|pwd|read|readarray|readonly|return|set|shift|shopt|source|test|times|trap|type|typeset|ulimit|umask|unalias|unset)(?=\$|[)\\s;|&])"), + lookbehind: true, + alias: "class-name"), + GrammarToken( + "boolean", + compileHighlightPattern( + "(^|[\\s;|&]|[<>]\\()(?:false|true)(?=\$|[)\\s;|&])"), + lookbehind: true), + GrammarToken("file-descriptor", compileHighlightPattern("\\B&\\d\\b"), + alias: "important"), + GrammarToken( + "operator", + compileHighlightPattern( + "\\d?<>|>\\||\\+=|=[=~]?|!=?|<<[<-]?|[&\\d]?>>|\\d[<>]&?|[<>][&=]?|&[>&]?|\\|[&|]?"), + inside: () => _g7), + GrammarToken("punctuation", + compileHighlightPattern("\\\$?\\(\\(?|\\)\\)?|\\.\\.|[{}[\\];\\\\]")), + GrammarToken("number", + compileHighlightPattern("(^|\\s)(?:[1-9]\\d*|0)(?:[.,]\\d+)?\\b"), + lookbehind: true), +]); + +final Grammar _g1 = Grammar([ + GrammarToken( + "environment", + compileHighlightPattern( + "(^|[\\s;|&]|[<>]\\()\\b(?:BASH|BASHOPTS|BASH_ALIASES|BASH_ARGC|BASH_ARGV|BASH_CMDS|BASH_COMPLETION_COMPAT_DIR|BASH_LINENO|BASH_REMATCH|BASH_SOURCE|BASH_VERSINFO|BASH_VERSION|COLORTERM|COLUMNS|COMP_WORDBREAKS|DBUS_SESSION_BUS_ADDRESS|DEFAULTS_PATH|DESKTOP_SESSION|DIRSTACK|DISPLAY|EUID|GDMSESSION|GDM_LANG|GNOME_KEYRING_CONTROL|GNOME_KEYRING_PID|GPG_AGENT_INFO|GROUPS|HISTCONTROL|HISTFILE|HISTFILESIZE|HISTSIZE|HOME|HOSTNAME|HOSTTYPE|IFS|INSTANCE|JOB|LANG|LANGUAGE|LC_ADDRESS|LC_ALL|LC_IDENTIFICATION|LC_MEASUREMENT|LC_MONETARY|LC_NAME|LC_NUMERIC|LC_PAPER|LC_TELEPHONE|LC_TIME|LESSCLOSE|LESSOPEN|LINES|LOGNAME|LS_COLORS|MACHTYPE|MAILCHECK|MANDATORY_PATH|NO_AT_BRIDGE|OLDPWD|OPTERR|OPTIND|ORBIT_SOCKETDIR|OSTYPE|PAPERSIZE|PATH|PIPESTATUS|PPID|PS1|PS2|PS3|PS4|PWD|RANDOM|REPLY|SECONDS|SELINUX_INIT|SESSION|SESSIONTYPE|SESSION_MANAGER|SHELL|SHELLOPTS|SHLVL|SSH_AUTH_SOCK|TERM|UID|UPSTART_EVENTS|UPSTART_INSTANCE|UPSTART_JOB|UPSTART_SESSION|USER|WINDOWID|XAUTHORITY|XDG_CONFIG_DIRS|XDG_CURRENT_DESKTOP|XDG_DATA_DIRS|XDG_GREETER_DATA_DIR|XDG_MENU_PREFIX|XDG_RUNTIME_DIR|XDG_SEAT|XDG_SEAT_PATH|XDG_SESSION_DESKTOP|XDG_SESSION_ID|XDG_SESSION_PATH|XDG_SESSION_TYPE|XDG_VTNR|XMODIFIERS)\\b"), + lookbehind: true, + alias: "constant"), +]); + +final Grammar _g2 = Grammar([ + GrammarToken( + "bash", compileHighlightPattern("(^([\"']?)\\w+\\2)[ \\t]+\\S.*"), + lookbehind: true, alias: "punctuation", inside: () => _g0), + GrammarToken( + "environment", + compileHighlightPattern( + "\\\$\\b(?:BASH|BASHOPTS|BASH_ALIASES|BASH_ARGC|BASH_ARGV|BASH_CMDS|BASH_COMPLETION_COMPAT_DIR|BASH_LINENO|BASH_REMATCH|BASH_SOURCE|BASH_VERSINFO|BASH_VERSION|COLORTERM|COLUMNS|COMP_WORDBREAKS|DBUS_SESSION_BUS_ADDRESS|DEFAULTS_PATH|DESKTOP_SESSION|DIRSTACK|DISPLAY|EUID|GDMSESSION|GDM_LANG|GNOME_KEYRING_CONTROL|GNOME_KEYRING_PID|GPG_AGENT_INFO|GROUPS|HISTCONTROL|HISTFILE|HISTFILESIZE|HISTSIZE|HOME|HOSTNAME|HOSTTYPE|IFS|INSTANCE|JOB|LANG|LANGUAGE|LC_ADDRESS|LC_ALL|LC_IDENTIFICATION|LC_MEASUREMENT|LC_MONETARY|LC_NAME|LC_NUMERIC|LC_PAPER|LC_TELEPHONE|LC_TIME|LESSCLOSE|LESSOPEN|LINES|LOGNAME|LS_COLORS|MACHTYPE|MAILCHECK|MANDATORY_PATH|NO_AT_BRIDGE|OLDPWD|OPTERR|OPTIND|ORBIT_SOCKETDIR|OSTYPE|PAPERSIZE|PATH|PIPESTATUS|PPID|PS1|PS2|PS3|PS4|PWD|RANDOM|REPLY|SECONDS|SELINUX_INIT|SESSION|SESSIONTYPE|SESSION_MANAGER|SHELL|SHELLOPTS|SHLVL|SSH_AUTH_SOCK|TERM|UID|UPSTART_EVENTS|UPSTART_INSTANCE|UPSTART_JOB|UPSTART_SESSION|USER|WINDOWID|XAUTHORITY|XDG_CONFIG_DIRS|XDG_CURRENT_DESKTOP|XDG_DATA_DIRS|XDG_GREETER_DATA_DIR|XDG_MENU_PREFIX|XDG_RUNTIME_DIR|XDG_SEAT|XDG_SEAT_PATH|XDG_SESSION_DESKTOP|XDG_SESSION_ID|XDG_SESSION_PATH|XDG_SESSION_TYPE|XDG_VTNR|XMODIFIERS)\\b"), + alias: "constant"), + GrammarToken( + "variable", compileHighlightPattern("\\\$?\\(\\([\\s\\S]+?\\)\\)"), + greedy: true, inside: () => _g3), + GrammarToken("variable", + compileHighlightPattern("\\\$\\((?:\\([^)]+\\)|[^()])+\\)|`[^`]+`"), + greedy: true, inside: () => _g4), + GrammarToken("variable", compileHighlightPattern("\\\$\\{[^}]+\\}"), + greedy: true, inside: () => _g8), + GrammarToken("variable", compileHighlightPattern("\\\$(?:\\w+|[#?*!@\$])")), + GrammarToken( + "entity", + compileHighlightPattern( + "\\\\(?:[abceEfnrtv\\\\\"]|O?[0-7]{1,3}|U[0-9a-fA-F]{8}|u[0-9a-fA-F]{4}|x[0-9a-fA-F]{1,2})")), +]); + +final Grammar _g3 = Grammar([ + GrammarToken( + "variable", compileHighlightPattern("(^\\\$\\(\\([\\s\\S]+)\\)\\)"), + lookbehind: true), + GrammarToken("variable", compileHighlightPattern("^\\\$\\(\\(")), + GrammarToken( + "number", + compileHighlightPattern( + "\\b0x[\\dA-Fa-f]+\\b|(?:\\b\\d+(?:\\.\\d*)?|\\B\\.\\d+)(?:[Ee]-?\\d+)?")), + GrammarToken( + "operator", + compileHighlightPattern( + "--|\\+\\+|\\*\\*=?|<<=?|>>=?|&&|\\|\\||[=!+\\-*/%<>^&|]=?|[?~:]")), + GrammarToken("punctuation", compileHighlightPattern("\\(\\(?|\\)\\)?|,|;")), +]); + +final Grammar _g4 = Grammar([ + GrammarToken("variable", compileHighlightPattern("^\\\$\\(|^`|\\)\$|`\$")), + GrammarToken("comment", compileHighlightPattern("(^|[^\"{\\\\\$])#.*"), + lookbehind: true), + GrammarToken( + "function-name", + compileHighlightPattern( + "(\\bfunction\\s+)[\\w-]+(?=(?:\\s*\\(?:\\s*\\))?\\s*\\{)"), + lookbehind: true, + alias: "function"), + GrammarToken("function-name", + compileHighlightPattern("\\b[\\w-]+(?=\\s*\\(\\s*\\)\\s*\\{)"), + alias: "function"), + GrammarToken("for-or-select", + compileHighlightPattern("(\\b(?:for|select)\\s+)\\w+(?=\\s+in\\s)"), + lookbehind: true, alias: "variable"), + GrammarToken("assign-left", + compileHighlightPattern("(^|[\\s;|&]|[<>]\\()\\w+(?:\\.\\w+)*(?=\\+?=)"), + lookbehind: true, alias: "variable", inside: () => _g1), + GrammarToken( + "parameter", + compileHighlightPattern( + "(^|\\s)-{1,2}(?:\\w+:[+-]?)?\\w+(?:\\.\\w+)*(?=[=\\s]|\$)"), + lookbehind: true, + alias: "variable"), + GrammarToken( + "string", + compileHighlightPattern( + "((?:^|[^<])<<-?\\s*)(\\w+)\\s[\\s\\S]*?(?:\\r?\\n|\\r)\\2"), + lookbehind: true, + greedy: true, + inside: () => _g2), + GrammarToken( + "string", + compileHighlightPattern( + "((?:^|[^<])<<-?\\s*)([\"'])(\\w+)\\2\\s[\\s\\S]*?(?:\\r?\\n|\\r)\\3"), + lookbehind: true, + greedy: true, + inside: () => _g5), + GrammarToken( + "string", + compileHighlightPattern( + "(^|[^\\\\](?:\\\\\\\\)*)\"(?:\\\\[\\s\\S]|\\\$\\([^)]+\\)|\\\$(?!\\()|`[^`]+`|[^\"\\\\`\$])*\""), + lookbehind: true, + greedy: true, + inside: () => _g2), + GrammarToken("string", compileHighlightPattern("(^|[^\$\\\\])'[^']*'"), + lookbehind: true, greedy: true), + GrammarToken( + "string", compileHighlightPattern("\\\$'(?:[^'\\\\]|\\\\[\\s\\S])*'"), + greedy: true, inside: () => _g6), + GrammarToken( + "environment", + compileHighlightPattern( + "\\\$?\\b(?:BASH|BASHOPTS|BASH_ALIASES|BASH_ARGC|BASH_ARGV|BASH_CMDS|BASH_COMPLETION_COMPAT_DIR|BASH_LINENO|BASH_REMATCH|BASH_SOURCE|BASH_VERSINFO|BASH_VERSION|COLORTERM|COLUMNS|COMP_WORDBREAKS|DBUS_SESSION_BUS_ADDRESS|DEFAULTS_PATH|DESKTOP_SESSION|DIRSTACK|DISPLAY|EUID|GDMSESSION|GDM_LANG|GNOME_KEYRING_CONTROL|GNOME_KEYRING_PID|GPG_AGENT_INFO|GROUPS|HISTCONTROL|HISTFILE|HISTFILESIZE|HISTSIZE|HOME|HOSTNAME|HOSTTYPE|IFS|INSTANCE|JOB|LANG|LANGUAGE|LC_ADDRESS|LC_ALL|LC_IDENTIFICATION|LC_MEASUREMENT|LC_MONETARY|LC_NAME|LC_NUMERIC|LC_PAPER|LC_TELEPHONE|LC_TIME|LESSCLOSE|LESSOPEN|LINES|LOGNAME|LS_COLORS|MACHTYPE|MAILCHECK|MANDATORY_PATH|NO_AT_BRIDGE|OLDPWD|OPTERR|OPTIND|ORBIT_SOCKETDIR|OSTYPE|PAPERSIZE|PATH|PIPESTATUS|PPID|PS1|PS2|PS3|PS4|PWD|RANDOM|REPLY|SECONDS|SELINUX_INIT|SESSION|SESSIONTYPE|SESSION_MANAGER|SHELL|SHELLOPTS|SHLVL|SSH_AUTH_SOCK|TERM|UID|UPSTART_EVENTS|UPSTART_INSTANCE|UPSTART_JOB|UPSTART_SESSION|USER|WINDOWID|XAUTHORITY|XDG_CONFIG_DIRS|XDG_CURRENT_DESKTOP|XDG_DATA_DIRS|XDG_GREETER_DATA_DIR|XDG_MENU_PREFIX|XDG_RUNTIME_DIR|XDG_SEAT|XDG_SEAT_PATH|XDG_SESSION_DESKTOP|XDG_SESSION_ID|XDG_SESSION_PATH|XDG_SESSION_TYPE|XDG_VTNR|XMODIFIERS)\\b"), + alias: "constant"), + GrammarToken( + "function", + compileHighlightPattern( + "(^|[\\s;|&]|[<>]\\()(?:add|apropos|apt|apt-cache|apt-get|aptitude|aspell|automysqlbackup|awk|basename|bash|bc|bconsole|bg|bzip2|cal|cargo|cat|cfdisk|chgrp|chkconfig|chmod|chown|chroot|cksum|clear|cmp|column|comm|composer|cp|cron|crontab|csplit|curl|cut|date|dc|dd|ddrescue|debootstrap|df|diff|diff3|dig|dir|dircolors|dirname|dirs|dmesg|docker|docker-compose|du|egrep|eject|env|ethtool|expand|expect|expr|fdformat|fdisk|fg|fgrep|file|find|fmt|fold|format|free|fsck|ftp|fuser|gawk|git|gparted|grep|groupadd|groupdel|groupmod|groups|grub-mkconfig|gzip|halt|head|hg|history|host|hostname|htop|iconv|id|ifconfig|ifdown|ifup|import|install|ip|java|jobs|join|kill|killall|less|link|ln|locate|logname|logrotate|look|lpc|lpr|lprint|lprintd|lprintq|lprm|ls|lsof|lynx|make|man|mc|mdadm|mkconfig|mkdir|mke2fs|mkfifo|mkfs|mkisofs|mknod|mkswap|mmv|more|most|mount|mtools|mtr|mutt|mv|nano|nc|netstat|nice|nl|node|nohup|notify-send|npm|nslookup|op|open|parted|passwd|paste|pathchk|ping|pkill|pnpm|podman|podman-compose|popd|pr|printcap|printenv|ps|pushd|pv|quota|quotacheck|quotactl|ram|rar|rcp|reboot|remsync|rename|renice|rev|rm|rmdir|rpm|rsync|scp|screen|sdiff|sed|sendmail|seq|service|sftp|sh|shellcheck|shuf|shutdown|sleep|slocate|sort|split|ssh|stat|strace|su|sudo|sum|suspend|swapon|sync|sysctl|tac|tail|tar|tee|time|timeout|top|touch|tr|traceroute|tsort|tty|umount|uname|unexpand|uniq|units|unrar|unshar|unzip|update-grub|uptime|useradd|userdel|usermod|users|uudecode|uuencode|v|vcpkg|vdir|vi|vim|virsh|vmstat|wait|watch|wc|wget|whereis|which|who|whoami|write|xargs|xdg-open|yarn|yes|zenity|zip|zsh|zypper)(?=\$|[)\\s;|&])"), + lookbehind: true), + GrammarToken( + "keyword", + compileHighlightPattern( + "(^|[\\s;|&]|[<>]\\()(?:case|do|done|elif|else|esac|fi|for|function|if|in|select|then|until|while)(?=\$|[)\\s;|&])"), + lookbehind: true), + GrammarToken( + "builtin", + compileHighlightPattern( + "(^|[\\s;|&]|[<>]\\()(?:\\.|:|alias|bind|break|builtin|caller|cd|command|continue|declare|echo|enable|eval|exec|exit|export|getopts|hash|help|let|local|logout|mapfile|printf|pwd|read|readarray|readonly|return|set|shift|shopt|source|test|times|trap|type|typeset|ulimit|umask|unalias|unset)(?=\$|[)\\s;|&])"), + lookbehind: true, + alias: "class-name"), + GrammarToken( + "boolean", + compileHighlightPattern( + "(^|[\\s;|&]|[<>]\\()(?:false|true)(?=\$|[)\\s;|&])"), + lookbehind: true), + GrammarToken("file-descriptor", compileHighlightPattern("\\B&\\d\\b"), + alias: "important"), + GrammarToken( + "operator", + compileHighlightPattern( + "\\d?<>|>\\||\\+=|=[=~]?|!=?|<<[<-]?|[&\\d]?>>|\\d[<>]&?|[<>][&=]?|&[>&]?|\\|[&|]?"), + inside: () => _g7), + GrammarToken("punctuation", + compileHighlightPattern("\\\$?\\(\\(?|\\)\\)?|\\.\\.|[{}[\\];\\\\]")), + GrammarToken("number", + compileHighlightPattern("(^|\\s)(?:[1-9]\\d*|0)(?:[.,]\\d+)?\\b"), + lookbehind: true), +]); + +final Grammar _g5 = Grammar([ + GrammarToken( + "bash", compileHighlightPattern("(^([\"']?)\\w+\\2)[ \\t]+\\S.*"), + lookbehind: true, alias: "punctuation", inside: () => _g0), +]); + +final Grammar _g6 = Grammar([ + GrammarToken( + "entity", + compileHighlightPattern( + "\\\\(?:[abceEfnrtv\\\\\"]|O?[0-7]{1,3}|U[0-9a-fA-F]{8}|u[0-9a-fA-F]{4}|x[0-9a-fA-F]{1,2})")), +]); + +final Grammar _g7 = Grammar([ + GrammarToken("file-descriptor", compileHighlightPattern("^\\d"), + alias: "important"), +]); + +final Grammar _g8 = Grammar([ + GrammarToken("operator", + compileHighlightPattern(":[-=?+]?|[!\\/]|##?|%%?|\\^\\^?|,,?")), + GrammarToken("punctuation", compileHighlightPattern("[\\[\\]]")), + GrammarToken( + "environment", + compileHighlightPattern( + "(\\{)\\b(?:BASH|BASHOPTS|BASH_ALIASES|BASH_ARGC|BASH_ARGV|BASH_CMDS|BASH_COMPLETION_COMPAT_DIR|BASH_LINENO|BASH_REMATCH|BASH_SOURCE|BASH_VERSINFO|BASH_VERSION|COLORTERM|COLUMNS|COMP_WORDBREAKS|DBUS_SESSION_BUS_ADDRESS|DEFAULTS_PATH|DESKTOP_SESSION|DIRSTACK|DISPLAY|EUID|GDMSESSION|GDM_LANG|GNOME_KEYRING_CONTROL|GNOME_KEYRING_PID|GPG_AGENT_INFO|GROUPS|HISTCONTROL|HISTFILE|HISTFILESIZE|HISTSIZE|HOME|HOSTNAME|HOSTTYPE|IFS|INSTANCE|JOB|LANG|LANGUAGE|LC_ADDRESS|LC_ALL|LC_IDENTIFICATION|LC_MEASUREMENT|LC_MONETARY|LC_NAME|LC_NUMERIC|LC_PAPER|LC_TELEPHONE|LC_TIME|LESSCLOSE|LESSOPEN|LINES|LOGNAME|LS_COLORS|MACHTYPE|MAILCHECK|MANDATORY_PATH|NO_AT_BRIDGE|OLDPWD|OPTERR|OPTIND|ORBIT_SOCKETDIR|OSTYPE|PAPERSIZE|PATH|PIPESTATUS|PPID|PS1|PS2|PS3|PS4|PWD|RANDOM|REPLY|SECONDS|SELINUX_INIT|SESSION|SESSIONTYPE|SESSION_MANAGER|SHELL|SHELLOPTS|SHLVL|SSH_AUTH_SOCK|TERM|UID|UPSTART_EVENTS|UPSTART_INSTANCE|UPSTART_JOB|UPSTART_SESSION|USER|WINDOWID|XAUTHORITY|XDG_CONFIG_DIRS|XDG_CURRENT_DESKTOP|XDG_DATA_DIRS|XDG_GREETER_DATA_DIR|XDG_MENU_PREFIX|XDG_RUNTIME_DIR|XDG_SEAT|XDG_SEAT_PATH|XDG_SESSION_DESKTOP|XDG_SESSION_ID|XDG_SESSION_PATH|XDG_SESSION_TYPE|XDG_VTNR|XMODIFIERS)\\b"), + lookbehind: true, + alias: "constant"), +]); diff --git a/lib/highlight/batch.dart b/lib/highlight/batch.dart new file mode 100644 index 0000000..41828a6 --- /dev/null +++ b/lib/highlight/batch.dart @@ -0,0 +1,151 @@ +// GENERATED CODE — do not modify by hand. Regenerate with tool/highlight_codegen. +// ignore_for_file: lines_longer_than_80_chars, public_member_api_docs +// ignore_for_file: prefer_single_quotes, require_trailing_commas, directives_ordering + +import '../highlight.dart'; + +/// Syntax grammar for `batch`. +/// +/// Import this library only when you need `batch` highlighting; unused +/// languages are dropped from the build. +abstract final class HighlightBatch { + /// The grammar for `batch`. + static final Grammar grammar = _g0; +} + +final Grammar _g0 = Grammar([ + GrammarToken("comment", compileHighlightPattern("^::.*", multiLine: true)), + GrammarToken( + "comment", + compileHighlightPattern( + "((?:^|[&(])[ \\t]*)rem\\b(?:[^^&)\\r\\n]|\\^(?:\\r\\n|[\\s\\S]))*", + caseSensitive: false, + multiLine: true), + lookbehind: true), + GrammarToken("label", compileHighlightPattern("^:.*", multiLine: true), + alias: "property"), + GrammarToken( + "command", + compileHighlightPattern( + "((?:^|[&(])[ \\t]*)for(?: \\/[a-z?](?:[ :](?:\"[^\"]*\"|[^\\s\"/]\\S*))?)* \\S+ in \\([^)]+\\) do", + caseSensitive: false, + multiLine: true), + lookbehind: true, + inside: () => _g1), + GrammarToken( + "command", + compileHighlightPattern( + "((?:^|[&(])[ \\t]*)if(?: \\/[a-z?](?:[ :](?:\"[^\"]*\"|[^\\s\"/]\\S*))?)* (?:not )?(?:cmdextversion \\d+|defined \\w+|errorlevel \\d+|exist \\S+|(?:\"[^\"]*\"|(?!\")(?:(?!==)\\S)+)?(?:==| (?:equ|geq|gtr|leq|lss|neq) )(?:\"[^\"]*\"|[^\\s\"]\\S*))", + caseSensitive: false, + multiLine: true), + lookbehind: true, + inside: () => _g3), + GrammarToken( + "command", + compileHighlightPattern("((?:^|[&()])[ \\t]*)else\\b", + caseSensitive: false, multiLine: true), + lookbehind: true, + inside: () => _g4), + GrammarToken( + "command", + compileHighlightPattern( + "((?:^|[&(])[ \\t]*)set(?: \\/[a-z](?:[ :](?:\"[^\"]*\"|[^\\s\"/]\\S*))?)* (?:[^^&)\\r\\n]|\\^(?:\\r\\n|[\\s\\S]))*", + caseSensitive: false, + multiLine: true), + lookbehind: true, + inside: () => _g5), + GrammarToken( + "command", + compileHighlightPattern( + "((?:^|[&(])[ \\t]*@?)\\w+\\b(?:\"(?:[\\\\\"]\"|[^\"])*\"(?!\")|[^\"^&)\\r\\n]|\\^(?:\\r\\n|[\\s\\S]))*", + multiLine: true), + lookbehind: true, + inside: () => _g6), + GrammarToken("operator", compileHighlightPattern("[&@]")), + GrammarToken("punctuation", compileHighlightPattern("[()']")), +]); + +final Grammar _g1 = Grammar([ + GrammarToken("keyword", + compileHighlightPattern("\\b(?:do|in)\\b|^for\\b", caseSensitive: false)), + GrammarToken( + "string", compileHighlightPattern("\"(?:[\\\\\"]\"|[^\"])*\"(?!\")")), + GrammarToken( + "parameter", + compileHighlightPattern("\\/[a-z?]+(?=[ :]|\$):?|-[a-z]\\b|--[a-z-]+\\b", + caseSensitive: false, multiLine: true), + alias: "attr-name", + inside: () => _g2), + GrammarToken("variable", compileHighlightPattern("%%?[~:\\w]+%?|!\\S+!")), + GrammarToken("number", compileHighlightPattern("(?:\\b|-)\\d+\\b")), + GrammarToken("punctuation", compileHighlightPattern("[()',]")), +]); + +final Grammar _g2 = Grammar([ + GrammarToken("punctuation", compileHighlightPattern(":")), +]); + +final Grammar _g3 = Grammar([ + GrammarToken( + "keyword", + compileHighlightPattern( + "\\b(?:cmdextversion|defined|errorlevel|exist|not)\\b|^if\\b", + caseSensitive: false)), + GrammarToken( + "string", compileHighlightPattern("\"(?:[\\\\\"]\"|[^\"])*\"(?!\")")), + GrammarToken( + "parameter", + compileHighlightPattern("\\/[a-z?]+(?=[ :]|\$):?|-[a-z]\\b|--[a-z-]+\\b", + caseSensitive: false, multiLine: true), + alias: "attr-name", + inside: () => _g2), + GrammarToken("variable", compileHighlightPattern("%%?[~:\\w]+%?|!\\S+!")), + GrammarToken("number", compileHighlightPattern("(?:\\b|-)\\d+\\b")), + GrammarToken( + "operator", + compileHighlightPattern("\\^|==|\\b(?:equ|geq|gtr|leq|lss|neq)\\b", + caseSensitive: false)), +]); + +final Grammar _g4 = Grammar([ + GrammarToken( + "keyword", compileHighlightPattern("^else\\b", caseSensitive: false)), +]); + +final Grammar _g5 = Grammar([ + GrammarToken( + "keyword", compileHighlightPattern("^set\\b", caseSensitive: false)), + GrammarToken( + "string", compileHighlightPattern("\"(?:[\\\\\"]\"|[^\"])*\"(?!\")")), + GrammarToken( + "parameter", + compileHighlightPattern("\\/[a-z?]+(?=[ :]|\$):?|-[a-z]\\b|--[a-z-]+\\b", + caseSensitive: false, multiLine: true), + alias: "attr-name", + inside: () => _g2), + GrammarToken("variable", compileHighlightPattern("%%?[~:\\w]+%?|!\\S+!")), + GrammarToken("variable", + compileHighlightPattern("\\w+(?=(?:[*\\/%+\\-&^|]|<<|>>)?=)")), + GrammarToken("number", compileHighlightPattern("(?:\\b|-)\\d+\\b")), + GrammarToken( + "operator", compileHighlightPattern("[*\\/%+\\-&^|]=?|<<=?|>>=?|[!~_=]")), + GrammarToken("punctuation", compileHighlightPattern("[()',]")), +]); + +final Grammar _g6 = Grammar([ + GrammarToken("keyword", compileHighlightPattern("^\\w+\\b")), + GrammarToken( + "string", compileHighlightPattern("\"(?:[\\\\\"]\"|[^\"])*\"(?!\")")), + GrammarToken( + "parameter", + compileHighlightPattern("\\/[a-z?]+(?=[ :]|\$):?|-[a-z]\\b|--[a-z-]+\\b", + caseSensitive: false, multiLine: true), + alias: "attr-name", + inside: () => _g2), + GrammarToken( + "label", compileHighlightPattern("(^\\s*):\\S+", multiLine: true), + lookbehind: true, alias: "property"), + GrammarToken("variable", compileHighlightPattern("%%?[~:\\w]+%?|!\\S+!")), + GrammarToken("number", compileHighlightPattern("(?:\\b|-)\\d+\\b")), + GrammarToken("operator", compileHighlightPattern("\\^")), +]); diff --git a/lib/highlight/c.dart b/lib/highlight/c.dart new file mode 100644 index 0000000..47f4cd0 --- /dev/null +++ b/lib/highlight/c.dart @@ -0,0 +1,106 @@ +// GENERATED CODE — do not modify by hand. Regenerate with tool/highlight_codegen. +// ignore_for_file: lines_longer_than_80_chars, public_member_api_docs +// ignore_for_file: prefer_single_quotes, require_trailing_commas, directives_ordering + +import '../highlight.dart'; + +/// Syntax grammar for `c`. +/// +/// Import this library only when you need `c` highlighting; unused +/// languages are dropped from the build. +abstract final class HighlightC { + /// The grammar for `c`. + static final Grammar grammar = _g0; +} + +final Grammar _g0 = Grammar([ + GrammarToken( + "comment", + compileHighlightPattern( + "\\/\\/(?:[^\\r\\n\\\\]|\\\\(?:\\r\\n?|\\n|(?![\\r\\n])))*|\\/\\*[\\s\\S]*?(?:\\*\\/|\$)"), + greedy: true), + GrammarToken( + "char", + compileHighlightPattern( + "'(?:\\\\(?:\\r\\n|[\\s\\S])|[^'\\\\\\r\\n]){0,32}'"), + greedy: true), + GrammarToken( + "macro", + compileHighlightPattern( + "(^[\\t ]*)#\\s*[a-z](?:[^\\r\\n\\\\/]|\\/(?!\\*)|\\/\\*(?:[^*]|\\*(?!\\/))*\\*\\/|\\\\(?:\\r\\n|[\\s\\S]))*", + caseSensitive: false, + multiLine: true), + lookbehind: true, + greedy: true, + alias: "property", + inside: () => _g1), + GrammarToken( + "string", + compileHighlightPattern( + "\"(?:\\\\(?:\\r\\n|[\\s\\S])|[^\"\\\\\\r\\n])*\""), + greedy: true), + GrammarToken( + "class-name", + compileHighlightPattern( + "(\\b(?:enum|struct)\\s+(?:__attribute__\\s*\\(\\([\\s\\S]*?\\)\\)\\s*)?)\\w+|\\b[a-z]\\w*_t\\b"), + lookbehind: true), + GrammarToken( + "keyword", + compileHighlightPattern( + "\\b(?:_Alignas|_Alignof|_Atomic|_Bool|_Complex|_Generic|_Imaginary|_Noreturn|_Static_assert|_Thread_local|__attribute__|asm|auto|break|case|char|const|continue|default|do|double|else|enum|extern|float|for|goto|if|inline|int|long|register|return|short|signed|sizeof|static|struct|switch|typedef|typeof|union|unsigned|void|volatile|while)\\b")), + GrammarToken( + "constant", + compileHighlightPattern( + "\\b(?:EOF|NULL|SEEK_CUR|SEEK_END|SEEK_SET|__DATE__|__FILE__|__LINE__|__TIMESTAMP__|__TIME__|__func__|stderr|stdin|stdout)\\b")), + GrammarToken( + "function", + compileHighlightPattern("\\b[a-z_]\\w*(?=\\s*\\()", + caseSensitive: false)), + GrammarToken( + "number", + compileHighlightPattern( + "(?:\\b0x(?:[\\da-f]+(?:\\.[\\da-f]*)?|\\.[\\da-f]+)(?:p[+-]?\\d+)?|(?:\\b\\d+(?:\\.\\d*)?|\\B\\.\\d+)(?:e[+-]?\\d+)?)[ful]{0,4}", + caseSensitive: false)), + GrammarToken( + "operator", + compileHighlightPattern( + ">>=?|<<=?|->|([-+&|:])\\1|[?:~]|[-+*/%&|^!=<>]=?")), + GrammarToken("punctuation", compileHighlightPattern("[{}[\\];(),.:]")), +]); + +final Grammar _g1 = Grammar([ + GrammarToken("string", compileHighlightPattern("^(#\\s*include\\s*)<[^>]+>"), + lookbehind: true), + GrammarToken( + "string", + compileHighlightPattern( + "\"(?:\\\\(?:\\r\\n|[\\s\\S])|[^\"\\\\\\r\\n])*\""), + greedy: true), + GrammarToken( + "char", + compileHighlightPattern( + "'(?:\\\\(?:\\r\\n|[\\s\\S])|[^'\\\\\\r\\n]){0,32}'"), + greedy: true), + GrammarToken( + "comment", + compileHighlightPattern( + "\\/\\/(?:[^\\r\\n\\\\]|\\\\(?:\\r\\n?|\\n|(?![\\r\\n])))*|\\/\\*[\\s\\S]*?(?:\\*\\/|\$)"), + greedy: true), + GrammarToken( + "macro-name", + compileHighlightPattern("(^#\\s*define\\s+)\\w+\\b(?!\\()", + caseSensitive: false), + lookbehind: true), + GrammarToken( + "macro-name", + compileHighlightPattern("(^#\\s*define\\s+)\\w+\\b(?=\\()", + caseSensitive: false), + lookbehind: true, + alias: "function"), + GrammarToken("directive", compileHighlightPattern("^(#\\s*)[a-z]+"), + lookbehind: true, alias: "keyword"), + GrammarToken("directive-hash", compileHighlightPattern("^#")), + GrammarToken("punctuation", compileHighlightPattern("##|\\\\(?=[\\r\\n])")), + GrammarToken("expression", compileHighlightPattern("\\S[\\s\\S]*"), + inside: () => _g0), +]); diff --git a/lib/highlight/clike.dart b/lib/highlight/clike.dart new file mode 100644 index 0000000..1eef8eb --- /dev/null +++ b/lib/highlight/clike.dart @@ -0,0 +1,53 @@ +// GENERATED CODE — do not modify by hand. Regenerate with tool/highlight_codegen. +// ignore_for_file: lines_longer_than_80_chars, public_member_api_docs +// ignore_for_file: prefer_single_quotes, require_trailing_commas, directives_ordering + +import '../highlight.dart'; + +/// Syntax grammar for `clike`. +/// +/// Import this library only when you need `clike` highlighting; unused +/// languages are dropped from the build. +abstract final class HighlightClike { + /// The grammar for `clike`. + static final Grammar grammar = _g0; +} + +final Grammar _g0 = Grammar([ + GrammarToken("comment", + compileHighlightPattern("(^|[^\\\\])\\/\\*[\\s\\S]*?(?:\\*\\/|\$)"), + lookbehind: true, greedy: true), + GrammarToken("comment", compileHighlightPattern("(^|[^\\\\:])\\/\\/.*"), + lookbehind: true, greedy: true), + GrammarToken( + "string", + compileHighlightPattern( + "([\"'])(?:\\\\(?:\\r\\n|[\\s\\S])|(?!\\1)[^\\\\\\r\\n])*\\1"), + greedy: true), + GrammarToken( + "class-name", + compileHighlightPattern( + "(\\b(?:class|extends|implements|instanceof|interface|new)\\s+)[\\w.\\\\]+"), + lookbehind: true, + inside: () => _g1), + GrammarToken( + "keyword", + compileHighlightPattern( + "\\b(?:break|catch|continue|do|else|finally|for|function|if|in|instanceof|new|null|return|throw|try|while)\\b")), + GrammarToken("boolean", compileHighlightPattern("\\b(?:false|true)\\b")), + GrammarToken("function", compileHighlightPattern("\\b\\w+(?=\\()")), + GrammarToken( + "number", + compileHighlightPattern( + "\\b0x[\\da-f]+\\b|(?:\\b\\d+(?:\\.\\d*)?|\\B\\.\\d+)(?:e[+-]?\\d+)?", + caseSensitive: false)), + GrammarToken( + "operator", + compileHighlightPattern( + "[<>]=?|[!=]=?=?|--?|\\+\\+?|&&?|\\|\\|?|[?*/~^%]")), + GrammarToken("punctuation", compileHighlightPattern("[{}[\\];(),.:]")), +]); + +final Grammar _g1 = Grammar([ + GrammarToken("punctuation", compileHighlightPattern("[.\\\\]")), +]); diff --git a/lib/highlight/clojure.dart b/lib/highlight/clojure.dart new file mode 100644 index 0000000..1d05bac --- /dev/null +++ b/lib/highlight/clojure.dart @@ -0,0 +1,41 @@ +// GENERATED CODE — do not modify by hand. Regenerate with tool/highlight_codegen. +// ignore_for_file: lines_longer_than_80_chars, public_member_api_docs +// ignore_for_file: prefer_single_quotes, require_trailing_commas, directives_ordering + +import '../highlight.dart'; + +/// Syntax grammar for `clojure`. +/// +/// Import this library only when you need `clojure` highlighting; unused +/// languages are dropped from the build. +abstract final class HighlightClojure { + /// The grammar for `clojure`. + static final Grammar grammar = _g0; +} + +final Grammar _g0 = Grammar([ + GrammarToken("comment", compileHighlightPattern(";.*"), greedy: true), + GrammarToken("string", compileHighlightPattern("\"(?:[^\"\\\\]|\\\\.)*\""), + greedy: true), + GrammarToken("char", compileHighlightPattern("\\\\\\w+")), + GrammarToken("symbol", + compileHighlightPattern("(^|[\\s()\\[\\]{},])::?[\\w*+!?'<>=/.-]+"), + lookbehind: true), + GrammarToken( + "keyword", + compileHighlightPattern( + "(\\()(?:-|->|->>|\\.|\\.\\.|\\*|\\/|\\+|<|<=|=|==|>|>=|accessor|agent|agent-errors|aget|alength|all-ns|alter|and|append-child|apply|array-map|aset|aset-boolean|aset-byte|aset-char|aset-double|aset-float|aset-int|aset-long|aset-short|assert|assoc|await|await-for|bean|binding|bit-and|bit-not|bit-or|bit-shift-left|bit-shift-right|bit-xor|boolean|branch\\?|butlast|byte|cast|char|children|class|clear-agent-errors|comment|commute|comp|comparator|complement|concat|cond|conj|cons|constantly|construct-proxy|contains\\?|count|create-ns|create-struct|cycle|dec|declare|def|def-|definline|definterface|defmacro|defmethod|defmulti|defn|defn-|defonce|defproject|defprotocol|defrecord|defstruct|deftype|deref|difference|disj|dissoc|distinct|do|doall|doc|dorun|doseq|dosync|dotimes|doto|double|down|drop|drop-while|edit|end\\?|ensure|eval|every\\?|false\\?|ffirst|file-seq|filter|find|find-doc|find-ns|find-var|first|float|flush|fn|fnseq|for|frest|gensym|get|get-proxy-class|hash-map|hash-set|identical\\?|identity|if|if-let|if-not|import|in-ns|inc|index|insert-child|insert-left|insert-right|inspect-table|inspect-tree|instance\\?|int|interleave|intersection|into|into-array|iterate|join|key|keys|keyword|keyword\\?|last|lazy-cat|lazy-cons|left|lefts|let|line-seq|list|list\\*|load|load-file|locking|long|loop|macroexpand|macroexpand-1|make-array|make-node|map|map-invert|map\\?|mapcat|max|max-key|memfn|merge|merge-with|meta|min|min-key|monitor-enter|name|namespace|neg\\?|new|newline|next|nil\\?|node|not|not-any\\?|not-every\\?|not=|ns|ns-imports|ns-interns|ns-map|ns-name|ns-publics|ns-refers|ns-resolve|ns-unmap|nth|nthrest|or|parse|partial|path|peek|pop|pos\\?|pr|pr-str|print|print-str|println|println-str|prn|prn-str|project|proxy|proxy-mappings|quot|quote|rand|rand-int|range|re-find|re-groups|re-matcher|re-matches|re-pattern|re-seq|read|read-line|recur|reduce|ref|ref-set|refer|rem|remove|remove-method|remove-ns|rename|rename-keys|repeat|replace|replicate|resolve|rest|resultset-seq|reverse|rfirst|right|rights|root|rrest|rseq|second|select|select-keys|send|send-off|seq|seq-zip|seq\\?|set|set!|short|slurp|some|sort|sort-by|sorted-map|sorted-map-by|sorted-set|special-symbol\\?|split-at|split-with|str|string\\?|struct|struct-map|subs|subvec|symbol|symbol\\?|sync|take|take-nth|take-while|test|throw|time|to-array|to-array-2d|tree-seq|true\\?|try|union|up|update-proxy|val|vals|var|var-get|var-set|var\\?|vector|vector-zip|vector\\?|when|when-first|when-let|when-not|with-local-vars|with-meta|with-open|with-out-str|xml-seq|xml-zip|zero\\?|zipmap|zipper)(?=[\\s)]|\$)"), + lookbehind: true), + GrammarToken("boolean", compileHighlightPattern("\\b(?:false|nil|true)\\b")), + GrammarToken( + "number", + compileHighlightPattern( + "(^|[^\\w\$@])(?:\\d+(?:[/.]\\d+)?(?:e[+-]?\\d+)?|0x[a-f0-9]+|[1-9]\\d?r[a-z0-9]+)[lmn]?(?![\\w\$@])", + caseSensitive: false), + lookbehind: true), + GrammarToken("function", + compileHighlightPattern("((?:^|[^'])\\()[\\w*+!?'<>=/.-]+(?=[\\s)]|\$)"), + lookbehind: true), + GrammarToken("operator", compileHighlightPattern("[#@^`~]")), + GrammarToken("punctuation", compileHighlightPattern("[{}\\[\\](),]")), +]); diff --git a/lib/highlight/coffeescript.dart b/lib/highlight/coffeescript.dart new file mode 100644 index 0000000..34cf9b7 --- /dev/null +++ b/lib/highlight/coffeescript.dart @@ -0,0 +1,244 @@ +// GENERATED CODE — do not modify by hand. Regenerate with tool/highlight_codegen. +// ignore_for_file: lines_longer_than_80_chars, public_member_api_docs +// ignore_for_file: prefer_single_quotes, require_trailing_commas, directives_ordering + +import '../highlight.dart'; +import 'js.dart'; + +/// Syntax grammar for `coffeescript`. +/// +/// Import this library only when you need `coffeescript` highlighting; unused +/// languages are dropped from the build. +abstract final class HighlightCoffeescript { + /// The grammar for `coffeescript`. + static final Grammar grammar = _g0; +} + +final Grammar _g0 = Grammar([ + GrammarToken("multiline-comment", compileHighlightPattern("###[\\s\\S]+?###"), + alias: "comment"), + GrammarToken("block-regex", compileHighlightPattern("\\/{3}[\\s\\S]*?\\/{3}"), + alias: "regex", inside: () => _g1), + GrammarToken("comment", compileHighlightPattern("#(?!\\{).+")), + GrammarToken("hashbang", compileHighlightPattern("^#!.*"), + greedy: true, alias: "comment"), + GrammarToken( + "string-property", + compileHighlightPattern( + "((?:^|[,{])[ \\t]*)([\"'])(?:\\\\(?:\\r\\n|[\\s\\S])|(?!\\2)[^\\\\\\r\\n])*\\2(?=\\s*:)", + multiLine: true), + lookbehind: true, + greedy: true, + alias: "property"), + GrammarToken("inline-javascript", + compileHighlightPattern("`(?:\\\\[\\s\\S]|[^\\\\`])*`"), + inside: () => _g2), + GrammarToken("multiline-string", compileHighlightPattern("'''[\\s\\S]*?'''"), + greedy: true, alias: "string"), + GrammarToken( + "multiline-string", compileHighlightPattern("\"\"\"[\\s\\S]*?\"\"\""), + greedy: true, alias: "string", inside: () => _g3), + GrammarToken( + "string", compileHighlightPattern("'(?:\\\\[\\s\\S]|[^\\\\'])*'"), + greedy: true), + GrammarToken( + "string", compileHighlightPattern("\"(?:\\\\[\\s\\S]|[^\\\\\"])*\""), + greedy: true, inside: () => _g4), + GrammarToken( + "class-name", + compileHighlightPattern( + "(\\b(?:class|extends|implements|instanceof|interface|new)\\s+)[\\w.\\\\]+"), + lookbehind: true, + inside: () => _g5), + GrammarToken( + "class-name", + compileHighlightPattern( + "(^|[^\$\\w\\xA0-\\uFFFF])(?!\\s)[_\$A-Z\\xA0-\\uFFFF](?:(?!\\s)[\$\\w\\xA0-\\uFFFF])*(?=\\.(?:constructor|prototype))"), + lookbehind: true), + GrammarToken( + "regex", + compileHighlightPattern( + "((?:^|[^\$\\w\\xA0-\\uFFFF.\"'\\])\\s]|\\b(?:return|yield))\\s*)\\/(?:(?:\\[(?:[^\\]\\\\\\r\\n]|\\\\.)*\\]|\\\\.|[^/\\\\\\[\\r\\n])+\\/[dgimyus]{0,7}|(?:\\[(?:[^[\\]\\\\\\r\\n]|\\\\.|\\[(?:[^[\\]\\\\\\r\\n]|\\\\.|\\[(?:[^[\\]\\\\\\r\\n]|\\\\.)*\\])*\\])*\\]|\\\\.|[^/\\\\\\[\\r\\n])+\\/[dgimyus]{0,7}v[dgimyus]{0,7})(?=(?:\\s|\\/\\*(?:[^*]|\\*(?!\\/))*\\*\\/)*(?:\$|[\\r\\n,.;:})\\]]|\\/\\/))"), + lookbehind: true, + greedy: true, + inside: () => _g6), + GrammarToken( + "function-variable", + compileHighlightPattern( + "#?(?!\\s)[_\$a-zA-Z\\xA0-\\uFFFF](?:(?!\\s)[\$\\w\\xA0-\\uFFFF])*(?=\\s*[=:]\\s*(?:async\\s*)?(?:\\bfunction\\b|(?:\\((?:[^()]|\\([^()]*\\))*\\)|(?!\\s)[_\$a-zA-Z\\xA0-\\uFFFF](?:(?!\\s)[\$\\w\\xA0-\\uFFFF])*)\\s*=>))"), + alias: "function"), + GrammarToken( + "parameter", + compileHighlightPattern( + "(function(?:\\s+(?!\\s)[_\$a-zA-Z\\xA0-\\uFFFF](?:(?!\\s)[\$\\w\\xA0-\\uFFFF])*)?\\s*\\(\\s*)(?!\\s)(?:[^()\\s]|\\s+(?![\\s)])|\\([^()]*\\))+(?=\\s*\\))"), + lookbehind: true, + inside: () => _g0), + GrammarToken( + "parameter", + compileHighlightPattern( + "(^|[^\$\\w\\xA0-\\uFFFF])(?!\\s)[_\$a-z\\xA0-\\uFFFF](?:(?!\\s)[\$\\w\\xA0-\\uFFFF])*(?=\\s*=>)", + caseSensitive: false), + lookbehind: true, + inside: () => _g0), + GrammarToken( + "parameter", + compileHighlightPattern( + "(\\(\\s*)(?!\\s)(?:[^()\\s]|\\s+(?![\\s)])|\\([^()]*\\))+(?=\\s*\\)\\s*=>)"), + lookbehind: true, + inside: () => _g0), + GrammarToken( + "parameter", + compileHighlightPattern( + "((?:\\b|\\s|^)(?!(?:as|async|await|break|case|catch|class|const|continue|debugger|default|delete|do|else|enum|export|extends|finally|for|from|function|get|if|implements|import|in|instanceof|interface|let|new|null|of|package|private|protected|public|return|set|static|super|switch|this|throw|try|typeof|undefined|var|void|while|with|yield)(?![\$\\w\\xA0-\\uFFFF]))(?:(?!\\s)[_\$a-zA-Z\\xA0-\\uFFFF](?:(?!\\s)[\$\\w\\xA0-\\uFFFF])*\\s*)\\(\\s*|\\]\\s*\\(\\s*)(?!\\s)(?:[^()\\s]|\\s+(?![\\s)])|\\([^()]*\\))+(?=\\s*\\)\\s*\\{)"), + lookbehind: true, + inside: () => _g0), + GrammarToken( + "constant", compileHighlightPattern("\\b[A-Z](?:[A-Z_]|\\dx?)*\\b")), + GrammarToken( + "property", compileHighlightPattern("(?!\\d)\\w+(?=\\s*:(?!:))")), + GrammarToken( + "keyword", + compileHighlightPattern( + "\\b(?:and|break|by|catch|class|continue|debugger|delete|do|each|else|extend|extends|false|finally|for|if|in|instanceof|is|isnt|let|loop|namespace|new|no|not|null|of|off|on|or|own|return|super|switch|then|this|throw|true|try|typeof|undefined|unless|until|when|while|window|with|yes|yield)\\b")), + GrammarToken("boolean", compileHighlightPattern("\\b(?:false|true)\\b")), + GrammarToken( + "function", + compileHighlightPattern( + "#?(?!\\s)[_\$a-zA-Z\\xA0-\\uFFFF](?:(?!\\s)[\$\\w\\xA0-\\uFFFF])*(?=\\s*(?:\\.\\s*(?:apply|bind|call)\\s*)?\\()")), + GrammarToken( + "number", + compileHighlightPattern( + "(^|[^\\w\$])(?:NaN|Infinity|0[bB][01]+(?:_[01]+)*n?|0[oO][0-7]+(?:_[0-7]+)*n?|0[xX][\\dA-Fa-f]+(?:_[\\dA-Fa-f]+)*n?|\\d+(?:_\\d+)*n|(?:\\d+(?:_\\d+)*(?:\\.(?:\\d+(?:_\\d+)*)?)?|\\.\\d+(?:_\\d+)*)(?:[Ee][+-]?\\d+(?:_\\d+)*)?)(?![\\w\$])"), + lookbehind: true), + GrammarToken( + "literal-property", + compileHighlightPattern( + "((?:^|[,{])[ \\t]*)(?!\\s)[_\$a-zA-Z\\xA0-\\uFFFF](?:(?!\\s)[\$\\w\\xA0-\\uFFFF])*(?=\\s*:)", + multiLine: true), + lookbehind: true, + alias: "property"), + GrammarToken( + "operator", + compileHighlightPattern( + "--|\\+\\+|\\*\\*=?|=>|&&=?|\\|\\|=?|[!=]==|<<=?|>>>?=?|[-+*/%&|^!=<>]=?|\\.{3}|\\?\\?=?|\\?\\.?|[~:]")), + GrammarToken("punctuation", compileHighlightPattern("[{}[\\];(),.:]")), + GrammarToken("class-member", compileHighlightPattern("@(?!\\d)\\w+"), + alias: "variable"), +]); + +final Grammar _g1 = Grammar([ + GrammarToken("comment", compileHighlightPattern("#(?!\\{).+")), + GrammarToken("interpolation", compileHighlightPattern("#\\{[^}]+\\}"), + alias: "variable"), +]); + +final Grammar _g2 = Grammar([ + GrammarToken("delimiter", compileHighlightPattern("^`|`\$"), + alias: "punctuation"), + GrammarToken("script", compileHighlightPattern("[\\s\\S]+"), + alias: "language-javascript", inside: () => HighlightJs.grammar), +]); + +final Grammar _g3 = Grammar([ + GrammarToken("interpolation", compileHighlightPattern("#\\{[^}]+\\}"), + alias: "variable"), +]); + +final Grammar _g4 = Grammar([ + GrammarToken("interpolation", compileHighlightPattern("#\\{[^}]+\\}"), + alias: "variable"), +]); + +final Grammar _g5 = Grammar([ + GrammarToken("punctuation", compileHighlightPattern("[.\\\\]")), +]); + +final Grammar _g6 = Grammar([ + GrammarToken( + "regex-source", compileHighlightPattern("^(\\/)[\\s\\S]+(?=\\/[a-z]*\$)"), + lookbehind: true, alias: "language-regex", inside: () => _g7), + GrammarToken("regex-delimiter", compileHighlightPattern("^\\/|\\/\$")), + GrammarToken("regex-flags", compileHighlightPattern("^[a-z]+\$")), +]); + +final Grammar _g7 = Grammar([ + GrammarToken( + "char-class", + compileHighlightPattern( + "((?:^|[^\\\\])(?:\\\\\\\\)*)\\[(?:[^\\\\\\]]|\\\\[\\s\\S])*\\]"), + lookbehind: true, + inside: () => _g8), + GrammarToken( + "special-escape", compileHighlightPattern("\\\\[\\\\(){}[\\]^\$+*?|.]"), + alias: "escape"), + GrammarToken( + "char-set", + compileHighlightPattern("\\.|\\\\[wsd]|\\\\p\\{[^{}]+\\}", + caseSensitive: false), + alias: "class-name"), + GrammarToken( + "backreference", compileHighlightPattern("\\\\(?![123][0-7]{2})[1-9]"), + alias: "keyword"), + GrammarToken("backreference", compileHighlightPattern("\\\\k<[^<>']+>"), + alias: "keyword", inside: () => _g10), + GrammarToken("anchor", compileHighlightPattern("[\$^]|\\\\[ABbGZz]"), + alias: "function"), + GrammarToken( + "escape", + compileHighlightPattern( + "\\\\(?:x[\\da-fA-F]{2}|u[\\da-fA-F]{4}|u\\{[\\da-fA-F]+\\}|0[0-7]{0,2}|[123][0-7]{2}|c[a-zA-Z]|.)")), + GrammarToken( + "group", + compileHighlightPattern( + "\\((?:\\?(?:<[^<>']+>|'[^<>']+'|[>:]| _g11), + GrammarToken("group", compileHighlightPattern("\\)"), alias: "punctuation"), + GrammarToken("quantifier", + compileHighlightPattern("(?:[+*?]|\\{\\d+(?:,\\d*)?\\})[?+]?"), + alias: "number"), + GrammarToken("alternation", compileHighlightPattern("\\|"), alias: "keyword"), +]); + +final Grammar _g8 = Grammar([ + GrammarToken("char-class-negation", compileHighlightPattern("(^\\[)\\^"), + lookbehind: true, alias: "operator"), + GrammarToken("char-class-punctuation", compileHighlightPattern("^\\[|\\]\$"), + alias: "punctuation"), + GrammarToken( + "range", + compileHighlightPattern( + "(?:[^\\\\-]|\\\\(?:x[\\da-fA-F]{2}|u[\\da-fA-F]{4}|u\\{[\\da-fA-F]+\\}|0[0-7]{0,2}|[123][0-7]{2}|c[a-zA-Z]|.))-(?:[^\\\\-]|\\\\(?:x[\\da-fA-F]{2}|u[\\da-fA-F]{4}|u\\{[\\da-fA-F]+\\}|0[0-7]{0,2}|[123][0-7]{2}|c[a-zA-Z]|.))"), + inside: () => _g9), + GrammarToken( + "special-escape", compileHighlightPattern("\\\\[\\\\(){}[\\]^\$+*?|.]"), + alias: "escape"), + GrammarToken( + "char-set", + compileHighlightPattern("\\\\[wsd]|\\\\p\\{[^{}]+\\}", + caseSensitive: false), + alias: "class-name"), + GrammarToken( + "escape", + compileHighlightPattern( + "\\\\(?:x[\\da-fA-F]{2}|u[\\da-fA-F]{4}|u\\{[\\da-fA-F]+\\}|0[0-7]{0,2}|[123][0-7]{2}|c[a-zA-Z]|.)")), +]); + +final Grammar _g9 = Grammar([ + GrammarToken( + "escape", + compileHighlightPattern( + "\\\\(?:x[\\da-fA-F]{2}|u[\\da-fA-F]{4}|u\\{[\\da-fA-F]+\\}|0[0-7]{0,2}|[123][0-7]{2}|c[a-zA-Z]|.)")), + GrammarToken("range-punctuation", compileHighlightPattern("-"), + alias: "operator"), +]); + +final Grammar _g10 = Grammar([ + GrammarToken("group-name", compileHighlightPattern("(<|')[^<>']+(?=[>']\$)"), + lookbehind: true, alias: "variable"), +]); + +final Grammar _g11 = Grammar([ + GrammarToken("group-name", compileHighlightPattern("(<|')[^<>']+(?=[>']\$)"), + lookbehind: true, alias: "variable"), +]); diff --git a/lib/highlight/cpp.dart b/lib/highlight/cpp.dart new file mode 100644 index 0000000..7925cbf --- /dev/null +++ b/lib/highlight/cpp.dart @@ -0,0 +1,363 @@ +// GENERATED CODE — do not modify by hand. Regenerate with tool/highlight_codegen. +// ignore_for_file: lines_longer_than_80_chars, public_member_api_docs +// ignore_for_file: prefer_single_quotes, require_trailing_commas, directives_ordering + +import '../highlight.dart'; + +/// Syntax grammar for `cpp`. +/// +/// Import this library only when you need `cpp` highlighting; unused +/// languages are dropped from the build. +abstract final class HighlightCpp { + /// The grammar for `cpp`. + static final Grammar grammar = _g0; +} + +final Grammar _g0 = Grammar([ + GrammarToken( + "comment", + compileHighlightPattern( + "\\/\\/(?:[^\\r\\n\\\\]|\\\\(?:\\r\\n?|\\n|(?![\\r\\n])))*|\\/\\*[\\s\\S]*?(?:\\*\\/|\$)"), + greedy: true), + GrammarToken( + "char", + compileHighlightPattern( + "'(?:\\\\(?:\\r\\n|[\\s\\S])|[^'\\\\\\r\\n]){0,32}'"), + greedy: true), + GrammarToken( + "macro", + compileHighlightPattern( + "(^[\\t ]*)#\\s*[a-z](?:[^\\r\\n\\\\/]|\\/(?!\\*)|\\/\\*(?:[^*]|\\*(?!\\/))*\\*\\/|\\\\(?:\\r\\n|[\\s\\S]))*", + caseSensitive: false, + multiLine: true), + lookbehind: true, + greedy: true, + alias: "property", + inside: () => _g1), + GrammarToken( + "module", + compileHighlightPattern( + "(\\b(?:import|module)\\s+)(?:\"(?:\\\\(?:\\r\\n|[\\s\\S])|[^\"\\\\\\r\\n])*\"|<[^<>\\r\\n]*>|\\b(?!\\b(?:alignas|alignof|asm|auto|bool|break|case|catch|char|char16_t|char32_t|char8_t|class|co_await|co_return|co_yield|compl|concept|const|const_cast|consteval|constexpr|constinit|continue|decltype|default|delete|do|double|dynamic_cast|else|enum|explicit|export|extern|final|float|for|friend|goto|if|import|inline|int|int16_t|int32_t|int64_t|int8_t|long|module|mutable|namespace|new|noexcept|nullptr|operator|override|private|protected|public|register|reinterpret_cast|requires|return|short|signed|sizeof|static|static_assert|static_cast|struct|switch|template|this|thread_local|throw|try|typedef|typeid|typename|uint16_t|uint32_t|uint64_t|uint8_t|union|unsigned|using|virtual|void|volatile|wchar_t|while)\\b)\\w+(?:\\s*\\.\\s*\\w+)*\\b(?:\\s*:\\s*\\b(?!\\b(?:alignas|alignof|asm|auto|bool|break|case|catch|char|char16_t|char32_t|char8_t|class|co_await|co_return|co_yield|compl|concept|const|const_cast|consteval|constexpr|constinit|continue|decltype|default|delete|do|double|dynamic_cast|else|enum|explicit|export|extern|final|float|for|friend|goto|if|import|inline|int|int16_t|int32_t|int64_t|int8_t|long|module|mutable|namespace|new|noexcept|nullptr|operator|override|private|protected|public|register|reinterpret_cast|requires|return|short|signed|sizeof|static|static_assert|static_cast|struct|switch|template|this|thread_local|throw|try|typedef|typeid|typename|uint16_t|uint32_t|uint64_t|uint8_t|union|unsigned|using|virtual|void|volatile|wchar_t|while)\\b)\\w+(?:\\s*\\.\\s*\\w+)*\\b)?|:\\s*\\b(?!\\b(?:alignas|alignof|asm|auto|bool|break|case|catch|char|char16_t|char32_t|char8_t|class|co_await|co_return|co_yield|compl|concept|const|const_cast|consteval|constexpr|constinit|continue|decltype|default|delete|do|double|dynamic_cast|else|enum|explicit|export|extern|final|float|for|friend|goto|if|import|inline|int|int16_t|int32_t|int64_t|int8_t|long|module|mutable|namespace|new|noexcept|nullptr|operator|override|private|protected|public|register|reinterpret_cast|requires|return|short|signed|sizeof|static|static_assert|static_cast|struct|switch|template|this|thread_local|throw|try|typedef|typeid|typename|uint16_t|uint32_t|uint64_t|uint8_t|union|unsigned|using|virtual|void|volatile|wchar_t|while)\\b)\\w+(?:\\s*\\.\\s*\\w+)*\\b)"), + lookbehind: true, + greedy: true, + inside: () => _g2), + GrammarToken("raw-string", + compileHighlightPattern("R\"([^()\\\\ ]{0,16})\\([\\s\\S]*?\\)\\1\""), + greedy: true, alias: "string"), + GrammarToken( + "string", + compileHighlightPattern( + "\"(?:\\\\(?:\\r\\n|[\\s\\S])|[^\"\\\\\\r\\n])*\""), + greedy: true), + GrammarToken( + "base-clause", + compileHighlightPattern( + "(\\b(?:class|struct)\\s+\\w+\\s*:\\s*)[^;{}\"'\\s]+(?:\\s+[^;{}\"'\\s]+)*(?=\\s*[;{])"), + lookbehind: true, + greedy: true, + inside: () => _g3), + GrammarToken( + "class-name", + compileHighlightPattern( + "(\\b(?:class|concept|enum|struct|typename)\\s+)(?!\\b(?:alignas|alignof|asm|auto|bool|break|case|catch|char|char16_t|char32_t|char8_t|class|co_await|co_return|co_yield|compl|concept|const|const_cast|consteval|constexpr|constinit|continue|decltype|default|delete|do|double|dynamic_cast|else|enum|explicit|export|extern|final|float|for|friend|goto|if|import|inline|int|int16_t|int32_t|int64_t|int8_t|long|module|mutable|namespace|new|noexcept|nullptr|operator|override|private|protected|public|register|reinterpret_cast|requires|return|short|signed|sizeof|static|static_assert|static_cast|struct|switch|template|this|thread_local|throw|try|typedef|typeid|typename|uint16_t|uint32_t|uint64_t|uint8_t|union|unsigned|using|virtual|void|volatile|wchar_t|while)\\b)\\w+"), + lookbehind: true), + GrammarToken("class-name", + compileHighlightPattern("\\b[A-Z]\\w*(?=\\s*::\\s*\\w+\\s*\\()")), + GrammarToken( + "class-name", + compileHighlightPattern("\\b[A-Z_]\\w*(?=\\s*::\\s*~\\w+\\s*\\()", + caseSensitive: false)), + GrammarToken( + "class-name", + compileHighlightPattern( + "\\b\\w+(?=\\s*<(?:[^<>]|<(?:[^<>]|<[^<>]*>)*>)*>\\s*::\\s*\\w+\\s*\\()")), + GrammarToken( + "generic-function", + compileHighlightPattern( + "\\b(?!operator\\b)[a-z_]\\w*\\s*<(?:[^<>]|<[^<>]*>)*>(?=\\s*\\()", + caseSensitive: false), + inside: () => _g8), + GrammarToken( + "keyword", + compileHighlightPattern( + "\\b(?:alignas|alignof|asm|auto|bool|break|case|catch|char|char16_t|char32_t|char8_t|class|co_await|co_return|co_yield|compl|concept|const|const_cast|consteval|constexpr|constinit|continue|decltype|default|delete|do|double|dynamic_cast|else|enum|explicit|export|extern|final|float|for|friend|goto|if|import|inline|int|int16_t|int32_t|int64_t|int8_t|long|module|mutable|namespace|new|noexcept|nullptr|operator|override|private|protected|public|register|reinterpret_cast|requires|return|short|signed|sizeof|static|static_assert|static_cast|struct|switch|template|this|thread_local|throw|try|typedef|typeid|typename|uint16_t|uint32_t|uint64_t|uint8_t|union|unsigned|using|virtual|void|volatile|wchar_t|while)\\b")), + GrammarToken( + "constant", + compileHighlightPattern( + "\\b(?:EOF|NULL|SEEK_CUR|SEEK_END|SEEK_SET|__DATE__|__FILE__|__LINE__|__TIMESTAMP__|__TIME__|__func__|stderr|stdin|stdout)\\b")), + GrammarToken( + "function", + compileHighlightPattern("\\b[a-z_]\\w*(?=\\s*\\()", + caseSensitive: false)), + GrammarToken( + "number", + compileHighlightPattern( + "(?:\\b0b[01']+|\\b0x(?:[\\da-f']+(?:\\.[\\da-f']*)?|\\.[\\da-f']+)(?:p[+-]?[\\d']+)?|(?:\\b[\\d']+(?:\\.[\\d']*)?|\\B\\.[\\d']+)(?:e[+-]?[\\d']+)?)[ful]{0,4}", + caseSensitive: false), + greedy: true), + GrammarToken("double-colon", compileHighlightPattern("::"), + alias: "punctuation"), + GrammarToken( + "operator", + compileHighlightPattern( + ">>=?|<<=?|->|--|\\+\\+|&&|\\|\\||[?:~]|<=>|[-+*/%&|^!=<>]=?|\\b(?:and|and_eq|bitand|bitor|not|not_eq|or|or_eq|xor|xor_eq)\\b")), + GrammarToken("punctuation", compileHighlightPattern("[{}[\\];(),.:]")), + GrammarToken("boolean", compileHighlightPattern("\\b(?:false|true)\\b")), +]); + +final Grammar _g1 = Grammar([ + GrammarToken("string", compileHighlightPattern("^(#\\s*include\\s*)<[^>]+>"), + lookbehind: true), + GrammarToken( + "string", + compileHighlightPattern( + "\"(?:\\\\(?:\\r\\n|[\\s\\S])|[^\"\\\\\\r\\n])*\""), + greedy: true), + GrammarToken( + "char", + compileHighlightPattern( + "'(?:\\\\(?:\\r\\n|[\\s\\S])|[^'\\\\\\r\\n]){0,32}'"), + greedy: true), + GrammarToken( + "comment", + compileHighlightPattern( + "\\/\\/(?:[^\\r\\n\\\\]|\\\\(?:\\r\\n?|\\n|(?![\\r\\n])))*|\\/\\*[\\s\\S]*?(?:\\*\\/|\$)"), + greedy: true), + GrammarToken( + "macro-name", + compileHighlightPattern("(^#\\s*define\\s+)\\w+\\b(?!\\()", + caseSensitive: false), + lookbehind: true), + GrammarToken( + "macro-name", + compileHighlightPattern("(^#\\s*define\\s+)\\w+\\b(?=\\()", + caseSensitive: false), + lookbehind: true, + alias: "function"), + GrammarToken("directive", compileHighlightPattern("^(#\\s*)[a-z]+"), + lookbehind: true, alias: "keyword"), + GrammarToken("directive-hash", compileHighlightPattern("^#")), + GrammarToken("punctuation", compileHighlightPattern("##|\\\\(?=[\\r\\n])")), + GrammarToken("expression", compileHighlightPattern("\\S[\\s\\S]*"), + inside: () => _g0), +]); + +final Grammar _g2 = Grammar([ + GrammarToken("string", compileHighlightPattern("^[<\"][\\s\\S]+")), + GrammarToken("operator", compileHighlightPattern(":")), + GrammarToken("punctuation", compileHighlightPattern("\\.")), +]); + +final Grammar _g3 = Grammar([ + GrammarToken( + "comment", + compileHighlightPattern( + "\\/\\/(?:[^\\r\\n\\\\]|\\\\(?:\\r\\n?|\\n|(?![\\r\\n])))*|\\/\\*[\\s\\S]*?(?:\\*\\/|\$)"), + greedy: true), + GrammarToken( + "char", + compileHighlightPattern( + "'(?:\\\\(?:\\r\\n|[\\s\\S])|[^'\\\\\\r\\n]){0,32}'"), + greedy: true), + GrammarToken( + "macro", + compileHighlightPattern( + "(^[\\t ]*)#\\s*[a-z](?:[^\\r\\n\\\\/]|\\/(?!\\*)|\\/\\*(?:[^*]|\\*(?!\\/))*\\*\\/|\\\\(?:\\r\\n|[\\s\\S]))*", + caseSensitive: false, + multiLine: true), + lookbehind: true, + greedy: true, + alias: "property", + inside: () => _g4), + GrammarToken( + "module", + compileHighlightPattern( + "(\\b(?:import|module)\\s+)(?:\"(?:\\\\(?:\\r\\n|[\\s\\S])|[^\"\\\\\\r\\n])*\"|<[^<>\\r\\n]*>|\\b(?!\\b(?:alignas|alignof|asm|auto|bool|break|case|catch|char|char16_t|char32_t|char8_t|class|co_await|co_return|co_yield|compl|concept|const|const_cast|consteval|constexpr|constinit|continue|decltype|default|delete|do|double|dynamic_cast|else|enum|explicit|export|extern|final|float|for|friend|goto|if|import|inline|int|int16_t|int32_t|int64_t|int8_t|long|module|mutable|namespace|new|noexcept|nullptr|operator|override|private|protected|public|register|reinterpret_cast|requires|return|short|signed|sizeof|static|static_assert|static_cast|struct|switch|template|this|thread_local|throw|try|typedef|typeid|typename|uint16_t|uint32_t|uint64_t|uint8_t|union|unsigned|using|virtual|void|volatile|wchar_t|while)\\b)\\w+(?:\\s*\\.\\s*\\w+)*\\b(?:\\s*:\\s*\\b(?!\\b(?:alignas|alignof|asm|auto|bool|break|case|catch|char|char16_t|char32_t|char8_t|class|co_await|co_return|co_yield|compl|concept|const|const_cast|consteval|constexpr|constinit|continue|decltype|default|delete|do|double|dynamic_cast|else|enum|explicit|export|extern|final|float|for|friend|goto|if|import|inline|int|int16_t|int32_t|int64_t|int8_t|long|module|mutable|namespace|new|noexcept|nullptr|operator|override|private|protected|public|register|reinterpret_cast|requires|return|short|signed|sizeof|static|static_assert|static_cast|struct|switch|template|this|thread_local|throw|try|typedef|typeid|typename|uint16_t|uint32_t|uint64_t|uint8_t|union|unsigned|using|virtual|void|volatile|wchar_t|while)\\b)\\w+(?:\\s*\\.\\s*\\w+)*\\b)?|:\\s*\\b(?!\\b(?:alignas|alignof|asm|auto|bool|break|case|catch|char|char16_t|char32_t|char8_t|class|co_await|co_return|co_yield|compl|concept|const|const_cast|consteval|constexpr|constinit|continue|decltype|default|delete|do|double|dynamic_cast|else|enum|explicit|export|extern|final|float|for|friend|goto|if|import|inline|int|int16_t|int32_t|int64_t|int8_t|long|module|mutable|namespace|new|noexcept|nullptr|operator|override|private|protected|public|register|reinterpret_cast|requires|return|short|signed|sizeof|static|static_assert|static_cast|struct|switch|template|this|thread_local|throw|try|typedef|typeid|typename|uint16_t|uint32_t|uint64_t|uint8_t|union|unsigned|using|virtual|void|volatile|wchar_t|while)\\b)\\w+(?:\\s*\\.\\s*\\w+)*\\b)"), + lookbehind: true, + greedy: true, + inside: () => _g6), + GrammarToken("raw-string", + compileHighlightPattern("R\"([^()\\\\ ]{0,16})\\([\\s\\S]*?\\)\\1\""), + greedy: true, alias: "string"), + GrammarToken( + "string", + compileHighlightPattern( + "\"(?:\\\\(?:\\r\\n|[\\s\\S])|[^\"\\\\\\r\\n])*\""), + greedy: true), + GrammarToken( + "generic-function", + compileHighlightPattern( + "\\b(?!operator\\b)[a-z_]\\w*\\s*<(?:[^<>]|<[^<>]*>)*>(?=\\s*\\()", + caseSensitive: false), + inside: () => _g7), + GrammarToken( + "keyword", + compileHighlightPattern( + "\\b(?:alignas|alignof|asm|auto|bool|break|case|catch|char|char16_t|char32_t|char8_t|class|co_await|co_return|co_yield|compl|concept|const|const_cast|consteval|constexpr|constinit|continue|decltype|default|delete|do|double|dynamic_cast|else|enum|explicit|export|extern|final|float|for|friend|goto|if|import|inline|int|int16_t|int32_t|int64_t|int8_t|long|module|mutable|namespace|new|noexcept|nullptr|operator|override|private|protected|public|register|reinterpret_cast|requires|return|short|signed|sizeof|static|static_assert|static_cast|struct|switch|template|this|thread_local|throw|try|typedef|typeid|typename|uint16_t|uint32_t|uint64_t|uint8_t|union|unsigned|using|virtual|void|volatile|wchar_t|while)\\b")), + GrammarToken( + "constant", + compileHighlightPattern( + "\\b(?:EOF|NULL|SEEK_CUR|SEEK_END|SEEK_SET|__DATE__|__FILE__|__LINE__|__TIMESTAMP__|__TIME__|__func__|stderr|stdin|stdout)\\b")), + GrammarToken( + "function", + compileHighlightPattern("\\b[a-z_]\\w*(?=\\s*\\()", + caseSensitive: false)), + GrammarToken( + "number", + compileHighlightPattern( + "(?:\\b0b[01']+|\\b0x(?:[\\da-f']+(?:\\.[\\da-f']*)?|\\.[\\da-f']+)(?:p[+-]?[\\d']+)?|(?:\\b[\\d']+(?:\\.[\\d']*)?|\\B\\.[\\d']+)(?:e[+-]?[\\d']+)?)[ful]{0,4}", + caseSensitive: false), + greedy: true), + GrammarToken( + "class-name", + compileHighlightPattern("\\b[a-z_]\\w*\\b(?!\\s*::)", + caseSensitive: false)), + GrammarToken("double-colon", compileHighlightPattern("::"), + alias: "punctuation"), + GrammarToken( + "operator", + compileHighlightPattern( + ">>=?|<<=?|->|--|\\+\\+|&&|\\|\\||[?:~]|<=>|[-+*/%&|^!=<>]=?|\\b(?:and|and_eq|bitand|bitor|not|not_eq|or|or_eq|xor|xor_eq)\\b")), + GrammarToken("punctuation", compileHighlightPattern("[{}[\\];(),.:]")), + GrammarToken("boolean", compileHighlightPattern("\\b(?:false|true)\\b")), +]); + +final Grammar _g4 = Grammar([ + GrammarToken("string", compileHighlightPattern("^(#\\s*include\\s*)<[^>]+>"), + lookbehind: true), + GrammarToken( + "string", + compileHighlightPattern( + "\"(?:\\\\(?:\\r\\n|[\\s\\S])|[^\"\\\\\\r\\n])*\""), + greedy: true), + GrammarToken( + "char", + compileHighlightPattern( + "'(?:\\\\(?:\\r\\n|[\\s\\S])|[^'\\\\\\r\\n]){0,32}'"), + greedy: true), + GrammarToken( + "comment", + compileHighlightPattern( + "\\/\\/(?:[^\\r\\n\\\\]|\\\\(?:\\r\\n?|\\n|(?![\\r\\n])))*|\\/\\*[\\s\\S]*?(?:\\*\\/|\$)"), + greedy: true), + GrammarToken( + "macro-name", + compileHighlightPattern("(^#\\s*define\\s+)\\w+\\b(?!\\()", + caseSensitive: false), + lookbehind: true), + GrammarToken( + "macro-name", + compileHighlightPattern("(^#\\s*define\\s+)\\w+\\b(?=\\()", + caseSensitive: false), + lookbehind: true, + alias: "function"), + GrammarToken("directive", compileHighlightPattern("^(#\\s*)[a-z]+"), + lookbehind: true, alias: "keyword"), + GrammarToken("directive-hash", compileHighlightPattern("^#")), + GrammarToken("punctuation", compileHighlightPattern("##|\\\\(?=[\\r\\n])")), + GrammarToken("expression", compileHighlightPattern("\\S[\\s\\S]*"), + inside: () => _g5), +]); + +final Grammar _g5 = Grammar([ + GrammarToken( + "comment", + compileHighlightPattern( + "\\/\\/(?:[^\\r\\n\\\\]|\\\\(?:\\r\\n?|\\n|(?![\\r\\n])))*|\\/\\*[\\s\\S]*?(?:\\*\\/|\$)"), + greedy: true), + GrammarToken( + "char", + compileHighlightPattern( + "'(?:\\\\(?:\\r\\n|[\\s\\S])|[^'\\\\\\r\\n]){0,32}'"), + greedy: true), + GrammarToken( + "macro", + compileHighlightPattern( + "(^[\\t ]*)#\\s*[a-z](?:[^\\r\\n\\\\/]|\\/(?!\\*)|\\/\\*(?:[^*]|\\*(?!\\/))*\\*\\/|\\\\(?:\\r\\n|[\\s\\S]))*", + caseSensitive: false, + multiLine: true), + lookbehind: true, + greedy: true, + alias: "property", + inside: () => _g4), + GrammarToken( + "module", + compileHighlightPattern( + "(\\b(?:import|module)\\s+)(?:\"(?:\\\\(?:\\r\\n|[\\s\\S])|[^\"\\\\\\r\\n])*\"|<[^<>\\r\\n]*>|\\b(?!\\b(?:alignas|alignof|asm|auto|bool|break|case|catch|char|char16_t|char32_t|char8_t|class|co_await|co_return|co_yield|compl|concept|const|const_cast|consteval|constexpr|constinit|continue|decltype|default|delete|do|double|dynamic_cast|else|enum|explicit|export|extern|final|float|for|friend|goto|if|import|inline|int|int16_t|int32_t|int64_t|int8_t|long|module|mutable|namespace|new|noexcept|nullptr|operator|override|private|protected|public|register|reinterpret_cast|requires|return|short|signed|sizeof|static|static_assert|static_cast|struct|switch|template|this|thread_local|throw|try|typedef|typeid|typename|uint16_t|uint32_t|uint64_t|uint8_t|union|unsigned|using|virtual|void|volatile|wchar_t|while)\\b)\\w+(?:\\s*\\.\\s*\\w+)*\\b(?:\\s*:\\s*\\b(?!\\b(?:alignas|alignof|asm|auto|bool|break|case|catch|char|char16_t|char32_t|char8_t|class|co_await|co_return|co_yield|compl|concept|const|const_cast|consteval|constexpr|constinit|continue|decltype|default|delete|do|double|dynamic_cast|else|enum|explicit|export|extern|final|float|for|friend|goto|if|import|inline|int|int16_t|int32_t|int64_t|int8_t|long|module|mutable|namespace|new|noexcept|nullptr|operator|override|private|protected|public|register|reinterpret_cast|requires|return|short|signed|sizeof|static|static_assert|static_cast|struct|switch|template|this|thread_local|throw|try|typedef|typeid|typename|uint16_t|uint32_t|uint64_t|uint8_t|union|unsigned|using|virtual|void|volatile|wchar_t|while)\\b)\\w+(?:\\s*\\.\\s*\\w+)*\\b)?|:\\s*\\b(?!\\b(?:alignas|alignof|asm|auto|bool|break|case|catch|char|char16_t|char32_t|char8_t|class|co_await|co_return|co_yield|compl|concept|const|const_cast|consteval|constexpr|constinit|continue|decltype|default|delete|do|double|dynamic_cast|else|enum|explicit|export|extern|final|float|for|friend|goto|if|import|inline|int|int16_t|int32_t|int64_t|int8_t|long|module|mutable|namespace|new|noexcept|nullptr|operator|override|private|protected|public|register|reinterpret_cast|requires|return|short|signed|sizeof|static|static_assert|static_cast|struct|switch|template|this|thread_local|throw|try|typedef|typeid|typename|uint16_t|uint32_t|uint64_t|uint8_t|union|unsigned|using|virtual|void|volatile|wchar_t|while)\\b)\\w+(?:\\s*\\.\\s*\\w+)*\\b)"), + lookbehind: true, + greedy: true, + inside: () => _g6), + GrammarToken("raw-string", + compileHighlightPattern("R\"([^()\\\\ ]{0,16})\\([\\s\\S]*?\\)\\1\""), + greedy: true, alias: "string"), + GrammarToken( + "string", + compileHighlightPattern( + "\"(?:\\\\(?:\\r\\n|[\\s\\S])|[^\"\\\\\\r\\n])*\""), + greedy: true), + GrammarToken( + "class-name", + compileHighlightPattern( + "(\\b(?:class|concept|enum|struct|typename)\\s+)(?!\\b(?:alignas|alignof|asm|auto|bool|break|case|catch|char|char16_t|char32_t|char8_t|class|co_await|co_return|co_yield|compl|concept|const|const_cast|consteval|constexpr|constinit|continue|decltype|default|delete|do|double|dynamic_cast|else|enum|explicit|export|extern|final|float|for|friend|goto|if|import|inline|int|int16_t|int32_t|int64_t|int8_t|long|module|mutable|namespace|new|noexcept|nullptr|operator|override|private|protected|public|register|reinterpret_cast|requires|return|short|signed|sizeof|static|static_assert|static_cast|struct|switch|template|this|thread_local|throw|try|typedef|typeid|typename|uint16_t|uint32_t|uint64_t|uint8_t|union|unsigned|using|virtual|void|volatile|wchar_t|while)\\b)\\w+"), + lookbehind: true), + GrammarToken("class-name", + compileHighlightPattern("\\b[A-Z]\\w*(?=\\s*::\\s*\\w+\\s*\\()")), + GrammarToken( + "class-name", + compileHighlightPattern("\\b[A-Z_]\\w*(?=\\s*::\\s*~\\w+\\s*\\()", + caseSensitive: false)), + GrammarToken( + "class-name", + compileHighlightPattern( + "\\b\\w+(?=\\s*<(?:[^<>]|<(?:[^<>]|<[^<>]*>)*>)*>\\s*::\\s*\\w+\\s*\\()")), + GrammarToken( + "generic-function", + compileHighlightPattern( + "\\b(?!operator\\b)[a-z_]\\w*\\s*<(?:[^<>]|<[^<>]*>)*>(?=\\s*\\()", + caseSensitive: false), + inside: () => _g7), + GrammarToken( + "keyword", + compileHighlightPattern( + "\\b(?:alignas|alignof|asm|auto|bool|break|case|catch|char|char16_t|char32_t|char8_t|class|co_await|co_return|co_yield|compl|concept|const|const_cast|consteval|constexpr|constinit|continue|decltype|default|delete|do|double|dynamic_cast|else|enum|explicit|export|extern|final|float|for|friend|goto|if|import|inline|int|int16_t|int32_t|int64_t|int8_t|long|module|mutable|namespace|new|noexcept|nullptr|operator|override|private|protected|public|register|reinterpret_cast|requires|return|short|signed|sizeof|static|static_assert|static_cast|struct|switch|template|this|thread_local|throw|try|typedef|typeid|typename|uint16_t|uint32_t|uint64_t|uint8_t|union|unsigned|using|virtual|void|volatile|wchar_t|while)\\b")), + GrammarToken( + "constant", + compileHighlightPattern( + "\\b(?:EOF|NULL|SEEK_CUR|SEEK_END|SEEK_SET|__DATE__|__FILE__|__LINE__|__TIMESTAMP__|__TIME__|__func__|stderr|stdin|stdout)\\b")), + GrammarToken( + "function", + compileHighlightPattern("\\b[a-z_]\\w*(?=\\s*\\()", + caseSensitive: false)), + GrammarToken( + "number", + compileHighlightPattern( + "(?:\\b0b[01']+|\\b0x(?:[\\da-f']+(?:\\.[\\da-f']*)?|\\.[\\da-f']+)(?:p[+-]?[\\d']+)?|(?:\\b[\\d']+(?:\\.[\\d']*)?|\\B\\.[\\d']+)(?:e[+-]?[\\d']+)?)[ful]{0,4}", + caseSensitive: false), + greedy: true), + GrammarToken("double-colon", compileHighlightPattern("::"), + alias: "punctuation"), + GrammarToken( + "operator", + compileHighlightPattern( + ">>=?|<<=?|->|--|\\+\\+|&&|\\|\\||[?:~]|<=>|[-+*/%&|^!=<>]=?|\\b(?:and|and_eq|bitand|bitor|not|not_eq|or|or_eq|xor|xor_eq)\\b")), + GrammarToken("punctuation", compileHighlightPattern("[{}[\\];(),.:]")), + GrammarToken("boolean", compileHighlightPattern("\\b(?:false|true)\\b")), +]); + +final Grammar _g6 = Grammar([ + GrammarToken("string", compileHighlightPattern("^[<\"][\\s\\S]+")), + GrammarToken("operator", compileHighlightPattern(":")), + GrammarToken("punctuation", compileHighlightPattern("\\.")), +]); + +final Grammar _g7 = Grammar([ + GrammarToken("function", compileHighlightPattern("^\\w+")), + GrammarToken("generic", compileHighlightPattern("<[\\s\\S]+"), + alias: "class-name", inside: () => _g5), +]); + +final Grammar _g8 = Grammar([ + GrammarToken("function", compileHighlightPattern("^\\w+")), + GrammarToken("generic", compileHighlightPattern("<[\\s\\S]+"), + alias: "class-name", inside: () => _g0), +]); diff --git a/lib/highlight/csharp.dart b/lib/highlight/csharp.dart new file mode 100644 index 0000000..5bddbeb --- /dev/null +++ b/lib/highlight/csharp.dart @@ -0,0 +1,292 @@ +// GENERATED CODE — do not modify by hand. Regenerate with tool/highlight_codegen. +// ignore_for_file: lines_longer_than_80_chars, public_member_api_docs +// ignore_for_file: prefer_single_quotes, require_trailing_commas, directives_ordering + +import '../highlight.dart'; + +/// Syntax grammar for `csharp`. +/// +/// Import this library only when you need `csharp` highlighting; unused +/// languages are dropped from the build. +abstract final class HighlightCsharp { + /// The grammar for `csharp`. + static final Grammar grammar = _g0; +} + +final Grammar _g0 = Grammar([ + GrammarToken("comment", + compileHighlightPattern("(^|[^\\\\])\\/\\*[\\s\\S]*?(?:\\*\\/|\$)"), + lookbehind: true, greedy: true), + GrammarToken("comment", compileHighlightPattern("(^|[^\\\\:])\\/\\/.*"), + lookbehind: true, greedy: true), + GrammarToken( + "interpolation-string", + compileHighlightPattern( + "(^|[^\\\\])(?:\\\$@|@\\\$)\"(?:\"\"|\\\\[\\s\\S]|\\{\\{|(?:\\{(?!\\{)(?:(?![}:])(?:[^\"'/()]|(?:\\/(?![*/])|\\/\\/[^\\r\\n]*[\\r\\n]|\\/\\*(?:[^*]|\\*(?!\\/))*\\*\\/|(?:\"(?:\\\\.|[^\\\\\"\\r\\n])*\"|'(?:[^\\r\\n'\\\\]|\\\\.|\\\\[Uux][\\da-fA-F]{1,8})'))|\\((?:[^\"'/()]|(?:\\/(?![*/])|\\/\\/[^\\r\\n]*[\\r\\n]|\\/\\*(?:[^*]|\\*(?!\\/))*\\*\\/|(?:\"(?:\\\\.|[^\\\\\"\\r\\n])*\"|'(?:[^\\r\\n'\\\\]|\\\\.|\\\\[Uux][\\da-fA-F]{1,8})'))|\\((?:[^\"'/()]|(?:\\/(?![*/])|\\/\\/[^\\r\\n]*[\\r\\n]|\\/\\*(?:[^*]|\\*(?!\\/))*\\*\\/|(?:\"(?:\\\\.|[^\\\\\"\\r\\n])*\"|'(?:[^\\r\\n'\\\\]|\\\\.|\\\\[Uux][\\da-fA-F]{1,8})'))|\\((?:[^\"'/()]|(?:\\/(?![*/])|\\/\\/[^\\r\\n]*[\\r\\n]|\\/\\*(?:[^*]|\\*(?!\\/))*\\*\\/|(?:\"(?:\\\\.|[^\\\\\"\\r\\n])*\"|'(?:[^\\r\\n'\\\\]|\\\\.|\\\\[Uux][\\da-fA-F]{1,8})'))|\\([^\\s\\S]*\\))*\\))*\\))*\\)))*(?::[^}\\r\\n]+)?\\})|[^\\\\{\"])*\""), + lookbehind: true, + greedy: true, + inside: () => _g1), + GrammarToken( + "interpolation-string", + compileHighlightPattern( + "(^|[^@\\\\])\\\$\"(?:\\\\.|\\{\\{|(?:\\{(?!\\{)(?:(?![}:])(?:[^\"'/()]|\\/(?!\\*)|\\/\\*(?:[^*]|\\*(?!\\/))*\\*\\/|(?:\"(?:\\\\.|[^\\\\\"\\r\\n])*\"|'(?:[^\\r\\n'\\\\]|\\\\.|\\\\[Uux][\\da-fA-F]{1,8})')|\\((?:[^\"'/()]|\\/(?!\\*)|\\/\\*(?:[^*]|\\*(?!\\/))*\\*\\/|(?:\"(?:\\\\.|[^\\\\\"\\r\\n])*\"|'(?:[^\\r\\n'\\\\]|\\\\.|\\\\[Uux][\\da-fA-F]{1,8})')|\\((?:[^\"'/()]|\\/(?!\\*)|\\/\\*(?:[^*]|\\*(?!\\/))*\\*\\/|(?:\"(?:\\\\.|[^\\\\\"\\r\\n])*\"|'(?:[^\\r\\n'\\\\]|\\\\.|\\\\[Uux][\\da-fA-F]{1,8})')|\\((?:[^\"'/()]|\\/(?!\\*)|\\/\\*(?:[^*]|\\*(?!\\/))*\\*\\/|(?:\"(?:\\\\.|[^\\\\\"\\r\\n])*\"|'(?:[^\\r\\n'\\\\]|\\\\.|\\\\[Uux][\\da-fA-F]{1,8})')|\\([^\\s\\S]*\\))*\\))*\\))*\\)))*(?::[^}\\r\\n]+)?\\})|[^\\\\\"{])*\""), + lookbehind: true, + greedy: true, + inside: () => _g4), + GrammarToken( + "char", + compileHighlightPattern( + "'(?:[^\\r\\n'\\\\]|\\\\.|\\\\[Uux][\\da-fA-F]{1,8})'"), + greedy: true), + GrammarToken( + "string", + compileHighlightPattern( + "(^|[^\$\\\\])(?:@\"(?:\"\"|\\\\[\\s\\S]|[^\\\\\"])*\"(?!\"))"), + lookbehind: true, + greedy: true), + GrammarToken( + "string", + compileHighlightPattern( + "(^|[^@\$\\\\])(?:\"(?:\\\\.|[^\\\\\"\\r\\n])*\")"), + lookbehind: true, + greedy: true), + GrammarToken( + "namespace", + compileHighlightPattern( + "(\\b(?:namespace|using)\\s+)(?:@?\\b[A-Za-z_]\\w*\\b)(?:\\s*\\.\\s*(?:@?\\b[A-Za-z_]\\w*\\b))*(?=\\s*[;{])"), + lookbehind: true, + inside: () => _g7), + GrammarToken( + "type-expression", + compileHighlightPattern( + "(\\b(?:default|sizeof|typeof)\\s*\\(\\s*(?!\\s))(?:[^()\\s]|\\s(?!\\s)|(?:\\((?:[^()]|(?:\\((?:[^()]|(?:\\((?:[^()]|(?:\\((?:[^()]|[^\\s\\S])*\\)))*\\)))*\\)))*\\)))*(?=\\s*\\))"), + lookbehind: true, + alias: "class-name", + inside: () => _g8), + GrammarToken( + "return-type", + compileHighlightPattern( + "(?:(?:(?:\\((?:[^,()<>[\\];=+\\-*/%&|^]|(?:<(?:[^<>;=+\\-*/%&|^]|(?:<(?:[^<>;=+\\-*/%&|^]|(?:<(?:[^<>;=+\\-*/%&|^]|(?:<(?:[^<>;=+\\-*/%&|^]|[^\\s\\S])*>))*>))*>))*>)|(?:\\((?:[^()]|(?:\\((?:[^()]|(?:\\((?:[^()]|(?:\\((?:[^()]|[^\\s\\S])*\\)))*\\)))*\\)))*\\))|(?:\\[\\s*(?:,\\s*)*\\]))+(?:,(?:[^,()<>[\\];=+\\-*/%&|^]|(?:<(?:[^<>;=+\\-*/%&|^]|(?:<(?:[^<>;=+\\-*/%&|^]|(?:<(?:[^<>;=+\\-*/%&|^]|(?:<(?:[^<>;=+\\-*/%&|^]|[^\\s\\S])*>))*>))*>))*>)|(?:\\((?:[^()]|(?:\\((?:[^()]|(?:\\((?:[^()]|(?:\\((?:[^()]|[^\\s\\S])*\\)))*\\)))*\\)))*\\))|(?:\\[\\s*(?:,\\s*)*\\]))+)+\\))|(?:(?!(?:\\b(?:class|enum|interface|record|struct|add|alias|and|ascending|async|await|by|descending|from(?=\\s*(?:\\w|\$))|get|global|group|into|init(?=\\s*;)|join|let|nameof|not|notnull|on|or|orderby|partial|remove|select|set|unmanaged|value|when|where|with(?=\\s*{)|abstract|as|base|break|case|catch|checked|const|continue|default|delegate|do|else|event|explicit|extern|finally|fixed|for|foreach|goto|if|implicit|in|internal|is|lock|namespace|new|null|operator|out|override|params|private|protected|public|readonly|ref|return|sealed|sizeof|stackalloc|static|switch|this|throw|try|typeof|unchecked|unsafe|using|virtual|volatile|while|yield)\\b))(?:(?:@?\\b[A-Za-z_]\\w*\\b)(?:\\s*(?:<(?:[^<>;=+\\-*/%&|^]|(?:<(?:[^<>;=+\\-*/%&|^]|(?:<(?:[^<>;=+\\-*/%&|^]|(?:<(?:[^<>;=+\\-*/%&|^]|[^\\s\\S])*>))*>))*>))*>))?)(?:\\s*\\.\\s*(?:(?:@?\\b[A-Za-z_]\\w*\\b)(?:\\s*(?:<(?:[^<>;=+\\-*/%&|^]|(?:<(?:[^<>;=+\\-*/%&|^]|(?:<(?:[^<>;=+\\-*/%&|^]|(?:<(?:[^<>;=+\\-*/%&|^]|[^\\s\\S])*>))*>))*>))*>))?))*))(?:\\s*(?:\\?\\s*)?(?:\\[\\s*(?:,\\s*)*\\]))*(?:\\s*\\?)?)(?=\\s+(?:(?:(?!(?:\\b(?:class|enum|interface|record|struct|add|alias|and|ascending|async|await|by|descending|from(?=\\s*(?:\\w|\$))|get|global|group|into|init(?=\\s*;)|join|let|nameof|not|notnull|on|or|orderby|partial|remove|select|set|unmanaged|value|when|where|with(?=\\s*{)|abstract|as|base|break|case|catch|checked|const|continue|default|delegate|do|else|event|explicit|extern|finally|fixed|for|foreach|goto|if|implicit|in|internal|is|lock|namespace|new|null|operator|out|override|params|private|protected|public|readonly|ref|return|sealed|sizeof|stackalloc|static|switch|this|throw|try|typeof|unchecked|unsafe|using|virtual|volatile|while|yield)\\b))(?:(?:@?\\b[A-Za-z_]\\w*\\b)(?:\\s*(?:<(?:[^<>;=+\\-*/%&|^]|(?:<(?:[^<>;=+\\-*/%&|^]|(?:<(?:[^<>;=+\\-*/%&|^]|(?:<(?:[^<>;=+\\-*/%&|^]|[^\\s\\S])*>))*>))*>))*>))?)(?:\\s*\\.\\s*(?:(?:@?\\b[A-Za-z_]\\w*\\b)(?:\\s*(?:<(?:[^<>;=+\\-*/%&|^]|(?:<(?:[^<>;=+\\-*/%&|^]|(?:<(?:[^<>;=+\\-*/%&|^]|(?:<(?:[^<>;=+\\-*/%&|^]|[^\\s\\S])*>))*>))*>))*>))?))*)\\s*(?:=>|[({]|\\.\\s*this\\s*\\[)|this\\s*\\[))"), + alias: "class-name", + inside: () => _g8), + GrammarToken( + "constructor-invocation", + compileHighlightPattern( + "(\\bnew\\s+)(?:(?:(?:\\((?:[^,()<>[\\];=+\\-*/%&|^]|(?:<(?:[^<>;=+\\-*/%&|^]|(?:<(?:[^<>;=+\\-*/%&|^]|(?:<(?:[^<>;=+\\-*/%&|^]|(?:<(?:[^<>;=+\\-*/%&|^]|[^\\s\\S])*>))*>))*>))*>)|(?:\\((?:[^()]|(?:\\((?:[^()]|(?:\\((?:[^()]|(?:\\((?:[^()]|[^\\s\\S])*\\)))*\\)))*\\)))*\\))|(?:\\[\\s*(?:,\\s*)*\\]))+(?:,(?:[^,()<>[\\];=+\\-*/%&|^]|(?:<(?:[^<>;=+\\-*/%&|^]|(?:<(?:[^<>;=+\\-*/%&|^]|(?:<(?:[^<>;=+\\-*/%&|^]|(?:<(?:[^<>;=+\\-*/%&|^]|[^\\s\\S])*>))*>))*>))*>)|(?:\\((?:[^()]|(?:\\((?:[^()]|(?:\\((?:[^()]|(?:\\((?:[^()]|[^\\s\\S])*\\)))*\\)))*\\)))*\\))|(?:\\[\\s*(?:,\\s*)*\\]))+)+\\))|(?:(?!(?:\\b(?:class|enum|interface|record|struct|add|alias|and|ascending|async|await|by|descending|from(?=\\s*(?:\\w|\$))|get|global|group|into|init(?=\\s*;)|join|let|nameof|not|notnull|on|or|orderby|partial|remove|select|set|unmanaged|value|when|where|with(?=\\s*{)|abstract|as|base|break|case|catch|checked|const|continue|default|delegate|do|else|event|explicit|extern|finally|fixed|for|foreach|goto|if|implicit|in|internal|is|lock|namespace|new|null|operator|out|override|params|private|protected|public|readonly|ref|return|sealed|sizeof|stackalloc|static|switch|this|throw|try|typeof|unchecked|unsafe|using|virtual|volatile|while|yield)\\b))(?:(?:@?\\b[A-Za-z_]\\w*\\b)(?:\\s*(?:<(?:[^<>;=+\\-*/%&|^]|(?:<(?:[^<>;=+\\-*/%&|^]|(?:<(?:[^<>;=+\\-*/%&|^]|(?:<(?:[^<>;=+\\-*/%&|^]|[^\\s\\S])*>))*>))*>))*>))?)(?:\\s*\\.\\s*(?:(?:@?\\b[A-Za-z_]\\w*\\b)(?:\\s*(?:<(?:[^<>;=+\\-*/%&|^]|(?:<(?:[^<>;=+\\-*/%&|^]|(?:<(?:[^<>;=+\\-*/%&|^]|(?:<(?:[^<>;=+\\-*/%&|^]|[^\\s\\S])*>))*>))*>))*>))?))*))(?:\\s*(?:\\?\\s*)?(?:\\[\\s*(?:,\\s*)*\\]))*(?:\\s*\\?)?)(?=\\s*[[({])"), + lookbehind: true, + alias: "class-name", + inside: () => _g8), + GrammarToken( + "generic-method", + compileHighlightPattern( + "(?:@?\\b[A-Za-z_]\\w*\\b)\\s*(?:<(?:[^<>;=+\\-*/%&|^]|(?:<(?:[^<>;=+\\-*/%&|^]|(?:<(?:[^<>;=+\\-*/%&|^]|(?:<(?:[^<>;=+\\-*/%&|^]|[^\\s\\S])*>))*>))*>))*>)(?=\\s*\\()"), + inside: () => _g9), + GrammarToken( + "type-list", + compileHighlightPattern( + "\\b((?:(?:\\b(?:class|enum|interface|record|struct)\\b)\\s+(?:(?:@?\\b[A-Za-z_]\\w*\\b)(?:\\s*(?:<(?:[^<>;=+\\-*/%&|^]|(?:<(?:[^<>;=+\\-*/%&|^]|(?:<(?:[^<>;=+\\-*/%&|^]|(?:<(?:[^<>;=+\\-*/%&|^]|[^\\s\\S])*>))*>))*>))*>))?)|record\\s+(?:(?:@?\\b[A-Za-z_]\\w*\\b)(?:\\s*(?:<(?:[^<>;=+\\-*/%&|^]|(?:<(?:[^<>;=+\\-*/%&|^]|(?:<(?:[^<>;=+\\-*/%&|^]|(?:<(?:[^<>;=+\\-*/%&|^]|[^\\s\\S])*>))*>))*>))*>))?)\\s*(?:\\((?:[^()]|(?:\\((?:[^()]|(?:\\((?:[^()]|(?:\\((?:[^()]|[^\\s\\S])*\\)))*\\)))*\\)))*\\))|where\\s+(?:@?\\b[A-Za-z_]\\w*\\b))\\s*:\\s*)(?:(?:(?:(?:\\((?:[^,()<>[\\];=+\\-*/%&|^]|(?:<(?:[^<>;=+\\-*/%&|^]|(?:<(?:[^<>;=+\\-*/%&|^]|(?:<(?:[^<>;=+\\-*/%&|^]|(?:<(?:[^<>;=+\\-*/%&|^]|[^\\s\\S])*>))*>))*>))*>)|(?:\\((?:[^()]|(?:\\((?:[^()]|(?:\\((?:[^()]|(?:\\((?:[^()]|[^\\s\\S])*\\)))*\\)))*\\)))*\\))|(?:\\[\\s*(?:,\\s*)*\\]))+(?:,(?:[^,()<>[\\];=+\\-*/%&|^]|(?:<(?:[^<>;=+\\-*/%&|^]|(?:<(?:[^<>;=+\\-*/%&|^]|(?:<(?:[^<>;=+\\-*/%&|^]|(?:<(?:[^<>;=+\\-*/%&|^]|[^\\s\\S])*>))*>))*>))*>)|(?:\\((?:[^()]|(?:\\((?:[^()]|(?:\\((?:[^()]|(?:\\((?:[^()]|[^\\s\\S])*\\)))*\\)))*\\)))*\\))|(?:\\[\\s*(?:,\\s*)*\\]))+)+\\))|(?:(?!(?:\\b(?:class|enum|interface|record|struct|add|alias|and|ascending|async|await|by|descending|from(?=\\s*(?:\\w|\$))|get|global|group|into|init(?=\\s*;)|join|let|nameof|not|notnull|on|or|orderby|partial|remove|select|set|unmanaged|value|when|where|with(?=\\s*{)|abstract|as|base|break|case|catch|checked|const|continue|default|delegate|do|else|event|explicit|extern|finally|fixed|for|foreach|goto|if|implicit|in|internal|is|lock|namespace|new|null|operator|out|override|params|private|protected|public|readonly|ref|return|sealed|sizeof|stackalloc|static|switch|this|throw|try|typeof|unchecked|unsafe|using|virtual|volatile|while|yield)\\b))(?:(?:@?\\b[A-Za-z_]\\w*\\b)(?:\\s*(?:<(?:[^<>;=+\\-*/%&|^]|(?:<(?:[^<>;=+\\-*/%&|^]|(?:<(?:[^<>;=+\\-*/%&|^]|(?:<(?:[^<>;=+\\-*/%&|^]|[^\\s\\S])*>))*>))*>))*>))?)(?:\\s*\\.\\s*(?:(?:@?\\b[A-Za-z_]\\w*\\b)(?:\\s*(?:<(?:[^<>;=+\\-*/%&|^]|(?:<(?:[^<>;=+\\-*/%&|^]|(?:<(?:[^<>;=+\\-*/%&|^]|(?:<(?:[^<>;=+\\-*/%&|^]|[^\\s\\S])*>))*>))*>))*>))?))*))(?:\\s*(?:\\?\\s*)?(?:\\[\\s*(?:,\\s*)*\\]))*(?:\\s*\\?)?)|(?:\\b(?:bool|byte|char|decimal|double|dynamic|float|int|long|object|sbyte|short|string|uint|ulong|ushort|var|void|class|enum|interface|record|struct|add|alias|and|ascending|async|await|by|descending|from(?=\\s*(?:\\w|\$))|get|global|group|into|init(?=\\s*;)|join|let|nameof|not|notnull|on|or|orderby|partial|remove|select|set|unmanaged|value|when|where|with(?=\\s*{)|abstract|as|base|break|case|catch|checked|const|continue|default|delegate|do|else|event|explicit|extern|finally|fixed|for|foreach|goto|if|implicit|in|internal|is|lock|namespace|new|null|operator|out|override|params|private|protected|public|readonly|ref|return|sealed|sizeof|stackalloc|static|switch|this|throw|try|typeof|unchecked|unsafe|using|virtual|volatile|while|yield)\\b)|(?:(?:@?\\b[A-Za-z_]\\w*\\b)(?:\\s*(?:<(?:[^<>;=+\\-*/%&|^]|(?:<(?:[^<>;=+\\-*/%&|^]|(?:<(?:[^<>;=+\\-*/%&|^]|(?:<(?:[^<>;=+\\-*/%&|^]|[^\\s\\S])*>))*>))*>))*>))?)\\s*(?:\\((?:[^()]|(?:\\((?:[^()]|(?:\\((?:[^()]|(?:\\((?:[^()]|[^\\s\\S])*\\)))*\\)))*\\)))*\\))|(?:\\bnew\\s*\\(\\s*\\)))(?:\\s*,\\s*(?:(?:(?:(?:\\((?:[^,()<>[\\];=+\\-*/%&|^]|(?:<(?:[^<>;=+\\-*/%&|^]|(?:<(?:[^<>;=+\\-*/%&|^]|(?:<(?:[^<>;=+\\-*/%&|^]|(?:<(?:[^<>;=+\\-*/%&|^]|[^\\s\\S])*>))*>))*>))*>)|(?:\\((?:[^()]|(?:\\((?:[^()]|(?:\\((?:[^()]|(?:\\((?:[^()]|[^\\s\\S])*\\)))*\\)))*\\)))*\\))|(?:\\[\\s*(?:,\\s*)*\\]))+(?:,(?:[^,()<>[\\];=+\\-*/%&|^]|(?:<(?:[^<>;=+\\-*/%&|^]|(?:<(?:[^<>;=+\\-*/%&|^]|(?:<(?:[^<>;=+\\-*/%&|^]|(?:<(?:[^<>;=+\\-*/%&|^]|[^\\s\\S])*>))*>))*>))*>)|(?:\\((?:[^()]|(?:\\((?:[^()]|(?:\\((?:[^()]|(?:\\((?:[^()]|[^\\s\\S])*\\)))*\\)))*\\)))*\\))|(?:\\[\\s*(?:,\\s*)*\\]))+)+\\))|(?:(?!(?:\\b(?:class|enum|interface|record|struct|add|alias|and|ascending|async|await|by|descending|from(?=\\s*(?:\\w|\$))|get|global|group|into|init(?=\\s*;)|join|let|nameof|not|notnull|on|or|orderby|partial|remove|select|set|unmanaged|value|when|where|with(?=\\s*{)|abstract|as|base|break|case|catch|checked|const|continue|default|delegate|do|else|event|explicit|extern|finally|fixed|for|foreach|goto|if|implicit|in|internal|is|lock|namespace|new|null|operator|out|override|params|private|protected|public|readonly|ref|return|sealed|sizeof|stackalloc|static|switch|this|throw|try|typeof|unchecked|unsafe|using|virtual|volatile|while|yield)\\b))(?:(?:@?\\b[A-Za-z_]\\w*\\b)(?:\\s*(?:<(?:[^<>;=+\\-*/%&|^]|(?:<(?:[^<>;=+\\-*/%&|^]|(?:<(?:[^<>;=+\\-*/%&|^]|(?:<(?:[^<>;=+\\-*/%&|^]|[^\\s\\S])*>))*>))*>))*>))?)(?:\\s*\\.\\s*(?:(?:@?\\b[A-Za-z_]\\w*\\b)(?:\\s*(?:<(?:[^<>;=+\\-*/%&|^]|(?:<(?:[^<>;=+\\-*/%&|^]|(?:<(?:[^<>;=+\\-*/%&|^]|(?:<(?:[^<>;=+\\-*/%&|^]|[^\\s\\S])*>))*>))*>))*>))?))*))(?:\\s*(?:\\?\\s*)?(?:\\[\\s*(?:,\\s*)*\\]))*(?:\\s*\\?)?)|(?:\\b(?:bool|byte|char|decimal|double|dynamic|float|int|long|object|sbyte|short|string|uint|ulong|ushort|var|void|class|enum|interface|record|struct|add|alias|and|ascending|async|await|by|descending|from(?=\\s*(?:\\w|\$))|get|global|group|into|init(?=\\s*;)|join|let|nameof|not|notnull|on|or|orderby|partial|remove|select|set|unmanaged|value|when|where|with(?=\\s*{)|abstract|as|base|break|case|catch|checked|const|continue|default|delegate|do|else|event|explicit|extern|finally|fixed|for|foreach|goto|if|implicit|in|internal|is|lock|namespace|new|null|operator|out|override|params|private|protected|public|readonly|ref|return|sealed|sizeof|stackalloc|static|switch|this|throw|try|typeof|unchecked|unsafe|using|virtual|volatile|while|yield)\\b)|(?:\\bnew\\s*\\(\\s*\\))))*(?=\\s*(?:where|[{;]|=>|\$))"), + lookbehind: true, + inside: () => _g10), + GrammarToken( + "preprocessor", compileHighlightPattern("(^[\\t ]*)#.*", multiLine: true), + lookbehind: true, alias: "property", inside: () => _g11), + GrammarToken( + "attribute", + compileHighlightPattern( + "((?:^|[^\\s\\w>)?])\\s*\\[\\s*)(?:(?:\\b(?:assembly|event|field|method|module|param|property|return|type)\\b)\\s*:\\s*)?(?:(?:(?!(?:\\b(?:class|enum|interface|record|struct|add|alias|and|ascending|async|await|by|descending|from(?=\\s*(?:\\w|\$))|get|global|group|into|init(?=\\s*;)|join|let|nameof|not|notnull|on|or|orderby|partial|remove|select|set|unmanaged|value|when|where|with(?=\\s*{)|abstract|as|base|break|case|catch|checked|const|continue|default|delegate|do|else|event|explicit|extern|finally|fixed|for|foreach|goto|if|implicit|in|internal|is|lock|namespace|new|null|operator|out|override|params|private|protected|public|readonly|ref|return|sealed|sizeof|stackalloc|static|switch|this|throw|try|typeof|unchecked|unsafe|using|virtual|volatile|while|yield)\\b))(?:(?:@?\\b[A-Za-z_]\\w*\\b)(?:\\s*(?:<(?:[^<>;=+\\-*/%&|^]|(?:<(?:[^<>;=+\\-*/%&|^]|(?:<(?:[^<>;=+\\-*/%&|^]|(?:<(?:[^<>;=+\\-*/%&|^]|[^\\s\\S])*>))*>))*>))*>))?)(?:\\s*\\.\\s*(?:(?:@?\\b[A-Za-z_]\\w*\\b)(?:\\s*(?:<(?:[^<>;=+\\-*/%&|^]|(?:<(?:[^<>;=+\\-*/%&|^]|(?:<(?:[^<>;=+\\-*/%&|^]|(?:<(?:[^<>;=+\\-*/%&|^]|[^\\s\\S])*>))*>))*>))*>))?))*)(?:\\s*\\((?:[^\"'/()]|(?:\\/(?![*/])|\\/\\/[^\\r\\n]*[\\r\\n]|\\/\\*(?:[^*]|\\*(?!\\/))*\\*\\/|(?:\"(?:\\\\.|[^\\\\\"\\r\\n])*\"|'(?:[^\\r\\n'\\\\]|\\\\.|\\\\[Uux][\\da-fA-F]{1,8})'))|\\((?:[^\"'/()]|(?:\\/(?![*/])|\\/\\/[^\\r\\n]*[\\r\\n]|\\/\\*(?:[^*]|\\*(?!\\/))*\\*\\/|(?:\"(?:\\\\.|[^\\\\\"\\r\\n])*\"|'(?:[^\\r\\n'\\\\]|\\\\.|\\\\[Uux][\\da-fA-F]{1,8})'))|\\((?:[^\"'/()]|(?:\\/(?![*/])|\\/\\/[^\\r\\n]*[\\r\\n]|\\/\\*(?:[^*]|\\*(?!\\/))*\\*\\/|(?:\"(?:\\\\.|[^\\\\\"\\r\\n])*\"|'(?:[^\\r\\n'\\\\]|\\\\.|\\\\[Uux][\\da-fA-F]{1,8})'))|\\((?:[^\"'/()]|(?:\\/(?![*/])|\\/\\/[^\\r\\n]*[\\r\\n]|\\/\\*(?:[^*]|\\*(?!\\/))*\\*\\/|(?:\"(?:\\\\.|[^\\\\\"\\r\\n])*\"|'(?:[^\\r\\n'\\\\]|\\\\.|\\\\[Uux][\\da-fA-F]{1,8})'))|\\([^\\s\\S]*\\))*\\))*\\))*\\))*\\))?)(?:\\s*,\\s*(?:(?:(?!(?:\\b(?:class|enum|interface|record|struct|add|alias|and|ascending|async|await|by|descending|from(?=\\s*(?:\\w|\$))|get|global|group|into|init(?=\\s*;)|join|let|nameof|not|notnull|on|or|orderby|partial|remove|select|set|unmanaged|value|when|where|with(?=\\s*{)|abstract|as|base|break|case|catch|checked|const|continue|default|delegate|do|else|event|explicit|extern|finally|fixed|for|foreach|goto|if|implicit|in|internal|is|lock|namespace|new|null|operator|out|override|params|private|protected|public|readonly|ref|return|sealed|sizeof|stackalloc|static|switch|this|throw|try|typeof|unchecked|unsafe|using|virtual|volatile|while|yield)\\b))(?:(?:@?\\b[A-Za-z_]\\w*\\b)(?:\\s*(?:<(?:[^<>;=+\\-*/%&|^]|(?:<(?:[^<>;=+\\-*/%&|^]|(?:<(?:[^<>;=+\\-*/%&|^]|(?:<(?:[^<>;=+\\-*/%&|^]|[^\\s\\S])*>))*>))*>))*>))?)(?:\\s*\\.\\s*(?:(?:@?\\b[A-Za-z_]\\w*\\b)(?:\\s*(?:<(?:[^<>;=+\\-*/%&|^]|(?:<(?:[^<>;=+\\-*/%&|^]|(?:<(?:[^<>;=+\\-*/%&|^]|(?:<(?:[^<>;=+\\-*/%&|^]|[^\\s\\S])*>))*>))*>))*>))?))*)(?:\\s*\\((?:[^\"'/()]|(?:\\/(?![*/])|\\/\\/[^\\r\\n]*[\\r\\n]|\\/\\*(?:[^*]|\\*(?!\\/))*\\*\\/|(?:\"(?:\\\\.|[^\\\\\"\\r\\n])*\"|'(?:[^\\r\\n'\\\\]|\\\\.|\\\\[Uux][\\da-fA-F]{1,8})'))|\\((?:[^\"'/()]|(?:\\/(?![*/])|\\/\\/[^\\r\\n]*[\\r\\n]|\\/\\*(?:[^*]|\\*(?!\\/))*\\*\\/|(?:\"(?:\\\\.|[^\\\\\"\\r\\n])*\"|'(?:[^\\r\\n'\\\\]|\\\\.|\\\\[Uux][\\da-fA-F]{1,8})'))|\\((?:[^\"'/()]|(?:\\/(?![*/])|\\/\\/[^\\r\\n]*[\\r\\n]|\\/\\*(?:[^*]|\\*(?!\\/))*\\*\\/|(?:\"(?:\\\\.|[^\\\\\"\\r\\n])*\"|'(?:[^\\r\\n'\\\\]|\\\\.|\\\\[Uux][\\da-fA-F]{1,8})'))|\\((?:[^\"'/()]|(?:\\/(?![*/])|\\/\\/[^\\r\\n]*[\\r\\n]|\\/\\*(?:[^*]|\\*(?!\\/))*\\*\\/|(?:\"(?:\\\\.|[^\\\\\"\\r\\n])*\"|'(?:[^\\r\\n'\\\\]|\\\\.|\\\\[Uux][\\da-fA-F]{1,8})'))|\\([^\\s\\S]*\\))*\\))*\\))*\\))*\\))?))*(?=\\s*\\])"), + lookbehind: true, + greedy: true, + inside: () => _g12), + GrammarToken( + "class-name", + compileHighlightPattern( + "(\\busing\\s+static\\s+)(?:(?!(?:\\b(?:class|enum|interface|record|struct|add|alias|and|ascending|async|await|by|descending|from(?=\\s*(?:\\w|\$))|get|global|group|into|init(?=\\s*;)|join|let|nameof|not|notnull|on|or|orderby|partial|remove|select|set|unmanaged|value|when|where|with(?=\\s*{)|abstract|as|base|break|case|catch|checked|const|continue|default|delegate|do|else|event|explicit|extern|finally|fixed|for|foreach|goto|if|implicit|in|internal|is|lock|namespace|new|null|operator|out|override|params|private|protected|public|readonly|ref|return|sealed|sizeof|stackalloc|static|switch|this|throw|try|typeof|unchecked|unsafe|using|virtual|volatile|while|yield)\\b))(?:(?:@?\\b[A-Za-z_]\\w*\\b)(?:\\s*(?:<(?:[^<>;=+\\-*/%&|^]|(?:<(?:[^<>;=+\\-*/%&|^]|(?:<(?:[^<>;=+\\-*/%&|^]|(?:<(?:[^<>;=+\\-*/%&|^]|[^\\s\\S])*>))*>))*>))*>))?)(?:\\s*\\.\\s*(?:(?:@?\\b[A-Za-z_]\\w*\\b)(?:\\s*(?:<(?:[^<>;=+\\-*/%&|^]|(?:<(?:[^<>;=+\\-*/%&|^]|(?:<(?:[^<>;=+\\-*/%&|^]|(?:<(?:[^<>;=+\\-*/%&|^]|[^\\s\\S])*>))*>))*>))*>))?))*)(?=\\s*;)"), + lookbehind: true, + inside: () => _g8), + GrammarToken( + "class-name", + compileHighlightPattern( + "(\\busing\\s+(?:@?\\b[A-Za-z_]\\w*\\b)\\s*=\\s*)(?:(?:(?:\\((?:[^,()<>[\\];=+\\-*/%&|^]|(?:<(?:[^<>;=+\\-*/%&|^]|(?:<(?:[^<>;=+\\-*/%&|^]|(?:<(?:[^<>;=+\\-*/%&|^]|(?:<(?:[^<>;=+\\-*/%&|^]|[^\\s\\S])*>))*>))*>))*>)|(?:\\((?:[^()]|(?:\\((?:[^()]|(?:\\((?:[^()]|(?:\\((?:[^()]|[^\\s\\S])*\\)))*\\)))*\\)))*\\))|(?:\\[\\s*(?:,\\s*)*\\]))+(?:,(?:[^,()<>[\\];=+\\-*/%&|^]|(?:<(?:[^<>;=+\\-*/%&|^]|(?:<(?:[^<>;=+\\-*/%&|^]|(?:<(?:[^<>;=+\\-*/%&|^]|(?:<(?:[^<>;=+\\-*/%&|^]|[^\\s\\S])*>))*>))*>))*>)|(?:\\((?:[^()]|(?:\\((?:[^()]|(?:\\((?:[^()]|(?:\\((?:[^()]|[^\\s\\S])*\\)))*\\)))*\\)))*\\))|(?:\\[\\s*(?:,\\s*)*\\]))+)+\\))|(?:(?!(?:\\b(?:class|enum|interface|record|struct|add|alias|and|ascending|async|await|by|descending|from(?=\\s*(?:\\w|\$))|get|global|group|into|init(?=\\s*;)|join|let|nameof|not|notnull|on|or|orderby|partial|remove|select|set|unmanaged|value|when|where|with(?=\\s*{)|abstract|as|base|break|case|catch|checked|const|continue|default|delegate|do|else|event|explicit|extern|finally|fixed|for|foreach|goto|if|implicit|in|internal|is|lock|namespace|new|null|operator|out|override|params|private|protected|public|readonly|ref|return|sealed|sizeof|stackalloc|static|switch|this|throw|try|typeof|unchecked|unsafe|using|virtual|volatile|while|yield)\\b))(?:(?:@?\\b[A-Za-z_]\\w*\\b)(?:\\s*(?:<(?:[^<>;=+\\-*/%&|^]|(?:<(?:[^<>;=+\\-*/%&|^]|(?:<(?:[^<>;=+\\-*/%&|^]|(?:<(?:[^<>;=+\\-*/%&|^]|[^\\s\\S])*>))*>))*>))*>))?)(?:\\s*\\.\\s*(?:(?:@?\\b[A-Za-z_]\\w*\\b)(?:\\s*(?:<(?:[^<>;=+\\-*/%&|^]|(?:<(?:[^<>;=+\\-*/%&|^]|(?:<(?:[^<>;=+\\-*/%&|^]|(?:<(?:[^<>;=+\\-*/%&|^]|[^\\s\\S])*>))*>))*>))*>))?))*))(?:\\s*(?:\\?\\s*)?(?:\\[\\s*(?:,\\s*)*\\]))*(?:\\s*\\?)?)(?=\\s*;)"), + lookbehind: true, + inside: () => _g8), + GrammarToken( + "class-name", + compileHighlightPattern( + "(\\busing\\s+)(?:@?\\b[A-Za-z_]\\w*\\b)(?=\\s*=)"), + lookbehind: true), + GrammarToken( + "class-name", + compileHighlightPattern( + "(\\b(?:\\b(?:class|enum|interface|record|struct)\\b)\\s+)(?:(?:@?\\b[A-Za-z_]\\w*\\b)(?:\\s*(?:<(?:[^<>;=+\\-*/%&|^]|(?:<(?:[^<>;=+\\-*/%&|^]|(?:<(?:[^<>;=+\\-*/%&|^]|(?:<(?:[^<>;=+\\-*/%&|^]|[^\\s\\S])*>))*>))*>))*>))?)"), + lookbehind: true, + inside: () => _g8), + GrammarToken( + "class-name", + compileHighlightPattern( + "(\\bcatch\\s*\\(\\s*)(?:(?!(?:\\b(?:class|enum|interface|record|struct|add|alias|and|ascending|async|await|by|descending|from(?=\\s*(?:\\w|\$))|get|global|group|into|init(?=\\s*;)|join|let|nameof|not|notnull|on|or|orderby|partial|remove|select|set|unmanaged|value|when|where|with(?=\\s*{)|abstract|as|base|break|case|catch|checked|const|continue|default|delegate|do|else|event|explicit|extern|finally|fixed|for|foreach|goto|if|implicit|in|internal|is|lock|namespace|new|null|operator|out|override|params|private|protected|public|readonly|ref|return|sealed|sizeof|stackalloc|static|switch|this|throw|try|typeof|unchecked|unsafe|using|virtual|volatile|while|yield)\\b))(?:(?:@?\\b[A-Za-z_]\\w*\\b)(?:\\s*(?:<(?:[^<>;=+\\-*/%&|^]|(?:<(?:[^<>;=+\\-*/%&|^]|(?:<(?:[^<>;=+\\-*/%&|^]|(?:<(?:[^<>;=+\\-*/%&|^]|[^\\s\\S])*>))*>))*>))*>))?)(?:\\s*\\.\\s*(?:(?:@?\\b[A-Za-z_]\\w*\\b)(?:\\s*(?:<(?:[^<>;=+\\-*/%&|^]|(?:<(?:[^<>;=+\\-*/%&|^]|(?:<(?:[^<>;=+\\-*/%&|^]|(?:<(?:[^<>;=+\\-*/%&|^]|[^\\s\\S])*>))*>))*>))*>))?))*)"), + lookbehind: true, + inside: () => _g8), + GrammarToken("class-name", + compileHighlightPattern("(\\bwhere\\s+)(?:@?\\b[A-Za-z_]\\w*\\b)"), + lookbehind: true), + GrammarToken( + "class-name", + compileHighlightPattern( + "(\\b(?:is(?:\\s+not)?|as)\\s+)(?:(?:(?!(?:\\b(?:class|enum|interface|record|struct|add|alias|and|ascending|async|await|by|descending|from(?=\\s*(?:\\w|\$))|get|global|group|into|init(?=\\s*;)|join|let|nameof|not|notnull|on|or|orderby|partial|remove|select|set|unmanaged|value|when|where|with(?=\\s*{)|abstract|as|base|break|case|catch|checked|const|continue|default|delegate|do|else|event|explicit|extern|finally|fixed|for|foreach|goto|if|implicit|in|internal|is|lock|namespace|new|null|operator|out|override|params|private|protected|public|readonly|ref|return|sealed|sizeof|stackalloc|static|switch|this|throw|try|typeof|unchecked|unsafe|using|virtual|volatile|while|yield)\\b))(?:(?:@?\\b[A-Za-z_]\\w*\\b)(?:\\s*(?:<(?:[^<>;=+\\-*/%&|^]|(?:<(?:[^<>;=+\\-*/%&|^]|(?:<(?:[^<>;=+\\-*/%&|^]|(?:<(?:[^<>;=+\\-*/%&|^]|[^\\s\\S])*>))*>))*>))*>))?)(?:\\s*\\.\\s*(?:(?:@?\\b[A-Za-z_]\\w*\\b)(?:\\s*(?:<(?:[^<>;=+\\-*/%&|^]|(?:<(?:[^<>;=+\\-*/%&|^]|(?:<(?:[^<>;=+\\-*/%&|^]|(?:<(?:[^<>;=+\\-*/%&|^]|[^\\s\\S])*>))*>))*>))*>))?))*)(?:\\s*(?:\\?\\s*)?(?:\\[\\s*(?:,\\s*)*\\]))*(?:\\s*\\?)?)"), + lookbehind: true, + inside: () => _g8), + GrammarToken( + "class-name", + compileHighlightPattern( + "\\b(?:(?:(?:\\((?:[^,()<>[\\];=+\\-*/%&|^]|(?:<(?:[^<>;=+\\-*/%&|^]|(?:<(?:[^<>;=+\\-*/%&|^]|(?:<(?:[^<>;=+\\-*/%&|^]|(?:<(?:[^<>;=+\\-*/%&|^]|[^\\s\\S])*>))*>))*>))*>)|(?:\\((?:[^()]|(?:\\((?:[^()]|(?:\\((?:[^()]|(?:\\((?:[^()]|[^\\s\\S])*\\)))*\\)))*\\)))*\\))|(?:\\[\\s*(?:,\\s*)*\\]))+(?:,(?:[^,()<>[\\];=+\\-*/%&|^]|(?:<(?:[^<>;=+\\-*/%&|^]|(?:<(?:[^<>;=+\\-*/%&|^]|(?:<(?:[^<>;=+\\-*/%&|^]|(?:<(?:[^<>;=+\\-*/%&|^]|[^\\s\\S])*>))*>))*>))*>)|(?:\\((?:[^()]|(?:\\((?:[^()]|(?:\\((?:[^()]|(?:\\((?:[^()]|[^\\s\\S])*\\)))*\\)))*\\)))*\\))|(?:\\[\\s*(?:,\\s*)*\\]))+)+\\))|(?:(?!(?:\\b(?:class|enum|interface|record|struct|add|alias|and|ascending|async|await|by|descending|from(?=\\s*(?:\\w|\$))|get|global|group|into|init(?=\\s*;)|join|let|nameof|not|notnull|on|or|orderby|partial|remove|select|set|unmanaged|value|when|where|with(?=\\s*{)|abstract|as|base|break|case|catch|checked|const|continue|default|delegate|do|else|event|explicit|extern|finally|fixed|for|foreach|goto|if|implicit|in|internal|is|lock|namespace|new|null|operator|out|override|params|private|protected|public|readonly|ref|return|sealed|sizeof|stackalloc|static|switch|this|throw|try|typeof|unchecked|unsafe|using|virtual|volatile|while|yield)\\b))(?:(?:@?\\b[A-Za-z_]\\w*\\b)(?:\\s*(?:<(?:[^<>;=+\\-*/%&|^]|(?:<(?:[^<>;=+\\-*/%&|^]|(?:<(?:[^<>;=+\\-*/%&|^]|(?:<(?:[^<>;=+\\-*/%&|^]|[^\\s\\S])*>))*>))*>))*>))?)(?:\\s*\\.\\s*(?:(?:@?\\b[A-Za-z_]\\w*\\b)(?:\\s*(?:<(?:[^<>;=+\\-*/%&|^]|(?:<(?:[^<>;=+\\-*/%&|^]|(?:<(?:[^<>;=+\\-*/%&|^]|(?:<(?:[^<>;=+\\-*/%&|^]|[^\\s\\S])*>))*>))*>))*>))?))*))(?:\\s*(?:\\?\\s*)?(?:\\[\\s*(?:,\\s*)*\\]))*(?:\\s*\\?)?)(?=\\s+(?!(?:\\b(?:bool|byte|char|decimal|double|dynamic|float|int|long|object|sbyte|short|string|uint|ulong|ushort|var|void|class|enum|interface|record|struct|abstract|as|base|break|case|catch|checked|const|continue|default|delegate|do|else|event|explicit|extern|finally|fixed|for|foreach|goto|if|implicit|in|internal|is|lock|namespace|new|null|operator|out|override|params|private|protected|public|readonly|ref|return|sealed|sizeof|stackalloc|static|switch|this|throw|try|typeof|unchecked|unsafe|using|virtual|volatile|while|yield)\\b)|with\\s*\\{)(?:@?\\b[A-Za-z_]\\w*\\b)(?:\\s*[=,;:{)\\]]|\\s+(?:in|when)\\b))"), + inside: () => _g8), + GrammarToken( + "keyword", + compileHighlightPattern( + "\\b(?:bool|byte|char|decimal|double|dynamic|float|int|long|object|sbyte|short|string|uint|ulong|ushort|var|void|class|enum|interface|record|struct|add|alias|and|ascending|async|await|by|descending|from(?=\\s*(?:\\w|\$))|get|global|group|into|init(?=\\s*;)|join|let|nameof|not|notnull|on|or|orderby|partial|remove|select|set|unmanaged|value|when|where|with(?=\\s*{)|abstract|as|base|break|case|catch|checked|const|continue|default|delegate|do|else|event|explicit|extern|finally|fixed|for|foreach|goto|if|implicit|in|internal|is|lock|namespace|new|null|operator|out|override|params|private|protected|public|readonly|ref|return|sealed|sizeof|stackalloc|static|switch|this|throw|try|typeof|unchecked|unsafe|using|virtual|volatile|while|yield)\\b")), + GrammarToken("boolean", compileHighlightPattern("\\b(?:false|true)\\b")), + GrammarToken("function", compileHighlightPattern("\\b\\w+(?=\\()")), + GrammarToken("range", compileHighlightPattern("\\.\\."), alias: "operator"), + GrammarToken( + "number", + compileHighlightPattern( + "(?:\\b0(?:x[\\da-f_]*[\\da-f]|b[01_]*[01])|(?:\\B\\.\\d+(?:_+\\d+)*|\\b\\d+(?:_+\\d+)*(?:\\.\\d+(?:_+\\d+)*)?)(?:e[-+]?\\d+(?:_+\\d+)*)?)(?:[dflmu]|lu|ul)?\\b", + caseSensitive: false)), + GrammarToken( + "operator", + compileHighlightPattern( + ">>=?|<<=?|[-=]>|([-+&|])\\1|~|\\?\\?=?|[-+*/%&|^!=<>]=?")), + GrammarToken("named-parameter", + compileHighlightPattern("([(,]\\s*)(?:@?\\b[A-Za-z_]\\w*\\b)(?=\\s*:)"), + lookbehind: true, alias: "punctuation"), + GrammarToken( + "punctuation", compileHighlightPattern("\\?\\.?|::|[{}[\\];(),.:]")), +]); + +final Grammar _g1 = Grammar([ + GrammarToken( + "interpolation", + compileHighlightPattern( + "((?:^|[^{])(?:\\{\\{)*)(?:\\{(?!\\{)(?:(?![}:])(?:[^\"'/()]|(?:\\/(?![*/])|\\/\\/[^\\r\\n]*[\\r\\n]|\\/\\*(?:[^*]|\\*(?!\\/))*\\*\\/|(?:\"(?:\\\\.|[^\\\\\"\\r\\n])*\"|'(?:[^\\r\\n'\\\\]|\\\\.|\\\\[Uux][\\da-fA-F]{1,8})'))|\\((?:[^\"'/()]|(?:\\/(?![*/])|\\/\\/[^\\r\\n]*[\\r\\n]|\\/\\*(?:[^*]|\\*(?!\\/))*\\*\\/|(?:\"(?:\\\\.|[^\\\\\"\\r\\n])*\"|'(?:[^\\r\\n'\\\\]|\\\\.|\\\\[Uux][\\da-fA-F]{1,8})'))|\\((?:[^\"'/()]|(?:\\/(?![*/])|\\/\\/[^\\r\\n]*[\\r\\n]|\\/\\*(?:[^*]|\\*(?!\\/))*\\*\\/|(?:\"(?:\\\\.|[^\\\\\"\\r\\n])*\"|'(?:[^\\r\\n'\\\\]|\\\\.|\\\\[Uux][\\da-fA-F]{1,8})'))|\\((?:[^\"'/()]|(?:\\/(?![*/])|\\/\\/[^\\r\\n]*[\\r\\n]|\\/\\*(?:[^*]|\\*(?!\\/))*\\*\\/|(?:\"(?:\\\\.|[^\\\\\"\\r\\n])*\"|'(?:[^\\r\\n'\\\\]|\\\\.|\\\\[Uux][\\da-fA-F]{1,8})'))|\\([^\\s\\S]*\\))*\\))*\\))*\\)))*(?::[^}\\r\\n]+)?\\})"), + lookbehind: true, + inside: () => _g2), + GrammarToken("string", compileHighlightPattern("[\\s\\S]+")), +]); + +final Grammar _g2 = Grammar([ + GrammarToken( + "format-string", + compileHighlightPattern( + "(^\\{(?:(?![}:])(?:[^\"'/()]|(?:\\/(?![*/])|\\/\\/[^\\r\\n]*[\\r\\n]|\\/\\*(?:[^*]|\\*(?!\\/))*\\*\\/|(?:\"(?:\\\\.|[^\\\\\"\\r\\n])*\"|'(?:[^\\r\\n'\\\\]|\\\\.|\\\\[Uux][\\da-fA-F]{1,8})'))|\\((?:[^\"'/()]|(?:\\/(?![*/])|\\/\\/[^\\r\\n]*[\\r\\n]|\\/\\*(?:[^*]|\\*(?!\\/))*\\*\\/|(?:\"(?:\\\\.|[^\\\\\"\\r\\n])*\"|'(?:[^\\r\\n'\\\\]|\\\\.|\\\\[Uux][\\da-fA-F]{1,8})'))|\\((?:[^\"'/()]|(?:\\/(?![*/])|\\/\\/[^\\r\\n]*[\\r\\n]|\\/\\*(?:[^*]|\\*(?!\\/))*\\*\\/|(?:\"(?:\\\\.|[^\\\\\"\\r\\n])*\"|'(?:[^\\r\\n'\\\\]|\\\\.|\\\\[Uux][\\da-fA-F]{1,8})'))|\\((?:[^\"'/()]|(?:\\/(?![*/])|\\/\\/[^\\r\\n]*[\\r\\n]|\\/\\*(?:[^*]|\\*(?!\\/))*\\*\\/|(?:\"(?:\\\\.|[^\\\\\"\\r\\n])*\"|'(?:[^\\r\\n'\\\\]|\\\\.|\\\\[Uux][\\da-fA-F]{1,8})'))|\\([^\\s\\S]*\\))*\\))*\\))*\\)))*)(?::[^}\\r\\n]+)(?=\\}\$)"), + lookbehind: true, + inside: () => _g3), + GrammarToken("punctuation", compileHighlightPattern("^\\{|\\}\$")), + GrammarToken("expression", compileHighlightPattern("[\\s\\S]+"), + alias: "language-csharp", inside: () => _g0), +]); + +final Grammar _g3 = Grammar([ + GrammarToken("punctuation", compileHighlightPattern("^:")), +]); + +final Grammar _g4 = Grammar([ + GrammarToken( + "interpolation", + compileHighlightPattern( + "((?:^|[^{])(?:\\{\\{)*)(?:\\{(?!\\{)(?:(?![}:])(?:[^\"'/()]|\\/(?!\\*)|\\/\\*(?:[^*]|\\*(?!\\/))*\\*\\/|(?:\"(?:\\\\.|[^\\\\\"\\r\\n])*\"|'(?:[^\\r\\n'\\\\]|\\\\.|\\\\[Uux][\\da-fA-F]{1,8})')|\\((?:[^\"'/()]|\\/(?!\\*)|\\/\\*(?:[^*]|\\*(?!\\/))*\\*\\/|(?:\"(?:\\\\.|[^\\\\\"\\r\\n])*\"|'(?:[^\\r\\n'\\\\]|\\\\.|\\\\[Uux][\\da-fA-F]{1,8})')|\\((?:[^\"'/()]|\\/(?!\\*)|\\/\\*(?:[^*]|\\*(?!\\/))*\\*\\/|(?:\"(?:\\\\.|[^\\\\\"\\r\\n])*\"|'(?:[^\\r\\n'\\\\]|\\\\.|\\\\[Uux][\\da-fA-F]{1,8})')|\\((?:[^\"'/()]|\\/(?!\\*)|\\/\\*(?:[^*]|\\*(?!\\/))*\\*\\/|(?:\"(?:\\\\.|[^\\\\\"\\r\\n])*\"|'(?:[^\\r\\n'\\\\]|\\\\.|\\\\[Uux][\\da-fA-F]{1,8})')|\\([^\\s\\S]*\\))*\\))*\\))*\\)))*(?::[^}\\r\\n]+)?\\})"), + lookbehind: true, + inside: () => _g5), + GrammarToken("string", compileHighlightPattern("[\\s\\S]+")), +]); + +final Grammar _g5 = Grammar([ + GrammarToken( + "format-string", + compileHighlightPattern( + "(^\\{(?:(?![}:])(?:[^\"'/()]|\\/(?!\\*)|\\/\\*(?:[^*]|\\*(?!\\/))*\\*\\/|(?:\"(?:\\\\.|[^\\\\\"\\r\\n])*\"|'(?:[^\\r\\n'\\\\]|\\\\.|\\\\[Uux][\\da-fA-F]{1,8})')|\\((?:[^\"'/()]|\\/(?!\\*)|\\/\\*(?:[^*]|\\*(?!\\/))*\\*\\/|(?:\"(?:\\\\.|[^\\\\\"\\r\\n])*\"|'(?:[^\\r\\n'\\\\]|\\\\.|\\\\[Uux][\\da-fA-F]{1,8})')|\\((?:[^\"'/()]|\\/(?!\\*)|\\/\\*(?:[^*]|\\*(?!\\/))*\\*\\/|(?:\"(?:\\\\.|[^\\\\\"\\r\\n])*\"|'(?:[^\\r\\n'\\\\]|\\\\.|\\\\[Uux][\\da-fA-F]{1,8})')|\\((?:[^\"'/()]|\\/(?!\\*)|\\/\\*(?:[^*]|\\*(?!\\/))*\\*\\/|(?:\"(?:\\\\.|[^\\\\\"\\r\\n])*\"|'(?:[^\\r\\n'\\\\]|\\\\.|\\\\[Uux][\\da-fA-F]{1,8})')|\\([^\\s\\S]*\\))*\\))*\\))*\\)))*)(?::[^}\\r\\n]+)(?=\\}\$)"), + lookbehind: true, + inside: () => _g6), + GrammarToken("punctuation", compileHighlightPattern("^\\{|\\}\$")), + GrammarToken("expression", compileHighlightPattern("[\\s\\S]+"), + alias: "language-csharp", inside: () => _g0), +]); + +final Grammar _g6 = Grammar([ + GrammarToken("punctuation", compileHighlightPattern("^:")), +]); + +final Grammar _g7 = Grammar([ + GrammarToken("punctuation", compileHighlightPattern("\\.")), +]); + +final Grammar _g8 = Grammar([ + GrammarToken( + "keyword", + compileHighlightPattern( + "\\b(?:bool|byte|char|decimal|double|dynamic|float|int|long|object|sbyte|short|string|uint|ulong|ushort|var|void|class|enum|interface|record|struct|add|alias|and|ascending|async|await|by|descending|from(?=\\s*(?:\\w|\$))|get|global|group|into|init(?=\\s*;)|join|let|nameof|not|notnull|on|or|orderby|partial|remove|select|set|unmanaged|value|when|where|with(?=\\s*{)|abstract|as|base|break|case|catch|checked|const|continue|default|delegate|do|else|event|explicit|extern|finally|fixed|for|foreach|goto|if|implicit|in|internal|is|lock|namespace|new|null|operator|out|override|params|private|protected|public|readonly|ref|return|sealed|sizeof|stackalloc|static|switch|this|throw|try|typeof|unchecked|unsafe|using|virtual|volatile|while|yield)\\b")), + GrammarToken("punctuation", compileHighlightPattern("[<>()?,.:[\\]]")), +]); + +final Grammar _g9 = Grammar([ + GrammarToken( + "function", compileHighlightPattern("^(?:@?\\b[A-Za-z_]\\w*\\b)")), + GrammarToken( + "generic", + compileHighlightPattern( + "<(?:[^<>;=+\\-*/%&|^]|(?:<(?:[^<>;=+\\-*/%&|^]|(?:<(?:[^<>;=+\\-*/%&|^]|(?:<(?:[^<>;=+\\-*/%&|^]|[^\\s\\S])*>))*>))*>))*>"), + alias: "class-name", + inside: () => _g8), +]); + +final Grammar _g10 = Grammar([ + GrammarToken( + "record-arguments", + compileHighlightPattern( + "(^(?!new\\s*\\()(?:(?:@?\\b[A-Za-z_]\\w*\\b)(?:\\s*(?:<(?:[^<>;=+\\-*/%&|^]|(?:<(?:[^<>;=+\\-*/%&|^]|(?:<(?:[^<>;=+\\-*/%&|^]|(?:<(?:[^<>;=+\\-*/%&|^]|[^\\s\\S])*>))*>))*>))*>))?)\\s*)(?:\\((?:[^()]|(?:\\((?:[^()]|(?:\\((?:[^()]|(?:\\((?:[^()]|[^\\s\\S])*\\)))*\\)))*\\)))*\\))"), + lookbehind: true, + greedy: true, + inside: () => _g0), + GrammarToken( + "keyword", + compileHighlightPattern( + "\\b(?:bool|byte|char|decimal|double|dynamic|float|int|long|object|sbyte|short|string|uint|ulong|ushort|var|void|class|enum|interface|record|struct|add|alias|and|ascending|async|await|by|descending|from(?=\\s*(?:\\w|\$))|get|global|group|into|init(?=\\s*;)|join|let|nameof|not|notnull|on|or|orderby|partial|remove|select|set|unmanaged|value|when|where|with(?=\\s*{)|abstract|as|base|break|case|catch|checked|const|continue|default|delegate|do|else|event|explicit|extern|finally|fixed|for|foreach|goto|if|implicit|in|internal|is|lock|namespace|new|null|operator|out|override|params|private|protected|public|readonly|ref|return|sealed|sizeof|stackalloc|static|switch|this|throw|try|typeof|unchecked|unsafe|using|virtual|volatile|while|yield)\\b")), + GrammarToken( + "class-name", + compileHighlightPattern( + "(?:(?:\\((?:[^,()<>[\\];=+\\-*/%&|^]|(?:<(?:[^<>;=+\\-*/%&|^]|(?:<(?:[^<>;=+\\-*/%&|^]|(?:<(?:[^<>;=+\\-*/%&|^]|(?:<(?:[^<>;=+\\-*/%&|^]|[^\\s\\S])*>))*>))*>))*>)|(?:\\((?:[^()]|(?:\\((?:[^()]|(?:\\((?:[^()]|(?:\\((?:[^()]|[^\\s\\S])*\\)))*\\)))*\\)))*\\))|(?:\\[\\s*(?:,\\s*)*\\]))+(?:,(?:[^,()<>[\\];=+\\-*/%&|^]|(?:<(?:[^<>;=+\\-*/%&|^]|(?:<(?:[^<>;=+\\-*/%&|^]|(?:<(?:[^<>;=+\\-*/%&|^]|(?:<(?:[^<>;=+\\-*/%&|^]|[^\\s\\S])*>))*>))*>))*>)|(?:\\((?:[^()]|(?:\\((?:[^()]|(?:\\((?:[^()]|(?:\\((?:[^()]|[^\\s\\S])*\\)))*\\)))*\\)))*\\))|(?:\\[\\s*(?:,\\s*)*\\]))+)+\\))|(?:(?!(?:\\b(?:class|enum|interface|record|struct|add|alias|and|ascending|async|await|by|descending|from(?=\\s*(?:\\w|\$))|get|global|group|into|init(?=\\s*;)|join|let|nameof|not|notnull|on|or|orderby|partial|remove|select|set|unmanaged|value|when|where|with(?=\\s*{)|abstract|as|base|break|case|catch|checked|const|continue|default|delegate|do|else|event|explicit|extern|finally|fixed|for|foreach|goto|if|implicit|in|internal|is|lock|namespace|new|null|operator|out|override|params|private|protected|public|readonly|ref|return|sealed|sizeof|stackalloc|static|switch|this|throw|try|typeof|unchecked|unsafe|using|virtual|volatile|while|yield)\\b))(?:(?:@?\\b[A-Za-z_]\\w*\\b)(?:\\s*(?:<(?:[^<>;=+\\-*/%&|^]|(?:<(?:[^<>;=+\\-*/%&|^]|(?:<(?:[^<>;=+\\-*/%&|^]|(?:<(?:[^<>;=+\\-*/%&|^]|[^\\s\\S])*>))*>))*>))*>))?)(?:\\s*\\.\\s*(?:(?:@?\\b[A-Za-z_]\\w*\\b)(?:\\s*(?:<(?:[^<>;=+\\-*/%&|^]|(?:<(?:[^<>;=+\\-*/%&|^]|(?:<(?:[^<>;=+\\-*/%&|^]|(?:<(?:[^<>;=+\\-*/%&|^]|[^\\s\\S])*>))*>))*>))*>))?))*))(?:\\s*(?:\\?\\s*)?(?:\\[\\s*(?:,\\s*)*\\]))*(?:\\s*\\?)?"), + greedy: true, + inside: () => _g8), + GrammarToken("punctuation", compileHighlightPattern("[,()]")), +]); + +final Grammar _g11 = Grammar([ + GrammarToken( + "directive", + compileHighlightPattern( + "(#)\\b(?:define|elif|else|endif|endregion|error|if|line|nullable|pragma|region|undef|warning)\\b"), + lookbehind: true, + alias: "keyword"), +]); + +final Grammar _g12 = Grammar([ + GrammarToken( + "target", + compileHighlightPattern( + "^(?:\\b(?:assembly|event|field|method|module|param|property|return|type)\\b)(?=\\s*:)"), + alias: "keyword"), + GrammarToken( + "attribute-arguments", + compileHighlightPattern( + "\\((?:[^\"'/()]|(?:\\/(?![*/])|\\/\\/[^\\r\\n]*[\\r\\n]|\\/\\*(?:[^*]|\\*(?!\\/))*\\*\\/|(?:\"(?:\\\\.|[^\\\\\"\\r\\n])*\"|'(?:[^\\r\\n'\\\\]|\\\\.|\\\\[Uux][\\da-fA-F]{1,8})'))|\\((?:[^\"'/()]|(?:\\/(?![*/])|\\/\\/[^\\r\\n]*[\\r\\n]|\\/\\*(?:[^*]|\\*(?!\\/))*\\*\\/|(?:\"(?:\\\\.|[^\\\\\"\\r\\n])*\"|'(?:[^\\r\\n'\\\\]|\\\\.|\\\\[Uux][\\da-fA-F]{1,8})'))|\\((?:[^\"'/()]|(?:\\/(?![*/])|\\/\\/[^\\r\\n]*[\\r\\n]|\\/\\*(?:[^*]|\\*(?!\\/))*\\*\\/|(?:\"(?:\\\\.|[^\\\\\"\\r\\n])*\"|'(?:[^\\r\\n'\\\\]|\\\\.|\\\\[Uux][\\da-fA-F]{1,8})'))|\\((?:[^\"'/()]|(?:\\/(?![*/])|\\/\\/[^\\r\\n]*[\\r\\n]|\\/\\*(?:[^*]|\\*(?!\\/))*\\*\\/|(?:\"(?:\\\\.|[^\\\\\"\\r\\n])*\"|'(?:[^\\r\\n'\\\\]|\\\\.|\\\\[Uux][\\da-fA-F]{1,8})'))|\\([^\\s\\S]*\\))*\\))*\\))*\\))*\\)"), + inside: () => _g0), + GrammarToken( + "class-name", + compileHighlightPattern( + "(?!(?:\\b(?:class|enum|interface|record|struct|add|alias|and|ascending|async|await|by|descending|from(?=\\s*(?:\\w|\$))|get|global|group|into|init(?=\\s*;)|join|let|nameof|not|notnull|on|or|orderby|partial|remove|select|set|unmanaged|value|when|where|with(?=\\s*{)|abstract|as|base|break|case|catch|checked|const|continue|default|delegate|do|else|event|explicit|extern|finally|fixed|for|foreach|goto|if|implicit|in|internal|is|lock|namespace|new|null|operator|out|override|params|private|protected|public|readonly|ref|return|sealed|sizeof|stackalloc|static|switch|this|throw|try|typeof|unchecked|unsafe|using|virtual|volatile|while|yield)\\b))(?:(?:@?\\b[A-Za-z_]\\w*\\b)(?:\\s*(?:<(?:[^<>;=+\\-*/%&|^]|(?:<(?:[^<>;=+\\-*/%&|^]|(?:<(?:[^<>;=+\\-*/%&|^]|(?:<(?:[^<>;=+\\-*/%&|^]|[^\\s\\S])*>))*>))*>))*>))?)(?:\\s*\\.\\s*(?:(?:@?\\b[A-Za-z_]\\w*\\b)(?:\\s*(?:<(?:[^<>;=+\\-*/%&|^]|(?:<(?:[^<>;=+\\-*/%&|^]|(?:<(?:[^<>;=+\\-*/%&|^]|(?:<(?:[^<>;=+\\-*/%&|^]|[^\\s\\S])*>))*>))*>))*>))?))*"), + inside: () => _g13), + GrammarToken("punctuation", compileHighlightPattern("[:,]")), +]); + +final Grammar _g13 = Grammar([ + GrammarToken("punctuation", compileHighlightPattern("\\.")), +]); diff --git a/lib/highlight/css.dart b/lib/highlight/css.dart new file mode 100644 index 0000000..7ab295f --- /dev/null +++ b/lib/highlight/css.dart @@ -0,0 +1,78 @@ +// GENERATED CODE — do not modify by hand. Regenerate with tool/highlight_codegen. +// ignore_for_file: lines_longer_than_80_chars, public_member_api_docs +// ignore_for_file: prefer_single_quotes, require_trailing_commas, directives_ordering + +import '../highlight.dart'; + +/// Syntax grammar for `css`. +/// +/// Import this library only when you need `css` highlighting; unused +/// languages are dropped from the build. +abstract final class HighlightCss { + /// The grammar for `css`. + static final Grammar grammar = _g0; +} + +final Grammar _g0 = Grammar([ + GrammarToken("comment", compileHighlightPattern("\\/\\*[\\s\\S]*?\\*\\/")), + GrammarToken( + "atrule", + compileHighlightPattern( + "@[\\w-](?:[^;{\\s\"']|\\s+(?!\\s)|(?:\"(?:\\\\(?:\\r\\n|[\\s\\S])|[^\"\\\\\\r\\n])*\"|'(?:\\\\(?:\\r\\n|[\\s\\S])|[^'\\\\\\r\\n])*'))*?(?:;|(?=\\s*\\{))"), + inside: () => _g1), + GrammarToken( + "url", + compileHighlightPattern( + "\\burl\\((?:(?:\"(?:\\\\(?:\\r\\n|[\\s\\S])|[^\"\\\\\\r\\n])*\"|'(?:\\\\(?:\\r\\n|[\\s\\S])|[^'\\\\\\r\\n])*')|(?:[^\\\\\\r\\n()\"']|\\\\[\\s\\S])*)\\)", + caseSensitive: false), + greedy: true, + inside: () => _g2), + GrammarToken( + "selector", + compileHighlightPattern( + "(^|[{}\\s])[^{}\\s](?:[^{};\"'\\s]|\\s+(?![\\s{])|(?:\"(?:\\\\(?:\\r\\n|[\\s\\S])|[^\"\\\\\\r\\n])*\"|'(?:\\\\(?:\\r\\n|[\\s\\S])|[^'\\\\\\r\\n])*'))*(?=\\s*\\{)"), + lookbehind: true), + GrammarToken( + "string", + compileHighlightPattern( + "(?:\"(?:\\\\(?:\\r\\n|[\\s\\S])|[^\"\\\\\\r\\n])*\"|'(?:\\\\(?:\\r\\n|[\\s\\S])|[^'\\\\\\r\\n])*')"), + greedy: true), + GrammarToken( + "property", + compileHighlightPattern( + "(^|[^-\\w\\xA0-\\uFFFF])(?!\\s)[-_a-z\\xA0-\\uFFFF](?:(?!\\s)[-\\w\\xA0-\\uFFFF])*(?=\\s*:)", + caseSensitive: false), + lookbehind: true), + GrammarToken("important", + compileHighlightPattern("!important\\b", caseSensitive: false)), + GrammarToken( + "function", + compileHighlightPattern("(^|[^-a-z0-9])[-a-z0-9]+(?=\\()", + caseSensitive: false), + lookbehind: true), + GrammarToken("punctuation", compileHighlightPattern("[(){};:,]")), +]); + +final Grammar _g1 = Grammar([ + GrammarToken("rule", compileHighlightPattern("^@[\\w-]+")), + GrammarToken( + "selector-function-argument", + compileHighlightPattern( + "(\\bselector\\s*\\(\\s*(?![\\s)]))(?:[^()\\s]|\\s+(?![\\s)])|\\((?:[^()]|\\([^()]*\\))*\\))+(?=\\s*\\))"), + lookbehind: true, + alias: "selector"), + GrammarToken("keyword", + compileHighlightPattern("(^|[^\\w-])(?:and|not|only|or)(?![\\w-])"), + lookbehind: true), +], rest: () => _g0); + +final Grammar _g2 = Grammar([ + GrammarToken( + "function", compileHighlightPattern("^url", caseSensitive: false)), + GrammarToken("punctuation", compileHighlightPattern("^\\(|\\)\$")), + GrammarToken( + "string", + compileHighlightPattern( + "^(?:\"(?:\\\\(?:\\r\\n|[\\s\\S])|[^\"\\\\\\r\\n])*\"|'(?:\\\\(?:\\r\\n|[\\s\\S])|[^'\\\\\\r\\n])*')\$"), + alias: "url"), +]); diff --git a/lib/highlight/dart.dart b/lib/highlight/dart.dart new file mode 100644 index 0000000..29a15b6 --- /dev/null +++ b/lib/highlight/dart.dart @@ -0,0 +1,109 @@ +// GENERATED CODE — do not modify by hand. Regenerate with tool/highlight_codegen. +// ignore_for_file: lines_longer_than_80_chars, public_member_api_docs +// ignore_for_file: prefer_single_quotes, require_trailing_commas, directives_ordering + +import '../highlight.dart'; + +/// Syntax grammar for `dart`. +/// +/// Import this library only when you need `dart` highlighting; unused +/// languages are dropped from the build. +abstract final class HighlightDart { + /// The grammar for `dart`. + static final Grammar grammar = _g0; +} + +final Grammar _g0 = Grammar([ + GrammarToken("comment", + compileHighlightPattern("(^|[^\\\\])\\/\\*[\\s\\S]*?(?:\\*\\/|\$)"), + lookbehind: true, greedy: true), + GrammarToken("comment", compileHighlightPattern("(^|[^\\\\:])\\/\\/.*"), + lookbehind: true, greedy: true), + GrammarToken( + "string-literal", + compileHighlightPattern( + "r?(?:(\"\"\"|''')[\\s\\S]*?\\1|([\"'])(?:\\\\.|(?!\\2)[^\\\\\\r\\n])*\\2(?!\\2))"), + greedy: true, + inside: () => _g1), + GrammarToken("metadata", compileHighlightPattern("@\\w+"), alias: "function"), + GrammarToken( + "generics", + compileHighlightPattern( + "<(?:[\\w\\s,.&?]|<(?:[\\w\\s,.&?]|<(?:[\\w\\s,.&?]|<[\\w\\s,.&?]*>)*>)*>)*>"), + inside: () => _g3), + GrammarToken( + "class-name", + compileHighlightPattern( + "(^|[^\\w.])(?:[a-z]\\w*\\s*\\.\\s*)*(?:[A-Z]\\w*\\s*\\.\\s*)*[A-Z](?:[\\d_A-Z]*[a-z]\\w*)?\\b"), + lookbehind: true, + inside: () => _g4), + GrammarToken( + "class-name", + compileHighlightPattern( + "(^|[^\\w.])(?:[a-z]\\w*\\s*\\.\\s*)*(?:[A-Z]\\w*\\s*\\.\\s*)*[A-Z]\\w*(?=\\s+\\w+\\s*[;,=()])"), + lookbehind: true, + inside: () => _g4), + GrammarToken( + "keyword", compileHighlightPattern("\\b(?:async|sync|yield)\\*")), + GrammarToken( + "keyword", + compileHighlightPattern( + "\\b(?:abstract|assert|async|await|break|case|catch|class|const|continue|covariant|default|deferred|do|dynamic|else|enum|export|extends|extension|external|factory|final|finally|for|get|hide|if|implements|import|in|interface|library|mixin|new|null|on|operator|part|rethrow|return|set|show|static|super|switch|sync|this|throw|try|typedef|var|void|while|with|yield)\\b")), + GrammarToken("boolean", compileHighlightPattern("\\b(?:false|true)\\b")), + GrammarToken("function", compileHighlightPattern("\\b\\w+(?=\\()")), + GrammarToken( + "number", + compileHighlightPattern( + "\\b0x[\\da-f]+\\b|(?:\\b\\d+(?:\\.\\d*)?|\\B\\.\\d+)(?:e[+-]?\\d+)?", + caseSensitive: false)), + GrammarToken( + "operator", + compileHighlightPattern( + "\\bis!|\\b(?:as|is)\\b|\\+\\+|--|&&|\\|\\||<<=?|>>=?|~(?:\\/=?)?|[+\\-*\\/%&^|=!<>]=?|\\?")), + GrammarToken("punctuation", compileHighlightPattern("[{}[\\];(),.:]")), +]); + +final Grammar _g1 = Grammar([ + GrammarToken( + "interpolation", + compileHighlightPattern( + "((?:^|[^\\\\])(?:\\\\{2})*)\\\$(?:\\w+|\\{(?:[^{}]|\\{[^{}]*\\})*\\})"), + lookbehind: true, + inside: () => _g2), + GrammarToken("string", compileHighlightPattern("[\\s\\S]+")), +]); + +final Grammar _g2 = Grammar([ + GrammarToken("punctuation", compileHighlightPattern("^\\\$\\{?|\\}\$")), + GrammarToken("expression", compileHighlightPattern("[\\s\\S]+"), + inside: () => _g0), +]); + +final Grammar _g3 = Grammar([ + GrammarToken( + "class-name", + compileHighlightPattern( + "(^|[^\\w.])(?:[a-z]\\w*\\s*\\.\\s*)*(?:[A-Z]\\w*\\s*\\.\\s*)*[A-Z](?:[\\d_A-Z]*[a-z]\\w*)?\\b"), + lookbehind: true, + inside: () => _g4), + GrammarToken( + "keyword", compileHighlightPattern("\\b(?:async|sync|yield)\\*")), + GrammarToken( + "keyword", + compileHighlightPattern( + "\\b(?:abstract|assert|async|await|break|case|catch|class|const|continue|covariant|default|deferred|do|dynamic|else|enum|export|extends|extension|external|factory|final|finally|for|get|hide|if|implements|import|in|interface|library|mixin|new|null|on|operator|part|rethrow|return|set|show|static|super|switch|sync|this|throw|try|typedef|var|void|while|with|yield)\\b")), + GrammarToken("punctuation", compileHighlightPattern("[<>(),.:]")), + GrammarToken("operator", compileHighlightPattern("[?&|]")), +]); + +final Grammar _g4 = Grammar([ + GrammarToken( + "namespace", + compileHighlightPattern( + "^[a-z]\\w*(?:\\s*\\.\\s*[a-z]\\w*)*(?:\\s*\\.)?"), + inside: () => _g5), +]); + +final Grammar _g5 = Grammar([ + GrammarToken("punctuation", compileHighlightPattern("\\.")), +]); diff --git a/lib/highlight/diff.dart b/lib/highlight/diff.dart new file mode 100644 index 0000000..03fe885 --- /dev/null +++ b/lib/highlight/diff.dart @@ -0,0 +1,101 @@ +// GENERATED CODE — do not modify by hand. Regenerate with tool/highlight_codegen. +// ignore_for_file: lines_longer_than_80_chars, public_member_api_docs +// ignore_for_file: prefer_single_quotes, require_trailing_commas, directives_ordering + +import '../highlight.dart'; + +/// Syntax grammar for `diff`. +/// +/// Import this library only when you need `diff` highlighting; unused +/// languages are dropped from the build. +abstract final class HighlightDiff { + /// The grammar for `diff`. + static final Grammar grammar = _g0; +} + +final Grammar _g0 = Grammar([ + GrammarToken("coord", + compileHighlightPattern("^(?:\\*{3}|-{3}|\\+{3}).*\$", multiLine: true)), + GrammarToken("coord", compileHighlightPattern("^@@.*@@\$", multiLine: true)), + GrammarToken("coord", compileHighlightPattern("^\\d.*\$", multiLine: true)), + GrammarToken( + "deleted-sign", + compileHighlightPattern("^(?:[-].*(?:\\r\\n?|\\n|(?![\\s\\S])))+", + multiLine: true), + alias: "deleted", + inside: () => _g1), + GrammarToken( + "deleted-arrow", + compileHighlightPattern("^(?:[<].*(?:\\r\\n?|\\n|(?![\\s\\S])))+", + multiLine: true), + alias: "deleted", + inside: () => _g2), + GrammarToken( + "inserted-sign", + compileHighlightPattern("^(?:[+].*(?:\\r\\n?|\\n|(?![\\s\\S])))+", + multiLine: true), + alias: "inserted", + inside: () => _g3), + GrammarToken( + "inserted-arrow", + compileHighlightPattern("^(?:[>].*(?:\\r\\n?|\\n|(?![\\s\\S])))+", + multiLine: true), + alias: "inserted", + inside: () => _g4), + GrammarToken( + "unchanged", + compileHighlightPattern("^(?:[ ].*(?:\\r\\n?|\\n|(?![\\s\\S])))+", + multiLine: true), + inside: () => _g5), + GrammarToken( + "diff", + compileHighlightPattern("^(?:[!].*(?:\\r\\n?|\\n|(?![\\s\\S])))+", + multiLine: true), + alias: "bold", + inside: () => _g6), +]); + +final Grammar _g1 = Grammar([ + GrammarToken( + "line", compileHighlightPattern("(.)(?=[\\s\\S]).*(?:\\r\\n?|\\n)?"), + lookbehind: true), + GrammarToken("prefix", compileHighlightPattern("[\\s\\S]"), alias: "deleted"), +]); + +final Grammar _g2 = Grammar([ + GrammarToken( + "line", compileHighlightPattern("(.)(?=[\\s\\S]).*(?:\\r\\n?|\\n)?"), + lookbehind: true), + GrammarToken("prefix", compileHighlightPattern("[\\s\\S]"), alias: "deleted"), +]); + +final Grammar _g3 = Grammar([ + GrammarToken( + "line", compileHighlightPattern("(.)(?=[\\s\\S]).*(?:\\r\\n?|\\n)?"), + lookbehind: true), + GrammarToken("prefix", compileHighlightPattern("[\\s\\S]"), + alias: "inserted"), +]); + +final Grammar _g4 = Grammar([ + GrammarToken( + "line", compileHighlightPattern("(.)(?=[\\s\\S]).*(?:\\r\\n?|\\n)?"), + lookbehind: true), + GrammarToken("prefix", compileHighlightPattern("[\\s\\S]"), + alias: "inserted"), +]); + +final Grammar _g5 = Grammar([ + GrammarToken( + "line", compileHighlightPattern("(.)(?=[\\s\\S]).*(?:\\r\\n?|\\n)?"), + lookbehind: true), + GrammarToken("prefix", compileHighlightPattern("[\\s\\S]"), + alias: "unchanged"), +]); + +final Grammar _g6 = Grammar([ + GrammarToken( + "line", compileHighlightPattern("(.)(?=[\\s\\S]).*(?:\\r\\n?|\\n)?"), + lookbehind: true), + GrammarToken("prefix", compileHighlightPattern("[\\s\\S]"), alias: "diff"), +]); diff --git a/lib/highlight/docker.dart b/lib/highlight/docker.dart new file mode 100644 index 0000000..a6d9e32 --- /dev/null +++ b/lib/highlight/docker.dart @@ -0,0 +1,88 @@ +// GENERATED CODE — do not modify by hand. Regenerate with tool/highlight_codegen. +// ignore_for_file: lines_longer_than_80_chars, public_member_api_docs +// ignore_for_file: prefer_single_quotes, require_trailing_commas, directives_ordering + +import '../highlight.dart'; + +/// Syntax grammar for `docker`. +/// +/// Import this library only when you need `docker` highlighting; unused +/// languages are dropped from the build. +abstract final class HighlightDocker { + /// The grammar for `docker`. + static final Grammar grammar = _g0; +} + +final Grammar _g0 = Grammar([ + GrammarToken( + "instruction", + compileHighlightPattern( + "(^[ \\t]*)(?:ADD|ARG|CMD|COPY|ENTRYPOINT|ENV|EXPOSE|FROM|HEALTHCHECK|LABEL|MAINTAINER|ONBUILD|RUN|SHELL|STOPSIGNAL|USER|VOLUME|WORKDIR)(?=\\s)(?:\\\\.|[^\\r\\n\\\\])*(?:\\\\\$(?:\\s|#.*\$)*(?![\\s#])(?:\\\\.|[^\\r\\n\\\\])*)*", + caseSensitive: false, + multiLine: true), + lookbehind: true, + greedy: true, + inside: () => _g1), + GrammarToken( + "comment", compileHighlightPattern("(^[ \\t]*)#.*", multiLine: true), + lookbehind: true, greedy: true), +]); + +final Grammar _g1 = Grammar([ + GrammarToken( + "options", + compileHighlightPattern( + "(^(?:ONBUILD(?:[ \\t]+(?![ \\t])(?:\\\\[\\r\\n](?:\\s|\\\\[\\r\\n]|#.*(?!.))*(?![\\s#]|\\\\[\\r\\n]))?|\\\\[\\r\\n](?:\\s|\\\\[\\r\\n]|#.*(?!.))*(?![\\s#]|\\\\[\\r\\n])))?\\w+(?:[ \\t]+(?![ \\t])(?:\\\\[\\r\\n](?:\\s|\\\\[\\r\\n]|#.*(?!.))*(?![\\s#]|\\\\[\\r\\n]))?|\\\\[\\r\\n](?:\\s|\\\\[\\r\\n]|#.*(?!.))*(?![\\s#]|\\\\[\\r\\n])))--[\\w-]+=(?:\"(?:[^\"\\\\\\r\\n]|\\\\(?:\\r\\n|[\\s\\S]))*\"|'(?:[^'\\\\\\r\\n]|\\\\(?:\\r\\n|[\\s\\S]))*'|(?![\"'])(?:[^\\s\\\\]|\\\\.)+)(?:(?:[ \\t]+(?![ \\t])(?:\\\\[\\r\\n](?:\\s|\\\\[\\r\\n]|#.*(?!.))*(?![\\s#]|\\\\[\\r\\n]))?|\\\\[\\r\\n](?:\\s|\\\\[\\r\\n]|#.*(?!.))*(?![\\s#]|\\\\[\\r\\n]))--[\\w-]+=(?:\"(?:[^\"\\\\\\r\\n]|\\\\(?:\\r\\n|[\\s\\S]))*\"|'(?:[^'\\\\\\r\\n]|\\\\(?:\\r\\n|[\\s\\S]))*'|(?![\"'])(?:[^\\s\\\\]|\\\\.)+))*", + caseSensitive: false), + lookbehind: true, + greedy: true, + inside: () => _g2), + GrammarToken( + "keyword", + compileHighlightPattern( + "(^(?:ONBUILD(?:[ \\t]+(?![ \\t])(?:\\\\[\\r\\n](?:\\s|\\\\[\\r\\n]|#.*(?!.))*(?![\\s#]|\\\\[\\r\\n]))?|\\\\[\\r\\n](?:\\s|\\\\[\\r\\n]|#.*(?!.))*(?![\\s#]|\\\\[\\r\\n])))?HEALTHCHECK(?:[ \\t]+(?![ \\t])(?:\\\\[\\r\\n](?:\\s|\\\\[\\r\\n]|#.*(?!.))*(?![\\s#]|\\\\[\\r\\n]))?|\\\\[\\r\\n](?:\\s|\\\\[\\r\\n]|#.*(?!.))*(?![\\s#]|\\\\[\\r\\n]))(?:--[\\w-]+=(?:\"(?:[^\"\\\\\\r\\n]|\\\\(?:\\r\\n|[\\s\\S]))*\"|'(?:[^'\\\\\\r\\n]|\\\\(?:\\r\\n|[\\s\\S]))*'|(?![\"'])(?:[^\\s\\\\]|\\\\.)+)(?:[ \\t]+(?![ \\t])(?:\\\\[\\r\\n](?:\\s|\\\\[\\r\\n]|#.*(?!.))*(?![\\s#]|\\\\[\\r\\n]))?|\\\\[\\r\\n](?:\\s|\\\\[\\r\\n]|#.*(?!.))*(?![\\s#]|\\\\[\\r\\n])))*)(?:CMD|NONE)\\b", + caseSensitive: false), + lookbehind: true, + greedy: true), + GrammarToken( + "keyword", + compileHighlightPattern( + "(^(?:ONBUILD(?:[ \\t]+(?![ \\t])(?:\\\\[\\r\\n](?:\\s|\\\\[\\r\\n]|#.*(?!.))*(?![\\s#]|\\\\[\\r\\n]))?|\\\\[\\r\\n](?:\\s|\\\\[\\r\\n]|#.*(?!.))*(?![\\s#]|\\\\[\\r\\n])))?FROM(?:[ \\t]+(?![ \\t])(?:\\\\[\\r\\n](?:\\s|\\\\[\\r\\n]|#.*(?!.))*(?![\\s#]|\\\\[\\r\\n]))?|\\\\[\\r\\n](?:\\s|\\\\[\\r\\n]|#.*(?!.))*(?![\\s#]|\\\\[\\r\\n]))(?:--[\\w-]+=(?:\"(?:[^\"\\\\\\r\\n]|\\\\(?:\\r\\n|[\\s\\S]))*\"|'(?:[^'\\\\\\r\\n]|\\\\(?:\\r\\n|[\\s\\S]))*'|(?![\"'])(?:[^\\s\\\\]|\\\\.)+)(?:[ \\t]+(?![ \\t])(?:\\\\[\\r\\n](?:\\s|\\\\[\\r\\n]|#.*(?!.))*(?![\\s#]|\\\\[\\r\\n]))?|\\\\[\\r\\n](?:\\s|\\\\[\\r\\n]|#.*(?!.))*(?![\\s#]|\\\\[\\r\\n])))*(?!--)[^ \\t\\\\]+(?:[ \\t]+(?![ \\t])(?:\\\\[\\r\\n](?:\\s|\\\\[\\r\\n]|#.*(?!.))*(?![\\s#]|\\\\[\\r\\n]))?|\\\\[\\r\\n](?:\\s|\\\\[\\r\\n]|#.*(?!.))*(?![\\s#]|\\\\[\\r\\n])))AS", + caseSensitive: false), + lookbehind: true, + greedy: true), + GrammarToken( + "keyword", + compileHighlightPattern( + "(^ONBUILD(?:[ \\t]+(?![ \\t])(?:\\\\[\\r\\n](?:\\s|\\\\[\\r\\n]|#.*(?!.))*(?![\\s#]|\\\\[\\r\\n]))?|\\\\[\\r\\n](?:\\s|\\\\[\\r\\n]|#.*(?!.))*(?![\\s#]|\\\\[\\r\\n])))\\w+", + caseSensitive: false), + lookbehind: true, + greedy: true), + GrammarToken("keyword", compileHighlightPattern("^\\w+"), greedy: true), + GrammarToken( + "comment", compileHighlightPattern("(^[ \\t]*)#.*", multiLine: true), + lookbehind: true, greedy: true), + GrammarToken( + "string", + compileHighlightPattern( + "\"(?:[^\"\\\\\\r\\n]|\\\\(?:\\r\\n|[\\s\\S]))*\"|'(?:[^'\\\\\\r\\n]|\\\\(?:\\r\\n|[\\s\\S]))*'"), + greedy: true), + GrammarToken( + "variable", compileHighlightPattern("\\\$(?:\\w+|\\{[^{}\"'\\\\]*\\})")), + GrammarToken("operator", compileHighlightPattern("\\\\\$", multiLine: true)), +]); + +final Grammar _g2 = Grammar([ + GrammarToken("property", compileHighlightPattern("(^|\\s)--[\\w-]+"), + lookbehind: true), + GrammarToken( + "string", + compileHighlightPattern( + "\"(?:[^\"\\\\\\r\\n]|\\\\(?:\\r\\n|[\\s\\S]))*\"|'(?:[^'\\\\\\r\\n]|\\\\(?:\\r\\n|[\\s\\S]))*'"), + greedy: true), + GrammarToken( + "string", compileHighlightPattern("(=)(?![\"'])(?:[^\\s\\\\]|\\\\.)+"), + lookbehind: true), + GrammarToken("operator", compileHighlightPattern("\\\\\$", multiLine: true)), + GrammarToken("punctuation", compileHighlightPattern("=")), +]); diff --git a/lib/highlight/elixir.dart b/lib/highlight/elixir.dart new file mode 100644 index 0000000..65d1fd9 --- /dev/null +++ b/lib/highlight/elixir.dart @@ -0,0 +1,109 @@ +// GENERATED CODE — do not modify by hand. Regenerate with tool/highlight_codegen. +// ignore_for_file: lines_longer_than_80_chars, public_member_api_docs +// ignore_for_file: prefer_single_quotes, require_trailing_commas, directives_ordering + +import '../highlight.dart'; + +/// Syntax grammar for `elixir`. +/// +/// Import this library only when you need `elixir` highlighting; unused +/// languages are dropped from the build. +abstract final class HighlightElixir { + /// The grammar for `elixir`. + static final Grammar grammar = _g0; +} + +final Grammar _g0 = Grammar([ + GrammarToken( + "doc", + compileHighlightPattern( + "@(?:doc|moduledoc)\\s+(?:(\"\"\"|''')[\\s\\S]*?\\1|(\"|')(?:\\\\(?:\\r\\n|[\\s\\S])|(?!\\2)[^\\\\\\r\\n])*\\2)"), + inside: () => _g1), + GrammarToken("comment", compileHighlightPattern("#.*"), greedy: true), + GrammarToken( + "regex", + compileHighlightPattern( + "~[rR](?:(\"\"\"|''')(?:\\\\[\\s\\S]|(?!\\1)[^\\\\])+\\1|([\\/|\"'])(?:\\\\.|(?!\\2)[^\\\\\\r\\n])+\\2|\\((?:\\\\.|[^\\\\)\\r\\n])+\\)|\\[(?:\\\\.|[^\\\\\\]\\r\\n])+\\]|\\{(?:\\\\.|[^\\\\}\\r\\n])+\\}|<(?:\\\\.|[^\\\\>\\r\\n])+>)[uismxfr]*"), + greedy: true), + GrammarToken( + "string", + compileHighlightPattern( + "~[cCsSwW](?:(\"\"\"|''')(?:\\\\[\\s\\S]|(?!\\1)[^\\\\])+\\1|([\\/|\"'])(?:\\\\.|(?!\\2)[^\\\\\\r\\n])+\\2|\\((?:\\\\.|[^\\\\)\\r\\n])+\\)|\\[(?:\\\\.|[^\\\\\\]\\r\\n])+\\]|\\{(?:\\\\.|#\\{[^}]+\\}|#(?!\\{)|[^#\\\\}\\r\\n])+\\}|<(?:\\\\.|[^\\\\>\\r\\n])+>)[csa]?"), + greedy: true, + inside: () => _g2), + GrammarToken("string", compileHighlightPattern("(\"\"\"|''')[\\s\\S]*?\\1"), + greedy: true, inside: () => _g4), + GrammarToken( + "string", + compileHighlightPattern( + "(\"|')(?:\\\\(?:\\r\\n|[\\s\\S])|(?!\\1)[^\\\\\\r\\n])*\\1"), + greedy: true, + inside: () => _g6), + GrammarToken("atom", compileHighlightPattern("(^|[^:]):\\w+"), + lookbehind: true, alias: "symbol"), + GrammarToken("module", compileHighlightPattern("\\b[A-Z]\\w*\\b"), + alias: "class-name"), + GrammarToken("attr-name", compileHighlightPattern("\\b\\w+\\??:(?!:)")), + GrammarToken("argument", compileHighlightPattern("(^|[^&])&\\d+"), + lookbehind: true, alias: "variable"), + GrammarToken("attribute", compileHighlightPattern("@\\w+"), + alias: "variable"), + GrammarToken( + "function", + compileHighlightPattern( + "\\b[_a-zA-Z]\\w*[?!]?(?:(?=\\s*(?:\\.\\s*)?\\()|(?=\\/\\d))")), + GrammarToken( + "number", + compileHighlightPattern( + "\\b(?:0[box][a-f\\d_]+|\\d[\\d_]*)(?:\\.[\\d_]+)?(?:e[+-]?[\\d_]+)?\\b", + caseSensitive: false)), + GrammarToken( + "keyword", + compileHighlightPattern( + "\\b(?:after|alias|and|case|catch|cond|def(?:callback|delegate|exception|impl|macro|module|n|np|p|protocol|struct)?|do|else|end|fn|for|if|import|not|or|quote|raise|require|rescue|try|unless|unquote|use|when)\\b")), + GrammarToken("boolean", compileHighlightPattern("\\b(?:false|nil|true)\\b")), + GrammarToken( + "operator", + compileHighlightPattern( + "\\bin\\b|&&?|\\|[|>]?|\\\\\\\\|::|\\.\\.\\.?|\\+\\+?|-[->]?|<[-=>]|>=|!==?|\\B!|=(?:==?|[>~])?|[*\\/^]")), + GrammarToken("operator", compileHighlightPattern("([^<])<(?!<)"), + lookbehind: true), + GrammarToken("operator", compileHighlightPattern("([^>])>(?!>)"), + lookbehind: true), + GrammarToken("punctuation", compileHighlightPattern("<<|>>|[.,%\\[\\]{}()]")), +]); + +final Grammar _g1 = Grammar([ + GrammarToken("attribute", compileHighlightPattern("^@\\w+")), + GrammarToken("string", compileHighlightPattern("['\"][\\s\\S]+")), +]); + +final Grammar _g2 = Grammar([ + GrammarToken("interpolation", compileHighlightPattern("#\\{[^}]+\\}"), + inside: () => _g3), +]); + +final Grammar _g3 = Grammar([ + GrammarToken("delimiter", compileHighlightPattern("^#\\{|\\}\$"), + alias: "punctuation"), +], rest: () => _g0); + +final Grammar _g4 = Grammar([ + GrammarToken("interpolation", compileHighlightPattern("#\\{[^}]+\\}"), + inside: () => _g5), +]); + +final Grammar _g5 = Grammar([ + GrammarToken("delimiter", compileHighlightPattern("^#\\{|\\}\$"), + alias: "punctuation"), +], rest: () => _g0); + +final Grammar _g6 = Grammar([ + GrammarToken("interpolation", compileHighlightPattern("#\\{[^}]+\\}"), + inside: () => _g7), +]); + +final Grammar _g7 = Grammar([ + GrammarToken("delimiter", compileHighlightPattern("^#\\{|\\}\$"), + alias: "punctuation"), +], rest: () => _g0); diff --git a/lib/highlight/elm.dart b/lib/highlight/elm.dart new file mode 100644 index 0000000..9a9df9a --- /dev/null +++ b/lib/highlight/elm.dart @@ -0,0 +1,62 @@ +// GENERATED CODE — do not modify by hand. Regenerate with tool/highlight_codegen. +// ignore_for_file: lines_longer_than_80_chars, public_member_api_docs +// ignore_for_file: prefer_single_quotes, require_trailing_commas, directives_ordering + +import '../highlight.dart'; + +/// Syntax grammar for `elm`. +/// +/// Import this library only when you need `elm` highlighting; unused +/// languages are dropped from the build. +abstract final class HighlightElm { + /// The grammar for `elm`. + static final Grammar grammar = _g0; +} + +final Grammar _g0 = Grammar([ + GrammarToken("comment", compileHighlightPattern("--.*|\\{-[\\s\\S]*?-\\}")), + GrammarToken( + "char", + compileHighlightPattern( + "'(?:[^\\\\'\\r\\n]|\\\\(?:[abfnrtv\\\\']|\\d+|x[0-9a-fA-F]+|u\\{[0-9a-fA-F]+\\}))'"), + greedy: true), + GrammarToken("string", compileHighlightPattern("\"\"\"[\\s\\S]*?\"\"\""), + greedy: true), + GrammarToken( + "string", compileHighlightPattern("\"(?:[^\\\\\"\\r\\n]|\\\\.)*\""), + greedy: true), + GrammarToken( + "import-statement", + compileHighlightPattern( + "(^[\\t ]*)import\\s+[A-Z]\\w*(?:\\.[A-Z]\\w*)*(?:\\s+as\\s+(?:[A-Z]\\w*)(?:\\.[A-Z]\\w*)*)?(?:\\s+exposing\\s+)?", + multiLine: true), + lookbehind: true, + inside: () => _g1), + GrammarToken( + "keyword", + compileHighlightPattern( + "\\b(?:alias|as|case|else|exposing|if|in|infixl|infixr|let|module|of|then|type)\\b")), + GrammarToken( + "builtin", + compileHighlightPattern( + "\\b(?:abs|acos|always|asin|atan|atan2|ceiling|clamp|compare|cos|curry|degrees|e|flip|floor|fromPolar|identity|isInfinite|isNaN|logBase|max|min|negate|never|not|pi|radians|rem|round|sin|sqrt|tan|toFloat|toPolar|toString|truncate|turns|uncurry|xor)\\b")), + GrammarToken( + "number", + compileHighlightPattern( + "\\b(?:\\d+(?:\\.\\d+)?(?:e[+-]?\\d+)?|0x[0-9a-f]+)\\b", + caseSensitive: false)), + GrammarToken( + "operator", + compileHighlightPattern( + "\\s\\.\\s|[+\\-/*=.\$<>:&|^?%#@~!]{2,}|[+\\-/*=\$<>:&|^?%#@~!]")), + GrammarToken( + "hvariable", compileHighlightPattern("\\b(?:[A-Z]\\w*\\.)*[a-z]\\w*\\b")), + GrammarToken( + "constant", compileHighlightPattern("\\b(?:[A-Z]\\w*\\.)*[A-Z]\\w*\\b")), + GrammarToken("punctuation", compileHighlightPattern("[{}[\\]|(),.:]")), +]); + +final Grammar _g1 = Grammar([ + GrammarToken( + "keyword", compileHighlightPattern("\\b(?:as|exposing|import)\\b")), +]); diff --git a/lib/highlight/erlang.dart b/lib/highlight/erlang.dart new file mode 100644 index 0000000..7564541 --- /dev/null +++ b/lib/highlight/erlang.dart @@ -0,0 +1,55 @@ +// GENERATED CODE — do not modify by hand. Regenerate with tool/highlight_codegen. +// ignore_for_file: lines_longer_than_80_chars, public_member_api_docs +// ignore_for_file: prefer_single_quotes, require_trailing_commas, directives_ordering + +import '../highlight.dart'; + +/// Syntax grammar for `erlang`. +/// +/// Import this library only when you need `erlang` highlighting; unused +/// languages are dropped from the build. +abstract final class HighlightErlang { + /// The grammar for `erlang`. + static final Grammar grammar = _g0; +} + +final Grammar _g0 = Grammar([ + GrammarToken("comment", compileHighlightPattern("%.+")), + GrammarToken( + "string", compileHighlightPattern("\"(?:\\\\.|[^\\\\\"\\r\\n])*\""), + greedy: true), + GrammarToken("quoted-function", + compileHighlightPattern("'(?:\\\\.|[^\\\\'\\r\\n])+'(?=\\()"), + alias: "function"), + GrammarToken( + "quoted-atom", compileHighlightPattern("'(?:\\\\.|[^\\\\'\\r\\n])+'"), + alias: "atom"), + GrammarToken("boolean", compileHighlightPattern("\\b(?:false|true)\\b")), + GrammarToken( + "keyword", + compileHighlightPattern( + "\\b(?:after|begin|case|catch|end|fun|if|of|receive|try|when)\\b")), + GrammarToken("number", compileHighlightPattern("\\\$\\\\?.")), + GrammarToken("number", + compileHighlightPattern("\\b\\d+#[a-z0-9]+", caseSensitive: false)), + GrammarToken( + "number", + compileHighlightPattern( + "(?:\\b\\d+(?:\\.\\d*)?|\\B\\.\\d+)(?:e[+-]?\\d+)?", + caseSensitive: false)), + GrammarToken("function", compileHighlightPattern("\\b[a-z][\\w@]*(?=\\()")), + GrammarToken( + "variable", compileHighlightPattern("(^|[^@])(?:\\b|\\?)[A-Z_][\\w@]*"), + lookbehind: true), + GrammarToken( + "operator", + compileHighlightPattern( + "[=\\/<>:]=|=[:\\/]=|\\+\\+?|--?|[=*\\/!]|\\b(?:and|andalso|band|bnot|bor|bsl|bsr|bxor|div|not|or|orelse|rem|xor)\\b")), + GrammarToken("operator", compileHighlightPattern("(^|[^<])<(?!<)"), + lookbehind: true), + GrammarToken("operator", compileHighlightPattern("(^|[^>])>(?!>)"), + lookbehind: true), + GrammarToken("atom", compileHighlightPattern("\\b[a-z][\\w@]*")), + GrammarToken( + "punctuation", compileHighlightPattern("[()[\\]{}:;,.#|]|<<|>>")), +]); diff --git a/lib/highlight/fsharp.dart b/lib/highlight/fsharp.dart new file mode 100644 index 0000000..33cb30e --- /dev/null +++ b/lib/highlight/fsharp.dart @@ -0,0 +1,87 @@ +// GENERATED CODE — do not modify by hand. Regenerate with tool/highlight_codegen. +// ignore_for_file: lines_longer_than_80_chars, public_member_api_docs +// ignore_for_file: prefer_single_quotes, require_trailing_commas, directives_ordering + +import '../highlight.dart'; + +/// Syntax grammar for `fsharp`. +/// +/// Import this library only when you need `fsharp` highlighting; unused +/// languages are dropped from the build. +abstract final class HighlightFsharp { + /// The grammar for `fsharp`. + static final Grammar grammar = _g0; +} + +final Grammar _g0 = Grammar([ + GrammarToken("comment", + compileHighlightPattern("(^|[^\\\\])\\(\\*(?!\\))[\\s\\S]*?\\*\\)"), + lookbehind: true, greedy: true), + GrammarToken("comment", compileHighlightPattern("(^|[^\\\\:])\\/\\/.*"), + lookbehind: true, greedy: true), + GrammarToken("annotation", compileHighlightPattern("\\[<.+?>\\]"), + greedy: true, inside: () => _g1), + GrammarToken( + "char", + compileHighlightPattern( + "'(?:[^\\\\']|\\\\(?:.|\\d{3}|x[a-fA-F\\d]{2}|u[a-fA-F\\d]{4}|U[a-fA-F\\d]{8}))'B?"), + greedy: true), + GrammarToken( + "string", + compileHighlightPattern( + "(?:\"\"\"[\\s\\S]*?\"\"\"|@\"(?:\"\"|[^\"])*\"|\"(?:\\\\[\\s\\S]|[^\\\\\"])*\")B?"), + greedy: true), + GrammarToken( + "class-name", + compileHighlightPattern( + "(\\b(?:exception|inherit|interface|new|of|type)\\s+|\\w\\s*:\\s*|\\s:\\??>\\s*)[.\\w]+\\b(?:\\s*(?:->|\\*)\\s*[.\\w]+\\b)*(?!\\s*[:.])"), + lookbehind: true, + inside: () => _g2), + GrammarToken( + "preprocessor", compileHighlightPattern("(^[\\t ]*)#.*", multiLine: true), + lookbehind: true, alias: "property", inside: () => _g3), + GrammarToken( + "keyword", + compileHighlightPattern( + "\\b(?:let|return|use|yield)(?:!\\B|\\b)|\\b(?:abstract|and|as|asr|assert|atomic|base|begin|break|checked|class|component|const|constraint|constructor|continue|default|delegate|do|done|downcast|downto|eager|elif|else|end|event|exception|extern|external|false|finally|fixed|for|fun|function|functor|global|if|in|include|inherit|inline|interface|internal|land|lazy|lor|lsl|lsr|lxor|match|member|method|mixin|mod|module|mutable|namespace|new|not|null|object|of|open|or|override|parallel|private|process|protected|public|pure|rec|sealed|select|sig|static|struct|tailcall|then|to|trait|true|try|type|upcast|val|virtual|void|volatile|when|while|with)\\b")), + GrammarToken("boolean", compileHighlightPattern("\\b(?:false|true)\\b")), + GrammarToken("function", compileHighlightPattern("\\b\\w+(?=\\()")), + GrammarToken( + "number", compileHighlightPattern("\\b0x[\\da-fA-F]+(?:LF|lf|un)?\\b")), + GrammarToken("number", compileHighlightPattern("\\b0b[01]+(?:uy|y)?\\b")), + GrammarToken( + "number", + compileHighlightPattern( + "(?:\\b\\d+(?:\\.\\d*)?|\\B\\.\\d+)(?:[fm]|e[+-]?\\d+)?\\b", + caseSensitive: false)), + GrammarToken( + "number", compileHighlightPattern("\\b\\d+(?:[IlLsy]|UL|u[lsy]?)?\\b")), + GrammarToken( + "operator", + compileHighlightPattern( + "([<>~&^])\\1\\1|([*.:<>&])\\2|<-|->|[!=:]=|?|\\??(?:<=|>=|<>|[-+*/%=<>])\\??|[!?^&]|~[+~-]|:>|:\\?>?")), + GrammarToken("computation-expression", + compileHighlightPattern("\\b[_a-z]\\w*(?=\\s*\\{)", caseSensitive: false), + alias: "keyword"), + GrammarToken("punctuation", compileHighlightPattern("[{}[\\];(),.:]")), +]); + +final Grammar _g1 = Grammar([ + GrammarToken("punctuation", compileHighlightPattern("^\\[<|>\\]\$")), + GrammarToken("class-name", + compileHighlightPattern("^\\w+\$|(^|;\\s*)[A-Z]\\w*(?=\\()"), + lookbehind: true), + GrammarToken("annotation-content", compileHighlightPattern("[\\s\\S]+"), + inside: () => _g0), +]); + +final Grammar _g2 = Grammar([ + GrammarToken("operator", compileHighlightPattern("->|\\*")), + GrammarToken("punctuation", compileHighlightPattern("\\.")), +]); + +final Grammar _g3 = Grammar([ + GrammarToken("directive", + compileHighlightPattern("(^#)\\b(?:else|endif|if|light|line|nowarn)\\b"), + lookbehind: true, alias: "keyword"), +]); diff --git a/lib/highlight/git.dart b/lib/highlight/git.dart new file mode 100644 index 0000000..a016572 --- /dev/null +++ b/lib/highlight/git.dart @@ -0,0 +1,32 @@ +// GENERATED CODE — do not modify by hand. Regenerate with tool/highlight_codegen. +// ignore_for_file: lines_longer_than_80_chars, public_member_api_docs +// ignore_for_file: prefer_single_quotes, require_trailing_commas, directives_ordering + +import '../highlight.dart'; + +/// Syntax grammar for `git`. +/// +/// Import this library only when you need `git` highlighting; unused +/// languages are dropped from the build. +abstract final class HighlightGit { + /// The grammar for `git`. + static final Grammar grammar = _g0; +} + +final Grammar _g0 = Grammar([ + GrammarToken("comment", compileHighlightPattern("^#.*", multiLine: true)), + GrammarToken("deleted", compileHighlightPattern("^[-–].*", multiLine: true)), + GrammarToken("inserted", compileHighlightPattern("^\\+.*", multiLine: true)), + GrammarToken("string", + compileHighlightPattern("(\"|')(?:\\\\.|(?!\\1)[^\\\\\\r\\n])*\\1")), + GrammarToken( + "command", compileHighlightPattern("^.*\\\$ git .*\$", multiLine: true), + inside: () => _g1), + GrammarToken("coord", compileHighlightPattern("^@@.*@@\$", multiLine: true)), + GrammarToken("commit-sha1", + compileHighlightPattern("^commit \\w{40}\$", multiLine: true)), +]); + +final Grammar _g1 = Grammar([ + GrammarToken("parameter", compileHighlightPattern("\\s--?\\w+")), +]); diff --git a/lib/highlight/go.dart b/lib/highlight/go.dart new file mode 100644 index 0000000..b34962e --- /dev/null +++ b/lib/highlight/go.dart @@ -0,0 +1,61 @@ +// GENERATED CODE — do not modify by hand. Regenerate with tool/highlight_codegen. +// ignore_for_file: lines_longer_than_80_chars, public_member_api_docs +// ignore_for_file: prefer_single_quotes, require_trailing_commas, directives_ordering + +import '../highlight.dart'; + +/// Syntax grammar for `go`. +/// +/// Import this library only when you need `go` highlighting; unused +/// languages are dropped from the build. +abstract final class HighlightGo { + /// The grammar for `go`. + static final Grammar grammar = _g0; +} + +final Grammar _g0 = Grammar([ + GrammarToken("comment", + compileHighlightPattern("(^|[^\\\\])\\/\\*[\\s\\S]*?(?:\\*\\/|\$)"), + lookbehind: true, greedy: true), + GrammarToken("comment", compileHighlightPattern("(^|[^\\\\:])\\/\\/.*"), + lookbehind: true, greedy: true), + GrammarToken( + "char", compileHighlightPattern("'(?:\\\\.|[^'\\\\\\r\\n]){0,10}'"), + greedy: true), + GrammarToken( + "string", + compileHighlightPattern( + "(^|[^\\\\])\"(?:\\\\.|[^\"\\\\\\r\\n])*\"|`[^`]*`"), + lookbehind: true, + greedy: true), + GrammarToken( + "keyword", + compileHighlightPattern( + "\\b(?:break|case|chan|const|continue|default|defer|else|fallthrough|for|func|go(?:to)?|if|import|interface|map|package|range|return|select|struct|switch|type|var)\\b")), + GrammarToken( + "boolean", compileHighlightPattern("\\b(?:_|false|iota|nil|true)\\b")), + GrammarToken("function", compileHighlightPattern("\\b\\w+(?=\\()")), + GrammarToken( + "number", + compileHighlightPattern("\\b0(?:b[01_]+|o[0-7_]+)i?\\b", + caseSensitive: false)), + GrammarToken( + "number", + compileHighlightPattern( + "\\b0x(?:[a-f\\d_]+(?:\\.[a-f\\d_]*)?|\\.[a-f\\d_]+)(?:p[+-]?\\d+(?:_\\d+)*)?i?(?!\\w)", + caseSensitive: false)), + GrammarToken( + "number", + compileHighlightPattern( + "(?:\\b\\d[\\d_]*(?:\\.[\\d_]*)?|\\B\\.\\d[\\d_]*)(?:e[+-]?[\\d_]+)?i?(?!\\w)", + caseSensitive: false)), + GrammarToken( + "operator", + compileHighlightPattern( + "[*\\/%^!=]=?|\\+[=+]?|-[=-]?|\\|[=|]?|&(?:=|&|\\^=?)?|>(?:>=?|=)?|<(?:<=?|=|-)?|:=|\\.\\.\\.")), + GrammarToken("punctuation", compileHighlightPattern("[{}[\\];(),.:]")), + GrammarToken( + "builtin", + compileHighlightPattern( + "\\b(?:append|bool|byte|cap|close|complex|complex(?:64|128)|copy|delete|error|float(?:32|64)|u?int(?:8|16|32|64)?|imag|len|make|new|panic|print(?:ln)?|real|recover|rune|string|uintptr)\\b")), +]); diff --git a/lib/highlight/graphql.dart b/lib/highlight/graphql.dart new file mode 100644 index 0000000..ddd92e8 --- /dev/null +++ b/lib/highlight/graphql.dart @@ -0,0 +1,85 @@ +// GENERATED CODE — do not modify by hand. Regenerate with tool/highlight_codegen. +// ignore_for_file: lines_longer_than_80_chars, public_member_api_docs +// ignore_for_file: prefer_single_quotes, require_trailing_commas, directives_ordering + +import '../highlight.dart'; +import 'markdown.dart'; + +/// Syntax grammar for `graphql`. +/// +/// Import this library only when you need `graphql` highlighting; unused +/// languages are dropped from the build. +abstract final class HighlightGraphql { + /// The grammar for `graphql`. + static final Grammar grammar = _g0; +} + +final Grammar _g0 = Grammar([ + GrammarToken("comment", compileHighlightPattern("#.*")), + GrammarToken( + "description", + compileHighlightPattern( + "(?:\"\"\"(?:[^\"]|(?!\"\"\")\")*\"\"\"|\"(?:\\\\.|[^\\\\\"\\r\\n])*\")(?=\\s*[a-z_])", + caseSensitive: false), + greedy: true, + alias: "string", + inside: () => _g1), + GrammarToken( + "string", + compileHighlightPattern( + "\"\"\"(?:[^\"]|(?!\"\"\")\")*\"\"\"|\"(?:\\\\.|[^\\\\\"\\r\\n])*\""), + greedy: true), + GrammarToken( + "number", + compileHighlightPattern("(?:\\B-|\\b)\\d+(?:\\.\\d+)?(?:e[+-]?\\d+)?\\b", + caseSensitive: false)), + GrammarToken("boolean", compileHighlightPattern("\\b(?:false|true)\\b")), + GrammarToken("variable", + compileHighlightPattern("\\\$[a-z_]\\w*", caseSensitive: false)), + GrammarToken( + "directive", compileHighlightPattern("@[a-z_]\\w*", caseSensitive: false), + alias: "function"), + GrammarToken( + "attr-name", + compileHighlightPattern( + "\\b[a-z_]\\w*(?=\\s*(?:\\((?:[^()\"]|\"(?:\\\\.|[^\\\\\"\\r\\n])*\")*\\))?:)", + caseSensitive: false), + greedy: true), + GrammarToken("atom-input", compileHighlightPattern("\\b[A-Z]\\w*Input\\b"), + alias: "class-name"), + GrammarToken("scalar", + compileHighlightPattern("\\b(?:Boolean|Float|ID|Int|String)\\b")), + GrammarToken("constant", compileHighlightPattern("\\b[A-Z][A-Z_\\d]*\\b")), + GrammarToken( + "class-name", + compileHighlightPattern( + "(\\b(?:enum|implements|interface|on|scalar|type|union)\\s+|&\\s*|:\\s*|\\[)[A-Z_]\\w*"), + lookbehind: true), + GrammarToken( + "fragment", + compileHighlightPattern( + "(\\bfragment\\s+|\\.{3}\\s*(?!on\\b))[a-zA-Z_]\\w*"), + lookbehind: true, + alias: "function"), + GrammarToken("definition-mutation", + compileHighlightPattern("(\\bmutation\\s+)[a-zA-Z_]\\w*"), + lookbehind: true, alias: "function"), + GrammarToken("definition-query", + compileHighlightPattern("(\\bquery\\s+)[a-zA-Z_]\\w*"), + lookbehind: true, alias: "function"), + GrammarToken( + "keyword", + compileHighlightPattern( + "\\b(?:directive|enum|extend|fragment|implements|input|interface|mutation|on|query|repeatable|scalar|schema|subscription|type|union)\\b")), + GrammarToken("operator", compileHighlightPattern("[!=|&]|\\.{3}")), + GrammarToken("property-query", compileHighlightPattern("\\w+(?=\\s*\\()")), + GrammarToken("object", compileHighlightPattern("\\w+(?=\\s*\\{)")), + GrammarToken("punctuation", compileHighlightPattern("[!(){}\\[\\]:=,]")), + GrammarToken("property", compileHighlightPattern("\\w+")), +]); + +final Grammar _g1 = Grammar([ + GrammarToken("language-markdown", + compileHighlightPattern("(^\"(?:\"\")?)(?!\\1)[\\s\\S]+(?=\\1\$)"), + lookbehind: true, inside: () => HighlightMarkdown.grammar), +]); diff --git a/lib/highlight/groovy.dart b/lib/highlight/groovy.dart new file mode 100644 index 0000000..ffed684 --- /dev/null +++ b/lib/highlight/groovy.dart @@ -0,0 +1,86 @@ +// GENERATED CODE — do not modify by hand. Regenerate with tool/highlight_codegen. +// ignore_for_file: lines_longer_than_80_chars, public_member_api_docs +// ignore_for_file: prefer_single_quotes, require_trailing_commas, directives_ordering + +import '../highlight.dart'; + +/// Syntax grammar for `groovy`. +/// +/// Import this library only when you need `groovy` highlighting; unused +/// languages are dropped from the build. +abstract final class HighlightGroovy { + /// The grammar for `groovy`. + static final Grammar grammar = _g0; +} + +final Grammar _g0 = Grammar([ + GrammarToken("comment", + compileHighlightPattern("(^|[^\\\\])\\/\\*[\\s\\S]*?(?:\\*\\/|\$)"), + lookbehind: true, greedy: true), + GrammarToken("comment", compileHighlightPattern("(^|[^\\\\:])\\/\\/.*"), + lookbehind: true, greedy: true), + GrammarToken("shebang", compileHighlightPattern("#!.+"), + greedy: true, alias: "comment"), + GrammarToken( + "interpolation-string", + compileHighlightPattern( + "\"\"\"(?:[^\\\\]|\\\\[\\s\\S])*?\"\"\"|([\"/])(?:\\\\.|(?!\\1)[^\\\\\\r\\n])*\\1|\\\$\\/(?:[^/\$]|\\\$(?:[/\$]|(?![/\$]))|\\/(?!\\\$))*\\/\\\$"), + greedy: true, + inside: () => _g1), + GrammarToken( + "string", + compileHighlightPattern( + "'''(?:[^\\\\]|\\\\[\\s\\S])*?'''|'(?:\\\\.|[^\\\\'\\r\\n])*'"), + greedy: true), + GrammarToken( + "class-name", + compileHighlightPattern( + "(\\b(?:class|extends|implements|instanceof|interface|new)\\s+)[\\w.\\\\]+"), + lookbehind: true, + inside: () => _g3), + GrammarToken( + "keyword", + compileHighlightPattern( + "\\b(?:abstract|as|assert|boolean|break|byte|case|catch|char|class|const|continue|def|default|do|double|else|enum|extends|final|finally|float|for|goto|if|implements|import|in|instanceof|int|interface|long|native|new|package|private|protected|public|return|short|static|strictfp|super|switch|synchronized|this|throw|throws|trait|transient|try|void|volatile|while)\\b")), + GrammarToken("boolean", compileHighlightPattern("\\b(?:false|true)\\b")), + GrammarToken("annotation", compileHighlightPattern("(^|[^.])@\\w+"), + lookbehind: true, alias: "punctuation"), + GrammarToken("function", compileHighlightPattern("\\b\\w+(?=\\()")), + GrammarToken( + "number", + compileHighlightPattern( + "\\b(?:0b[01_]+|0x[\\da-f_]+(?:\\.[\\da-f_p\\-]+)?|[\\d_]+(?:\\.[\\d_]+)?(?:e[+-]?\\d+)?)[glidf]?\\b", + caseSensitive: false)), + GrammarToken( + "operator", + compileHighlightPattern( + "(^|[^.])(?:~|==?~?|\\?[.:]?|\\*(?:[.=]|\\*=?)?|\\.[@&]|\\.\\.<|\\.\\.(?!\\.)|-[-=>]?|\\+[+=]?|!=?|<(?:<=?|=>?)?|>(?:>>?=?|=)?|&[&=]?|\\|[|=]?|\\/=?|\\^=?|%=?)"), + lookbehind: true), + GrammarToken( + "spock-block", + compileHighlightPattern( + "\\b(?:and|cleanup|expect|given|setup|then|when|where):")), + GrammarToken("punctuation", compileHighlightPattern("\\.+|[{}[\\];(),:\$]")), +]); + +final Grammar _g1 = Grammar([ + GrammarToken( + "interpolation", + compileHighlightPattern( + "((?:^|[^\\\\\$])(?:\\\\{2})*)\\\$(?:\\w+|\\{[^{}]*\\})"), + lookbehind: true, + inside: () => _g2), + GrammarToken("string", compileHighlightPattern("[\\s\\S]+")), +]); + +final Grammar _g2 = Grammar([ + GrammarToken( + "interpolation-punctuation", compileHighlightPattern("^\\\$\\{?|\\}\$"), + alias: "punctuation"), + GrammarToken("expression", compileHighlightPattern("[\\s\\S]+"), + inside: () => _g0), +]); + +final Grammar _g3 = Grammar([ + GrammarToken("punctuation", compileHighlightPattern("[.\\\\]")), +]); diff --git a/lib/highlight/handlebars.dart b/lib/highlight/handlebars.dart new file mode 100644 index 0000000..aa2e25a --- /dev/null +++ b/lib/highlight/handlebars.dart @@ -0,0 +1,44 @@ +// GENERATED CODE — do not modify by hand. Regenerate with tool/highlight_codegen. +// ignore_for_file: lines_longer_than_80_chars, public_member_api_docs +// ignore_for_file: prefer_single_quotes, require_trailing_commas, directives_ordering + +import '../highlight.dart'; + +/// Syntax grammar for `handlebars`. +/// +/// Import this library only when you need `handlebars` highlighting; unused +/// languages are dropped from the build. +abstract final class HighlightHandlebars { + /// The grammar for `handlebars`. + static final Grammar grammar = _g0; +} + +final Grammar _g0 = Grammar([ + GrammarToken("comment", compileHighlightPattern("\\{\\{![\\s\\S]*?\\}\\}")), + GrammarToken("delimiter", compileHighlightPattern("^\\{\\{\\{?|\\}\\}\\}?\$"), + alias: "punctuation"), + GrammarToken("string", + compileHighlightPattern("([\"'])(?:\\\\.|(?!\\1)[^\\\\\\r\\n])*\\1")), + GrammarToken( + "number", + compileHighlightPattern( + "\\b0x[\\dA-Fa-f]+\\b|(?:\\b\\d+(?:\\.\\d*)?|\\B\\.\\d+)(?:[Ee][+-]?\\d+)?")), + GrammarToken("boolean", compileHighlightPattern("\\b(?:false|true)\\b")), + GrammarToken( + "block", + compileHighlightPattern( + "^(\\s*(?:~\\s*)?)[#\\/]\\S+?(?=\\s*(?:~\\s*)?\$|\\s)"), + lookbehind: true, + alias: "keyword"), + GrammarToken("brackets", compileHighlightPattern("\\[[^\\]]+\\]"), + inside: () => _g1), + GrammarToken("punctuation", + compileHighlightPattern("[!\"#%&':()*+,.\\/;<=>@\\[\\\\\\]^`{|}~]")), + GrammarToken("variable", + compileHighlightPattern("[^!\"#%&'()*+,\\/;<=>@\\[\\\\\\]^`{|}~\\s]+")), +]); + +final Grammar _g1 = Grammar([ + GrammarToken("punctuation", compileHighlightPattern("\\[|\\]")), + GrammarToken("variable", compileHighlightPattern("[\\s\\S]+")), +]); diff --git a/lib/highlight/haskell.dart b/lib/highlight/haskell.dart new file mode 100644 index 0000000..f37693b --- /dev/null +++ b/lib/highlight/haskell.dart @@ -0,0 +1,81 @@ +// GENERATED CODE — do not modify by hand. Regenerate with tool/highlight_codegen. +// ignore_for_file: lines_longer_than_80_chars, public_member_api_docs +// ignore_for_file: prefer_single_quotes, require_trailing_commas, directives_ordering + +import '../highlight.dart'; + +/// Syntax grammar for `haskell`. +/// +/// Import this library only when you need `haskell` highlighting; unused +/// languages are dropped from the build. +abstract final class HighlightHaskell { + /// The grammar for `haskell`. + static final Grammar grammar = _g0; +} + +final Grammar _g0 = Grammar([ + GrammarToken( + "comment", + compileHighlightPattern( + "(^|[^-!#\$%*+=?&@|~.:<>^\\\\\\/])(?:--(?:(?=.)[^-!#\$%*+=?&@|~.:<>^\\\\\\/].*|\$)|\\{-[\\s\\S]*?-\\})", + multiLine: true), + lookbehind: true), + GrammarToken( + "char", + compileHighlightPattern( + "'(?:[^\\\\']|\\\\(?:[abfnrtv\\\\\"'&]|\\^[A-Z@[\\]^_]|ACK|BEL|BS|CAN|CR|DC1|DC2|DC3|DC4|DEL|DLE|EM|ENQ|EOT|ESC|ETB|ETX|FF|FS|GS|HT|LF|NAK|NUL|RS|SI|SO|SOH|SP|STX|SUB|SYN|US|VT|\\d+|o[0-7]+|x[0-9a-fA-F]+))'"), + alias: "string"), + GrammarToken("string", + compileHighlightPattern("\"(?:[^\\\\\"]|\\\\(?:\\S|\\s+\\\\))*\""), + greedy: true), + GrammarToken( + "keyword", + compileHighlightPattern( + "\\b(?:case|class|data|deriving|do|else|if|in|infixl|infixr|instance|let|module|newtype|of|primitive|then|type|where)\\b")), + GrammarToken( + "import-statement", + compileHighlightPattern( + "(^[\\t ]*)import\\s+(?:qualified\\s+)?(?:[A-Z][\\w']*)(?:\\.[A-Z][\\w']*)*(?:\\s+as\\s+(?:[A-Z][\\w']*)(?:\\.[A-Z][\\w']*)*)?(?:\\s+hiding\\b)?", + multiLine: true), + lookbehind: true, + inside: () => _g1), + GrammarToken( + "builtin", + compileHighlightPattern( + "\\b(?:abs|acos|acosh|all|and|any|appendFile|approxRational|asTypeOf|asin|asinh|atan|atan2|atanh|basicIORun|break|catch|ceiling|chr|compare|concat|concatMap|const|cos|cosh|curry|cycle|decodeFloat|denominator|digitToInt|div|divMod|drop|dropWhile|either|elem|encodeFloat|enumFrom|enumFromThen|enumFromThenTo|enumFromTo|error|even|exp|exponent|fail|filter|flip|floatDigits|floatRadix|floatRange|floor|fmap|foldl|foldl1|foldr|foldr1|fromDouble|fromEnum|fromInt|fromInteger|fromIntegral|fromRational|fst|gcd|getChar|getContents|getLine|group|head|id|inRange|index|init|intToDigit|interact|ioError|isAlpha|isAlphaNum|isAscii|isControl|isDenormalized|isDigit|isHexDigit|isIEEE|isInfinite|isLower|isNaN|isNegativeZero|isOctDigit|isPrint|isSpace|isUpper|iterate|last|lcm|length|lex|lexDigits|lexLitChar|lines|log|logBase|lookup|map|mapM|mapM_|max|maxBound|maximum|maybe|min|minBound|minimum|mod|negate|not|notElem|null|numerator|odd|or|ord|otherwise|pack|pi|pred|primExitWith|print|product|properFraction|putChar|putStr|putStrLn|quot|quotRem|range|rangeSize|read|readDec|readFile|readFloat|readHex|readIO|readInt|readList|readLitChar|readLn|readOct|readParen|readSigned|reads|readsPrec|realToFrac|recip|rem|repeat|replicate|return|reverse|round|scaleFloat|scanl|scanl1|scanr|scanr1|seq|sequence|sequence_|show|showChar|showInt|showList|showLitChar|showParen|showSigned|showString|shows|showsPrec|significand|signum|sin|sinh|snd|sort|span|splitAt|sqrt|subtract|succ|sum|tail|take|takeWhile|tan|tanh|threadToIOResult|toEnum|toInt|toInteger|toLower|toRational|toUpper|truncate|uncurry|undefined|unlines|until|unwords|unzip|unzip3|userError|words|writeFile|zip|zip3|zipWith|zipWith3)\\b")), + GrammarToken( + "number", + compileHighlightPattern( + "\\b(?:\\d+(?:\\.\\d+)?(?:e[+-]?\\d+)?|0o[0-7]+|0x[0-9a-f]+)\\b", + caseSensitive: false)), + GrammarToken("operator", + compileHighlightPattern("`(?:[A-Z][\\w']*\\.)*[_a-z][\\w']*`"), + greedy: true), + GrammarToken("operator", compileHighlightPattern("(\\s)\\.(?=\\s)"), + lookbehind: true), + GrammarToken( + "operator", + compileHighlightPattern( + "[-!#\$%*+=?&@|~:<>^\\\\\\/][-!#\$%*+=?&@|~.:<>^\\\\\\/]*|\\.[-!#\$%*+=?&@|~.:<>^\\\\\\/]+")), + GrammarToken("hvariable", + compileHighlightPattern("\\b(?:[A-Z][\\w']*\\.)*[_a-z][\\w']*"), + inside: () => _g2), + GrammarToken("constant", + compileHighlightPattern("\\b(?:[A-Z][\\w']*\\.)*[A-Z][\\w']*"), + inside: () => _g3), + GrammarToken("punctuation", compileHighlightPattern("[{}[\\];(),.:]")), +]); + +final Grammar _g1 = Grammar([ + GrammarToken("keyword", + compileHighlightPattern("\\b(?:as|hiding|import|qualified)\\b")), + GrammarToken("punctuation", compileHighlightPattern("\\.")), +]); + +final Grammar _g2 = Grammar([ + GrammarToken("punctuation", compileHighlightPattern("\\.")), +]); + +final Grammar _g3 = Grammar([ + GrammarToken("punctuation", compileHighlightPattern("\\.")), +]); diff --git a/lib/highlight/html.dart b/lib/highlight/html.dart new file mode 100644 index 0000000..5e42792 --- /dev/null +++ b/lib/highlight/html.dart @@ -0,0 +1,197 @@ +// GENERATED CODE — do not modify by hand. Regenerate with tool/highlight_codegen. +// ignore_for_file: lines_longer_than_80_chars, public_member_api_docs +// ignore_for_file: prefer_single_quotes, require_trailing_commas, directives_ordering + +import '../highlight.dart'; +import 'css.dart'; +import 'js.dart'; + +/// Syntax grammar for `html`. +/// +/// Import this library only when you need `html` highlighting; unused +/// languages are dropped from the build. +abstract final class HighlightHtml { + /// The grammar for `html`. + static final Grammar grammar = _g0; +} + +final Grammar _g0 = Grammar([ + GrammarToken( + "comment", compileHighlightPattern(""), + greedy: true), + GrammarToken("prolog", compileHighlightPattern("<\\?[\\s\\S]+?\\?>"), + greedy: true), + GrammarToken( + "doctype", + compileHighlightPattern( + "\"'[\\]]|\"[^\"]*\"|'[^']*')+(?:\\[(?:[^<\"'\\]]|\"[^\"]*\"|'[^']*'|<(?!!--)|)*\\]\\s*)?>", + caseSensitive: false), + greedy: true, + inside: () => _g1), + GrammarToken( + "style", + compileHighlightPattern( + "(]*>)(?:))*\\]\\]>|(?!)", + caseSensitive: false), + lookbehind: true, + greedy: true, + inside: () => _g2), + GrammarToken( + "script", + compileHighlightPattern( + "(]*>)(?:))*\\]\\]>|(?!)", + caseSensitive: false), + lookbehind: true, + greedy: true, + inside: () => _g4), + GrammarToken( + "cdata", + compileHighlightPattern("", + caseSensitive: false), + greedy: true), + GrammarToken( + "tag", + compileHighlightPattern( + "<\\/?(?!\\d)[^\\s>\\/=\$<%]+(?:\\s(?:\\s*[^\\s>\\/=]+(?:\\s*=\\s*(?:\"[^\"]*\"|'[^']*'|[^\\s'\">=]+(?=[\\s>]))|(?=[\\s/>])))+)?\\s*\\/?>"), + greedy: true, + inside: () => _g6), + GrammarToken("entity", + compileHighlightPattern("&[\\da-z]{1,8};", caseSensitive: false), + alias: "named-entity"), + GrammarToken("entity", + compileHighlightPattern("&#x?[\\da-f]{1,8};", caseSensitive: false)), +]); + +final Grammar _g1 = Grammar([ + GrammarToken("internal-subset", + compileHighlightPattern("(^[^\\[]*\\[)[\\s\\S]+(?=\\]>\$)"), + lookbehind: true, greedy: true, inside: () => _g0), + GrammarToken("string", compileHighlightPattern("\"[^\"]*\"|'[^']*'"), + greedy: true), + GrammarToken("punctuation", compileHighlightPattern("^\$|[[\\]]")), + GrammarToken( + "doctype-tag", compileHighlightPattern("^DOCTYPE", caseSensitive: false)), + GrammarToken("name", compileHighlightPattern("[^\\s<>'\"]+")), +]); + +final Grammar _g2 = Grammar([ + GrammarToken( + "included-cdata", + compileHighlightPattern("", + caseSensitive: false), + inside: () => _g3), + GrammarToken("language-css", compileHighlightPattern("[\\s\\S]+"), + inside: () => HighlightCss.grammar), +]); + +final Grammar _g3 = Grammar([ + GrammarToken( + "language-css", + compileHighlightPattern("(^\$)", + caseSensitive: false), + lookbehind: true, + inside: () => HighlightCss.grammar), + GrammarToken( + "cdata", + compileHighlightPattern("^\$", + caseSensitive: false)), +]); + +final Grammar _g4 = Grammar([ + GrammarToken( + "included-cdata", + compileHighlightPattern("", + caseSensitive: false), + inside: () => _g5), + GrammarToken("language-javascript", compileHighlightPattern("[\\s\\S]+"), + inside: () => HighlightJs.grammar), +]); + +final Grammar _g5 = Grammar([ + GrammarToken( + "language-javascript", + compileHighlightPattern("(^\$)", + caseSensitive: false), + lookbehind: true, + inside: () => HighlightJs.grammar), + GrammarToken( + "cdata", + compileHighlightPattern("^\$", + caseSensitive: false)), +]); + +final Grammar _g6 = Grammar([ + GrammarToken("tag", compileHighlightPattern("^<\\/?[^\\s>\\/]+"), + inside: () => _g7), + GrammarToken( + "special-attr", + compileHighlightPattern( + "(^|[\"'\\s])(?:style)\\s*=\\s*(?:\"[^\"]*\"|'[^']*'|[^\\s'\">=]+(?=[\\s>]))", + caseSensitive: false), + lookbehind: true, + inside: () => _g8), + GrammarToken( + "special-attr", + compileHighlightPattern( + "(^|[\"'\\s])(?:on(?:abort|blur|change|click|composition(?:end|start|update)|dblclick|error|focus(?:in|out)?|key(?:down|up)|load|mouse(?:down|enter|leave|move|out|over|up)|reset|resize|scroll|select|slotchange|submit|unload|wheel))\\s*=\\s*(?:\"[^\"]*\"|'[^']*'|[^\\s'\">=]+(?=[\\s>]))", + caseSensitive: false), + lookbehind: true, + inside: () => _g10), + GrammarToken("attr-value", + compileHighlightPattern("=\\s*(?:\"[^\"]*\"|'[^']*'|[^\\s'\">=]+)"), + inside: () => _g12), + GrammarToken("punctuation", compileHighlightPattern("\\/?>")), + GrammarToken("attr-name", compileHighlightPattern("[^\\s>\\/]+"), + inside: () => _g13), +]); + +final Grammar _g7 = Grammar([ + GrammarToken("punctuation", compileHighlightPattern("^<\\/?")), + GrammarToken("namespace", compileHighlightPattern("^[^\\s>\\/:]+:")), +]); + +final Grammar _g8 = Grammar([ + GrammarToken("attr-name", compileHighlightPattern("^[^\\s=]+")), + GrammarToken("attr-value", compileHighlightPattern("=[\\s\\S]+"), + inside: () => _g9), +]); + +final Grammar _g9 = Grammar([ + GrammarToken("value", + compileHighlightPattern("(^=\\s*([\"']|(?![\"'])))\\S[\\s\\S]*(?=\\2\$)"), + lookbehind: true, alias: "css", inside: () => HighlightCss.grammar), + GrammarToken("punctuation", compileHighlightPattern("^="), + alias: "attr-equals"), + GrammarToken("punctuation", compileHighlightPattern("\"|'")), +]); + +final Grammar _g10 = Grammar([ + GrammarToken("attr-name", compileHighlightPattern("^[^\\s=]+")), + GrammarToken("attr-value", compileHighlightPattern("=[\\s\\S]+"), + inside: () => _g11), +]); + +final Grammar _g11 = Grammar([ + GrammarToken("value", + compileHighlightPattern("(^=\\s*([\"']|(?![\"'])))\\S[\\s\\S]*(?=\\2\$)"), + lookbehind: true, alias: "javascript", inside: () => HighlightJs.grammar), + GrammarToken("punctuation", compileHighlightPattern("^="), + alias: "attr-equals"), + GrammarToken("punctuation", compileHighlightPattern("\"|'")), +]); + +final Grammar _g12 = Grammar([ + GrammarToken("punctuation", compileHighlightPattern("^="), + alias: "attr-equals"), + GrammarToken("punctuation", compileHighlightPattern("^(\\s*)[\"']|[\"']\$"), + lookbehind: true), + GrammarToken("entity", + compileHighlightPattern("&[\\da-z]{1,8};", caseSensitive: false), + alias: "named-entity"), + GrammarToken("entity", + compileHighlightPattern("&#x?[\\da-f]{1,8};", caseSensitive: false)), +]); + +final Grammar _g13 = Grammar([ + GrammarToken("namespace", compileHighlightPattern("^[^\\s>\\/:]+:")), +]); diff --git a/lib/highlight/http.dart b/lib/highlight/http.dart new file mode 100644 index 0000000..6ce9ab1 --- /dev/null +++ b/lib/highlight/http.dart @@ -0,0 +1,137 @@ +// GENERATED CODE — do not modify by hand. Regenerate with tool/highlight_codegen. +// ignore_for_file: lines_longer_than_80_chars, public_member_api_docs +// ignore_for_file: prefer_single_quotes, require_trailing_commas, directives_ordering + +import '../highlight.dart'; +import 'css.dart'; +import 'html.dart'; +import 'js.dart'; +import 'json.dart'; +import 'plain.dart'; +import 'xml.dart'; + +/// Syntax grammar for `http`. +/// +/// Import this library only when you need `http` highlighting; unused +/// languages are dropped from the build. +abstract final class HighlightHttp { + /// The grammar for `http`. + static final Grammar grammar = _g0; +} + +final Grammar _g0 = Grammar([ + GrammarToken( + "request-line", + compileHighlightPattern( + "^(?:CONNECT|DELETE|GET|HEAD|OPTIONS|PATCH|POST|PRI|PUT|SEARCH|TRACE)\\s(?:https?:\\/\\/|\\/)\\S*\\sHTTP\\/[\\d.]+", + multiLine: true), + inside: () => _g1), + GrammarToken("response-status", + compileHighlightPattern("^HTTP\\/[\\d.]+ \\d+ .+", multiLine: true), + inside: () => _g2), + GrammarToken( + "application-javascript", + compileHighlightPattern( + "(content-type:\\s*application\\/javascript(?:(?:\\r\\n?|\\n)[\\w-].*)*(?:\\r(?:\\n|(?!\\n))|\\n))[^ \\t\\w-][\\s\\S]*", + caseSensitive: false), + lookbehind: true, + inside: () => HighlightJs.grammar), + GrammarToken( + "application-json", + compileHighlightPattern( + "(content-type:\\s*(?:application\\/json|\\w+\\/(?:[\\w.-]+\\+)+json(?![+\\w.-]))(?:(?:\\r\\n?|\\n)[\\w-].*)*(?:\\r(?:\\n|(?!\\n))|\\n))[^ \\t\\w-][\\s\\S]*", + caseSensitive: false), + lookbehind: true, + inside: () => HighlightJson.grammar), + GrammarToken( + "application-xml", + compileHighlightPattern( + "(content-type:\\s*(?:application\\/xml|\\w+\\/(?:[\\w.-]+\\+)+xml(?![+\\w.-]))(?:(?:\\r\\n?|\\n)[\\w-].*)*(?:\\r(?:\\n|(?!\\n))|\\n))[^ \\t\\w-][\\s\\S]*", + caseSensitive: false), + lookbehind: true, + inside: () => HighlightXml.grammar), + GrammarToken( + "text-xml", + compileHighlightPattern( + "(content-type:\\s*text\\/xml(?:(?:\\r\\n?|\\n)[\\w-].*)*(?:\\r(?:\\n|(?!\\n))|\\n))[^ \\t\\w-][\\s\\S]*", + caseSensitive: false), + lookbehind: true, + inside: () => HighlightXml.grammar), + GrammarToken( + "text-html", + compileHighlightPattern( + "(content-type:\\s*text\\/html(?:(?:\\r\\n?|\\n)[\\w-].*)*(?:\\r(?:\\n|(?!\\n))|\\n))[^ \\t\\w-][\\s\\S]*", + caseSensitive: false), + lookbehind: true, + inside: () => HighlightHtml.grammar), + GrammarToken( + "text-css", + compileHighlightPattern( + "(content-type:\\s*text\\/css(?:(?:\\r\\n?|\\n)[\\w-].*)*(?:\\r(?:\\n|(?!\\n))|\\n))[^ \\t\\w-][\\s\\S]*", + caseSensitive: false), + lookbehind: true, + inside: () => HighlightCss.grammar), + GrammarToken( + "text-plain", + compileHighlightPattern( + "(content-type:\\s*text\\/plain(?:(?:\\r\\n?|\\n)[\\w-].*)*(?:\\r(?:\\n|(?!\\n))|\\n))[^ \\t\\w-][\\s\\S]*", + caseSensitive: false), + lookbehind: true, + inside: () => HighlightPlain.grammar), + GrammarToken( + "header", + compileHighlightPattern("^[\\w-]+:.+(?:(?:\\r\\n?|\\n)[ \\t].+)*", + multiLine: true), + inside: () => _g3), +]); + +final Grammar _g1 = Grammar([ + GrammarToken("method", compileHighlightPattern("^[A-Z]+\\b"), + alias: "property"), + GrammarToken("request-target", + compileHighlightPattern("^(\\s)(?:https?:\\/\\/|\\/)\\S*(?=\\s)"), + lookbehind: true, alias: "url"), + GrammarToken("http-version", compileHighlightPattern("^(\\s)HTTP\\/[\\d.]+"), + lookbehind: true, alias: "property"), +]); + +final Grammar _g2 = Grammar([ + GrammarToken("http-version", compileHighlightPattern("^HTTP\\/[\\d.]+"), + alias: "property"), + GrammarToken("status-code", compileHighlightPattern("^(\\s)\\d+(?=\\s)"), + lookbehind: true, alias: "number"), + GrammarToken("reason-phrase", compileHighlightPattern("^(\\s).+"), + lookbehind: true, alias: "string"), +]); + +final Grammar _g3 = Grammar([ + GrammarToken( + "header-value", + compileHighlightPattern( + "(^(?:Content-Security-Policy):[ \t]*(?![ \t]))[^]+", + caseSensitive: false), + lookbehind: true, + alias: "csp"), + GrammarToken( + "header-value", + compileHighlightPattern( + "(^(?:Public-Key-Pins(?:-Report-Only)?):[ \t]*(?![ \t]))[^]+", + caseSensitive: false), + lookbehind: true, + alias: "hpkp"), + GrammarToken( + "header-value", + compileHighlightPattern( + "(^(?:Strict-Transport-Security):[ \t]*(?![ \t]))[^]+", + caseSensitive: false), + lookbehind: true, + alias: "hsts"), + GrammarToken( + "header-value", + compileHighlightPattern("(^(?:[^:]+):[ \t]*(?![ \t]))[^]+", + caseSensitive: false), + lookbehind: true), + GrammarToken("header-name", compileHighlightPattern("^[^:]+"), + alias: "keyword"), + GrammarToken("punctuation", compileHighlightPattern("^:")), +]); diff --git a/lib/highlight/ini.dart b/lib/highlight/ini.dart new file mode 100644 index 0000000..0e23119 --- /dev/null +++ b/lib/highlight/ini.dart @@ -0,0 +1,58 @@ +// GENERATED CODE — do not modify by hand. Regenerate with tool/highlight_codegen. +// ignore_for_file: lines_longer_than_80_chars, public_member_api_docs +// ignore_for_file: prefer_single_quotes, require_trailing_commas, directives_ordering + +import '../highlight.dart'; + +/// Syntax grammar for `ini`. +/// +/// Import this library only when you need `ini` highlighting; unused +/// languages are dropped from the build. +abstract final class HighlightIni { + /// The grammar for `ini`. + static final Grammar grammar = _g0; +} + +final Grammar _g0 = Grammar([ + GrammarToken( + "comment", + compileHighlightPattern("(^[ \\f\\t\\v]*)[#;][^\\n\\r]*", + multiLine: true), + lookbehind: true), + GrammarToken( + "section", + compileHighlightPattern("(^[ \\f\\t\\v]*)\\[[^\\n\\r\\]]*\\]?", + multiLine: true), + lookbehind: true, + inside: () => _g1), + GrammarToken( + "key", + compileHighlightPattern( + "(^[ \\f\\t\\v]*)[^ \\f\\n\\r\\t\\v=]+(?:[ \\f\\t\\v]+[^ \\f\\n\\r\\t\\v=]+)*(?=[ \\f\\t\\v]*=)", + multiLine: true), + lookbehind: true, + alias: "attr-name"), + GrammarToken( + "value", + compileHighlightPattern( + "(=[ \\f\\t\\v]*)[^ \\f\\n\\r\\t\\v]+(?:[ \\f\\t\\v]+[^ \\f\\n\\r\\t\\v]+)*"), + lookbehind: true, + alias: "attr-value", + inside: () => _g2), + GrammarToken("punctuation", compileHighlightPattern("=")), +]); + +final Grammar _g1 = Grammar([ + GrammarToken( + "section-name", + compileHighlightPattern( + "(^\\[[ \\f\\t\\v]*)[^ \\f\\t\\v\\]]+(?:[ \\f\\t\\v]+[^ \\f\\t\\v\\]]+)*"), + lookbehind: true, + alias: "selector"), + GrammarToken("punctuation", compileHighlightPattern("\\[|\\]")), +]); + +final Grammar _g2 = Grammar([ + GrammarToken("inner-value", compileHighlightPattern("^(\"|').+(?=\\1\$)"), + lookbehind: true), +]); diff --git a/lib/highlight/java.dart b/lib/highlight/java.dart new file mode 100644 index 0000000..3cb0190 --- /dev/null +++ b/lib/highlight/java.dart @@ -0,0 +1,155 @@ +// GENERATED CODE — do not modify by hand. Regenerate with tool/highlight_codegen. +// ignore_for_file: lines_longer_than_80_chars, public_member_api_docs +// ignore_for_file: prefer_single_quotes, require_trailing_commas, directives_ordering + +import '../highlight.dart'; + +/// Syntax grammar for `java`. +/// +/// Import this library only when you need `java` highlighting; unused +/// languages are dropped from the build. +abstract final class HighlightJava { + /// The grammar for `java`. + static final Grammar grammar = _g0; +} + +final Grammar _g0 = Grammar([ + GrammarToken("comment", + compileHighlightPattern("(^|[^\\\\])\\/\\*[\\s\\S]*?(?:\\*\\/|\$)"), + lookbehind: true, greedy: true), + GrammarToken("comment", compileHighlightPattern("(^|[^\\\\:])\\/\\/.*"), + lookbehind: true, greedy: true), + GrammarToken( + "triple-quoted-string", + compileHighlightPattern( + "\"\"\"[ \\t]*[\\r\\n](?:(?:\"|\"\")?(?:\\\\.|[^\"\\\\]))*\"\"\""), + greedy: true, + alias: "string"), + GrammarToken( + "char", compileHighlightPattern("'(?:\\\\.|[^'\\\\\\r\\n]){1,6}'"), + greedy: true), + GrammarToken("string", + compileHighlightPattern("(^|[^\\\\])\"(?:\\\\.|[^\"\\\\\\r\\n])*\""), + lookbehind: true, greedy: true), + GrammarToken("annotation", + compileHighlightPattern("(^|[^.])@\\w+(?:\\s*\\.\\s*\\w+)*"), + lookbehind: true, alias: "punctuation"), + GrammarToken( + "generics", + compileHighlightPattern( + "<(?:[\\w\\s,.?]|&(?!&)|<(?:[\\w\\s,.?]|&(?!&)|<(?:[\\w\\s,.?]|&(?!&)|<(?:[\\w\\s,.?]|&(?!&))*>)*>)*>)*>"), + inside: () => _g1), + GrammarToken( + "import", + compileHighlightPattern( + "(\\bimport\\s+)(?:[a-z]\\w*\\s*\\.\\s*)*(?:[A-Z]\\w*\\s*\\.\\s*)*(?:[A-Z]\\w*|\\*)(?=\\s*;)"), + lookbehind: true, + inside: () => _g4), + GrammarToken( + "import", + compileHighlightPattern( + "(\\bimport\\s+static\\s+)(?:[a-z]\\w*\\s*\\.\\s*)*(?:[A-Z]\\w*\\s*\\.\\s*)*(?:\\w+|\\*)(?=\\s*;)"), + lookbehind: true, + alias: "static", + inside: () => _g5), + GrammarToken( + "namespace", + compileHighlightPattern( + "(\\b(?:exports|import(?:\\s+static)?|module|open|opens|package|provides|requires|to|transitive|uses|with)\\s+)(?!\\b(?:abstract|assert|boolean|break|byte|case|catch|char|class|const|continue|default|do|double|else|enum|exports|extends|final|finally|float|for|goto|if|implements|import|instanceof|int|interface|long|module|native|new|non-sealed|null|open|opens|package|permits|private|protected|provides|public|record(?!\\s*[(){}[\\]<>=%~.:,;?+\\-*/&|^])|requires|return|sealed|short|static|strictfp|super|switch|synchronized|this|throw|throws|to|transient|transitive|try|uses|var|void|volatile|while|with|yield)\\b)[a-z]\\w*(?:\\.[a-z]\\w*)*\\.?"), + lookbehind: true, + inside: () => _g6), + GrammarToken( + "class-name", + compileHighlightPattern( + "(^|[^\\w.])(?:[a-z]\\w*\\s*\\.\\s*)*(?:[A-Z]\\w*\\s*\\.\\s*)*[A-Z](?:[\\d_A-Z]*[a-z]\\w*)?\\b"), + lookbehind: true, + inside: () => _g2), + GrammarToken( + "class-name", + compileHighlightPattern( + "(^|[^\\w.])(?:[a-z]\\w*\\s*\\.\\s*)*(?:[A-Z]\\w*\\s*\\.\\s*)*[A-Z]\\w*(?=\\s+\\w+\\s*[;,=()]|\\s*(?:\\[[\\s,]*\\]\\s*)?::\\s*new\\b)"), + lookbehind: true, + inside: () => _g2), + GrammarToken( + "class-name", + compileHighlightPattern( + "(\\b(?:class|enum|extends|implements|instanceof|interface|new|record|throws)\\s+)(?:[a-z]\\w*\\s*\\.\\s*)*(?:[A-Z]\\w*\\s*\\.\\s*)*[A-Z]\\w*\\b"), + lookbehind: true, + inside: () => _g2), + GrammarToken( + "keyword", + compileHighlightPattern( + "\\b(?:abstract|assert|boolean|break|byte|case|catch|char|class|const|continue|default|do|double|else|enum|exports|extends|final|finally|float|for|goto|if|implements|import|instanceof|int|interface|long|module|native|new|non-sealed|null|open|opens|package|permits|private|protected|provides|public|record(?!\\s*[(){}[\\]<>=%~.:,;?+\\-*/&|^])|requires|return|sealed|short|static|strictfp|super|switch|synchronized|this|throw|throws|to|transient|transitive|try|uses|var|void|volatile|while|with|yield)\\b")), + GrammarToken("boolean", compileHighlightPattern("\\b(?:false|true)\\b")), + GrammarToken("function", compileHighlightPattern("\\b\\w+(?=\\()")), + GrammarToken("function", compileHighlightPattern("(::\\s*)[a-z_]\\w*"), + lookbehind: true), + GrammarToken( + "number", + compileHighlightPattern( + "\\b0b[01][01_]*L?\\b|\\b0x(?:\\.[\\da-f_p+-]+|[\\da-f_]+(?:\\.[\\da-f_p+-]+)?)\\b|(?:\\b\\d[\\d_]*(?:\\.[\\d_]*)?|\\B\\.\\d[\\d_]*)(?:e[+-]?\\d[\\d_]*)?[dfl]?", + caseSensitive: false)), + GrammarToken( + "operator", + compileHighlightPattern( + "(^|[^.])(?:<<=?|>>>?=?|->|--|\\+\\+|&&|\\|\\||::|[?:~]|[-+*/%&|^!=<>]=?)", + multiLine: true), + lookbehind: true), + GrammarToken("punctuation", compileHighlightPattern("[{}[\\];(),.:]")), + GrammarToken("constant", compileHighlightPattern("\\b[A-Z][A-Z_\\d]+\\b")), +]); + +final Grammar _g1 = Grammar([ + GrammarToken( + "class-name", + compileHighlightPattern( + "(^|[^\\w.])(?:[a-z]\\w*\\s*\\.\\s*)*(?:[A-Z]\\w*\\s*\\.\\s*)*[A-Z](?:[\\d_A-Z]*[a-z]\\w*)?\\b"), + lookbehind: true, + inside: () => _g2), + GrammarToken( + "keyword", + compileHighlightPattern( + "\\b(?:abstract|assert|boolean|break|byte|case|catch|char|class|const|continue|default|do|double|else|enum|exports|extends|final|finally|float|for|goto|if|implements|import|instanceof|int|interface|long|module|native|new|non-sealed|null|open|opens|package|permits|private|protected|provides|public|record(?!\\s*[(){}[\\]<>=%~.:,;?+\\-*/&|^])|requires|return|sealed|short|static|strictfp|super|switch|synchronized|this|throw|throws|to|transient|transitive|try|uses|var|void|volatile|while|with|yield)\\b")), + GrammarToken("punctuation", compileHighlightPattern("[<>(),.:]")), + GrammarToken("operator", compileHighlightPattern("[?&|]")), +]); + +final Grammar _g2 = Grammar([ + GrammarToken( + "namespace", + compileHighlightPattern( + "^[a-z]\\w*(?:\\s*\\.\\s*[a-z]\\w*)*(?:\\s*\\.)?"), + inside: () => _g3), + GrammarToken("punctuation", compileHighlightPattern("\\.")), +]); + +final Grammar _g3 = Grammar([ + GrammarToken("punctuation", compileHighlightPattern("\\.")), +]); + +final Grammar _g4 = Grammar([ + GrammarToken( + "namespace", + compileHighlightPattern( + "^[a-z]\\w*(?:\\s*\\.\\s*[a-z]\\w*)*(?:\\s*\\.)?"), + inside: () => _g3), + GrammarToken("punctuation", compileHighlightPattern("\\.")), + GrammarToken("operator", compileHighlightPattern("\\*")), + GrammarToken("class-name", compileHighlightPattern("\\w+")), +]); + +final Grammar _g5 = Grammar([ + GrammarToken( + "namespace", + compileHighlightPattern( + "^[a-z]\\w*(?:\\s*\\.\\s*[a-z]\\w*)*(?:\\s*\\.)?"), + inside: () => _g3), + GrammarToken("static", compileHighlightPattern("\\b\\w+\$")), + GrammarToken("punctuation", compileHighlightPattern("\\.")), + GrammarToken("operator", compileHighlightPattern("\\*")), + GrammarToken("class-name", compileHighlightPattern("\\w+")), +]); + +final Grammar _g6 = Grammar([ + GrammarToken("punctuation", compileHighlightPattern("\\.")), +]); diff --git a/lib/highlight/js.dart b/lib/highlight/js.dart new file mode 100644 index 0000000..653a06e --- /dev/null +++ b/lib/highlight/js.dart @@ -0,0 +1,155 @@ +// GENERATED CODE — do not modify by hand. Regenerate with tool/highlight_codegen. +// ignore_for_file: lines_longer_than_80_chars, public_member_api_docs +// ignore_for_file: prefer_single_quotes, require_trailing_commas, directives_ordering + +import '../highlight.dart'; +import 'regex.dart'; + +/// Syntax grammar for `js`. +/// +/// Import this library only when you need `js` highlighting; unused +/// languages are dropped from the build. +abstract final class HighlightJs { + /// The grammar for `js`. + static final Grammar grammar = _g0; +} + +final Grammar _g0 = Grammar([ + GrammarToken("comment", + compileHighlightPattern("(^|[^\\\\])\\/\\*[\\s\\S]*?(?:\\*\\/|\$)"), + lookbehind: true, greedy: true), + GrammarToken("comment", compileHighlightPattern("(^|[^\\\\:])\\/\\/.*"), + lookbehind: true, greedy: true), + GrammarToken("hashbang", compileHighlightPattern("^#!.*"), + greedy: true, alias: "comment"), + GrammarToken( + "template-string", + compileHighlightPattern( + "`(?:\\\\[\\s\\S]|\\\$\\{(?:[^{}]|\\{(?:[^{}]|\\{[^}]*\\})*\\})+\\}|(?!\\\$\\{)[^\\\\`])*`"), + greedy: true, + inside: () => _g1), + GrammarToken( + "string-property", + compileHighlightPattern( + "((?:^|[,{])[ \\t]*)([\"'])(?:\\\\(?:\\r\\n|[\\s\\S])|(?!\\2)[^\\\\\\r\\n])*\\2(?=\\s*:)", + multiLine: true), + lookbehind: true, + greedy: true, + alias: "property"), + GrammarToken( + "string", + compileHighlightPattern( + "([\"'])(?:\\\\(?:\\r\\n|[\\s\\S])|(?!\\1)[^\\\\\\r\\n])*\\1"), + greedy: true), + GrammarToken( + "class-name", + compileHighlightPattern( + "(\\b(?:class|extends|implements|instanceof|interface|new)\\s+)[\\w.\\\\]+"), + lookbehind: true, + inside: () => _g3), + GrammarToken( + "class-name", + compileHighlightPattern( + "(^|[^\$\\w\\xA0-\\uFFFF])(?!\\s)[_\$A-Z\\xA0-\\uFFFF](?:(?!\\s)[\$\\w\\xA0-\\uFFFF])*(?=\\.(?:constructor|prototype))"), + lookbehind: true), + GrammarToken( + "regex", + compileHighlightPattern( + "((?:^|[^\$\\w\\xA0-\\uFFFF.\"'\\])\\s]|\\b(?:return|yield))\\s*)\\/(?:(?:\\[(?:[^\\]\\\\\\r\\n]|\\\\.)*\\]|\\\\.|[^/\\\\\\[\\r\\n])+\\/[dgimyus]{0,7}|(?:\\[(?:[^[\\]\\\\\\r\\n]|\\\\.|\\[(?:[^[\\]\\\\\\r\\n]|\\\\.|\\[(?:[^[\\]\\\\\\r\\n]|\\\\.)*\\])*\\])*\\]|\\\\.|[^/\\\\\\[\\r\\n])+\\/[dgimyus]{0,7}v[dgimyus]{0,7})(?=(?:\\s|\\/\\*(?:[^*]|\\*(?!\\/))*\\*\\/)*(?:\$|[\\r\\n,.;:})\\]]|\\/\\/))"), + lookbehind: true, + greedy: true, + inside: () => _g4), + GrammarToken( + "function-variable", + compileHighlightPattern( + "#?(?!\\s)[_\$a-zA-Z\\xA0-\\uFFFF](?:(?!\\s)[\$\\w\\xA0-\\uFFFF])*(?=\\s*[=:]\\s*(?:async\\s*)?(?:\\bfunction\\b|(?:\\((?:[^()]|\\([^()]*\\))*\\)|(?!\\s)[_\$a-zA-Z\\xA0-\\uFFFF](?:(?!\\s)[\$\\w\\xA0-\\uFFFF])*)\\s*=>))"), + alias: "function"), + GrammarToken( + "parameter", + compileHighlightPattern( + "(function(?:\\s+(?!\\s)[_\$a-zA-Z\\xA0-\\uFFFF](?:(?!\\s)[\$\\w\\xA0-\\uFFFF])*)?\\s*\\(\\s*)(?!\\s)(?:[^()\\s]|\\s+(?![\\s)])|\\([^()]*\\))+(?=\\s*\\))"), + lookbehind: true, + inside: () => _g0), + GrammarToken( + "parameter", + compileHighlightPattern( + "(^|[^\$\\w\\xA0-\\uFFFF])(?!\\s)[_\$a-z\\xA0-\\uFFFF](?:(?!\\s)[\$\\w\\xA0-\\uFFFF])*(?=\\s*=>)", + caseSensitive: false), + lookbehind: true, + inside: () => _g0), + GrammarToken( + "parameter", + compileHighlightPattern( + "(\\(\\s*)(?!\\s)(?:[^()\\s]|\\s+(?![\\s)])|\\([^()]*\\))+(?=\\s*\\)\\s*=>)"), + lookbehind: true, + inside: () => _g0), + GrammarToken( + "parameter", + compileHighlightPattern( + "((?:\\b|\\s|^)(?!(?:as|async|await|break|case|catch|class|const|continue|debugger|default|delete|do|else|enum|export|extends|finally|for|from|function|get|if|implements|import|in|instanceof|interface|let|new|null|of|package|private|protected|public|return|set|static|super|switch|this|throw|try|typeof|undefined|var|void|while|with|yield)(?![\$\\w\\xA0-\\uFFFF]))(?:(?!\\s)[_\$a-zA-Z\\xA0-\\uFFFF](?:(?!\\s)[\$\\w\\xA0-\\uFFFF])*\\s*)\\(\\s*|\\]\\s*\\(\\s*)(?!\\s)(?:[^()\\s]|\\s+(?![\\s)])|\\([^()]*\\))+(?=\\s*\\)\\s*\\{)"), + lookbehind: true, + inside: () => _g0), + GrammarToken( + "constant", compileHighlightPattern("\\b[A-Z](?:[A-Z_]|\\dx?)*\\b")), + GrammarToken("keyword", compileHighlightPattern("((?:^|\\})\\s*)catch\\b"), + lookbehind: true), + GrammarToken( + "keyword", + compileHighlightPattern( + "(^|[^.]|\\.\\.\\.\\s*)\\b(?:as|assert(?=\\s*\\{)|async(?=\\s*(?:function\\b|\\(|[\$\\w\\xA0-\\uFFFF]|\$))|await|break|case|class|const|continue|debugger|default|delete|do|else|enum|export|extends|finally(?=\\s*(?:\\{|\$))|for|from(?=\\s*(?:['\"]|\$))|function|(?:get|set)(?=\\s*(?:[#\\[\$\\w\\xA0-\\uFFFF]|\$))|if|implements|import|in|instanceof|interface|let|new|null|of|package|private|protected|public|return|static|super|switch|this|throw|try|typeof|undefined|var|void|while|with|yield)\\b"), + lookbehind: true), + GrammarToken("boolean", compileHighlightPattern("\\b(?:false|true)\\b")), + GrammarToken( + "function", + compileHighlightPattern( + "#?(?!\\s)[_\$a-zA-Z\\xA0-\\uFFFF](?:(?!\\s)[\$\\w\\xA0-\\uFFFF])*(?=\\s*(?:\\.\\s*(?:apply|bind|call)\\s*)?\\()")), + GrammarToken( + "number", + compileHighlightPattern( + "(^|[^\\w\$])(?:NaN|Infinity|0[bB][01]+(?:_[01]+)*n?|0[oO][0-7]+(?:_[0-7]+)*n?|0[xX][\\dA-Fa-f]+(?:_[\\dA-Fa-f]+)*n?|\\d+(?:_\\d+)*n|(?:\\d+(?:_\\d+)*(?:\\.(?:\\d+(?:_\\d+)*)?)?|\\.\\d+(?:_\\d+)*)(?:[Ee][+-]?\\d+(?:_\\d+)*)?)(?![\\w\$])"), + lookbehind: true), + GrammarToken( + "literal-property", + compileHighlightPattern( + "((?:^|[,{])[ \\t]*)(?!\\s)[_\$a-zA-Z\\xA0-\\uFFFF](?:(?!\\s)[\$\\w\\xA0-\\uFFFF])*(?=\\s*:)", + multiLine: true), + lookbehind: true, + alias: "property"), + GrammarToken( + "operator", + compileHighlightPattern( + "--|\\+\\+|\\*\\*=?|=>|&&=?|\\|\\|=?|[!=]==|<<=?|>>>?=?|[-+*/%&|^!=<>]=?|\\.{3}|\\?\\?=?|\\?\\.?|[~:]")), + GrammarToken("punctuation", compileHighlightPattern("[{}[\\];(),.:]")), +]); + +final Grammar _g1 = Grammar([ + GrammarToken("template-punctuation", compileHighlightPattern("^`|`\$"), + alias: "string"), + GrammarToken( + "interpolation", + compileHighlightPattern( + "((?:^|[^\\\\])(?:\\\\{2})*)\\\$\\{(?:[^{}]|\\{(?:[^{}]|\\{[^}]*\\})*\\})+\\}"), + lookbehind: true, + inside: () => _g2), + GrammarToken("string", compileHighlightPattern("[\\s\\S]+")), +]); + +final Grammar _g2 = Grammar([ + GrammarToken( + "interpolation-punctuation", compileHighlightPattern("^\\\$\\{|\\}\$"), + alias: "punctuation"), +], rest: () => _g0); + +final Grammar _g3 = Grammar([ + GrammarToken("punctuation", compileHighlightPattern("[.\\\\]")), +]); + +final Grammar _g4 = Grammar([ + GrammarToken( + "regex-source", compileHighlightPattern("^(\\/)[\\s\\S]+(?=\\/[a-z]*\$)"), + lookbehind: true, + alias: "language-regex", + inside: () => HighlightRegex.grammar), + GrammarToken("regex-delimiter", compileHighlightPattern("^\\/|\\/\$")), + GrammarToken("regex-flags", compileHighlightPattern("^[a-z]+\$")), +]); diff --git a/lib/highlight/json.dart b/lib/highlight/json.dart new file mode 100644 index 0000000..ffafcb9 --- /dev/null +++ b/lib/highlight/json.dart @@ -0,0 +1,40 @@ +// GENERATED CODE — do not modify by hand. Regenerate with tool/highlight_codegen. +// ignore_for_file: lines_longer_than_80_chars, public_member_api_docs +// ignore_for_file: prefer_single_quotes, require_trailing_commas, directives_ordering + +import '../highlight.dart'; + +/// Syntax grammar for `json`. +/// +/// Import this library only when you need `json` highlighting; unused +/// languages are dropped from the build. +abstract final class HighlightJson { + /// The grammar for `json`. + static final Grammar grammar = _g0; +} + +final Grammar _g0 = Grammar([ + GrammarToken( + "property", + compileHighlightPattern( + "(^|[^\\\\])\"(?:\\\\.|[^\\\\\"\\r\\n])*\"(?=\\s*:)"), + lookbehind: true, + greedy: true), + GrammarToken( + "string", + compileHighlightPattern( + "(^|[^\\\\])\"(?:\\\\.|[^\\\\\"\\r\\n])*\"(?!\\s*:)"), + lookbehind: true, + greedy: true), + GrammarToken("comment", + compileHighlightPattern("\\/\\/.*|\\/\\*[\\s\\S]*?(?:\\*\\/|\$)"), + greedy: true), + GrammarToken( + "number", + compileHighlightPattern("-?\\b\\d+(?:\\.\\d+)?(?:e[+-]?\\d+)?\\b", + caseSensitive: false)), + GrammarToken("punctuation", compileHighlightPattern("[{}[\\],]")), + GrammarToken("operator", compileHighlightPattern(":")), + GrammarToken("boolean", compileHighlightPattern("\\b(?:false|true)\\b")), + GrammarToken("null", compileHighlightPattern("\\bnull\\b"), alias: "keyword"), +]); diff --git a/lib/highlight/json5.dart b/lib/highlight/json5.dart new file mode 100644 index 0000000..c3a59fd --- /dev/null +++ b/lib/highlight/json5.dart @@ -0,0 +1,43 @@ +// GENERATED CODE — do not modify by hand. Regenerate with tool/highlight_codegen. +// ignore_for_file: lines_longer_than_80_chars, public_member_api_docs +// ignore_for_file: prefer_single_quotes, require_trailing_commas, directives_ordering + +import '../highlight.dart'; + +/// Syntax grammar for `json5`. +/// +/// Import this library only when you need `json5` highlighting; unused +/// languages are dropped from the build. +abstract final class HighlightJson5 { + /// The grammar for `json5`. + static final Grammar grammar = _g0; +} + +final Grammar _g0 = Grammar([ + GrammarToken( + "property", + compileHighlightPattern( + "(\"|')(?:\\\\(?:\\r\\n?|\\n|.)|(?!\\1)[^\\\\\\r\\n])*\\1(?=\\s*:)"), + greedy: true), + GrammarToken( + "property", + compileHighlightPattern( + "(?!\\s)[_\$a-zA-Z\\xA0-\\uFFFF](?:(?!\\s)[\$\\w\\xA0-\\uFFFF])*(?=\\s*:)"), + alias: "unquoted"), + GrammarToken( + "string", + compileHighlightPattern( + "(\"|')(?:\\\\(?:\\r\\n?|\\n|.)|(?!\\1)[^\\\\\\r\\n])*\\1"), + greedy: true), + GrammarToken("comment", + compileHighlightPattern("\\/\\/.*|\\/\\*[\\s\\S]*?(?:\\*\\/|\$)"), + greedy: true), + GrammarToken( + "number", + compileHighlightPattern( + "[+-]?\\b(?:NaN|Infinity|0x[a-fA-F\\d]+)\\b|[+-]?(?:\\b\\d+(?:\\.\\d*)?|\\B\\.\\d+)(?:[eE][+-]?\\d+\\b)?")), + GrammarToken("punctuation", compileHighlightPattern("[{}[\\],]")), + GrammarToken("operator", compileHighlightPattern(":")), + GrammarToken("boolean", compileHighlightPattern("\\b(?:false|true)\\b")), + GrammarToken("null", compileHighlightPattern("\\bnull\\b"), alias: "keyword"), +]); diff --git a/lib/highlight/jsx.dart b/lib/highlight/jsx.dart new file mode 100644 index 0000000..ee51fde --- /dev/null +++ b/lib/highlight/jsx.dart @@ -0,0 +1,826 @@ +// GENERATED CODE — do not modify by hand. Regenerate with tool/highlight_codegen. +// ignore_for_file: lines_longer_than_80_chars, public_member_api_docs +// ignore_for_file: prefer_single_quotes, require_trailing_commas, directives_ordering + +import '../highlight.dart'; + +/// Syntax grammar for `jsx`. +/// +/// Import this library only when you need `jsx` highlighting; unused +/// languages are dropped from the build. +abstract final class HighlightJsx { + /// The grammar for `jsx`. + static final Grammar grammar = _g0; +} + +final Grammar _g0 = Grammar([ + GrammarToken("comment", + compileHighlightPattern("(^|[^\\\\])\\/\\*[\\s\\S]*?(?:\\*\\/|\$)"), + lookbehind: true, greedy: true), + GrammarToken("comment", compileHighlightPattern("(^|[^\\\\:])\\/\\/.*"), + lookbehind: true, greedy: true), + GrammarToken("prolog", compileHighlightPattern("<\\?[\\s\\S]+?\\?>"), + greedy: true), + GrammarToken( + "doctype", + compileHighlightPattern( + "\"'[\\]]|\"[^\"]*\"|'[^']*')+(?:\\[(?:[^<\"'\\]]|\"[^\"]*\"|'[^']*'|<(?!!--)|)*\\]\\s*)?>", + caseSensitive: false), + greedy: true, + inside: () => _g1), + GrammarToken( + "style", + compileHighlightPattern( + "(]*>)(?:))*\\]\\]>|(?!)", + caseSensitive: false), + lookbehind: true, + greedy: true, + inside: () => _g2), + GrammarToken( + "script", + compileHighlightPattern( + "(]*>)(?:))*\\]\\]>|(?!)", + caseSensitive: false), + lookbehind: true, + greedy: true, + inside: () => _g7), + GrammarToken( + "cdata", + compileHighlightPattern("", + caseSensitive: false), + greedy: true), + GrammarToken( + "tag", + compileHighlightPattern( + "<\\/?(?:[\\w.:-]+(?:(?:\\s|\\/\\/.*(?!.)|\\/\\*(?:[^*]|\\*(?!\\/))\\*\\/)+(?:[\\w.:\$-]+(?:=(?:\"(?:\\\\[\\s\\S]|[^\\\\\"])*\"|'(?:\\\\[\\s\\S]|[^\\\\'])*'|[^\\s{'\"/>=]+|(?:\\{(?:\\{(?:\\{[^{}]*\\}|[^{}])*\\}|[^{}])*\\})))?|(?:\\{(?:\\s|\\/\\/.*(?!.)|\\/\\*(?:[^*]|\\*(?!\\/))\\*\\/)*\\.{3}(?:[^{}]|(?:\\{(?:\\{(?:\\{[^{}]*\\}|[^{}])*\\}|[^{}])*\\}))*\\})))*(?:\\s|\\/\\/.*(?!.)|\\/\\*(?:[^*]|\\*(?!\\/))\\*\\/)*\\/?)?>"), + greedy: true, + inside: () => _g19), + GrammarToken("entity", + compileHighlightPattern("&[\\da-z]{1,8};", caseSensitive: false), + alias: "named-entity"), + GrammarToken("entity", + compileHighlightPattern("&#x?[\\da-f]{1,8};", caseSensitive: false)), + GrammarToken("hashbang", compileHighlightPattern("^#!.*"), + greedy: true, alias: "comment"), + GrammarToken( + "template-string", + compileHighlightPattern( + "`(?:\\\\[\\s\\S]|\\\$\\{(?:[^{}]|\\{(?:[^{}]|\\{[^}]*\\})*\\})+\\}|(?!\\\$\\{)[^\\\\`])*`"), + greedy: true, + inside: () => _g28), + GrammarToken( + "string-property", + compileHighlightPattern( + "((?:^|[,{])[ \\t]*)([\"'])(?:\\\\(?:\\r\\n|[\\s\\S])|(?!\\2)[^\\\\\\r\\n])*\\2(?=\\s*:)", + multiLine: true), + lookbehind: true, + greedy: true, + alias: "property"), + GrammarToken( + "string", + compileHighlightPattern( + "([\"'])(?:\\\\(?:\\r\\n|[\\s\\S])|(?!\\1)[^\\\\\\r\\n])*\\1"), + greedy: true), + GrammarToken( + "class-name", + compileHighlightPattern( + "(\\b(?:class|extends|implements|instanceof|interface|new)\\s+)[\\w.\\\\]+"), + lookbehind: true, + inside: () => _g31), + GrammarToken( + "class-name", + compileHighlightPattern( + "(^|[^\$\\w\\xA0-\\uFFFF])(?!\\s)[_\$A-Z\\xA0-\\uFFFF](?:(?!\\s)[\$\\w\\xA0-\\uFFFF])*(?=\\.(?:constructor|prototype))"), + lookbehind: true), + GrammarToken( + "regex", + compileHighlightPattern( + "((?:^|[^\$\\w\\xA0-\\uFFFF.\"'\\])\\s]|\\b(?:return|yield))\\s*)\\/(?:(?:\\[(?:[^\\]\\\\\\r\\n]|\\\\.)*\\]|\\\\.|[^/\\\\\\[\\r\\n])+\\/[dgimyus]{0,7}|(?:\\[(?:[^[\\]\\\\\\r\\n]|\\\\.|\\[(?:[^[\\]\\\\\\r\\n]|\\\\.|\\[(?:[^[\\]\\\\\\r\\n]|\\\\.)*\\])*\\])*\\]|\\\\.|[^/\\\\\\[\\r\\n])+\\/[dgimyus]{0,7}v[dgimyus]{0,7})(?=(?:\\s|\\/\\*(?:[^*]|\\*(?!\\/))*\\*\\/)*(?:\$|[\\r\\n,.;:})\\]]|\\/\\/))"), + lookbehind: true, + greedy: true, + inside: () => _g32), + GrammarToken( + "function-variable", + compileHighlightPattern( + "#?(?!\\s)[_\$a-zA-Z\\xA0-\\uFFFF](?:(?!\\s)[\$\\w\\xA0-\\uFFFF])*(?=\\s*[=:]\\s*(?:async\\s*)?(?:\\bfunction\\b|(?:\\((?:[^()]|\\([^()]*\\))*\\)|(?!\\s)[_\$a-zA-Z\\xA0-\\uFFFF](?:(?!\\s)[\$\\w\\xA0-\\uFFFF])*)\\s*=>))"), + alias: "function"), + GrammarToken( + "parameter", + compileHighlightPattern( + "(function(?:\\s+(?!\\s)[_\$a-zA-Z\\xA0-\\uFFFF](?:(?!\\s)[\$\\w\\xA0-\\uFFFF])*)?\\s*\\(\\s*)(?!\\s)(?:[^()\\s]|\\s+(?![\\s)])|\\([^()]*\\))+(?=\\s*\\))"), + lookbehind: true, + inside: () => _g30), + GrammarToken( + "parameter", + compileHighlightPattern( + "(^|[^\$\\w\\xA0-\\uFFFF])(?!\\s)[_\$a-z\\xA0-\\uFFFF](?:(?!\\s)[\$\\w\\xA0-\\uFFFF])*(?=\\s*=>)", + caseSensitive: false), + lookbehind: true, + inside: () => _g30), + GrammarToken( + "parameter", + compileHighlightPattern( + "(\\(\\s*)(?!\\s)(?:[^()\\s]|\\s+(?![\\s)])|\\([^()]*\\))+(?=\\s*\\)\\s*=>)"), + lookbehind: true, + inside: () => _g30), + GrammarToken( + "parameter", + compileHighlightPattern( + "((?:\\b|\\s|^)(?!(?:as|async|await|break|case|catch|class|const|continue|debugger|default|delete|do|else|enum|export|extends|finally|for|from|function|get|if|implements|import|in|instanceof|interface|let|new|null|of|package|private|protected|public|return|set|static|super|switch|this|throw|try|typeof|undefined|var|void|while|with|yield)(?![\$\\w\\xA0-\\uFFFF]))(?:(?!\\s)[_\$a-zA-Z\\xA0-\\uFFFF](?:(?!\\s)[\$\\w\\xA0-\\uFFFF])*\\s*)\\(\\s*|\\]\\s*\\(\\s*)(?!\\s)(?:[^()\\s]|\\s+(?![\\s)])|\\([^()]*\\))+(?=\\s*\\)\\s*\\{)"), + lookbehind: true, + inside: () => _g30), + GrammarToken( + "constant", compileHighlightPattern("\\b[A-Z](?:[A-Z_]|\\dx?)*\\b")), + GrammarToken("keyword", compileHighlightPattern("((?:^|\\})\\s*)catch\\b"), + lookbehind: true), + GrammarToken( + "keyword", + compileHighlightPattern( + "(^|[^.]|\\.\\.\\.\\s*)\\b(?:as|assert(?=\\s*\\{)|async(?=\\s*(?:function\\b|\\(|[\$\\w\\xA0-\\uFFFF]|\$))|await|break|case|class|const|continue|debugger|default|delete|do|else|enum|export|extends|finally(?=\\s*(?:\\{|\$))|for|from(?=\\s*(?:['\"]|\$))|function|(?:get|set)(?=\\s*(?:[#\\[\$\\w\\xA0-\\uFFFF]|\$))|if|implements|import|in|instanceof|interface|let|new|null|of|package|private|protected|public|return|static|super|switch|this|throw|try|typeof|undefined|var|void|while|with|yield)\\b"), + lookbehind: true), + GrammarToken("boolean", compileHighlightPattern("\\b(?:false|true)\\b")), + GrammarToken( + "function", + compileHighlightPattern( + "#?(?!\\s)[_\$a-zA-Z\\xA0-\\uFFFF](?:(?!\\s)[\$\\w\\xA0-\\uFFFF])*(?=\\s*(?:\\.\\s*(?:apply|bind|call)\\s*)?\\()")), + GrammarToken( + "number", + compileHighlightPattern( + "(^|[^\\w\$])(?:NaN|Infinity|0[bB][01]+(?:_[01]+)*n?|0[oO][0-7]+(?:_[0-7]+)*n?|0[xX][\\dA-Fa-f]+(?:_[\\dA-Fa-f]+)*n?|\\d+(?:_\\d+)*n|(?:\\d+(?:_\\d+)*(?:\\.(?:\\d+(?:_\\d+)*)?)?|\\.\\d+(?:_\\d+)*)(?:[Ee][+-]?\\d+(?:_\\d+)*)?)(?![\\w\$])"), + lookbehind: true), + GrammarToken( + "literal-property", + compileHighlightPattern( + "((?:^|[,{])[ \\t]*)(?!\\s)[_\$a-zA-Z\\xA0-\\uFFFF](?:(?!\\s)[\$\\w\\xA0-\\uFFFF])*(?=\\s*:)", + multiLine: true), + lookbehind: true, + alias: "property"), + GrammarToken( + "operator", + compileHighlightPattern( + "--|\\+\\+|\\*\\*=?|=>|&&=?|\\|\\|=?|[!=]==|<<=?|>>>?=?|[-+*/%&|^!=<>]=?|\\.{3}|\\?\\?=?|\\?\\.?|[~:]")), + GrammarToken("punctuation", compileHighlightPattern("[{}[\\];(),.:]")), +]); + +final Grammar _g1 = Grammar([ + GrammarToken("internal-subset", + compileHighlightPattern("(^[^\\[]*\\[)[\\s\\S]+(?=\\]>\$)"), + lookbehind: true, greedy: true, inside: () => _g0), + GrammarToken("string", compileHighlightPattern("\"[^\"]*\"|'[^']*'"), + greedy: true), + GrammarToken("punctuation", compileHighlightPattern("^\$|[[\\]]")), + GrammarToken( + "doctype-tag", compileHighlightPattern("^DOCTYPE", caseSensitive: false)), + GrammarToken("name", compileHighlightPattern("[^\\s<>'\"]+")), +]); + +final Grammar _g2 = Grammar([ + GrammarToken( + "included-cdata", + compileHighlightPattern("", + caseSensitive: false), + inside: () => _g3), + GrammarToken("language-css", compileHighlightPattern("[\\s\\S]+"), + inside: () => _g4), +]); + +final Grammar _g3 = Grammar([ + GrammarToken( + "language-css", + compileHighlightPattern("(^\$)", + caseSensitive: false), + lookbehind: true, + inside: () => _g4), + GrammarToken( + "cdata", + compileHighlightPattern("^\$", + caseSensitive: false)), +]); + +final Grammar _g4 = Grammar([ + GrammarToken("comment", compileHighlightPattern("\\/\\*[\\s\\S]*?\\*\\/")), + GrammarToken( + "atrule", + compileHighlightPattern( + "@[\\w-](?:[^;{\\s\"']|\\s+(?!\\s)|(?:\"(?:\\\\(?:\\r\\n|[\\s\\S])|[^\"\\\\\\r\\n])*\"|'(?:\\\\(?:\\r\\n|[\\s\\S])|[^'\\\\\\r\\n])*'))*?(?:;|(?=\\s*\\{))"), + inside: () => _g5), + GrammarToken( + "url", + compileHighlightPattern( + "\\burl\\((?:(?:\"(?:\\\\(?:\\r\\n|[\\s\\S])|[^\"\\\\\\r\\n])*\"|'(?:\\\\(?:\\r\\n|[\\s\\S])|[^'\\\\\\r\\n])*')|(?:[^\\\\\\r\\n()\"']|\\\\[\\s\\S])*)\\)", + caseSensitive: false), + greedy: true, + inside: () => _g6), + GrammarToken( + "selector", + compileHighlightPattern( + "(^|[{}\\s])[^{}\\s](?:[^{};\"'\\s]|\\s+(?![\\s{])|(?:\"(?:\\\\(?:\\r\\n|[\\s\\S])|[^\"\\\\\\r\\n])*\"|'(?:\\\\(?:\\r\\n|[\\s\\S])|[^'\\\\\\r\\n])*'))*(?=\\s*\\{)"), + lookbehind: true), + GrammarToken( + "string", + compileHighlightPattern( + "(?:\"(?:\\\\(?:\\r\\n|[\\s\\S])|[^\"\\\\\\r\\n])*\"|'(?:\\\\(?:\\r\\n|[\\s\\S])|[^'\\\\\\r\\n])*')"), + greedy: true), + GrammarToken( + "property", + compileHighlightPattern( + "(^|[^-\\w\\xA0-\\uFFFF])(?!\\s)[-_a-z\\xA0-\\uFFFF](?:(?!\\s)[-\\w\\xA0-\\uFFFF])*(?=\\s*:)", + caseSensitive: false), + lookbehind: true), + GrammarToken("important", + compileHighlightPattern("!important\\b", caseSensitive: false)), + GrammarToken( + "function", + compileHighlightPattern("(^|[^-a-z0-9])[-a-z0-9]+(?=\\()", + caseSensitive: false), + lookbehind: true), + GrammarToken("punctuation", compileHighlightPattern("[(){};:,]")), +]); + +final Grammar _g5 = Grammar([ + GrammarToken("rule", compileHighlightPattern("^@[\\w-]+")), + GrammarToken( + "selector-function-argument", + compileHighlightPattern( + "(\\bselector\\s*\\(\\s*(?![\\s)]))(?:[^()\\s]|\\s+(?![\\s)])|\\((?:[^()]|\\([^()]*\\))*\\))+(?=\\s*\\))"), + lookbehind: true, + alias: "selector"), + GrammarToken("keyword", + compileHighlightPattern("(^|[^\\w-])(?:and|not|only|or)(?![\\w-])"), + lookbehind: true), +], rest: () => _g4); + +final Grammar _g6 = Grammar([ + GrammarToken( + "function", compileHighlightPattern("^url", caseSensitive: false)), + GrammarToken("punctuation", compileHighlightPattern("^\\(|\\)\$")), + GrammarToken( + "string", + compileHighlightPattern( + "^(?:\"(?:\\\\(?:\\r\\n|[\\s\\S])|[^\"\\\\\\r\\n])*\"|'(?:\\\\(?:\\r\\n|[\\s\\S])|[^'\\\\\\r\\n])*')\$"), + alias: "url"), +]); + +final Grammar _g7 = Grammar([ + GrammarToken( + "included-cdata", + compileHighlightPattern("", + caseSensitive: false), + inside: () => _g8), + GrammarToken("language-javascript", compileHighlightPattern("[\\s\\S]+"), + inside: () => _g9), +]); + +final Grammar _g8 = Grammar([ + GrammarToken( + "language-javascript", + compileHighlightPattern("(^\$)", + caseSensitive: false), + lookbehind: true, + inside: () => _g9), + GrammarToken( + "cdata", + compileHighlightPattern("^\$", + caseSensitive: false)), +]); + +final Grammar _g9 = Grammar([ + GrammarToken("comment", + compileHighlightPattern("(^|[^\\\\])\\/\\*[\\s\\S]*?(?:\\*\\/|\$)"), + lookbehind: true, greedy: true), + GrammarToken("comment", compileHighlightPattern("(^|[^\\\\:])\\/\\/.*"), + lookbehind: true, greedy: true), + GrammarToken("hashbang", compileHighlightPattern("^#!.*"), + greedy: true, alias: "comment"), + GrammarToken( + "template-string", + compileHighlightPattern( + "`(?:\\\\[\\s\\S]|\\\$\\{(?:[^{}]|\\{(?:[^{}]|\\{[^}]*\\})*\\})+\\}|(?!\\\$\\{)[^\\\\`])*`"), + greedy: true, + inside: () => _g10), + GrammarToken( + "string-property", + compileHighlightPattern( + "((?:^|[,{])[ \\t]*)([\"'])(?:\\\\(?:\\r\\n|[\\s\\S])|(?!\\2)[^\\\\\\r\\n])*\\2(?=\\s*:)", + multiLine: true), + lookbehind: true, + greedy: true, + alias: "property"), + GrammarToken( + "string", + compileHighlightPattern( + "([\"'])(?:\\\\(?:\\r\\n|[\\s\\S])|(?!\\1)[^\\\\\\r\\n])*\\1"), + greedy: true), + GrammarToken( + "class-name", + compileHighlightPattern( + "(\\b(?:class|extends|implements|instanceof|interface|new)\\s+)[\\w.\\\\]+"), + lookbehind: true, + inside: () => _g12), + GrammarToken( + "class-name", + compileHighlightPattern( + "(^|[^\$\\w\\xA0-\\uFFFF])(?!\\s)[_\$A-Z\\xA0-\\uFFFF](?:(?!\\s)[\$\\w\\xA0-\\uFFFF])*(?=\\.(?:constructor|prototype))"), + lookbehind: true), + GrammarToken( + "regex", + compileHighlightPattern( + "((?:^|[^\$\\w\\xA0-\\uFFFF.\"'\\])\\s]|\\b(?:return|yield))\\s*)\\/(?:(?:\\[(?:[^\\]\\\\\\r\\n]|\\\\.)*\\]|\\\\.|[^/\\\\\\[\\r\\n])+\\/[dgimyus]{0,7}|(?:\\[(?:[^[\\]\\\\\\r\\n]|\\\\.|\\[(?:[^[\\]\\\\\\r\\n]|\\\\.|\\[(?:[^[\\]\\\\\\r\\n]|\\\\.)*\\])*\\])*\\]|\\\\.|[^/\\\\\\[\\r\\n])+\\/[dgimyus]{0,7}v[dgimyus]{0,7})(?=(?:\\s|\\/\\*(?:[^*]|\\*(?!\\/))*\\*\\/)*(?:\$|[\\r\\n,.;:})\\]]|\\/\\/))"), + lookbehind: true, + greedy: true, + inside: () => _g13), + GrammarToken( + "function-variable", + compileHighlightPattern( + "#?(?!\\s)[_\$a-zA-Z\\xA0-\\uFFFF](?:(?!\\s)[\$\\w\\xA0-\\uFFFF])*(?=\\s*[=:]\\s*(?:async\\s*)?(?:\\bfunction\\b|(?:\\((?:[^()]|\\([^()]*\\))*\\)|(?!\\s)[_\$a-zA-Z\\xA0-\\uFFFF](?:(?!\\s)[\$\\w\\xA0-\\uFFFF])*)\\s*=>))"), + alias: "function"), + GrammarToken( + "parameter", + compileHighlightPattern( + "(function(?:\\s+(?!\\s)[_\$a-zA-Z\\xA0-\\uFFFF](?:(?!\\s)[\$\\w\\xA0-\\uFFFF])*)?\\s*\\(\\s*)(?!\\s)(?:[^()\\s]|\\s+(?![\\s)])|\\([^()]*\\))+(?=\\s*\\))"), + lookbehind: true, + inside: () => _g9), + GrammarToken( + "parameter", + compileHighlightPattern( + "(^|[^\$\\w\\xA0-\\uFFFF])(?!\\s)[_\$a-z\\xA0-\\uFFFF](?:(?!\\s)[\$\\w\\xA0-\\uFFFF])*(?=\\s*=>)", + caseSensitive: false), + lookbehind: true, + inside: () => _g9), + GrammarToken( + "parameter", + compileHighlightPattern( + "(\\(\\s*)(?!\\s)(?:[^()\\s]|\\s+(?![\\s)])|\\([^()]*\\))+(?=\\s*\\)\\s*=>)"), + lookbehind: true, + inside: () => _g9), + GrammarToken( + "parameter", + compileHighlightPattern( + "((?:\\b|\\s|^)(?!(?:as|async|await|break|case|catch|class|const|continue|debugger|default|delete|do|else|enum|export|extends|finally|for|from|function|get|if|implements|import|in|instanceof|interface|let|new|null|of|package|private|protected|public|return|set|static|super|switch|this|throw|try|typeof|undefined|var|void|while|with|yield)(?![\$\\w\\xA0-\\uFFFF]))(?:(?!\\s)[_\$a-zA-Z\\xA0-\\uFFFF](?:(?!\\s)[\$\\w\\xA0-\\uFFFF])*\\s*)\\(\\s*|\\]\\s*\\(\\s*)(?!\\s)(?:[^()\\s]|\\s+(?![\\s)])|\\([^()]*\\))+(?=\\s*\\)\\s*\\{)"), + lookbehind: true, + inside: () => _g9), + GrammarToken( + "constant", compileHighlightPattern("\\b[A-Z](?:[A-Z_]|\\dx?)*\\b")), + GrammarToken("keyword", compileHighlightPattern("((?:^|\\})\\s*)catch\\b"), + lookbehind: true), + GrammarToken( + "keyword", + compileHighlightPattern( + "(^|[^.]|\\.\\.\\.\\s*)\\b(?:as|assert(?=\\s*\\{)|async(?=\\s*(?:function\\b|\\(|[\$\\w\\xA0-\\uFFFF]|\$))|await|break|case|class|const|continue|debugger|default|delete|do|else|enum|export|extends|finally(?=\\s*(?:\\{|\$))|for|from(?=\\s*(?:['\"]|\$))|function|(?:get|set)(?=\\s*(?:[#\\[\$\\w\\xA0-\\uFFFF]|\$))|if|implements|import|in|instanceof|interface|let|new|null|of|package|private|protected|public|return|static|super|switch|this|throw|try|typeof|undefined|var|void|while|with|yield)\\b"), + lookbehind: true), + GrammarToken("boolean", compileHighlightPattern("\\b(?:false|true)\\b")), + GrammarToken( + "function", + compileHighlightPattern( + "#?(?!\\s)[_\$a-zA-Z\\xA0-\\uFFFF](?:(?!\\s)[\$\\w\\xA0-\\uFFFF])*(?=\\s*(?:\\.\\s*(?:apply|bind|call)\\s*)?\\()")), + GrammarToken( + "number", + compileHighlightPattern( + "(^|[^\\w\$])(?:NaN|Infinity|0[bB][01]+(?:_[01]+)*n?|0[oO][0-7]+(?:_[0-7]+)*n?|0[xX][\\dA-Fa-f]+(?:_[\\dA-Fa-f]+)*n?|\\d+(?:_\\d+)*n|(?:\\d+(?:_\\d+)*(?:\\.(?:\\d+(?:_\\d+)*)?)?|\\.\\d+(?:_\\d+)*)(?:[Ee][+-]?\\d+(?:_\\d+)*)?)(?![\\w\$])"), + lookbehind: true), + GrammarToken( + "literal-property", + compileHighlightPattern( + "((?:^|[,{])[ \\t]*)(?!\\s)[_\$a-zA-Z\\xA0-\\uFFFF](?:(?!\\s)[\$\\w\\xA0-\\uFFFF])*(?=\\s*:)", + multiLine: true), + lookbehind: true, + alias: "property"), + GrammarToken( + "operator", + compileHighlightPattern( + "--|\\+\\+|\\*\\*=?|=>|&&=?|\\|\\|=?|[!=]==|<<=?|>>>?=?|[-+*/%&|^!=<>]=?|\\.{3}|\\?\\?=?|\\?\\.?|[~:]")), + GrammarToken("punctuation", compileHighlightPattern("[{}[\\];(),.:]")), +]); + +final Grammar _g10 = Grammar([ + GrammarToken("template-punctuation", compileHighlightPattern("^`|`\$"), + alias: "string"), + GrammarToken( + "interpolation", + compileHighlightPattern( + "((?:^|[^\\\\])(?:\\\\{2})*)\\\$\\{(?:[^{}]|\\{(?:[^{}]|\\{[^}]*\\})*\\})+\\}"), + lookbehind: true, + inside: () => _g11), + GrammarToken("string", compileHighlightPattern("[\\s\\S]+")), +]); + +final Grammar _g11 = Grammar([ + GrammarToken( + "interpolation-punctuation", compileHighlightPattern("^\\\$\\{|\\}\$"), + alias: "punctuation"), +], rest: () => _g9); + +final Grammar _g12 = Grammar([ + GrammarToken("punctuation", compileHighlightPattern("[.\\\\]")), +]); + +final Grammar _g13 = Grammar([ + GrammarToken( + "regex-source", compileHighlightPattern("^(\\/)[\\s\\S]+(?=\\/[a-z]*\$)"), + lookbehind: true, alias: "language-regex", inside: () => _g14), + GrammarToken("regex-delimiter", compileHighlightPattern("^\\/|\\/\$")), + GrammarToken("regex-flags", compileHighlightPattern("^[a-z]+\$")), +]); + +final Grammar _g14 = Grammar([ + GrammarToken( + "char-class", + compileHighlightPattern( + "((?:^|[^\\\\])(?:\\\\\\\\)*)\\[(?:[^\\\\\\]]|\\\\[\\s\\S])*\\]"), + lookbehind: true, + inside: () => _g15), + GrammarToken( + "special-escape", compileHighlightPattern("\\\\[\\\\(){}[\\]^\$+*?|.]"), + alias: "escape"), + GrammarToken( + "char-set", + compileHighlightPattern("\\.|\\\\[wsd]|\\\\p\\{[^{}]+\\}", + caseSensitive: false), + alias: "class-name"), + GrammarToken( + "backreference", compileHighlightPattern("\\\\(?![123][0-7]{2})[1-9]"), + alias: "keyword"), + GrammarToken("backreference", compileHighlightPattern("\\\\k<[^<>']+>"), + alias: "keyword", inside: () => _g17), + GrammarToken("anchor", compileHighlightPattern("[\$^]|\\\\[ABbGZz]"), + alias: "function"), + GrammarToken( + "escape", + compileHighlightPattern( + "\\\\(?:x[\\da-fA-F]{2}|u[\\da-fA-F]{4}|u\\{[\\da-fA-F]+\\}|0[0-7]{0,2}|[123][0-7]{2}|c[a-zA-Z]|.)")), + GrammarToken( + "group", + compileHighlightPattern( + "\\((?:\\?(?:<[^<>']+>|'[^<>']+'|[>:]| _g18), + GrammarToken("group", compileHighlightPattern("\\)"), alias: "punctuation"), + GrammarToken("quantifier", + compileHighlightPattern("(?:[+*?]|\\{\\d+(?:,\\d*)?\\})[?+]?"), + alias: "number"), + GrammarToken("alternation", compileHighlightPattern("\\|"), alias: "keyword"), +]); + +final Grammar _g15 = Grammar([ + GrammarToken("char-class-negation", compileHighlightPattern("(^\\[)\\^"), + lookbehind: true, alias: "operator"), + GrammarToken("char-class-punctuation", compileHighlightPattern("^\\[|\\]\$"), + alias: "punctuation"), + GrammarToken( + "range", + compileHighlightPattern( + "(?:[^\\\\-]|\\\\(?:x[\\da-fA-F]{2}|u[\\da-fA-F]{4}|u\\{[\\da-fA-F]+\\}|0[0-7]{0,2}|[123][0-7]{2}|c[a-zA-Z]|.))-(?:[^\\\\-]|\\\\(?:x[\\da-fA-F]{2}|u[\\da-fA-F]{4}|u\\{[\\da-fA-F]+\\}|0[0-7]{0,2}|[123][0-7]{2}|c[a-zA-Z]|.))"), + inside: () => _g16), + GrammarToken( + "special-escape", compileHighlightPattern("\\\\[\\\\(){}[\\]^\$+*?|.]"), + alias: "escape"), + GrammarToken( + "char-set", + compileHighlightPattern("\\\\[wsd]|\\\\p\\{[^{}]+\\}", + caseSensitive: false), + alias: "class-name"), + GrammarToken( + "escape", + compileHighlightPattern( + "\\\\(?:x[\\da-fA-F]{2}|u[\\da-fA-F]{4}|u\\{[\\da-fA-F]+\\}|0[0-7]{0,2}|[123][0-7]{2}|c[a-zA-Z]|.)")), +]); + +final Grammar _g16 = Grammar([ + GrammarToken( + "escape", + compileHighlightPattern( + "\\\\(?:x[\\da-fA-F]{2}|u[\\da-fA-F]{4}|u\\{[\\da-fA-F]+\\}|0[0-7]{0,2}|[123][0-7]{2}|c[a-zA-Z]|.)")), + GrammarToken("range-punctuation", compileHighlightPattern("-"), + alias: "operator"), +]); + +final Grammar _g17 = Grammar([ + GrammarToken("group-name", compileHighlightPattern("(<|')[^<>']+(?=[>']\$)"), + lookbehind: true, alias: "variable"), +]); + +final Grammar _g18 = Grammar([ + GrammarToken("group-name", compileHighlightPattern("(<|')[^<>']+(?=[>']\$)"), + lookbehind: true, alias: "variable"), +]); + +final Grammar _g19 = Grammar([ + GrammarToken("tag", compileHighlightPattern("^<\\/?[^\\s>\\/]*"), + inside: () => _g20), + GrammarToken( + "script", + compileHighlightPattern( + "=(?:\\{(?:\\{(?:\\{[^{}]*\\}|[^{}])*\\}|[^{}])*\\})"), + alias: "language-javascript", + inside: () => _g21), + GrammarToken( + "special-attr", + compileHighlightPattern( + "(^|[\"'\\s])(?:style)\\s*=\\s*(?:\"[^\"]*\"|'[^']*'|[^\\s'\">=]+(?=[\\s>]))", + caseSensitive: false), + lookbehind: true, + inside: () => _g22), + GrammarToken( + "special-attr", + compileHighlightPattern( + "(^|[\"'\\s])(?:on(?:abort|blur|change|click|composition(?:end|start|update)|dblclick|error|focus(?:in|out)?|key(?:down|up)|load|mouse(?:down|enter|leave|move|out|over|up)|reset|resize|scroll|select|slotchange|submit|unload|wheel))\\s*=\\s*(?:\"[^\"]*\"|'[^']*'|[^\\s'\">=]+(?=[\\s>]))", + caseSensitive: false), + lookbehind: true, + inside: () => _g24), + GrammarToken( + "attr-value", + compileHighlightPattern( + "=(?!\\{)(?:\"(?:\\\\[\\s\\S]|[^\\\\\"])*\"|'(?:\\\\[\\s\\S]|[^\\\\'])*'|[^\\s'\">]+)"), + inside: () => _g26), + GrammarToken("punctuation", compileHighlightPattern("\\/?>")), + GrammarToken( + "spread", + compileHighlightPattern( + "(?:\\{(?:\\s|\\/\\/.*(?!.)|\\/\\*(?:[^*]|\\*(?!\\/))\\*\\/)*\\.{3}(?:[^{}]|(?:\\{(?:\\{(?:\\{[^{}]*\\}|[^{}])*\\}|[^{}])*\\}))*\\})"), + inside: () => _g0), + GrammarToken("attr-name", compileHighlightPattern("[^\\s>\\/]+"), + inside: () => _g27), + GrammarToken("comment", + compileHighlightPattern("(^|[^\\\\])\\/\\*[\\s\\S]*?(?:\\*\\/|\$)"), + lookbehind: true, greedy: true), + GrammarToken("comment", compileHighlightPattern("(^|[^\\\\:])\\/\\/.*"), + lookbehind: true, greedy: true), +]); + +final Grammar _g20 = Grammar([ + GrammarToken("punctuation", compileHighlightPattern("^<\\/?")), + GrammarToken("namespace", compileHighlightPattern("^[^\\s>\\/:]+:")), + GrammarToken( + "class-name", compileHighlightPattern("^[A-Z]\\w*(?:\\.[A-Z]\\w*)*\$")), +]); + +final Grammar _g21 = Grammar([ + GrammarToken("script-punctuation", compileHighlightPattern("^=(?=\\{)"), + alias: "punctuation"), +], rest: () => _g0); + +final Grammar _g22 = Grammar([ + GrammarToken("attr-name", compileHighlightPattern("^[^\\s=]+")), + GrammarToken("attr-value", compileHighlightPattern("=[\\s\\S]+"), + inside: () => _g23), +]); + +final Grammar _g23 = Grammar([ + GrammarToken("value", + compileHighlightPattern("(^=\\s*([\"']|(?![\"'])))\\S[\\s\\S]*(?=\\2\$)"), + lookbehind: true, alias: "css", inside: () => _g4), + GrammarToken("punctuation", compileHighlightPattern("^="), + alias: "attr-equals"), + GrammarToken("punctuation", compileHighlightPattern("\"|'")), +]); + +final Grammar _g24 = Grammar([ + GrammarToken("attr-name", compileHighlightPattern("^[^\\s=]+")), + GrammarToken("attr-value", compileHighlightPattern("=[\\s\\S]+"), + inside: () => _g25), +]); + +final Grammar _g25 = Grammar([ + GrammarToken("value", + compileHighlightPattern("(^=\\s*([\"']|(?![\"'])))\\S[\\s\\S]*(?=\\2\$)"), + lookbehind: true, alias: "javascript", inside: () => _g9), + GrammarToken("punctuation", compileHighlightPattern("^="), + alias: "attr-equals"), + GrammarToken("punctuation", compileHighlightPattern("\"|'")), +]); + +final Grammar _g26 = Grammar([ + GrammarToken("punctuation", compileHighlightPattern("^="), + alias: "attr-equals"), + GrammarToken("punctuation", compileHighlightPattern("^(\\s*)[\"']|[\"']\$"), + lookbehind: true), + GrammarToken("entity", + compileHighlightPattern("&[\\da-z]{1,8};", caseSensitive: false), + alias: "named-entity"), + GrammarToken("entity", + compileHighlightPattern("&#x?[\\da-f]{1,8};", caseSensitive: false)), +]); + +final Grammar _g27 = Grammar([ + GrammarToken("namespace", compileHighlightPattern("^[^\\s>\\/:]+:")), +]); + +final Grammar _g28 = Grammar([ + GrammarToken("template-punctuation", compileHighlightPattern("^`|`\$"), + alias: "string"), + GrammarToken( + "interpolation", + compileHighlightPattern( + "((?:^|[^\\\\])(?:\\\\{2})*)\\\$\\{(?:[^{}]|\\{(?:[^{}]|\\{[^}]*\\})*\\})+\\}"), + lookbehind: true, + inside: () => _g29), + GrammarToken("string", compileHighlightPattern("[\\s\\S]+")), +]); + +final Grammar _g29 = Grammar([ + GrammarToken( + "interpolation-punctuation", compileHighlightPattern("^\\\$\\{|\\}\$"), + alias: "punctuation"), +], rest: () => _g30); + +final Grammar _g30 = Grammar([ + GrammarToken("comment", + compileHighlightPattern("(^|[^\\\\])\\/\\*[\\s\\S]*?(?:\\*\\/|\$)"), + lookbehind: true, greedy: true), + GrammarToken("comment", compileHighlightPattern("(^|[^\\\\:])\\/\\/.*"), + lookbehind: true, greedy: true), + GrammarToken("hashbang", compileHighlightPattern("^#!.*"), + greedy: true, alias: "comment"), + GrammarToken( + "template-string", + compileHighlightPattern( + "`(?:\\\\[\\s\\S]|\\\$\\{(?:[^{}]|\\{(?:[^{}]|\\{[^}]*\\})*\\})+\\}|(?!\\\$\\{)[^\\\\`])*`"), + greedy: true, + inside: () => _g28), + GrammarToken( + "string-property", + compileHighlightPattern( + "((?:^|[,{])[ \\t]*)([\"'])(?:\\\\(?:\\r\\n|[\\s\\S])|(?!\\2)[^\\\\\\r\\n])*\\2(?=\\s*:)", + multiLine: true), + lookbehind: true, + greedy: true, + alias: "property"), + GrammarToken( + "string", + compileHighlightPattern( + "([\"'])(?:\\\\(?:\\r\\n|[\\s\\S])|(?!\\1)[^\\\\\\r\\n])*\\1"), + greedy: true), + GrammarToken( + "class-name", + compileHighlightPattern( + "(\\b(?:class|extends|implements|instanceof|interface|new)\\s+)[\\w.\\\\]+"), + lookbehind: true, + inside: () => _g31), + GrammarToken( + "class-name", + compileHighlightPattern( + "(^|[^\$\\w\\xA0-\\uFFFF])(?!\\s)[_\$A-Z\\xA0-\\uFFFF](?:(?!\\s)[\$\\w\\xA0-\\uFFFF])*(?=\\.(?:constructor|prototype))"), + lookbehind: true), + GrammarToken( + "regex", + compileHighlightPattern( + "((?:^|[^\$\\w\\xA0-\\uFFFF.\"'\\])\\s]|\\b(?:return|yield))\\s*)\\/(?:(?:\\[(?:[^\\]\\\\\\r\\n]|\\\\.)*\\]|\\\\.|[^/\\\\\\[\\r\\n])+\\/[dgimyus]{0,7}|(?:\\[(?:[^[\\]\\\\\\r\\n]|\\\\.|\\[(?:[^[\\]\\\\\\r\\n]|\\\\.|\\[(?:[^[\\]\\\\\\r\\n]|\\\\.)*\\])*\\])*\\]|\\\\.|[^/\\\\\\[\\r\\n])+\\/[dgimyus]{0,7}v[dgimyus]{0,7})(?=(?:\\s|\\/\\*(?:[^*]|\\*(?!\\/))*\\*\\/)*(?:\$|[\\r\\n,.;:})\\]]|\\/\\/))"), + lookbehind: true, + greedy: true, + inside: () => _g32), + GrammarToken( + "function-variable", + compileHighlightPattern( + "#?(?!\\s)[_\$a-zA-Z\\xA0-\\uFFFF](?:(?!\\s)[\$\\w\\xA0-\\uFFFF])*(?=\\s*[=:]\\s*(?:async\\s*)?(?:\\bfunction\\b|(?:\\((?:[^()]|\\([^()]*\\))*\\)|(?!\\s)[_\$a-zA-Z\\xA0-\\uFFFF](?:(?!\\s)[\$\\w\\xA0-\\uFFFF])*)\\s*=>))"), + alias: "function"), + GrammarToken( + "parameter", + compileHighlightPattern( + "(function(?:\\s+(?!\\s)[_\$a-zA-Z\\xA0-\\uFFFF](?:(?!\\s)[\$\\w\\xA0-\\uFFFF])*)?\\s*\\(\\s*)(?!\\s)(?:[^()\\s]|\\s+(?![\\s)])|\\([^()]*\\))+(?=\\s*\\))"), + lookbehind: true, + inside: () => _g30), + GrammarToken( + "parameter", + compileHighlightPattern( + "(^|[^\$\\w\\xA0-\\uFFFF])(?!\\s)[_\$a-z\\xA0-\\uFFFF](?:(?!\\s)[\$\\w\\xA0-\\uFFFF])*(?=\\s*=>)", + caseSensitive: false), + lookbehind: true, + inside: () => _g30), + GrammarToken( + "parameter", + compileHighlightPattern( + "(\\(\\s*)(?!\\s)(?:[^()\\s]|\\s+(?![\\s)])|\\([^()]*\\))+(?=\\s*\\)\\s*=>)"), + lookbehind: true, + inside: () => _g30), + GrammarToken( + "parameter", + compileHighlightPattern( + "((?:\\b|\\s|^)(?!(?:as|async|await|break|case|catch|class|const|continue|debugger|default|delete|do|else|enum|export|extends|finally|for|from|function|get|if|implements|import|in|instanceof|interface|let|new|null|of|package|private|protected|public|return|set|static|super|switch|this|throw|try|typeof|undefined|var|void|while|with|yield)(?![\$\\w\\xA0-\\uFFFF]))(?:(?!\\s)[_\$a-zA-Z\\xA0-\\uFFFF](?:(?!\\s)[\$\\w\\xA0-\\uFFFF])*\\s*)\\(\\s*|\\]\\s*\\(\\s*)(?!\\s)(?:[^()\\s]|\\s+(?![\\s)])|\\([^()]*\\))+(?=\\s*\\)\\s*\\{)"), + lookbehind: true, + inside: () => _g30), + GrammarToken( + "constant", compileHighlightPattern("\\b[A-Z](?:[A-Z_]|\\dx?)*\\b")), + GrammarToken("keyword", compileHighlightPattern("((?:^|\\})\\s*)catch\\b"), + lookbehind: true), + GrammarToken( + "keyword", + compileHighlightPattern( + "(^|[^.]|\\.\\.\\.\\s*)\\b(?:as|assert(?=\\s*\\{)|async(?=\\s*(?:function\\b|\\(|[\$\\w\\xA0-\\uFFFF]|\$))|await|break|case|class|const|continue|debugger|default|delete|do|else|enum|export|extends|finally(?=\\s*(?:\\{|\$))|for|from(?=\\s*(?:['\"]|\$))|function|(?:get|set)(?=\\s*(?:[#\\[\$\\w\\xA0-\\uFFFF]|\$))|if|implements|import|in|instanceof|interface|let|new|null|of|package|private|protected|public|return|static|super|switch|this|throw|try|typeof|undefined|var|void|while|with|yield)\\b"), + lookbehind: true), + GrammarToken("boolean", compileHighlightPattern("\\b(?:false|true)\\b")), + GrammarToken( + "function", + compileHighlightPattern( + "#?(?!\\s)[_\$a-zA-Z\\xA0-\\uFFFF](?:(?!\\s)[\$\\w\\xA0-\\uFFFF])*(?=\\s*(?:\\.\\s*(?:apply|bind|call)\\s*)?\\()")), + GrammarToken( + "number", + compileHighlightPattern( + "(^|[^\\w\$])(?:NaN|Infinity|0[bB][01]+(?:_[01]+)*n?|0[oO][0-7]+(?:_[0-7]+)*n?|0[xX][\\dA-Fa-f]+(?:_[\\dA-Fa-f]+)*n?|\\d+(?:_\\d+)*n|(?:\\d+(?:_\\d+)*(?:\\.(?:\\d+(?:_\\d+)*)?)?|\\.\\d+(?:_\\d+)*)(?:[Ee][+-]?\\d+(?:_\\d+)*)?)(?![\\w\$])"), + lookbehind: true), + GrammarToken( + "literal-property", + compileHighlightPattern( + "((?:^|[,{])[ \\t]*)(?!\\s)[_\$a-zA-Z\\xA0-\\uFFFF](?:(?!\\s)[\$\\w\\xA0-\\uFFFF])*(?=\\s*:)", + multiLine: true), + lookbehind: true, + alias: "property"), + GrammarToken( + "operator", + compileHighlightPattern( + "--|\\+\\+|\\*\\*=?|=>|&&=?|\\|\\|=?|[!=]==|<<=?|>>>?=?|[-+*/%&|^!=<>]=?|\\.{3}|\\?\\?=?|\\?\\.?|[~:]")), + GrammarToken("punctuation", compileHighlightPattern("[{}[\\];(),.:]")), +]); + +final Grammar _g31 = Grammar([ + GrammarToken("punctuation", compileHighlightPattern("[.\\\\]")), +]); + +final Grammar _g32 = Grammar([ + GrammarToken( + "regex-source", compileHighlightPattern("^(\\/)[\\s\\S]+(?=\\/[a-z]*\$)"), + lookbehind: true, alias: "language-regex", inside: () => _g33), + GrammarToken("regex-delimiter", compileHighlightPattern("^\\/|\\/\$")), + GrammarToken("regex-flags", compileHighlightPattern("^[a-z]+\$")), +]); + +final Grammar _g33 = Grammar([ + GrammarToken( + "char-class", + compileHighlightPattern( + "((?:^|[^\\\\])(?:\\\\\\\\)*)\\[(?:[^\\\\\\]]|\\\\[\\s\\S])*\\]"), + lookbehind: true, + inside: () => _g34), + GrammarToken( + "special-escape", compileHighlightPattern("\\\\[\\\\(){}[\\]^\$+*?|.]"), + alias: "escape"), + GrammarToken( + "char-set", + compileHighlightPattern("\\.|\\\\[wsd]|\\\\p\\{[^{}]+\\}", + caseSensitive: false), + alias: "class-name"), + GrammarToken( + "backreference", compileHighlightPattern("\\\\(?![123][0-7]{2})[1-9]"), + alias: "keyword"), + GrammarToken("backreference", compileHighlightPattern("\\\\k<[^<>']+>"), + alias: "keyword", inside: () => _g36), + GrammarToken("anchor", compileHighlightPattern("[\$^]|\\\\[ABbGZz]"), + alias: "function"), + GrammarToken( + "escape", + compileHighlightPattern( + "\\\\(?:x[\\da-fA-F]{2}|u[\\da-fA-F]{4}|u\\{[\\da-fA-F]+\\}|0[0-7]{0,2}|[123][0-7]{2}|c[a-zA-Z]|.)")), + GrammarToken( + "group", + compileHighlightPattern( + "\\((?:\\?(?:<[^<>']+>|'[^<>']+'|[>:]| _g37), + GrammarToken("group", compileHighlightPattern("\\)"), alias: "punctuation"), + GrammarToken("quantifier", + compileHighlightPattern("(?:[+*?]|\\{\\d+(?:,\\d*)?\\})[?+]?"), + alias: "number"), + GrammarToken("alternation", compileHighlightPattern("\\|"), alias: "keyword"), +]); + +final Grammar _g34 = Grammar([ + GrammarToken("char-class-negation", compileHighlightPattern("(^\\[)\\^"), + lookbehind: true, alias: "operator"), + GrammarToken("char-class-punctuation", compileHighlightPattern("^\\[|\\]\$"), + alias: "punctuation"), + GrammarToken( + "range", + compileHighlightPattern( + "(?:[^\\\\-]|\\\\(?:x[\\da-fA-F]{2}|u[\\da-fA-F]{4}|u\\{[\\da-fA-F]+\\}|0[0-7]{0,2}|[123][0-7]{2}|c[a-zA-Z]|.))-(?:[^\\\\-]|\\\\(?:x[\\da-fA-F]{2}|u[\\da-fA-F]{4}|u\\{[\\da-fA-F]+\\}|0[0-7]{0,2}|[123][0-7]{2}|c[a-zA-Z]|.))"), + inside: () => _g35), + GrammarToken( + "special-escape", compileHighlightPattern("\\\\[\\\\(){}[\\]^\$+*?|.]"), + alias: "escape"), + GrammarToken( + "char-set", + compileHighlightPattern("\\\\[wsd]|\\\\p\\{[^{}]+\\}", + caseSensitive: false), + alias: "class-name"), + GrammarToken( + "escape", + compileHighlightPattern( + "\\\\(?:x[\\da-fA-F]{2}|u[\\da-fA-F]{4}|u\\{[\\da-fA-F]+\\}|0[0-7]{0,2}|[123][0-7]{2}|c[a-zA-Z]|.)")), +]); + +final Grammar _g35 = Grammar([ + GrammarToken( + "escape", + compileHighlightPattern( + "\\\\(?:x[\\da-fA-F]{2}|u[\\da-fA-F]{4}|u\\{[\\da-fA-F]+\\}|0[0-7]{0,2}|[123][0-7]{2}|c[a-zA-Z]|.)")), + GrammarToken("range-punctuation", compileHighlightPattern("-"), + alias: "operator"), +]); + +final Grammar _g36 = Grammar([ + GrammarToken("group-name", compileHighlightPattern("(<|')[^<>']+(?=[>']\$)"), + lookbehind: true, alias: "variable"), +]); + +final Grammar _g37 = Grammar([ + GrammarToken("group-name", compileHighlightPattern("(<|')[^<>']+(?=[>']\$)"), + lookbehind: true, alias: "variable"), +]); diff --git a/lib/highlight/julia.dart b/lib/highlight/julia.dart new file mode 100644 index 0000000..7d33557 --- /dev/null +++ b/lib/highlight/julia.dart @@ -0,0 +1,53 @@ +// GENERATED CODE — do not modify by hand. Regenerate with tool/highlight_codegen. +// ignore_for_file: lines_longer_than_80_chars, public_member_api_docs +// ignore_for_file: prefer_single_quotes, require_trailing_commas, directives_ordering + +import '../highlight.dart'; + +/// Syntax grammar for `julia`. +/// +/// Import this library only when you need `julia` highlighting; unused +/// languages are dropped from the build. +abstract final class HighlightJulia { + /// The grammar for `julia`. + static final Grammar grammar = _g0; +} + +final Grammar _g0 = Grammar([ + GrammarToken( + "comment", + compileHighlightPattern( + "(^|[^\\\\])(?:#=(?:[^#=]|=(?!#)|#(?!=)|#=(?:[^#=]|=(?!#)|#(?!=))*=#)*=#|#.*)"), + lookbehind: true), + GrammarToken("regex", + compileHighlightPattern("r\"(?:\\\\.|[^\"\\\\\\r\\n])*\"[imsx]{0,4}"), + greedy: true), + GrammarToken( + "string", + compileHighlightPattern( + "\"\"\"[\\s\\S]+?\"\"\"|(?:\\b\\w+)?\"(?:\\\\.|[^\"\\\\\\r\\n])*\"|`(?:[^\\\\`\\r\\n]|\\\\.)*`"), + greedy: true), + GrammarToken( + "char", + compileHighlightPattern( + "(^|[^\\w'])'(?:\\\\[^\\r\\n][^'\\r\\n]*|[^\\\\\\r\\n])'"), + lookbehind: true, + greedy: true), + GrammarToken( + "keyword", + compileHighlightPattern( + "\\b(?:abstract|baremodule|begin|bitstype|break|catch|ccall|const|continue|do|else|elseif|end|export|finally|for|function|global|if|immutable|import|importall|in|let|local|macro|module|print|println|quote|return|struct|try|type|typealias|using|while)\\b")), + GrammarToken("boolean", compileHighlightPattern("\\b(?:false|true)\\b")), + GrammarToken( + "number", + compileHighlightPattern( + "(?:\\b(?=\\d)|\\B(?=\\.))(?:0[box])?(?:[\\da-f]+(?:_[\\da-f]+)*(?:\\.(?:\\d+(?:_\\d+)*)?)?|\\.\\d+(?:_\\d+)*)(?:[efp][+-]?\\d+(?:_\\d+)*)?j?", + caseSensitive: false)), + GrammarToken( + "operator", + compileHighlightPattern( + "&&|\\|\\||[-+*^%÷⊻&\$\\\\]=?|\\/[\\/=]?|!=?=?|\\|[=>]?|<(?:<=?|[=:|])?|>(?:=|>>?=?)?|==?=?|[~≠≤≥'√∛]")), + GrammarToken("punctuation", compileHighlightPattern("::?|[{}[\\]();,.?]")), + GrammarToken("constant", + compileHighlightPattern("\\b(?:(?:Inf|NaN)(?:16|32|64)?|im|pi)\\b|[πℯ]")), +]); diff --git a/lib/highlight/kotlin.dart b/lib/highlight/kotlin.dart new file mode 100644 index 0000000..2d6ec8b --- /dev/null +++ b/lib/highlight/kotlin.dart @@ -0,0 +1,93 @@ +// GENERATED CODE — do not modify by hand. Regenerate with tool/highlight_codegen. +// ignore_for_file: lines_longer_than_80_chars, public_member_api_docs +// ignore_for_file: prefer_single_quotes, require_trailing_commas, directives_ordering + +import '../highlight.dart'; + +/// Syntax grammar for `kotlin`. +/// +/// Import this library only when you need `kotlin` highlighting; unused +/// languages are dropped from the build. +abstract final class HighlightKotlin { + /// The grammar for `kotlin`. + static final Grammar grammar = _g0; +} + +final Grammar _g0 = Grammar([ + GrammarToken("comment", + compileHighlightPattern("(^|[^\\\\])\\/\\*[\\s\\S]*?(?:\\*\\/|\$)"), + lookbehind: true, greedy: true), + GrammarToken("comment", compileHighlightPattern("(^|[^\\\\:])\\/\\/.*"), + lookbehind: true, greedy: true), + GrammarToken( + "string-literal", + compileHighlightPattern( + "\"\"\"(?:[^\$]|\\\$(?:(?!\\{)|\\{[^{}]*\\}))*?\"\"\""), + alias: "multiline", + inside: () => _g1), + GrammarToken( + "string-literal", + compileHighlightPattern( + "\"(?:[^\"\\\\\\r\\n\$]|\\\\.|\\\$(?:(?!\\{)|\\{[^{}]*\\}))*\""), + alias: "singleline", + inside: () => _g3), + GrammarToken( + "char", + compileHighlightPattern( + "'(?:[^'\\\\\\r\\n]|\\\\(?:.|u[a-fA-F0-9]{0,4}))'"), + greedy: true), + GrammarToken("annotation", + compileHighlightPattern("\\B@(?:\\w+:)?(?:[A-Z]\\w*|\\[[^\\]]+\\])"), + alias: "builtin"), + GrammarToken( + "keyword", + compileHighlightPattern( + "(^|[^.])\\b(?:abstract|actual|annotation|as|break|by|catch|class|companion|const|constructor|continue|crossinline|data|do|dynamic|else|enum|expect|external|final|finally|for|fun|get|if|import|in|infix|init|inline|inner|interface|internal|is|lateinit|noinline|null|object|open|operator|out|override|package|private|protected|public|reified|return|sealed|set|super|suspend|tailrec|this|throw|to|try|typealias|val|var|vararg|when|where|while)\\b"), + lookbehind: true), + GrammarToken("boolean", compileHighlightPattern("\\b(?:false|true)\\b")), + GrammarToken("label", compileHighlightPattern("\\b\\w+@|@\\w+\\b"), + alias: "symbol"), + GrammarToken("function", + compileHighlightPattern("(?:`[^\\r\\n`]+`|\\b\\w+)(?=\\s*\\()"), + greedy: true), + GrammarToken("function", + compileHighlightPattern("(\\.)(?:`[^\\r\\n`]+`|\\w+)(?=\\s*\\{)"), + lookbehind: true, greedy: true), + GrammarToken( + "number", + compileHighlightPattern( + "\\b(?:0[xX][\\da-fA-F]+(?:_[\\da-fA-F]+)*|0[bB][01]+(?:_[01]+)*|\\d+(?:_\\d+)*(?:\\.\\d+(?:_\\d+)*)?(?:[eE][+-]?\\d+(?:_\\d+)*)?[fFL]?)\\b")), + GrammarToken( + "operator", + compileHighlightPattern( + "\\+[+=]?|-[-=>]?|==?=?|!(?:!|==?)?|[\\/*%<>]=?|[?:]:?|\\.\\.|&&|\\|\\||\\b(?:and|inv|or|shl|shr|ushr|xor)\\b")), + GrammarToken("punctuation", compileHighlightPattern("[{}[\\];(),.:]")), +]); + +final Grammar _g1 = Grammar([ + GrammarToken( + "interpolation", + compileHighlightPattern("\\\$(?:[a-z_]\\w*|\\{[^{}]*\\})", + caseSensitive: false), + inside: () => _g2), + GrammarToken("string", compileHighlightPattern("[\\s\\S]+")), +]); + +final Grammar _g2 = Grammar([ + GrammarToken( + "interpolation-punctuation", compileHighlightPattern("^\\\$\\{?|\\}\$"), + alias: "punctuation"), + GrammarToken("expression", compileHighlightPattern("[\\s\\S]+"), + inside: () => _g0), +]); + +final Grammar _g3 = Grammar([ + GrammarToken( + "interpolation", + compileHighlightPattern( + "((?:^|[^\\\\])(?:\\\\{2})*)\\\$(?:[a-z_]\\w*|\\{[^{}]*\\})", + caseSensitive: false), + lookbehind: true, + inside: () => _g2), + GrammarToken("string", compileHighlightPattern("[\\s\\S]+")), +]); diff --git a/lib/highlight/latex.dart b/lib/highlight/latex.dart new file mode 100644 index 0000000..ae3cddf --- /dev/null +++ b/lib/highlight/latex.dart @@ -0,0 +1,63 @@ +// GENERATED CODE — do not modify by hand. Regenerate with tool/highlight_codegen. +// ignore_for_file: lines_longer_than_80_chars, public_member_api_docs +// ignore_for_file: prefer_single_quotes, require_trailing_commas, directives_ordering + +import '../highlight.dart'; + +/// Syntax grammar for `latex`. +/// +/// Import this library only when you need `latex` highlighting; unused +/// languages are dropped from the build. +abstract final class HighlightLatex { + /// The grammar for `latex`. + static final Grammar grammar = _g0; +} + +final Grammar _g0 = Grammar([ + GrammarToken("comment", compileHighlightPattern("%.*")), + GrammarToken( + "cdata", + compileHighlightPattern( + "(\\\\begin\\{((?:lstlisting|verbatim)\\*?)\\})[\\s\\S]*?(?=\\\\end\\{\\2\\})"), + lookbehind: true), + GrammarToken( + "equation", + compileHighlightPattern( + "\\\$\\\$(?:\\\\[\\s\\S]|[^\\\\\$])+\\\$\\\$|\\\$(?:\\\\[\\s\\S]|[^\\\\\$])+\\\$|\\\\\\([\\s\\S]*?\\\\\\)|\\\\\\[[\\s\\S]*?\\\\\\]"), + alias: "string", + inside: () => _g1), + GrammarToken( + "equation", + compileHighlightPattern( + "(\\\\begin\\{((?:align|eqnarray|equation|gather|math|multline)\\*?)\\})[\\s\\S]*?(?=\\\\end\\{\\2\\})"), + lookbehind: true, + alias: "string", + inside: () => _g1), + GrammarToken( + "keyword", + compileHighlightPattern( + "(\\\\(?:begin|cite|documentclass|end|label|ref|usepackage)(?:\\[[^\\]]+\\])?\\{)[^}]+(?=\\})"), + lookbehind: true), + GrammarToken("url", compileHighlightPattern("(\\\\url\\{)[^}]+(?=\\})"), + lookbehind: true), + GrammarToken( + "headline", + compileHighlightPattern( + "(\\\\(?:chapter|frametitle|paragraph|part|section|subparagraph|subsection|subsubparagraph|subsubsection|subsubsubparagraph)\\*?(?:\\[[^\\]]+\\])?\\{)[^}]+(?=\\})"), + lookbehind: true, + alias: "class-name"), + GrammarToken( + "function", + compileHighlightPattern("\\\\(?:[^a-z()[\\]]|[a-z*]+)", + caseSensitive: false), + alias: "selector"), + GrammarToken("punctuation", compileHighlightPattern("[[\\]{}&]")), +]); + +final Grammar _g1 = Grammar([ + GrammarToken( + "equation-command", + compileHighlightPattern("\\\\(?:[^a-z()[\\]]|[a-z*]+)", + caseSensitive: false), + alias: "regex"), +]); diff --git a/lib/highlight/less.dart b/lib/highlight/less.dart new file mode 100644 index 0000000..0ed786c --- /dev/null +++ b/lib/highlight/less.dart @@ -0,0 +1,82 @@ +// GENERATED CODE — do not modify by hand. Regenerate with tool/highlight_codegen. +// ignore_for_file: lines_longer_than_80_chars, public_member_api_docs +// ignore_for_file: prefer_single_quotes, require_trailing_commas, directives_ordering + +import '../highlight.dart'; + +/// Syntax grammar for `less`. +/// +/// Import this library only when you need `less` highlighting; unused +/// languages are dropped from the build. +abstract final class HighlightLess { + /// The grammar for `less`. + static final Grammar grammar = _g0; +} + +final Grammar _g0 = Grammar([ + GrammarToken("comment", compileHighlightPattern("\\/\\*[\\s\\S]*?\\*\\/")), + GrammarToken("comment", compileHighlightPattern("(^|[^\\\\])\\/\\/.*"), + lookbehind: true), + GrammarToken( + "atrule", + compileHighlightPattern( + "@[\\w-](?:\\((?:[^(){}]|\\([^(){}]*\\))*\\)|[^(){};\\s]|\\s+(?!\\s))*?(?=\\s*\\{)"), + inside: () => _g1), + GrammarToken( + "url", + compileHighlightPattern( + "\\burl\\((?:(?:\"(?:\\\\(?:\\r\\n|[\\s\\S])|[^\"\\\\\\r\\n])*\"|'(?:\\\\(?:\\r\\n|[\\s\\S])|[^'\\\\\\r\\n])*')|(?:[^\\\\\\r\\n()\"']|\\\\[\\s\\S])*)\\)", + caseSensitive: false), + greedy: true, + inside: () => _g2), + GrammarToken( + "selector", + compileHighlightPattern( + "(?:@\\{[\\w-]+\\}|[^{};\\s@])(?:@\\{[\\w-]+\\}|\\((?:[^(){}]|\\([^(){}]*\\))*\\)|[^(){};@\\s]|\\s+(?!\\s))*?(?=\\s*\\{)"), + inside: () => _g3), + GrammarToken( + "string", + compileHighlightPattern( + "(?:\"(?:\\\\(?:\\r\\n|[\\s\\S])|[^\"\\\\\\r\\n])*\"|'(?:\\\\(?:\\r\\n|[\\s\\S])|[^'\\\\\\r\\n])*')"), + greedy: true), + GrammarToken("variable", compileHighlightPattern("@[\\w-]+\\s*:"), + inside: () => _g4), + GrammarToken("variable", compileHighlightPattern("@@?[\\w-]+")), + GrammarToken("mixin-usage", + compileHighlightPattern("([{;]\\s*)[.#](?!\\d)[\\w-].*?(?=[(;])"), + lookbehind: true, alias: "function"), + GrammarToken("property", + compileHighlightPattern("(?:@\\{[\\w-]+\\}|[\\w-])+(?:\\+_?)?(?=\\s*:)")), + GrammarToken("important", + compileHighlightPattern("!important\\b", caseSensitive: false)), + GrammarToken( + "function", + compileHighlightPattern("(^|[^-a-z0-9])[-a-z0-9]+(?=\\()", + caseSensitive: false), + lookbehind: true), + GrammarToken("punctuation", compileHighlightPattern("[(){};:,]")), + GrammarToken("operator", compileHighlightPattern("[+\\-*\\/]")), +]); + +final Grammar _g1 = Grammar([ + GrammarToken("punctuation", compileHighlightPattern("[:()]")), +]); + +final Grammar _g2 = Grammar([ + GrammarToken( + "function", compileHighlightPattern("^url", caseSensitive: false)), + GrammarToken("punctuation", compileHighlightPattern("^\\(|\\)\$")), + GrammarToken( + "string", + compileHighlightPattern( + "^(?:\"(?:\\\\(?:\\r\\n|[\\s\\S])|[^\"\\\\\\r\\n])*\"|'(?:\\\\(?:\\r\\n|[\\s\\S])|[^'\\\\\\r\\n])*')\$"), + alias: "url"), +]); + +final Grammar _g3 = Grammar([ + GrammarToken("variable", compileHighlightPattern("@+[\\w-]+")), +]); + +final Grammar _g4 = Grammar([ + GrammarToken("punctuation", compileHighlightPattern(":")), +]); diff --git a/lib/highlight/lua.dart b/lib/highlight/lua.dart new file mode 100644 index 0000000..c296cb0 --- /dev/null +++ b/lib/highlight/lua.dart @@ -0,0 +1,43 @@ +// GENERATED CODE — do not modify by hand. Regenerate with tool/highlight_codegen. +// ignore_for_file: lines_longer_than_80_chars, public_member_api_docs +// ignore_for_file: prefer_single_quotes, require_trailing_commas, directives_ordering + +import '../highlight.dart'; + +/// Syntax grammar for `lua`. +/// +/// Import this library only when you need `lua` highlighting; unused +/// languages are dropped from the build. +abstract final class HighlightLua { + /// The grammar for `lua`. + static final Grammar grammar = _g0; +} + +final Grammar _g0 = Grammar([ + GrammarToken( + "comment", + compileHighlightPattern("^#!.+|--(?:\\[(=*)\\[[\\s\\S]*?\\]\\1\\]|.*)", + multiLine: true)), + GrammarToken( + "string", + compileHighlightPattern( + "([\"'])(?:(?!\\1)[^\\\\\\r\\n]|\\\\z(?:\\r\\n|\\s)|\\\\(?:\\r\\n|[^z]))*\\1|\\[(=*)\\[[\\s\\S]*?\\]\\2\\]"), + greedy: true), + GrammarToken( + "number", + compileHighlightPattern( + "\\b0x[a-f\\d]+(?:\\.[a-f\\d]*)?(?:p[+-]?\\d+)?\\b|\\b\\d+(?:\\.\\B|(?:\\.\\d*)?(?:e[+-]?\\d+)?\\b)|\\B\\.\\d+(?:e[+-]?\\d+)?\\b", + caseSensitive: false)), + GrammarToken( + "keyword", + compileHighlightPattern( + "\\b(?:and|break|do|else|elseif|end|false|for|function|goto|if|in|local|nil|not|or|repeat|return|then|true|until|while)\\b")), + GrammarToken( + "function", compileHighlightPattern("(?!\\d)\\w+(?=\\s*(?:[({]))")), + GrammarToken("operator", + compileHighlightPattern("[-+*%^&|#]|\\/\\/?|<[<=]?|>[>=]?|[=~]=?")), + GrammarToken("operator", compileHighlightPattern("(^|[^.])\\.\\.(?!\\.)"), + lookbehind: true), + GrammarToken( + "punctuation", compileHighlightPattern("[\\[\\](){},;]|\\.+|:+")), +]); diff --git a/lib/highlight/makefile.dart b/lib/highlight/makefile.dart new file mode 100644 index 0000000..576a0d5 --- /dev/null +++ b/lib/highlight/makefile.dart @@ -0,0 +1,56 @@ +// GENERATED CODE — do not modify by hand. Regenerate with tool/highlight_codegen. +// ignore_for_file: lines_longer_than_80_chars, public_member_api_docs +// ignore_for_file: prefer_single_quotes, require_trailing_commas, directives_ordering + +import '../highlight.dart'; + +/// Syntax grammar for `makefile`. +/// +/// Import this library only when you need `makefile` highlighting; unused +/// languages are dropped from the build. +abstract final class HighlightMakefile { + /// The grammar for `makefile`. + static final Grammar grammar = _g0; +} + +final Grammar _g0 = Grammar([ + GrammarToken( + "comment", + compileHighlightPattern( + "(^|[^\\\\])#(?:\\\\(?:\\r\\n|[\\s\\S])|[^\\\\\\r\\n])*"), + lookbehind: true), + GrammarToken( + "string", + compileHighlightPattern( + "([\"'])(?:\\\\(?:\\r\\n|[\\s\\S])|(?!\\1)[^\\\\\\r\\n])*\\1"), + greedy: true), + GrammarToken("builtin-target", + compileHighlightPattern("\\.[A-Z][^:#=\\s]+(?=\\s*:(?!=))"), + alias: "builtin"), + GrammarToken( + "target", + compileHighlightPattern("^(?:[^:=\\s]|[ \\t]+(?![\\s:]))+(?=\\s*:(?!=))", + multiLine: true), + alias: "symbol", + inside: () => _g1), + GrammarToken( + "variable", + compileHighlightPattern( + "\\\$+(?:(?!\\\$)[^(){}:#=\\s]+|\\([@*%<^+?][DF]\\)|(?=[({]))")), + GrammarToken( + "keyword", + compileHighlightPattern( + "-include\\b|\\b(?:define|else|endef|endif|export|ifn?def|ifn?eq|include|override|private|sinclude|undefine|unexport|vpath)\\b")), + GrammarToken( + "function", + compileHighlightPattern( + "(\\()(?:abspath|addsuffix|and|basename|call|dir|error|eval|file|filter(?:-out)?|findstring|firstword|flavor|foreach|guile|if|info|join|lastword|load|notdir|or|origin|patsubst|realpath|shell|sort|strip|subst|suffix|value|warning|wildcard|word(?:list|s)?)(?=[ \\t])"), + lookbehind: true), + GrammarToken("operator", compileHighlightPattern("(?:::|[?:+!])?=|[|@]")), + GrammarToken("punctuation", compileHighlightPattern("[:;(){}]")), +]); + +final Grammar _g1 = Grammar([ + GrammarToken("variable", + compileHighlightPattern("\\\$+(?:(?!\\\$)[^(){}:#=\\s]+|(?=[({]))")), +]); diff --git a/lib/highlight/markdown.dart b/lib/highlight/markdown.dart new file mode 100644 index 0000000..f67e546 --- /dev/null +++ b/lib/highlight/markdown.dart @@ -0,0 +1,801 @@ +// GENERATED CODE — do not modify by hand. Regenerate with tool/highlight_codegen. +// ignore_for_file: lines_longer_than_80_chars, public_member_api_docs +// ignore_for_file: prefer_single_quotes, require_trailing_commas, directives_ordering + +import '../highlight.dart'; +import 'yaml.dart'; + +/// Syntax grammar for `markdown`. +/// +/// Import this library only when you need `markdown` highlighting; unused +/// languages are dropped from the build. +abstract final class HighlightMarkdown { + /// The grammar for `markdown`. + static final Grammar grammar = _g0; +} + +final Grammar _g0 = Grammar([ + GrammarToken( + "comment", compileHighlightPattern(""), + greedy: true), + GrammarToken( + "front-matter-block", + compileHighlightPattern( + "(^(?:\\s*[\\r\\n])?)---(?!.)[\\s\\S]*?[\\r\\n]---(?!.)"), + lookbehind: true, + greedy: true, + inside: () => _g1), + GrammarToken( + "blockquote", compileHighlightPattern("^>(?:[\\t ]*>)*", multiLine: true), + alias: "punctuation"), + GrammarToken( + "table", + compileHighlightPattern( + "^\\|?(?:\\\\.|``(?:[^`\\r\\n]|`(?!`))+``|`[^`\\r\\n]+`|[^\\\\|\\r\\n`])+(?:\\|(?:\\\\.|``(?:[^`\\r\\n]|`(?!`))+``|`[^`\\r\\n]+`|[^\\\\|\\r\\n`])+)+\\|?(?:(?:\\n|\\r\\n?)|(?![\\s\\S]))\\|?[ \\t]*:?-{3,}:?[ \\t]*(?:\\|[ \\t]*:?-{3,}:?[ \\t]*)+\\|?(?:\\n|\\r\\n?)(?:\\|?(?:\\\\.|``(?:[^`\\r\\n]|`(?!`))+``|`[^`\\r\\n]+`|[^\\\\|\\r\\n`])+(?:\\|(?:\\\\.|``(?:[^`\\r\\n]|`(?!`))+``|`[^`\\r\\n]+`|[^\\\\|\\r\\n`])+)+\\|?(?:(?:\\n|\\r\\n?)|(?![\\s\\S])))*", + multiLine: true), + inside: () => _g2), + GrammarToken( + "code", + compileHighlightPattern( + "((?:^|\\n)[ \\t]*\\n|(?:^|\\r\\n?)[ \\t]*\\r\\n?)(?: {4}|\\t).+(?:(?:\\n|\\r\\n?)(?: {4}|\\t).+)*"), + lookbehind: true, + alias: "keyword"), + GrammarToken( + "code", compileHighlightPattern("^```[\\s\\S]*?^```\$", multiLine: true), + greedy: true, inside: () => _g6), + GrammarToken( + "title", + compileHighlightPattern("\\S.*(?:\\n|\\r\\n?)(?:==+|--+)(?=[ \\t]*\$)", + multiLine: true), + alias: "important", + inside: () => _g7), + GrammarToken("title", compileHighlightPattern("(^\\s*)#.+", multiLine: true), + lookbehind: true, alias: "important", inside: () => _g8), + GrammarToken( + "hr", + compileHighlightPattern("(^\\s*)([*-])(?:[\\t ]*\\2){2,}(?=\\s*\$)", + multiLine: true), + lookbehind: true, + alias: "punctuation"), + GrammarToken( + "list", + compileHighlightPattern("(^\\s*)(?:[*+-]|\\d+\\.)(?=[\\t ].)", + multiLine: true), + lookbehind: true, + alias: "punctuation"), + GrammarToken( + "url-reference", + compileHighlightPattern( + "!?\\[[^\\]]+\\]:[\\t ]+(?:\\S+|<(?:\\\\.|[^>\\\\])+>)(?:[\\t ]+(?:\"(?:\\\\.|[^\"\\\\])*\"|'(?:\\\\.|[^'\\\\])*'|\\((?:\\\\.|[^)\\\\])*\\)))?"), + alias: "url", + inside: () => _g9), + GrammarToken( + "bold", + compileHighlightPattern( + "((?:^|[^\\\\])(?:\\\\{2})*)(?:\\b__(?:(?!_)(?:\\\\.|[^\\\\\\n\\r]|(?:\\n|\\r\\n?)(?![\\r\\n]))|_(?:(?!_)(?:\\\\.|[^\\\\\\n\\r]|(?:\\n|\\r\\n?)(?![\\r\\n])))+_)+__\\b|\\*\\*(?:(?!\\*)(?:\\\\.|[^\\\\\\n\\r]|(?:\\n|\\r\\n?)(?![\\r\\n]))|\\*(?:(?!\\*)(?:\\\\.|[^\\\\\\n\\r]|(?:\\n|\\r\\n?)(?![\\r\\n])))+\\*)+\\*\\*)"), + lookbehind: true, + greedy: true, + inside: () => _g10), + GrammarToken( + "italic", + compileHighlightPattern( + "((?:^|[^\\\\])(?:\\\\{2})*)(?:\\b_(?:(?!_)(?:\\\\.|[^\\\\\\n\\r]|(?:\\n|\\r\\n?)(?![\\r\\n]))|__(?:(?!_)(?:\\\\.|[^\\\\\\n\\r]|(?:\\n|\\r\\n?)(?![\\r\\n])))+__)+_\\b|\\*(?:(?!\\*)(?:\\\\.|[^\\\\\\n\\r]|(?:\\n|\\r\\n?)(?![\\r\\n]))|\\*\\*(?:(?!\\*)(?:\\\\.|[^\\\\\\n\\r]|(?:\\n|\\r\\n?)(?![\\r\\n])))+\\*\\*)+\\*)"), + lookbehind: true, + greedy: true, + inside: () => _g14), + GrammarToken( + "strike", + compileHighlightPattern( + "((?:^|[^\\\\])(?:\\\\{2})*)(?:(~~?)(?:(?!~)(?:\\\\.|[^\\\\\\n\\r]|(?:\\n|\\r\\n?)(?![\\r\\n])))+\\2)"), + lookbehind: true, + greedy: true, + inside: () => _g16), + GrammarToken( + "code-snippet", + compileHighlightPattern( + "(^|[^\\\\`])(?:``[^`\\r\\n]+(?:`[^`\\r\\n]+)*``(?!`)|`[^`\\r\\n]+`(?!`))"), + lookbehind: true, + greedy: true, + alias: "code"), + GrammarToken( + "url", + compileHighlightPattern( + "((?:^|[^\\\\])(?:\\\\{2})*)(?:!?\\[(?:(?!\\])(?:\\\\.|[^\\\\\\n\\r]|(?:\\n|\\r\\n?)(?![\\r\\n])))+\\](?:\\([^\\s)]+(?:[\\t ]+\"(?:\\\\.|[^\"\\\\])*\")?\\)|[ \\t]?\\[(?:(?!\\])(?:\\\\.|[^\\\\\\n\\r]|(?:\\n|\\r\\n?)(?![\\r\\n])))+\\]))"), + lookbehind: true, + greedy: true, + inside: () => _g12), + GrammarToken("prolog", compileHighlightPattern("<\\?[\\s\\S]+?\\?>"), + greedy: true), + GrammarToken( + "doctype", + compileHighlightPattern( + "\"'[\\]]|\"[^\"]*\"|'[^']*')+(?:\\[(?:[^<\"'\\]]|\"[^\"]*\"|'[^']*'|<(?!!--)|)*\\]\\s*)?>", + caseSensitive: false), + greedy: true, + inside: () => _g18), + GrammarToken( + "style", + compileHighlightPattern( + "(]*>)(?:))*\\]\\]>|(?!)", + caseSensitive: false), + lookbehind: true, + greedy: true, + inside: () => _g19), + GrammarToken( + "script", + compileHighlightPattern( + "(]*>)(?:))*\\]\\]>|(?!)", + caseSensitive: false), + lookbehind: true, + greedy: true, + inside: () => _g24), + GrammarToken( + "cdata", + compileHighlightPattern("", + caseSensitive: false), + greedy: true), + GrammarToken( + "tag", + compileHighlightPattern( + "<\\/?(?!\\d)[^\\s>\\/=\$<%]+(?:\\s(?:\\s*[^\\s>\\/=]+(?:\\s*=\\s*(?:\"[^\"]*\"|'[^']*'|[^\\s'\">=]+(?=[\\s>]))|(?=[\\s/>])))+)?\\s*\\/?>"), + greedy: true, + inside: () => _g36), + GrammarToken("entity", + compileHighlightPattern("&[\\da-z]{1,8};", caseSensitive: false), + alias: "named-entity"), + GrammarToken("entity", + compileHighlightPattern("&#x?[\\da-f]{1,8};", caseSensitive: false)), +]); + +final Grammar _g1 = Grammar([ + GrammarToken("punctuation", compileHighlightPattern("^---|---\$")), + GrammarToken("front-matter", compileHighlightPattern("\\S+(?:\\s+\\S+)*"), + alias: "yaml", inside: () => HighlightYaml.grammar), +]); + +final Grammar _g2 = Grammar([ + GrammarToken( + "table-data-rows", + compileHighlightPattern( + "^(\\|?(?:\\\\.|``(?:[^`\\r\\n]|`(?!`))+``|`[^`\\r\\n]+`|[^\\\\|\\r\\n`])+(?:\\|(?:\\\\.|``(?:[^`\\r\\n]|`(?!`))+``|`[^`\\r\\n]+`|[^\\\\|\\r\\n`])+)+\\|?(?:(?:\\n|\\r\\n?)|(?![\\s\\S]))\\|?[ \\t]*:?-{3,}:?[ \\t]*(?:\\|[ \\t]*:?-{3,}:?[ \\t]*)+\\|?(?:\\n|\\r\\n?))(?:\\|?(?:\\\\.|``(?:[^`\\r\\n]|`(?!`))+``|`[^`\\r\\n]+`|[^\\\\|\\r\\n`])+(?:\\|(?:\\\\.|``(?:[^`\\r\\n]|`(?!`))+``|`[^`\\r\\n]+`|[^\\\\|\\r\\n`])+)+\\|?(?:(?:\\n|\\r\\n?)|(?![\\s\\S])))*\$"), + lookbehind: true, + inside: () => _g3), + GrammarToken( + "table-line", + compileHighlightPattern( + "^(\\|?(?:\\\\.|``(?:[^`\\r\\n]|`(?!`))+``|`[^`\\r\\n]+`|[^\\\\|\\r\\n`])+(?:\\|(?:\\\\.|``(?:[^`\\r\\n]|`(?!`))+``|`[^`\\r\\n]+`|[^\\\\|\\r\\n`])+)+\\|?(?:(?:\\n|\\r\\n?)|(?![\\s\\S])))\\|?[ \\t]*:?-{3,}:?[ \\t]*(?:\\|[ \\t]*:?-{3,}:?[ \\t]*)+\\|?(?:\\n|\\r\\n?)\$"), + lookbehind: true, + inside: () => _g4), + GrammarToken( + "table-header-row", + compileHighlightPattern( + "^\\|?(?:\\\\.|``(?:[^`\\r\\n]|`(?!`))+``|`[^`\\r\\n]+`|[^\\\\|\\r\\n`])+(?:\\|(?:\\\\.|``(?:[^`\\r\\n]|`(?!`))+``|`[^`\\r\\n]+`|[^\\\\|\\r\\n`])+)+\\|?(?:(?:\\n|\\r\\n?)|(?![\\s\\S]))\$"), + inside: () => _g5), +]); + +final Grammar _g3 = Grammar([ + GrammarToken( + "table-data", + compileHighlightPattern( + "(?:\\\\.|``(?:[^`\\r\\n]|`(?!`))+``|`[^`\\r\\n]+`|[^\\\\|\\r\\n`])+"), + inside: () => _g0), + GrammarToken("punctuation", compileHighlightPattern("\\|")), +]); + +final Grammar _g4 = Grammar([ + GrammarToken("punctuation", compileHighlightPattern("\\||:?-{3,}:?")), +]); + +final Grammar _g5 = Grammar([ + GrammarToken( + "table-header", + compileHighlightPattern( + "(?:\\\\.|``(?:[^`\\r\\n]|`(?!`))+``|`[^`\\r\\n]+`|[^\\\\|\\r\\n`])+"), + alias: "important", + inside: () => _g0), + GrammarToken("punctuation", compileHighlightPattern("\\|")), +]); + +final Grammar _g6 = Grammar([ + GrammarToken( + "code-block", + compileHighlightPattern( + "^(```.*(?:\\n|\\r\\n?))[\\s\\S]+?(?=(?:\\n|\\r\\n?)^```\$)", + multiLine: true), + lookbehind: true), + GrammarToken("code-language", compileHighlightPattern("^(```).+"), + lookbehind: true), + GrammarToken("punctuation", compileHighlightPattern("```")), +]); + +final Grammar _g7 = Grammar([ + GrammarToken("punctuation", compileHighlightPattern("==+\$|--+\$")), +]); + +final Grammar _g8 = Grammar([ + GrammarToken("punctuation", compileHighlightPattern("^#+|#+\$")), +]); + +final Grammar _g9 = Grammar([ + GrammarToken("variable", compileHighlightPattern("^(!?\\[)[^\\]]+"), + lookbehind: true), + GrammarToken( + "string", + compileHighlightPattern( + "(?:\"(?:\\\\.|[^\"\\\\])*\"|'(?:\\\\.|[^'\\\\])*'|\\((?:\\\\.|[^)\\\\])*\\))\$")), + GrammarToken("punctuation", compileHighlightPattern("^[\\[\\]!:]|[<>]")), +]); + +final Grammar _g10 = Grammar([ + GrammarToken("content", compileHighlightPattern("(^..)[\\s\\S]+(?=..\$)"), + lookbehind: true, inside: () => _g11), + GrammarToken("punctuation", compileHighlightPattern("\\*\\*|__")), +]); + +final Grammar _g11 = Grammar([ + GrammarToken( + "url", + compileHighlightPattern( + "((?:^|[^\\\\])(?:\\\\{2})*)(?:!?\\[(?:(?!\\])(?:\\\\.|[^\\\\\\n\\r]|(?:\\n|\\r\\n?)(?![\\r\\n])))+\\](?:\\([^\\s)]+(?:[\\t ]+\"(?:\\\\.|[^\"\\\\])*\")?\\)|[ \\t]?\\[(?:(?!\\])(?:\\\\.|[^\\\\\\n\\r]|(?:\\n|\\r\\n?)(?![\\r\\n])))+\\]))"), + lookbehind: true, + greedy: true, + inside: () => _g12), + GrammarToken( + "italic", + compileHighlightPattern( + "((?:^|[^\\\\])(?:\\\\{2})*)(?:\\b_(?:(?!_)(?:\\\\.|[^\\\\\\n\\r]|(?:\\n|\\r\\n?)(?![\\r\\n]))|__(?:(?!_)(?:\\\\.|[^\\\\\\n\\r]|(?:\\n|\\r\\n?)(?![\\r\\n])))+__)+_\\b|\\*(?:(?!\\*)(?:\\\\.|[^\\\\\\n\\r]|(?:\\n|\\r\\n?)(?![\\r\\n]))|\\*\\*(?:(?!\\*)(?:\\\\.|[^\\\\\\n\\r]|(?:\\n|\\r\\n?)(?![\\r\\n])))+\\*\\*)+\\*)"), + lookbehind: true, + greedy: true, + inside: () => _g14), + GrammarToken( + "strike", + compileHighlightPattern( + "((?:^|[^\\\\])(?:\\\\{2})*)(?:(~~?)(?:(?!~)(?:\\\\.|[^\\\\\\n\\r]|(?:\\n|\\r\\n?)(?![\\r\\n])))+\\2)"), + lookbehind: true, + greedy: true, + inside: () => _g16), + GrammarToken( + "code-snippet", + compileHighlightPattern( + "(^|[^\\\\`])(?:``[^`\\r\\n]+(?:`[^`\\r\\n]+)*``(?!`)|`[^`\\r\\n]+`(?!`))"), + lookbehind: true, + greedy: true, + alias: "code"), +]); + +final Grammar _g12 = Grammar([ + GrammarToken("operator", compileHighlightPattern("^!")), + GrammarToken("content", compileHighlightPattern("(^\\[)[^\\]]+(?=\\])"), + lookbehind: true, inside: () => _g13), + GrammarToken( + "variable", compileHighlightPattern("(^\\][ \\t]?\\[)[^\\]]+(?=\\]\$)"), + lookbehind: true), + GrammarToken("url", compileHighlightPattern("(^\\]\\()[^\\s)]+"), + lookbehind: true), + GrammarToken("string", + compileHighlightPattern("(^[ \\t]+)\"(?:\\\\.|[^\"\\\\])*\"(?=\\)\$)"), + lookbehind: true), +]); + +final Grammar _g13 = Grammar([ + GrammarToken( + "bold", + compileHighlightPattern( + "((?:^|[^\\\\])(?:\\\\{2})*)(?:\\b__(?:(?!_)(?:\\\\.|[^\\\\\\n\\r]|(?:\\n|\\r\\n?)(?![\\r\\n]))|_(?:(?!_)(?:\\\\.|[^\\\\\\n\\r]|(?:\\n|\\r\\n?)(?![\\r\\n])))+_)+__\\b|\\*\\*(?:(?!\\*)(?:\\\\.|[^\\\\\\n\\r]|(?:\\n|\\r\\n?)(?![\\r\\n]))|\\*(?:(?!\\*)(?:\\\\.|[^\\\\\\n\\r]|(?:\\n|\\r\\n?)(?![\\r\\n])))+\\*)+\\*\\*)"), + lookbehind: true, + greedy: true, + inside: () => _g10), + GrammarToken( + "italic", + compileHighlightPattern( + "((?:^|[^\\\\])(?:\\\\{2})*)(?:\\b_(?:(?!_)(?:\\\\.|[^\\\\\\n\\r]|(?:\\n|\\r\\n?)(?![\\r\\n]))|__(?:(?!_)(?:\\\\.|[^\\\\\\n\\r]|(?:\\n|\\r\\n?)(?![\\r\\n])))+__)+_\\b|\\*(?:(?!\\*)(?:\\\\.|[^\\\\\\n\\r]|(?:\\n|\\r\\n?)(?![\\r\\n]))|\\*\\*(?:(?!\\*)(?:\\\\.|[^\\\\\\n\\r]|(?:\\n|\\r\\n?)(?![\\r\\n])))+\\*\\*)+\\*)"), + lookbehind: true, + greedy: true, + inside: () => _g14), + GrammarToken( + "strike", + compileHighlightPattern( + "((?:^|[^\\\\])(?:\\\\{2})*)(?:(~~?)(?:(?!~)(?:\\\\.|[^\\\\\\n\\r]|(?:\\n|\\r\\n?)(?![\\r\\n])))+\\2)"), + lookbehind: true, + greedy: true, + inside: () => _g16), + GrammarToken( + "code-snippet", + compileHighlightPattern( + "(^|[^\\\\`])(?:``[^`\\r\\n]+(?:`[^`\\r\\n]+)*``(?!`)|`[^`\\r\\n]+`(?!`))"), + lookbehind: true, + greedy: true, + alias: "code"), +]); + +final Grammar _g14 = Grammar([ + GrammarToken("content", compileHighlightPattern("(^.)[\\s\\S]+(?=.\$)"), + lookbehind: true, inside: () => _g15), + GrammarToken("punctuation", compileHighlightPattern("[*_]")), +]); + +final Grammar _g15 = Grammar([ + GrammarToken( + "url", + compileHighlightPattern( + "((?:^|[^\\\\])(?:\\\\{2})*)(?:!?\\[(?:(?!\\])(?:\\\\.|[^\\\\\\n\\r]|(?:\\n|\\r\\n?)(?![\\r\\n])))+\\](?:\\([^\\s)]+(?:[\\t ]+\"(?:\\\\.|[^\"\\\\])*\")?\\)|[ \\t]?\\[(?:(?!\\])(?:\\\\.|[^\\\\\\n\\r]|(?:\\n|\\r\\n?)(?![\\r\\n])))+\\]))"), + lookbehind: true, + greedy: true, + inside: () => _g12), + GrammarToken( + "bold", + compileHighlightPattern( + "((?:^|[^\\\\])(?:\\\\{2})*)(?:\\b__(?:(?!_)(?:\\\\.|[^\\\\\\n\\r]|(?:\\n|\\r\\n?)(?![\\r\\n]))|_(?:(?!_)(?:\\\\.|[^\\\\\\n\\r]|(?:\\n|\\r\\n?)(?![\\r\\n])))+_)+__\\b|\\*\\*(?:(?!\\*)(?:\\\\.|[^\\\\\\n\\r]|(?:\\n|\\r\\n?)(?![\\r\\n]))|\\*(?:(?!\\*)(?:\\\\.|[^\\\\\\n\\r]|(?:\\n|\\r\\n?)(?![\\r\\n])))+\\*)+\\*\\*)"), + lookbehind: true, + greedy: true, + inside: () => _g10), + GrammarToken( + "strike", + compileHighlightPattern( + "((?:^|[^\\\\])(?:\\\\{2})*)(?:(~~?)(?:(?!~)(?:\\\\.|[^\\\\\\n\\r]|(?:\\n|\\r\\n?)(?![\\r\\n])))+\\2)"), + lookbehind: true, + greedy: true, + inside: () => _g16), + GrammarToken( + "code-snippet", + compileHighlightPattern( + "(^|[^\\\\`])(?:``[^`\\r\\n]+(?:`[^`\\r\\n]+)*``(?!`)|`[^`\\r\\n]+`(?!`))"), + lookbehind: true, + greedy: true, + alias: "code"), +]); + +final Grammar _g16 = Grammar([ + GrammarToken("content", compileHighlightPattern("(^~~?)[\\s\\S]+(?=\\1\$)"), + lookbehind: true, inside: () => _g17), + GrammarToken("punctuation", compileHighlightPattern("~~?")), +]); + +final Grammar _g17 = Grammar([ + GrammarToken( + "url", + compileHighlightPattern( + "((?:^|[^\\\\])(?:\\\\{2})*)(?:!?\\[(?:(?!\\])(?:\\\\.|[^\\\\\\n\\r]|(?:\\n|\\r\\n?)(?![\\r\\n])))+\\](?:\\([^\\s)]+(?:[\\t ]+\"(?:\\\\.|[^\"\\\\])*\")?\\)|[ \\t]?\\[(?:(?!\\])(?:\\\\.|[^\\\\\\n\\r]|(?:\\n|\\r\\n?)(?![\\r\\n])))+\\]))"), + lookbehind: true, + greedy: true, + inside: () => _g12), + GrammarToken( + "bold", + compileHighlightPattern( + "((?:^|[^\\\\])(?:\\\\{2})*)(?:\\b__(?:(?!_)(?:\\\\.|[^\\\\\\n\\r]|(?:\\n|\\r\\n?)(?![\\r\\n]))|_(?:(?!_)(?:\\\\.|[^\\\\\\n\\r]|(?:\\n|\\r\\n?)(?![\\r\\n])))+_)+__\\b|\\*\\*(?:(?!\\*)(?:\\\\.|[^\\\\\\n\\r]|(?:\\n|\\r\\n?)(?![\\r\\n]))|\\*(?:(?!\\*)(?:\\\\.|[^\\\\\\n\\r]|(?:\\n|\\r\\n?)(?![\\r\\n])))+\\*)+\\*\\*)"), + lookbehind: true, + greedy: true, + inside: () => _g10), + GrammarToken( + "italic", + compileHighlightPattern( + "((?:^|[^\\\\])(?:\\\\{2})*)(?:\\b_(?:(?!_)(?:\\\\.|[^\\\\\\n\\r]|(?:\\n|\\r\\n?)(?![\\r\\n]))|__(?:(?!_)(?:\\\\.|[^\\\\\\n\\r]|(?:\\n|\\r\\n?)(?![\\r\\n])))+__)+_\\b|\\*(?:(?!\\*)(?:\\\\.|[^\\\\\\n\\r]|(?:\\n|\\r\\n?)(?![\\r\\n]))|\\*\\*(?:(?!\\*)(?:\\\\.|[^\\\\\\n\\r]|(?:\\n|\\r\\n?)(?![\\r\\n])))+\\*\\*)+\\*)"), + lookbehind: true, + greedy: true, + inside: () => _g14), + GrammarToken( + "code-snippet", + compileHighlightPattern( + "(^|[^\\\\`])(?:``[^`\\r\\n]+(?:`[^`\\r\\n]+)*``(?!`)|`[^`\\r\\n]+`(?!`))"), + lookbehind: true, + greedy: true, + alias: "code"), +]); + +final Grammar _g18 = Grammar([ + GrammarToken("internal-subset", + compileHighlightPattern("(^[^\\[]*\\[)[\\s\\S]+(?=\\]>\$)"), + lookbehind: true, greedy: true, inside: () => _g0), + GrammarToken("string", compileHighlightPattern("\"[^\"]*\"|'[^']*'"), + greedy: true), + GrammarToken("punctuation", compileHighlightPattern("^\$|[[\\]]")), + GrammarToken( + "doctype-tag", compileHighlightPattern("^DOCTYPE", caseSensitive: false)), + GrammarToken("name", compileHighlightPattern("[^\\s<>'\"]+")), +]); + +final Grammar _g19 = Grammar([ + GrammarToken( + "included-cdata", + compileHighlightPattern("", + caseSensitive: false), + inside: () => _g20), + GrammarToken("language-css", compileHighlightPattern("[\\s\\S]+"), + inside: () => _g21), +]); + +final Grammar _g20 = Grammar([ + GrammarToken( + "language-css", + compileHighlightPattern("(^\$)", + caseSensitive: false), + lookbehind: true, + inside: () => _g21), + GrammarToken( + "cdata", + compileHighlightPattern("^\$", + caseSensitive: false)), +]); + +final Grammar _g21 = Grammar([ + GrammarToken("comment", compileHighlightPattern("\\/\\*[\\s\\S]*?\\*\\/")), + GrammarToken( + "atrule", + compileHighlightPattern( + "@[\\w-](?:[^;{\\s\"']|\\s+(?!\\s)|(?:\"(?:\\\\(?:\\r\\n|[\\s\\S])|[^\"\\\\\\r\\n])*\"|'(?:\\\\(?:\\r\\n|[\\s\\S])|[^'\\\\\\r\\n])*'))*?(?:;|(?=\\s*\\{))"), + inside: () => _g22), + GrammarToken( + "url", + compileHighlightPattern( + "\\burl\\((?:(?:\"(?:\\\\(?:\\r\\n|[\\s\\S])|[^\"\\\\\\r\\n])*\"|'(?:\\\\(?:\\r\\n|[\\s\\S])|[^'\\\\\\r\\n])*')|(?:[^\\\\\\r\\n()\"']|\\\\[\\s\\S])*)\\)", + caseSensitive: false), + greedy: true, + inside: () => _g23), + GrammarToken( + "selector", + compileHighlightPattern( + "(^|[{}\\s])[^{}\\s](?:[^{};\"'\\s]|\\s+(?![\\s{])|(?:\"(?:\\\\(?:\\r\\n|[\\s\\S])|[^\"\\\\\\r\\n])*\"|'(?:\\\\(?:\\r\\n|[\\s\\S])|[^'\\\\\\r\\n])*'))*(?=\\s*\\{)"), + lookbehind: true), + GrammarToken( + "string", + compileHighlightPattern( + "(?:\"(?:\\\\(?:\\r\\n|[\\s\\S])|[^\"\\\\\\r\\n])*\"|'(?:\\\\(?:\\r\\n|[\\s\\S])|[^'\\\\\\r\\n])*')"), + greedy: true), + GrammarToken( + "property", + compileHighlightPattern( + "(^|[^-\\w\\xA0-\\uFFFF])(?!\\s)[-_a-z\\xA0-\\uFFFF](?:(?!\\s)[-\\w\\xA0-\\uFFFF])*(?=\\s*:)", + caseSensitive: false), + lookbehind: true), + GrammarToken("important", + compileHighlightPattern("!important\\b", caseSensitive: false)), + GrammarToken( + "function", + compileHighlightPattern("(^|[^-a-z0-9])[-a-z0-9]+(?=\\()", + caseSensitive: false), + lookbehind: true), + GrammarToken("punctuation", compileHighlightPattern("[(){};:,]")), +]); + +final Grammar _g22 = Grammar([ + GrammarToken("rule", compileHighlightPattern("^@[\\w-]+")), + GrammarToken( + "selector-function-argument", + compileHighlightPattern( + "(\\bselector\\s*\\(\\s*(?![\\s)]))(?:[^()\\s]|\\s+(?![\\s)])|\\((?:[^()]|\\([^()]*\\))*\\))+(?=\\s*\\))"), + lookbehind: true, + alias: "selector"), + GrammarToken("keyword", + compileHighlightPattern("(^|[^\\w-])(?:and|not|only|or)(?![\\w-])"), + lookbehind: true), +], rest: () => _g21); + +final Grammar _g23 = Grammar([ + GrammarToken( + "function", compileHighlightPattern("^url", caseSensitive: false)), + GrammarToken("punctuation", compileHighlightPattern("^\\(|\\)\$")), + GrammarToken( + "string", + compileHighlightPattern( + "^(?:\"(?:\\\\(?:\\r\\n|[\\s\\S])|[^\"\\\\\\r\\n])*\"|'(?:\\\\(?:\\r\\n|[\\s\\S])|[^'\\\\\\r\\n])*')\$"), + alias: "url"), +]); + +final Grammar _g24 = Grammar([ + GrammarToken( + "included-cdata", + compileHighlightPattern("", + caseSensitive: false), + inside: () => _g25), + GrammarToken("language-javascript", compileHighlightPattern("[\\s\\S]+"), + inside: () => _g26), +]); + +final Grammar _g25 = Grammar([ + GrammarToken( + "language-javascript", + compileHighlightPattern("(^\$)", + caseSensitive: false), + lookbehind: true, + inside: () => _g26), + GrammarToken( + "cdata", + compileHighlightPattern("^\$", + caseSensitive: false)), +]); + +final Grammar _g26 = Grammar([ + GrammarToken("comment", + compileHighlightPattern("(^|[^\\\\])\\/\\*[\\s\\S]*?(?:\\*\\/|\$)"), + lookbehind: true, greedy: true), + GrammarToken("comment", compileHighlightPattern("(^|[^\\\\:])\\/\\/.*"), + lookbehind: true, greedy: true), + GrammarToken("hashbang", compileHighlightPattern("^#!.*"), + greedy: true, alias: "comment"), + GrammarToken( + "template-string", + compileHighlightPattern( + "`(?:\\\\[\\s\\S]|\\\$\\{(?:[^{}]|\\{(?:[^{}]|\\{[^}]*\\})*\\})+\\}|(?!\\\$\\{)[^\\\\`])*`"), + greedy: true, + inside: () => _g27), + GrammarToken( + "string-property", + compileHighlightPattern( + "((?:^|[,{])[ \\t]*)([\"'])(?:\\\\(?:\\r\\n|[\\s\\S])|(?!\\2)[^\\\\\\r\\n])*\\2(?=\\s*:)", + multiLine: true), + lookbehind: true, + greedy: true, + alias: "property"), + GrammarToken( + "string", + compileHighlightPattern( + "([\"'])(?:\\\\(?:\\r\\n|[\\s\\S])|(?!\\1)[^\\\\\\r\\n])*\\1"), + greedy: true), + GrammarToken( + "class-name", + compileHighlightPattern( + "(\\b(?:class|extends|implements|instanceof|interface|new)\\s+)[\\w.\\\\]+"), + lookbehind: true, + inside: () => _g29), + GrammarToken( + "class-name", + compileHighlightPattern( + "(^|[^\$\\w\\xA0-\\uFFFF])(?!\\s)[_\$A-Z\\xA0-\\uFFFF](?:(?!\\s)[\$\\w\\xA0-\\uFFFF])*(?=\\.(?:constructor|prototype))"), + lookbehind: true), + GrammarToken( + "regex", + compileHighlightPattern( + "((?:^|[^\$\\w\\xA0-\\uFFFF.\"'\\])\\s]|\\b(?:return|yield))\\s*)\\/(?:(?:\\[(?:[^\\]\\\\\\r\\n]|\\\\.)*\\]|\\\\.|[^/\\\\\\[\\r\\n])+\\/[dgimyus]{0,7}|(?:\\[(?:[^[\\]\\\\\\r\\n]|\\\\.|\\[(?:[^[\\]\\\\\\r\\n]|\\\\.|\\[(?:[^[\\]\\\\\\r\\n]|\\\\.)*\\])*\\])*\\]|\\\\.|[^/\\\\\\[\\r\\n])+\\/[dgimyus]{0,7}v[dgimyus]{0,7})(?=(?:\\s|\\/\\*(?:[^*]|\\*(?!\\/))*\\*\\/)*(?:\$|[\\r\\n,.;:})\\]]|\\/\\/))"), + lookbehind: true, + greedy: true, + inside: () => _g30), + GrammarToken( + "function-variable", + compileHighlightPattern( + "#?(?!\\s)[_\$a-zA-Z\\xA0-\\uFFFF](?:(?!\\s)[\$\\w\\xA0-\\uFFFF])*(?=\\s*[=:]\\s*(?:async\\s*)?(?:\\bfunction\\b|(?:\\((?:[^()]|\\([^()]*\\))*\\)|(?!\\s)[_\$a-zA-Z\\xA0-\\uFFFF](?:(?!\\s)[\$\\w\\xA0-\\uFFFF])*)\\s*=>))"), + alias: "function"), + GrammarToken( + "parameter", + compileHighlightPattern( + "(function(?:\\s+(?!\\s)[_\$a-zA-Z\\xA0-\\uFFFF](?:(?!\\s)[\$\\w\\xA0-\\uFFFF])*)?\\s*\\(\\s*)(?!\\s)(?:[^()\\s]|\\s+(?![\\s)])|\\([^()]*\\))+(?=\\s*\\))"), + lookbehind: true, + inside: () => _g26), + GrammarToken( + "parameter", + compileHighlightPattern( + "(^|[^\$\\w\\xA0-\\uFFFF])(?!\\s)[_\$a-z\\xA0-\\uFFFF](?:(?!\\s)[\$\\w\\xA0-\\uFFFF])*(?=\\s*=>)", + caseSensitive: false), + lookbehind: true, + inside: () => _g26), + GrammarToken( + "parameter", + compileHighlightPattern( + "(\\(\\s*)(?!\\s)(?:[^()\\s]|\\s+(?![\\s)])|\\([^()]*\\))+(?=\\s*\\)\\s*=>)"), + lookbehind: true, + inside: () => _g26), + GrammarToken( + "parameter", + compileHighlightPattern( + "((?:\\b|\\s|^)(?!(?:as|async|await|break|case|catch|class|const|continue|debugger|default|delete|do|else|enum|export|extends|finally|for|from|function|get|if|implements|import|in|instanceof|interface|let|new|null|of|package|private|protected|public|return|set|static|super|switch|this|throw|try|typeof|undefined|var|void|while|with|yield)(?![\$\\w\\xA0-\\uFFFF]))(?:(?!\\s)[_\$a-zA-Z\\xA0-\\uFFFF](?:(?!\\s)[\$\\w\\xA0-\\uFFFF])*\\s*)\\(\\s*|\\]\\s*\\(\\s*)(?!\\s)(?:[^()\\s]|\\s+(?![\\s)])|\\([^()]*\\))+(?=\\s*\\)\\s*\\{)"), + lookbehind: true, + inside: () => _g26), + GrammarToken( + "constant", compileHighlightPattern("\\b[A-Z](?:[A-Z_]|\\dx?)*\\b")), + GrammarToken("keyword", compileHighlightPattern("((?:^|\\})\\s*)catch\\b"), + lookbehind: true), + GrammarToken( + "keyword", + compileHighlightPattern( + "(^|[^.]|\\.\\.\\.\\s*)\\b(?:as|assert(?=\\s*\\{)|async(?=\\s*(?:function\\b|\\(|[\$\\w\\xA0-\\uFFFF]|\$))|await|break|case|class|const|continue|debugger|default|delete|do|else|enum|export|extends|finally(?=\\s*(?:\\{|\$))|for|from(?=\\s*(?:['\"]|\$))|function|(?:get|set)(?=\\s*(?:[#\\[\$\\w\\xA0-\\uFFFF]|\$))|if|implements|import|in|instanceof|interface|let|new|null|of|package|private|protected|public|return|static|super|switch|this|throw|try|typeof|undefined|var|void|while|with|yield)\\b"), + lookbehind: true), + GrammarToken("boolean", compileHighlightPattern("\\b(?:false|true)\\b")), + GrammarToken( + "function", + compileHighlightPattern( + "#?(?!\\s)[_\$a-zA-Z\\xA0-\\uFFFF](?:(?!\\s)[\$\\w\\xA0-\\uFFFF])*(?=\\s*(?:\\.\\s*(?:apply|bind|call)\\s*)?\\()")), + GrammarToken( + "number", + compileHighlightPattern( + "(^|[^\\w\$])(?:NaN|Infinity|0[bB][01]+(?:_[01]+)*n?|0[oO][0-7]+(?:_[0-7]+)*n?|0[xX][\\dA-Fa-f]+(?:_[\\dA-Fa-f]+)*n?|\\d+(?:_\\d+)*n|(?:\\d+(?:_\\d+)*(?:\\.(?:\\d+(?:_\\d+)*)?)?|\\.\\d+(?:_\\d+)*)(?:[Ee][+-]?\\d+(?:_\\d+)*)?)(?![\\w\$])"), + lookbehind: true), + GrammarToken( + "literal-property", + compileHighlightPattern( + "((?:^|[,{])[ \\t]*)(?!\\s)[_\$a-zA-Z\\xA0-\\uFFFF](?:(?!\\s)[\$\\w\\xA0-\\uFFFF])*(?=\\s*:)", + multiLine: true), + lookbehind: true, + alias: "property"), + GrammarToken( + "operator", + compileHighlightPattern( + "--|\\+\\+|\\*\\*=?|=>|&&=?|\\|\\|=?|[!=]==|<<=?|>>>?=?|[-+*/%&|^!=<>]=?|\\.{3}|\\?\\?=?|\\?\\.?|[~:]")), + GrammarToken("punctuation", compileHighlightPattern("[{}[\\];(),.:]")), +]); + +final Grammar _g27 = Grammar([ + GrammarToken("template-punctuation", compileHighlightPattern("^`|`\$"), + alias: "string"), + GrammarToken( + "interpolation", + compileHighlightPattern( + "((?:^|[^\\\\])(?:\\\\{2})*)\\\$\\{(?:[^{}]|\\{(?:[^{}]|\\{[^}]*\\})*\\})+\\}"), + lookbehind: true, + inside: () => _g28), + GrammarToken("string", compileHighlightPattern("[\\s\\S]+")), +]); + +final Grammar _g28 = Grammar([ + GrammarToken( + "interpolation-punctuation", compileHighlightPattern("^\\\$\\{|\\}\$"), + alias: "punctuation"), +], rest: () => _g26); + +final Grammar _g29 = Grammar([ + GrammarToken("punctuation", compileHighlightPattern("[.\\\\]")), +]); + +final Grammar _g30 = Grammar([ + GrammarToken( + "regex-source", compileHighlightPattern("^(\\/)[\\s\\S]+(?=\\/[a-z]*\$)"), + lookbehind: true, alias: "language-regex", inside: () => _g31), + GrammarToken("regex-delimiter", compileHighlightPattern("^\\/|\\/\$")), + GrammarToken("regex-flags", compileHighlightPattern("^[a-z]+\$")), +]); + +final Grammar _g31 = Grammar([ + GrammarToken( + "char-class", + compileHighlightPattern( + "((?:^|[^\\\\])(?:\\\\\\\\)*)\\[(?:[^\\\\\\]]|\\\\[\\s\\S])*\\]"), + lookbehind: true, + inside: () => _g32), + GrammarToken( + "special-escape", compileHighlightPattern("\\\\[\\\\(){}[\\]^\$+*?|.]"), + alias: "escape"), + GrammarToken( + "char-set", + compileHighlightPattern("\\.|\\\\[wsd]|\\\\p\\{[^{}]+\\}", + caseSensitive: false), + alias: "class-name"), + GrammarToken( + "backreference", compileHighlightPattern("\\\\(?![123][0-7]{2})[1-9]"), + alias: "keyword"), + GrammarToken("backreference", compileHighlightPattern("\\\\k<[^<>']+>"), + alias: "keyword", inside: () => _g34), + GrammarToken("anchor", compileHighlightPattern("[\$^]|\\\\[ABbGZz]"), + alias: "function"), + GrammarToken( + "escape", + compileHighlightPattern( + "\\\\(?:x[\\da-fA-F]{2}|u[\\da-fA-F]{4}|u\\{[\\da-fA-F]+\\}|0[0-7]{0,2}|[123][0-7]{2}|c[a-zA-Z]|.)")), + GrammarToken( + "group", + compileHighlightPattern( + "\\((?:\\?(?:<[^<>']+>|'[^<>']+'|[>:]| _g35), + GrammarToken("group", compileHighlightPattern("\\)"), alias: "punctuation"), + GrammarToken("quantifier", + compileHighlightPattern("(?:[+*?]|\\{\\d+(?:,\\d*)?\\})[?+]?"), + alias: "number"), + GrammarToken("alternation", compileHighlightPattern("\\|"), alias: "keyword"), +]); + +final Grammar _g32 = Grammar([ + GrammarToken("char-class-negation", compileHighlightPattern("(^\\[)\\^"), + lookbehind: true, alias: "operator"), + GrammarToken("char-class-punctuation", compileHighlightPattern("^\\[|\\]\$"), + alias: "punctuation"), + GrammarToken( + "range", + compileHighlightPattern( + "(?:[^\\\\-]|\\\\(?:x[\\da-fA-F]{2}|u[\\da-fA-F]{4}|u\\{[\\da-fA-F]+\\}|0[0-7]{0,2}|[123][0-7]{2}|c[a-zA-Z]|.))-(?:[^\\\\-]|\\\\(?:x[\\da-fA-F]{2}|u[\\da-fA-F]{4}|u\\{[\\da-fA-F]+\\}|0[0-7]{0,2}|[123][0-7]{2}|c[a-zA-Z]|.))"), + inside: () => _g33), + GrammarToken( + "special-escape", compileHighlightPattern("\\\\[\\\\(){}[\\]^\$+*?|.]"), + alias: "escape"), + GrammarToken( + "char-set", + compileHighlightPattern("\\\\[wsd]|\\\\p\\{[^{}]+\\}", + caseSensitive: false), + alias: "class-name"), + GrammarToken( + "escape", + compileHighlightPattern( + "\\\\(?:x[\\da-fA-F]{2}|u[\\da-fA-F]{4}|u\\{[\\da-fA-F]+\\}|0[0-7]{0,2}|[123][0-7]{2}|c[a-zA-Z]|.)")), +]); + +final Grammar _g33 = Grammar([ + GrammarToken( + "escape", + compileHighlightPattern( + "\\\\(?:x[\\da-fA-F]{2}|u[\\da-fA-F]{4}|u\\{[\\da-fA-F]+\\}|0[0-7]{0,2}|[123][0-7]{2}|c[a-zA-Z]|.)")), + GrammarToken("range-punctuation", compileHighlightPattern("-"), + alias: "operator"), +]); + +final Grammar _g34 = Grammar([ + GrammarToken("group-name", compileHighlightPattern("(<|')[^<>']+(?=[>']\$)"), + lookbehind: true, alias: "variable"), +]); + +final Grammar _g35 = Grammar([ + GrammarToken("group-name", compileHighlightPattern("(<|')[^<>']+(?=[>']\$)"), + lookbehind: true, alias: "variable"), +]); + +final Grammar _g36 = Grammar([ + GrammarToken("tag", compileHighlightPattern("^<\\/?[^\\s>\\/]+"), + inside: () => _g37), + GrammarToken( + "special-attr", + compileHighlightPattern( + "(^|[\"'\\s])(?:style)\\s*=\\s*(?:\"[^\"]*\"|'[^']*'|[^\\s'\">=]+(?=[\\s>]))", + caseSensitive: false), + lookbehind: true, + inside: () => _g38), + GrammarToken( + "special-attr", + compileHighlightPattern( + "(^|[\"'\\s])(?:on(?:abort|blur|change|click|composition(?:end|start|update)|dblclick|error|focus(?:in|out)?|key(?:down|up)|load|mouse(?:down|enter|leave|move|out|over|up)|reset|resize|scroll|select|slotchange|submit|unload|wheel))\\s*=\\s*(?:\"[^\"]*\"|'[^']*'|[^\\s'\">=]+(?=[\\s>]))", + caseSensitive: false), + lookbehind: true, + inside: () => _g40), + GrammarToken("attr-value", + compileHighlightPattern("=\\s*(?:\"[^\"]*\"|'[^']*'|[^\\s'\">=]+)"), + inside: () => _g42), + GrammarToken("punctuation", compileHighlightPattern("\\/?>")), + GrammarToken("attr-name", compileHighlightPattern("[^\\s>\\/]+"), + inside: () => _g43), +]); + +final Grammar _g37 = Grammar([ + GrammarToken("punctuation", compileHighlightPattern("^<\\/?")), + GrammarToken("namespace", compileHighlightPattern("^[^\\s>\\/:]+:")), +]); + +final Grammar _g38 = Grammar([ + GrammarToken("attr-name", compileHighlightPattern("^[^\\s=]+")), + GrammarToken("attr-value", compileHighlightPattern("=[\\s\\S]+"), + inside: () => _g39), +]); + +final Grammar _g39 = Grammar([ + GrammarToken("value", + compileHighlightPattern("(^=\\s*([\"']|(?![\"'])))\\S[\\s\\S]*(?=\\2\$)"), + lookbehind: true, alias: "css", inside: () => _g21), + GrammarToken("punctuation", compileHighlightPattern("^="), + alias: "attr-equals"), + GrammarToken("punctuation", compileHighlightPattern("\"|'")), +]); + +final Grammar _g40 = Grammar([ + GrammarToken("attr-name", compileHighlightPattern("^[^\\s=]+")), + GrammarToken("attr-value", compileHighlightPattern("=[\\s\\S]+"), + inside: () => _g41), +]); + +final Grammar _g41 = Grammar([ + GrammarToken("value", + compileHighlightPattern("(^=\\s*([\"']|(?![\"'])))\\S[\\s\\S]*(?=\\2\$)"), + lookbehind: true, alias: "javascript", inside: () => _g26), + GrammarToken("punctuation", compileHighlightPattern("^="), + alias: "attr-equals"), + GrammarToken("punctuation", compileHighlightPattern("\"|'")), +]); + +final Grammar _g42 = Grammar([ + GrammarToken("punctuation", compileHighlightPattern("^="), + alias: "attr-equals"), + GrammarToken("punctuation", compileHighlightPattern("^(\\s*)[\"']|[\"']\$"), + lookbehind: true), + GrammarToken("entity", + compileHighlightPattern("&[\\da-z]{1,8};", caseSensitive: false), + alias: "named-entity"), + GrammarToken("entity", + compileHighlightPattern("&#x?[\\da-f]{1,8};", caseSensitive: false)), +]); + +final Grammar _g43 = Grammar([ + GrammarToken("namespace", compileHighlightPattern("^[^\\s>\\/:]+:")), +]); diff --git a/lib/highlight/markup_templating.dart b/lib/highlight/markup_templating.dart new file mode 100644 index 0000000..4869c5c --- /dev/null +++ b/lib/highlight/markup_templating.dart @@ -0,0 +1,16 @@ +// GENERATED CODE — do not modify by hand. Regenerate with tool/highlight_codegen. +// ignore_for_file: lines_longer_than_80_chars, public_member_api_docs +// ignore_for_file: prefer_single_quotes, require_trailing_commas, directives_ordering + +import '../highlight.dart'; + +/// Syntax grammar for `markup-templating`. +/// +/// Import this library only when you need `markup-templating` highlighting; unused +/// languages are dropped from the build. +abstract final class HighlightMarkupTemplating { + /// The grammar for `markup-templating`. + static final Grammar grammar = _g0; +} + +final Grammar _g0 = Grammar([]); diff --git a/lib/highlight/nginx.dart b/lib/highlight/nginx.dart new file mode 100644 index 0000000..ff4cc0d --- /dev/null +++ b/lib/highlight/nginx.dart @@ -0,0 +1,60 @@ +// GENERATED CODE — do not modify by hand. Regenerate with tool/highlight_codegen. +// ignore_for_file: lines_longer_than_80_chars, public_member_api_docs +// ignore_for_file: prefer_single_quotes, require_trailing_commas, directives_ordering + +import '../highlight.dart'; + +/// Syntax grammar for `nginx`. +/// +/// Import this library only when you need `nginx` highlighting; unused +/// languages are dropped from the build. +abstract final class HighlightNginx { + /// The grammar for `nginx`. + static final Grammar grammar = _g0; +} + +final Grammar _g0 = Grammar([ + GrammarToken("comment", compileHighlightPattern("(^|[\\s{};])#.*"), + lookbehind: true, greedy: true), + GrammarToken( + "directive", + compileHighlightPattern( + "(^|\\s)\\w(?:[^;{}\"'\\\\\\s]|\\\\.|\"(?:[^\"\\\\]|\\\\.)*\"|'(?:[^'\\\\]|\\\\.)*'|\\s+(?:#.*(?!.)|(?![#\\s])))*?(?=\\s*[;{])"), + lookbehind: true, + greedy: true, + inside: () => _g1), + GrammarToken("punctuation", compileHighlightPattern("[{};]")), +]); + +final Grammar _g1 = Grammar([ + GrammarToken( + "string", + compileHighlightPattern( + "((?:^|[^\\\\])(?:\\\\\\\\)*)(?:\"(?:[^\"\\\\]|\\\\.)*\"|'(?:[^'\\\\]|\\\\.)*')"), + lookbehind: true, + greedy: true, + inside: () => _g2), + GrammarToken("comment", compileHighlightPattern("(\\s)#.*"), + lookbehind: true, greedy: true), + GrammarToken("keyword", compileHighlightPattern("^\\S+"), greedy: true), + GrammarToken("boolean", compileHighlightPattern("(\\s)(?:off|on)(?!\\S)"), + lookbehind: true), + GrammarToken("number", + compileHighlightPattern("(\\s)\\d+[a-z]*(?!\\S)", caseSensitive: false), + lookbehind: true), + GrammarToken( + "variable", + compileHighlightPattern( + "\\\$(?:\\w[a-z\\d]*(?:_[^\\x00-\\x1F\\s\"'\\\\()\$]*)?|\\{[^}\\s\"'\\\\]+\\})", + caseSensitive: false)), +]); + +final Grammar _g2 = Grammar([ + GrammarToken("escape", compileHighlightPattern("\\\\[\"'\\\\nrt]"), + alias: "entity"), + GrammarToken( + "variable", + compileHighlightPattern( + "\\\$(?:\\w[a-z\\d]*(?:_[^\\x00-\\x1F\\s\"'\\\\()\$]*)?|\\{[^}\\s\"'\\\\]+\\})", + caseSensitive: false)), +]); diff --git a/lib/highlight/objectivec.dart b/lib/highlight/objectivec.dart new file mode 100644 index 0000000..98def62 --- /dev/null +++ b/lib/highlight/objectivec.dart @@ -0,0 +1,101 @@ +// GENERATED CODE — do not modify by hand. Regenerate with tool/highlight_codegen. +// ignore_for_file: lines_longer_than_80_chars, public_member_api_docs +// ignore_for_file: prefer_single_quotes, require_trailing_commas, directives_ordering + +import '../highlight.dart'; + +/// Syntax grammar for `objectivec`. +/// +/// Import this library only when you need `objectivec` highlighting; unused +/// languages are dropped from the build. +abstract final class HighlightObjectivec { + /// The grammar for `objectivec`. + static final Grammar grammar = _g0; +} + +final Grammar _g0 = Grammar([ + GrammarToken( + "comment", + compileHighlightPattern( + "\\/\\/(?:[^\\r\\n\\\\]|\\\\(?:\\r\\n?|\\n|(?![\\r\\n])))*|\\/\\*[\\s\\S]*?(?:\\*\\/|\$)"), + greedy: true), + GrammarToken( + "char", + compileHighlightPattern( + "'(?:\\\\(?:\\r\\n|[\\s\\S])|[^'\\\\\\r\\n]){0,32}'"), + greedy: true), + GrammarToken( + "macro", + compileHighlightPattern( + "(^[\\t ]*)#\\s*[a-z](?:[^\\r\\n\\\\/]|\\/(?!\\*)|\\/\\*(?:[^*]|\\*(?!\\/))*\\*\\/|\\\\(?:\\r\\n|[\\s\\S]))*", + caseSensitive: false, + multiLine: true), + lookbehind: true, + greedy: true, + alias: "property", + inside: () => _g1), + GrammarToken( + "string", + compileHighlightPattern( + "@?\"(?:\\\\(?:\\r\\n|[\\s\\S])|[^\"\\\\\\r\\n])*\""), + greedy: true), + GrammarToken( + "keyword", + compileHighlightPattern( + "\\b(?:asm|auto|break|case|char|const|continue|default|do|double|else|enum|extern|float|for|goto|if|in|inline|int|long|register|return|self|short|signed|sizeof|static|struct|super|switch|typedef|typeof|union|unsigned|void|volatile|while)\\b|(?:@interface|@end|@implementation|@protocol|@class|@public|@protected|@private|@property|@try|@catch|@finally|@throw|@synthesize|@dynamic|@selector)\\b")), + GrammarToken( + "constant", + compileHighlightPattern( + "\\b(?:EOF|NULL|SEEK_CUR|SEEK_END|SEEK_SET|__DATE__|__FILE__|__LINE__|__TIMESTAMP__|__TIME__|__func__|stderr|stdin|stdout)\\b")), + GrammarToken( + "function", + compileHighlightPattern("\\b[a-z_]\\w*(?=\\s*\\()", + caseSensitive: false)), + GrammarToken( + "number", + compileHighlightPattern( + "(?:\\b0x(?:[\\da-f]+(?:\\.[\\da-f]*)?|\\.[\\da-f]+)(?:p[+-]?\\d+)?|(?:\\b\\d+(?:\\.\\d*)?|\\B\\.\\d+)(?:e[+-]?\\d+)?)[ful]{0,4}", + caseSensitive: false)), + GrammarToken( + "operator", + compileHighlightPattern( + "-[->]?|\\+\\+?|!=?|<>?=?|==?|&&?|\\|\\|?|[~^%?*\\/@]")), + GrammarToken("punctuation", compileHighlightPattern("[{}[\\];(),.:]")), +]); + +final Grammar _g1 = Grammar([ + GrammarToken("string", compileHighlightPattern("^(#\\s*include\\s*)<[^>]+>"), + lookbehind: true), + GrammarToken( + "string", + compileHighlightPattern( + "\"(?:\\\\(?:\\r\\n|[\\s\\S])|[^\"\\\\\\r\\n])*\""), + greedy: true), + GrammarToken( + "char", + compileHighlightPattern( + "'(?:\\\\(?:\\r\\n|[\\s\\S])|[^'\\\\\\r\\n]){0,32}'"), + greedy: true), + GrammarToken( + "comment", + compileHighlightPattern( + "\\/\\/(?:[^\\r\\n\\\\]|\\\\(?:\\r\\n?|\\n|(?![\\r\\n])))*|\\/\\*[\\s\\S]*?(?:\\*\\/|\$)"), + greedy: true), + GrammarToken( + "macro-name", + compileHighlightPattern("(^#\\s*define\\s+)\\w+\\b(?!\\()", + caseSensitive: false), + lookbehind: true), + GrammarToken( + "macro-name", + compileHighlightPattern("(^#\\s*define\\s+)\\w+\\b(?=\\()", + caseSensitive: false), + lookbehind: true, + alias: "function"), + GrammarToken("directive", compileHighlightPattern("^(#\\s*)[a-z]+"), + lookbehind: true, alias: "keyword"), + GrammarToken("directive-hash", compileHighlightPattern("^#")), + GrammarToken("punctuation", compileHighlightPattern("##|\\\\(?=[\\r\\n])")), + GrammarToken("expression", compileHighlightPattern("\\S[\\s\\S]*"), + inside: () => _g0), +]); diff --git a/lib/highlight/ocaml.dart b/lib/highlight/ocaml.dart new file mode 100644 index 0000000..51b7377 --- /dev/null +++ b/lib/highlight/ocaml.dart @@ -0,0 +1,67 @@ +// GENERATED CODE — do not modify by hand. Regenerate with tool/highlight_codegen. +// ignore_for_file: lines_longer_than_80_chars, public_member_api_docs +// ignore_for_file: prefer_single_quotes, require_trailing_commas, directives_ordering + +import '../highlight.dart'; + +/// Syntax grammar for `ocaml`. +/// +/// Import this library only when you need `ocaml` highlighting; unused +/// languages are dropped from the build. +abstract final class HighlightOcaml { + /// The grammar for `ocaml`. + static final Grammar grammar = _g0; +} + +final Grammar _g0 = Grammar([ + GrammarToken("comment", compileHighlightPattern("\\(\\*[\\s\\S]*?\\*\\)"), + greedy: true), + GrammarToken( + "char", + compileHighlightPattern( + "'(?:[^\\\\\\r\\n']|\\\\(?:.|[ox]?[0-9a-f]{1,3}))'", + caseSensitive: false), + greedy: true), + GrammarToken( + "string", + compileHighlightPattern( + "\"(?:\\\\(?:[\\s\\S]|\\r\\n)|[^\\\\\\r\\n\"])*\""), + greedy: true), + GrammarToken( + "string", compileHighlightPattern("\\{([a-z_]*)\\|[\\s\\S]*?\\|\\1\\}"), + greedy: true), + GrammarToken( + "number", + compileHighlightPattern("\\b(?:0b[01][01_]*|0o[0-7][0-7_]*)\\b", + caseSensitive: false)), + GrammarToken( + "number", + compileHighlightPattern( + "\\b0x[a-f0-9][a-f0-9_]*(?:\\.[a-f0-9_]*)?(?:p[+-]?\\d[\\d_]*)?(?!\\w)", + caseSensitive: false)), + GrammarToken( + "number", + compileHighlightPattern( + "\\b\\d[\\d_]*(?:\\.[\\d_]*)?(?:e[+-]?\\d[\\d_]*)?(?!\\w)", + caseSensitive: false)), + GrammarToken("directive", compileHighlightPattern("\\B#\\w+"), + alias: "property"), + GrammarToken("label", compileHighlightPattern("\\B~\\w+"), alias: "property"), + GrammarToken("type-variable", compileHighlightPattern("\\B'\\w+"), + alias: "function"), + GrammarToken("variant", compileHighlightPattern("`\\w+"), alias: "symbol"), + GrammarToken( + "keyword", + compileHighlightPattern( + "\\b(?:as|assert|begin|class|constraint|do|done|downto|else|end|exception|external|for|fun|function|functor|if|in|include|inherit|initializer|lazy|let|match|method|module|mutable|new|nonrec|object|of|open|private|rec|sig|struct|then|to|try|type|val|value|virtual|when|where|while|with)\\b")), + GrammarToken("boolean", compileHighlightPattern("\\b(?:false|true)\\b")), + GrammarToken("operator-like-punctuation", + compileHighlightPattern("\\[[<>|]|[>|]\\]|\\{<|>\\}"), + alias: "punctuation"), + GrammarToken( + "operator", + compileHighlightPattern( + "\\.[.~]|:[=>]|[=<>@^|&+\\-*\\/\$%!?~][!\$%&*+\\-.\\/:<=>?@^|~]*|\\b(?:and|asr|land|lor|lsl|lsr|lxor|mod|or)\\b")), + GrammarToken("punctuation", + compileHighlightPattern(";;|::|[(){}\\[\\].,:;#]|\\b_\\b")), +]); diff --git a/lib/highlight/perl.dart b/lib/highlight/perl.dart new file mode 100644 index 0000000..483d5e3 --- /dev/null +++ b/lib/highlight/perl.dart @@ -0,0 +1,80 @@ +// GENERATED CODE — do not modify by hand. Regenerate with tool/highlight_codegen. +// ignore_for_file: lines_longer_than_80_chars, public_member_api_docs +// ignore_for_file: prefer_single_quotes, require_trailing_commas, directives_ordering + +import '../highlight.dart'; + +/// Syntax grammar for `perl`. +/// +/// Import this library only when you need `perl` highlighting; unused +/// languages are dropped from the build. +abstract final class HighlightPerl { + /// The grammar for `perl`. + static final Grammar grammar = _g0; +} + +final Grammar _g0 = Grammar([ + GrammarToken("comment", + compileHighlightPattern("(^\\s*)=\\w[\\s\\S]*?=cut.*", multiLine: true), + lookbehind: true, greedy: true), + GrammarToken("comment", compileHighlightPattern("(^|[^\\\\\$])#.*"), + lookbehind: true, greedy: true), + GrammarToken( + "string", + compileHighlightPattern( + "\\b(?:q|qq|qw|qx)(?![a-zA-Z0-9])\\s*(?:([^a-zA-Z0-9\\s{(\\[<])(?:(?!\\1)[^\\\\]|\\\\[\\s\\S])*\\1|([a-zA-Z0-9])(?:(?!\\2)[^\\\\]|\\\\[\\s\\S])*\\2|(?:\\((?:[^()\\\\]|\\\\[\\s\\S])*\\)|\\{(?:[^{}\\\\]|\\\\[\\s\\S])*\\}|\\[(?:[^[\\]\\\\]|\\\\[\\s\\S])*\\]|<(?:[^<>\\\\]|\\\\[\\s\\S])*>))"), + greedy: true), + GrammarToken("string", + compileHighlightPattern("(\"|`)(?:(?!\\1)[^\\\\]|\\\\[\\s\\S])*\\1"), + greedy: true), + GrammarToken("string", compileHighlightPattern("'(?:[^'\\\\\\r\\n]|\\\\.)*'"), + greedy: true), + GrammarToken( + "regex", + compileHighlightPattern( + "\\b(?:m|qr)(?![a-zA-Z0-9])\\s*(?:([^a-zA-Z0-9\\s{(\\[<])(?:(?!\\1)[^\\\\]|\\\\[\\s\\S])*\\1|([a-zA-Z0-9])(?:(?!\\2)[^\\\\]|\\\\[\\s\\S])*\\2|(?:\\((?:[^()\\\\]|\\\\[\\s\\S])*\\)|\\{(?:[^{}\\\\]|\\\\[\\s\\S])*\\}|\\[(?:[^[\\]\\\\]|\\\\[\\s\\S])*\\]|<(?:[^<>\\\\]|\\\\[\\s\\S])*>))[msixpodualngc]*"), + greedy: true), + GrammarToken( + "regex", + compileHighlightPattern( + "(^|[^-])\\b(?:s|tr|y)(?![a-zA-Z0-9])\\s*(?:([^a-zA-Z0-9\\s{(\\[<])(?:(?!\\2)[^\\\\]|\\\\[\\s\\S])*\\2(?:(?!\\2)[^\\\\]|\\\\[\\s\\S])*\\2|([a-zA-Z0-9])(?:(?!\\3)[^\\\\]|\\\\[\\s\\S])*\\3(?:(?!\\3)[^\\\\]|\\\\[\\s\\S])*\\3|(?:\\((?:[^()\\\\]|\\\\[\\s\\S])*\\)|\\{(?:[^{}\\\\]|\\\\[\\s\\S])*\\}|\\[(?:[^[\\]\\\\]|\\\\[\\s\\S])*\\]|<(?:[^<>\\\\]|\\\\[\\s\\S])*>)\\s*(?:\\((?:[^()\\\\]|\\\\[\\s\\S])*\\)|\\{(?:[^{}\\\\]|\\\\[\\s\\S])*\\}|\\[(?:[^[\\]\\\\]|\\\\[\\s\\S])*\\]|<(?:[^<>\\\\]|\\\\[\\s\\S])*>))[msixpodualngcer]*"), + lookbehind: true, + greedy: true), + GrammarToken( + "regex", + compileHighlightPattern( + "\\/(?:[^\\/\\\\\\r\\n]|\\\\.)*\\/[msixpodualngc]*(?=\\s*(?:\$|[\\r\\n,.;})&|\\-+*~<>!?^]|(?:and|cmp|eq|ge|gt|le|lt|ne|not|or|x|xor)\\b))"), + greedy: true), + GrammarToken("variable", compileHighlightPattern("[&*\$@%]\\{\\^[A-Z]+\\}")), + GrammarToken("variable", compileHighlightPattern("[&*\$@%]\\^[A-Z_]")), + GrammarToken("variable", compileHighlightPattern("[&*\$@%]#?(?=\\{)")), + GrammarToken( + "variable", + compileHighlightPattern( + "[&*\$@%]#?(?:(?:::)*'?(?!\\d)[\\w\$]+(?![\\w\$]))+(?:::)*")), + GrammarToken("variable", compileHighlightPattern("[&*\$@%]\\d+")), + GrammarToken( + "variable", + compileHighlightPattern( + "(?!%=)[\$@%][!\"#\$%&'()*+,\\-.\\/:;<=>?@[\\\\\\]^_`{|}~]")), + GrammarToken("filehandle", compileHighlightPattern("<(?![<=])\\S*?>|\\b_\\b"), + alias: "symbol"), + GrammarToken("v-string", + compileHighlightPattern("v\\d+(?:\\.\\d+)*|\\d+(?:\\.\\d+){2,}"), + alias: "string"), + GrammarToken("function", compileHighlightPattern("(\\bsub[ \\t]+)\\w+"), + lookbehind: true), + GrammarToken( + "keyword", + compileHighlightPattern( + "\\b(?:any|break|continue|default|delete|die|do|else|elsif|eval|for|foreach|given|goto|if|last|local|my|next|our|package|print|redo|require|return|say|state|sub|switch|undef|unless|until|use|when|while)\\b")), + GrammarToken( + "number", + compileHighlightPattern( + "\\b(?:0x[\\dA-Fa-f](?:_?[\\dA-Fa-f])*|0b[01](?:_?[01])*|(?:(?:\\d(?:_?\\d)*)?\\.)?\\d(?:_?\\d)*(?:[Ee][+-]?\\d+)?)\\b")), + GrammarToken( + "operator", + compileHighlightPattern( + "-[rwxoRWXOezsfdlpSbctugkTBMAC]\\b|\\+[+=]?|-[-=>]?|\\*\\*?=?|\\/\\/?=?|=[=~>]?|~[~=]?|\\|\\|?=?|&&?=?|<(?:=>?|<=?)?|>>?=?|![~=]?|[%^]=?|\\.(?:=|\\.\\.?)?|[\\\\?]|\\bx(?:=|\\b)|\\b(?:and|cmp|eq|ge|gt|le|lt|ne|not|or|xor)\\b")), + GrammarToken("punctuation", compileHighlightPattern("[{}[\\];(),:]")), +]); diff --git a/lib/highlight/php.dart b/lib/highlight/php.dart new file mode 100644 index 0000000..f8638f1 --- /dev/null +++ b/lib/highlight/php.dart @@ -0,0 +1,429 @@ +// GENERATED CODE — do not modify by hand. Regenerate with tool/highlight_codegen. +// ignore_for_file: lines_longer_than_80_chars, public_member_api_docs +// ignore_for_file: prefer_single_quotes, require_trailing_commas, directives_ordering + +import '../highlight.dart'; + +/// Syntax grammar for `php`. +/// +/// Import this library only when you need `php` highlighting; unused +/// languages are dropped from the build. +abstract final class HighlightPhp { + /// The grammar for `php`. + static final Grammar grammar = _g0; +} + +final Grammar _g0 = Grammar([ + GrammarToken( + "delimiter", + compileHighlightPattern("\\?>\$|^<\\?(?:php(?=\\s)|=)?", + caseSensitive: false), + alias: "important"), + GrammarToken("comment", + compileHighlightPattern("\\/\\*[\\s\\S]*?\\*\\/|\\/\\/.*|#(?!\\[).*")), + GrammarToken("string", + compileHighlightPattern("<<<'([^']+)'[\\r\\n](?:.*[\\r\\n])*?\\1;"), + greedy: true, alias: "nowdoc-string", inside: () => _g1), + GrammarToken( + "string", + compileHighlightPattern( + "<<<(?:\"([^\"]+)\"[\\r\\n](?:.*[\\r\\n])*?\\1;|([a-z_]\\w*)[\\r\\n](?:.*[\\r\\n])*?\\2;)", + caseSensitive: false), + greedy: true, + alias: "heredoc-string", + inside: () => _g3), + GrammarToken( + "string", compileHighlightPattern("`(?:\\\\[\\s\\S]|[^\\\\`])*`"), + greedy: true, alias: "backtick-quoted-string"), + GrammarToken( + "string", compileHighlightPattern("'(?:\\\\[\\s\\S]|[^\\\\'])*'"), + greedy: true, alias: "single-quoted-string"), + GrammarToken( + "string", compileHighlightPattern("\"(?:\\\\[\\s\\S]|[^\\\\\"])*\""), + greedy: true, alias: "double-quoted-string", inside: () => _g5), + GrammarToken( + "attribute", + compileHighlightPattern( + "#\\[(?:[^\"'\\/#]|\\/(?![*/])|\\/\\/.*\$|#(?!\\[).*\$|\\/\\*(?:[^*]|\\*(?!\\/))*\\*\\/|\"(?:\\\\[\\s\\S]|[^\\\\\"])*\"|'(?:\\\\[\\s\\S]|[^\\\\'])*')+\\](?=\\s*[a-z\$#])", + caseSensitive: false, + multiLine: true), + greedy: true, + inside: () => _g6), + GrammarToken("variable", compileHighlightPattern("\\\$+(?:\\w+\\b|(?=\\{))")), + GrammarToken( + "package", + compileHighlightPattern( + "(namespace\\s+|use\\s+(?:function\\s+)?)(?:\\\\?\\b[a-z_]\\w*)+\\b(?!\\\\)", + caseSensitive: false), + lookbehind: true, + inside: () => _g9), + GrammarToken( + "class-name-definition", + compileHighlightPattern( + "(\\b(?:class|enum|interface|trait)\\s+)\\b[a-z_]\\w*(?!\\\\)\\b", + caseSensitive: false), + lookbehind: true, + alias: "class-name"), + GrammarToken( + "function-definition", + compileHighlightPattern("(\\bfunction\\s+)[a-z_]\\w*(?=\\s*\\()", + caseSensitive: false), + lookbehind: true, + alias: "function"), + GrammarToken( + "keyword", + compileHighlightPattern( + "(\\(\\s*)\\b(?:array|bool|boolean|float|int|integer|object|string)\\b(?=\\s*\\))", + caseSensitive: false), + lookbehind: true, + greedy: true, + alias: "type-casting"), + GrammarToken( + "keyword", + compileHighlightPattern( + "([(,?]\\s*)\\b(?:array(?!\\s*\\()|bool|callable|(?:false|null)(?=\\s*\\|)|float|int|iterable|mixed|object|self|static|string)\\b(?=\\s*\\\$)", + caseSensitive: false), + lookbehind: true, + greedy: true, + alias: "type-hint"), + GrammarToken( + "keyword", + compileHighlightPattern( + "(\\)\\s*:\\s*(?:\\?\\s*)?)\\b(?:array(?!\\s*\\()|bool|callable|(?:false|null)(?=\\s*\\|)|float|int|iterable|mixed|never|object|self|static|string|void)\\b", + caseSensitive: false), + lookbehind: true, + greedy: true, + alias: "return-type"), + GrammarToken( + "keyword", + compileHighlightPattern( + "\\b(?:array(?!\\s*\\()|bool|float|int|iterable|mixed|object|string|void)\\b", + caseSensitive: false), + greedy: true, + alias: "type-declaration"), + GrammarToken( + "keyword", + compileHighlightPattern( + "(\\|\\s*)(?:false|null)\\b|\\b(?:false|null)(?=\\s*\\|)", + caseSensitive: false), + lookbehind: true, + greedy: true, + alias: "type-declaration"), + GrammarToken( + "keyword", + compileHighlightPattern("\\b(?:parent|self|static)(?=\\s*::)", + caseSensitive: false), + greedy: true, + alias: "static-context"), + GrammarToken("keyword", + compileHighlightPattern("(\\byield\\s+)from\\b", caseSensitive: false), + lookbehind: true), + GrammarToken( + "keyword", compileHighlightPattern("\\bclass\\b", caseSensitive: false)), + GrammarToken( + "keyword", + compileHighlightPattern( + "((?:^|[^\\s>:]|(?:^|[^-])>|(?:^|[^:]):)\\s*)\\b(?:abstract|and|array|as|break|callable|case|catch|clone|const|continue|declare|default|die|do|echo|else|elseif|empty|enddeclare|endfor|endforeach|endif|endswitch|endwhile|enum|eval|exit|extends|final|finally|fn|for|foreach|function|global|goto|if|implements|include|include_once|instanceof|insteadof|interface|isset|list|match|namespace|never|new|or|parent|print|private|protected|public|readonly|require|require_once|return|self|static|switch|throw|trait|try|unset|use|var|while|xor|yield|__halt_compiler)\\b", + caseSensitive: false), + lookbehind: true), + GrammarToken( + "argument-name", + compileHighlightPattern("([(,]\\s*)\\b[a-z_]\\w*(?=\\s*:(?!:))", + caseSensitive: false), + lookbehind: true), + GrammarToken( + "class-name", + compileHighlightPattern( + "(\\b(?:extends|implements|instanceof|new(?!\\s+self|\\s+static))\\s+|\\bcatch\\s*\\()\\b[a-z_]\\w*(?!\\\\)\\b", + caseSensitive: false), + lookbehind: true, + greedy: true), + GrammarToken( + "class-name", + compileHighlightPattern("(\\|\\s*)\\b[a-z_]\\w*(?!\\\\)\\b", + caseSensitive: false), + lookbehind: true, + greedy: true), + GrammarToken( + "class-name", + compileHighlightPattern("\\b[a-z_]\\w*(?!\\\\)\\b(?=\\s*\\|)", + caseSensitive: false), + greedy: true), + GrammarToken( + "class-name", + compileHighlightPattern("(\\|\\s*)(?:\\\\?\\b[a-z_]\\w*)+\\b", + caseSensitive: false), + lookbehind: true, + greedy: true, + alias: "class-name-fully-qualified", + inside: () => _g10), + GrammarToken( + "class-name", + compileHighlightPattern("(?:\\\\?\\b[a-z_]\\w*)+\\b(?=\\s*\\|)", + caseSensitive: false), + greedy: true, + alias: "class-name-fully-qualified", + inside: () => _g11), + GrammarToken( + "class-name", + compileHighlightPattern( + "(\\b(?:extends|implements|instanceof|new(?!\\s+self\\b|\\s+static\\b))\\s+|\\bcatch\\s*\\()(?:\\\\?\\b[a-z_]\\w*)+\\b(?!\\\\)", + caseSensitive: false), + lookbehind: true, + greedy: true, + alias: "class-name-fully-qualified", + inside: () => _g12), + GrammarToken( + "class-name", + compileHighlightPattern("\\b[a-z_]\\w*(?=\\s*\\\$)", + caseSensitive: false), + greedy: true, + alias: "type-declaration"), + GrammarToken( + "class-name", + compileHighlightPattern("(?:\\\\?\\b[a-z_]\\w*)+(?=\\s*\\\$)", + caseSensitive: false), + greedy: true, + alias: "class-name-fully-qualified", + inside: () => _g13), + GrammarToken("class-name", + compileHighlightPattern("\\b[a-z_]\\w*(?=\\s*::)", caseSensitive: false), + greedy: true, alias: "static-context"), + GrammarToken( + "class-name", + compileHighlightPattern("(?:\\\\?\\b[a-z_]\\w*)+(?=\\s*::)", + caseSensitive: false), + greedy: true, + alias: "class-name-fully-qualified", + inside: () => _g14), + GrammarToken( + "class-name", + compileHighlightPattern("([(,?]\\s*)[a-z_]\\w*(?=\\s*\\\$)", + caseSensitive: false), + lookbehind: true, + greedy: true, + alias: "type-hint"), + GrammarToken( + "class-name", + compileHighlightPattern("([(,?]\\s*)(?:\\\\?\\b[a-z_]\\w*)+(?=\\s*\\\$)", + caseSensitive: false), + lookbehind: true, + greedy: true, + alias: "class-name-fully-qualified", + inside: () => _g15), + GrammarToken( + "class-name", + compileHighlightPattern( + "(\\)\\s*:\\s*(?:\\?\\s*)?)\\b[a-z_]\\w*(?!\\\\)\\b", + caseSensitive: false), + lookbehind: true, + greedy: true, + alias: "return-type"), + GrammarToken( + "class-name", + compileHighlightPattern( + "(\\)\\s*:\\s*(?:\\?\\s*)?)(?:\\\\?\\b[a-z_]\\w*)+\\b(?!\\\\)", + caseSensitive: false), + lookbehind: true, + greedy: true, + alias: "class-name-fully-qualified", + inside: () => _g16), + GrammarToken("constant", + compileHighlightPattern("\\b(?:false|true)\\b", caseSensitive: false), + alias: "boolean"), + GrammarToken( + "constant", + compileHighlightPattern("(::\\s*)\\b[a-z_]\\w*\\b(?!\\s*\\()", + caseSensitive: false), + lookbehind: true, + greedy: true), + GrammarToken( + "constant", + compileHighlightPattern( + "(\\b(?:case|const)\\s+)\\b[a-z_]\\w*(?=\\s*[;=])", + caseSensitive: false), + lookbehind: true, + greedy: true), + GrammarToken("constant", + compileHighlightPattern("\\b(?:null)\\b", caseSensitive: false)), + GrammarToken( + "constant", compileHighlightPattern("\\b[A-Z_][A-Z0-9_]*\\b(?!\\s*\\()")), + GrammarToken( + "function", + compileHighlightPattern( + "(^|[^\\\\\\w])\\\\?[a-z_](?:[\\w\\\\]*\\w)?(?=\\s*\\()", + caseSensitive: false), + lookbehind: true, + inside: () => _g17), + GrammarToken("property", compileHighlightPattern("(->\\s*)\\w+"), + lookbehind: true), + GrammarToken( + "number", + compileHighlightPattern( + "\\b0b[01]+(?:_[01]+)*\\b|\\b0o[0-7]+(?:_[0-7]+)*\\b|\\b0x[\\da-f]+(?:_[\\da-f]+)*\\b|(?:\\b\\d+(?:_\\d+)*\\.?(?:\\d+(?:_\\d+)*)?|\\B\\.\\d+)(?:e[+-]?\\d+)?", + caseSensitive: false)), + GrammarToken( + "operator", + compileHighlightPattern( + "|\\?\\?=?|\\.{3}|\\??->|[!=]=?=?|::|\\*\\*=?|--|\\+\\+|&&|\\|\\||<<|>>|[?~]|[/^|%*&<>.+-]=?")), + GrammarToken("punctuation", compileHighlightPattern("[{}\\[\\](),:;]")), +]); + +final Grammar _g1 = Grammar([ + GrammarToken( + "delimiter", + compileHighlightPattern("^<<<'[^']+'|[a-z_]\\w*;\$", + caseSensitive: false), + alias: "symbol", + inside: () => _g2), +]); + +final Grammar _g2 = Grammar([ + GrammarToken("punctuation", compileHighlightPattern("^<<<'?|[';]\$")), +]); + +final Grammar _g3 = Grammar([ + GrammarToken( + "delimiter", + compileHighlightPattern("^<<<(?:\"[^\"]+\"|[a-z_]\\w*)|[a-z_]\\w*;\$", + caseSensitive: false), + alias: "symbol", + inside: () => _g4), + GrammarToken( + "interpolation", + compileHighlightPattern( + "\\{\\\$(?:\\{(?:\\{[^{}]+\\}|[^{}]+)\\}|[^{}])+\\}|(^|[^\\\\{])\\\$+(?:\\w+(?:\\[[^\\r\\n\\[\\]]+\\]|->\\w+)?)"), + lookbehind: true, + inside: () => _g0), +]); + +final Grammar _g4 = Grammar([ + GrammarToken("punctuation", compileHighlightPattern("^<<<\"?|[\";]\$")), +]); + +final Grammar _g5 = Grammar([ + GrammarToken( + "interpolation", + compileHighlightPattern( + "\\{\\\$(?:\\{(?:\\{[^{}]+\\}|[^{}]+)\\}|[^{}])+\\}|(^|[^\\\\{])\\\$+(?:\\w+(?:\\[[^\\r\\n\\[\\]]+\\]|->\\w+)?)"), + lookbehind: true, + inside: () => _g0), +]); + +final Grammar _g6 = Grammar([ + GrammarToken( + "attribute-content", compileHighlightPattern("^(#\\[)[\\s\\S]+(?=\\]\$)"), + lookbehind: true, inside: () => _g7), + GrammarToken("delimiter", compileHighlightPattern("^#\\[|\\]\$"), + alias: "punctuation"), +]); + +final Grammar _g7 = Grammar([ + GrammarToken("comment", + compileHighlightPattern("\\/\\*[\\s\\S]*?\\*\\/|\\/\\/.*|#(?!\\[).*")), + GrammarToken("string", + compileHighlightPattern("<<<'([^']+)'[\\r\\n](?:.*[\\r\\n])*?\\1;"), + greedy: true, alias: "nowdoc-string", inside: () => _g1), + GrammarToken( + "string", + compileHighlightPattern( + "<<<(?:\"([^\"]+)\"[\\r\\n](?:.*[\\r\\n])*?\\1;|([a-z_]\\w*)[\\r\\n](?:.*[\\r\\n])*?\\2;)", + caseSensitive: false), + greedy: true, + alias: "heredoc-string", + inside: () => _g3), + GrammarToken( + "string", compileHighlightPattern("`(?:\\\\[\\s\\S]|[^\\\\`])*`"), + greedy: true, alias: "backtick-quoted-string"), + GrammarToken( + "string", compileHighlightPattern("'(?:\\\\[\\s\\S]|[^\\\\'])*'"), + greedy: true, alias: "single-quoted-string"), + GrammarToken( + "string", compileHighlightPattern("\"(?:\\\\[\\s\\S]|[^\\\\\"])*\""), + greedy: true, alias: "double-quoted-string", inside: () => _g5), + GrammarToken( + "attribute-class-name", + compileHighlightPattern("([^:]|^)\\b[a-z_]\\w*(?!\\\\)\\b", + caseSensitive: false), + lookbehind: true, + greedy: true, + alias: "class-name"), + GrammarToken( + "attribute-class-name", + compileHighlightPattern("([^:]|^)(?:\\\\?\\b[a-z_]\\w*)+", + caseSensitive: false), + lookbehind: true, + greedy: true, + alias: "class-name", + inside: () => _g8), + GrammarToken("constant", + compileHighlightPattern("\\b(?:false|true)\\b", caseSensitive: false), + alias: "boolean"), + GrammarToken( + "constant", + compileHighlightPattern("(::\\s*)\\b[a-z_]\\w*\\b(?!\\s*\\()", + caseSensitive: false), + lookbehind: true, + greedy: true), + GrammarToken( + "constant", + compileHighlightPattern( + "(\\b(?:case|const)\\s+)\\b[a-z_]\\w*(?=\\s*[;=])", + caseSensitive: false), + lookbehind: true, + greedy: true), + GrammarToken("constant", + compileHighlightPattern("\\b(?:null)\\b", caseSensitive: false)), + GrammarToken( + "constant", compileHighlightPattern("\\b[A-Z_][A-Z0-9_]*\\b(?!\\s*\\()")), + GrammarToken( + "number", + compileHighlightPattern( + "\\b0b[01]+(?:_[01]+)*\\b|\\b0o[0-7]+(?:_[0-7]+)*\\b|\\b0x[\\da-f]+(?:_[\\da-f]+)*\\b|(?:\\b\\d+(?:_\\d+)*\\.?(?:\\d+(?:_\\d+)*)?|\\B\\.\\d+)(?:e[+-]?\\d+)?", + caseSensitive: false)), + GrammarToken( + "operator", + compileHighlightPattern( + "|\\?\\?=?|\\.{3}|\\??->|[!=]=?=?|::|\\*\\*=?|--|\\+\\+|&&|\\|\\||<<|>>|[?~]|[/^|%*&<>.+-]=?")), + GrammarToken("punctuation", compileHighlightPattern("[{}\\[\\](),:;]")), +]); + +final Grammar _g8 = Grammar([ + GrammarToken("punctuation", compileHighlightPattern("\\\\")), +]); + +final Grammar _g9 = Grammar([ + GrammarToken("punctuation", compileHighlightPattern("\\\\")), +]); + +final Grammar _g10 = Grammar([ + GrammarToken("punctuation", compileHighlightPattern("\\\\")), +]); + +final Grammar _g11 = Grammar([ + GrammarToken("punctuation", compileHighlightPattern("\\\\")), +]); + +final Grammar _g12 = Grammar([ + GrammarToken("punctuation", compileHighlightPattern("\\\\")), +]); + +final Grammar _g13 = Grammar([ + GrammarToken("punctuation", compileHighlightPattern("\\\\")), +]); + +final Grammar _g14 = Grammar([ + GrammarToken("punctuation", compileHighlightPattern("\\\\")), +]); + +final Grammar _g15 = Grammar([ + GrammarToken("punctuation", compileHighlightPattern("\\\\")), +]); + +final Grammar _g16 = Grammar([ + GrammarToken("punctuation", compileHighlightPattern("\\\\")), +]); + +final Grammar _g17 = Grammar([ + GrammarToken("punctuation", compileHighlightPattern("\\\\")), +]); diff --git a/lib/highlight/plain.dart b/lib/highlight/plain.dart new file mode 100644 index 0000000..2465066 --- /dev/null +++ b/lib/highlight/plain.dart @@ -0,0 +1,16 @@ +// GENERATED CODE — do not modify by hand. Regenerate with tool/highlight_codegen. +// ignore_for_file: lines_longer_than_80_chars, public_member_api_docs +// ignore_for_file: prefer_single_quotes, require_trailing_commas, directives_ordering + +import '../highlight.dart'; + +/// Syntax grammar for `plain`. +/// +/// Import this library only when you need `plain` highlighting; unused +/// languages are dropped from the build. +abstract final class HighlightPlain { + /// The grammar for `plain`. + static final Grammar grammar = _g0; +} + +final Grammar _g0 = Grammar([]); diff --git a/lib/highlight/powershell.dart b/lib/highlight/powershell.dart new file mode 100644 index 0000000..6c3bdd6 --- /dev/null +++ b/lib/highlight/powershell.dart @@ -0,0 +1,67 @@ +// GENERATED CODE — do not modify by hand. Regenerate with tool/highlight_codegen. +// ignore_for_file: lines_longer_than_80_chars, public_member_api_docs +// ignore_for_file: prefer_single_quotes, require_trailing_commas, directives_ordering + +import '../highlight.dart'; + +/// Syntax grammar for `powershell`. +/// +/// Import this library only when you need `powershell` highlighting; unused +/// languages are dropped from the build. +abstract final class HighlightPowershell { + /// The grammar for `powershell`. + static final Grammar grammar = _g0; +} + +final Grammar _g0 = Grammar([ + GrammarToken("comment", compileHighlightPattern("(^|[^`])<#[\\s\\S]*?#>"), + lookbehind: true), + GrammarToken("comment", compileHighlightPattern("(^|[^`])#.*"), + lookbehind: true), + GrammarToken("string", compileHighlightPattern("\"(?:`[\\s\\S]|[^`\"])*\""), + greedy: true, inside: () => _g1), + GrammarToken("string", compileHighlightPattern("'(?:[^']|'')*'"), + greedy: true), + GrammarToken( + "namespace", + compileHighlightPattern( + "\\[[a-z](?:\\[(?:\\[[^\\]]*\\]|[^\\[\\]])*\\]|[^\\[\\]])*\\]", + caseSensitive: false)), + GrammarToken("boolean", + compileHighlightPattern("\\\$(?:false|true)\\b", caseSensitive: false)), + GrammarToken("variable", compileHighlightPattern("\\\$\\w+\\b")), + GrammarToken( + "function", + compileHighlightPattern( + "\\b(?:Add|Approve|Assert|Backup|Block|Checkpoint|Clear|Close|Compare|Complete|Compress|Confirm|Connect|Convert|ConvertFrom|ConvertTo|Copy|Debug|Deny|Disable|Disconnect|Dismount|Edit|Enable|Enter|Exit|Expand|Export|Find|ForEach|Format|Get|Grant|Group|Hide|Import|Initialize|Install|Invoke|Join|Limit|Lock|Measure|Merge|Move|New|Open|Optimize|Out|Ping|Pop|Protect|Publish|Push|Read|Receive|Redo|Register|Remove|Rename|Repair|Request|Reset|Resize|Resolve|Restart|Restore|Resume|Revoke|Save|Search|Select|Send|Set|Show|Skip|Sort|Split|Start|Step|Stop|Submit|Suspend|Switch|Sync|Tee|Test|Trace|Unblock|Undo|Uninstall|Unlock|Unprotect|Unpublish|Unregister|Update|Use|Wait|Watch|Where|Write)-[a-z]+\\b", + caseSensitive: false)), + GrammarToken( + "function", + compileHighlightPattern( + "\\b(?:ac|cat|chdir|clc|cli|clp|clv|compare|copy|cp|cpi|cpp|cvpa|dbp|del|diff|dir|ebp|echo|epal|epcsv|epsn|erase|fc|fl|ft|fw|gal|gbp|gc|gci|gcs|gdr|gi|gl|gm|gp|gps|group|gsv|gu|gv|gwmi|iex|ii|ipal|ipcsv|ipsn|irm|iwmi|iwr|kill|lp|ls|measure|mi|mount|move|mp|mv|nal|ndr|ni|nv|ogv|popd|ps|pushd|pwd|rbp|rd|rdr|ren|ri|rm|rmdir|rni|rnp|rp|rv|rvpa|rwmi|sal|saps|sasv|sbp|sc|select|set|shcm|si|sl|sleep|sls|sort|sp|spps|spsv|start|sv|swmi|tee|trcm|type|write)\\b", + caseSensitive: false)), + GrammarToken( + "keyword", + compileHighlightPattern( + "\\b(?:Begin|Break|Catch|Class|Continue|Data|Define|Do|DynamicParam|Else|ElseIf|End|Exit|Filter|Finally|For|ForEach|From|Function|If|InlineScript|Parallel|Param|Process|Return|Sequence|Switch|Throw|Trap|Try|Until|Using|Var|While|Workflow)\\b", + caseSensitive: false)), + GrammarToken( + "operator", + compileHighlightPattern( + "(^|\\W)(?:!|-(?:b?(?:and|x?or)|as|(?:Not)?(?:Contains|In|Like|Match)|eq|ge|gt|is(?:Not)?|Join|le|lt|ne|not|Replace|sh[lr])\\b|-[-=]?|\\+[+=]?|[*\\/%]=?)", + caseSensitive: false), + lookbehind: true), + GrammarToken("punctuation", compileHighlightPattern("[|{}[\\];(),.]")), +]); + +final Grammar _g1 = Grammar([ + GrammarToken( + "function", + compileHighlightPattern( + "(^|[^`])\\\$\\((?:\\\$\\([^\\r\\n()]*\\)|(?!\\\$\\()[^\\r\\n)])*\\)"), + lookbehind: true, + inside: () => _g0), + GrammarToken("boolean", + compileHighlightPattern("\\\$(?:false|true)\\b", caseSensitive: false)), + GrammarToken("variable", compileHighlightPattern("\\\$\\w+\\b")), +]); diff --git a/lib/highlight/protobuf.dart b/lib/highlight/protobuf.dart new file mode 100644 index 0000000..0087eb6 --- /dev/null +++ b/lib/highlight/protobuf.dart @@ -0,0 +1,91 @@ +// GENERATED CODE — do not modify by hand. Regenerate with tool/highlight_codegen. +// ignore_for_file: lines_longer_than_80_chars, public_member_api_docs +// ignore_for_file: prefer_single_quotes, require_trailing_commas, directives_ordering + +import '../highlight.dart'; + +/// Syntax grammar for `protobuf`. +/// +/// Import this library only when you need `protobuf` highlighting; unused +/// languages are dropped from the build. +abstract final class HighlightProtobuf { + /// The grammar for `protobuf`. + static final Grammar grammar = _g0; +} + +final Grammar _g0 = Grammar([ + GrammarToken("comment", + compileHighlightPattern("(^|[^\\\\])\\/\\*[\\s\\S]*?(?:\\*\\/|\$)"), + lookbehind: true, greedy: true), + GrammarToken("comment", compileHighlightPattern("(^|[^\\\\:])\\/\\/.*"), + lookbehind: true, greedy: true), + GrammarToken( + "string", + compileHighlightPattern( + "([\"'])(?:\\\\(?:\\r\\n|[\\s\\S])|(?!\\1)[^\\\\\\r\\n])*\\1"), + greedy: true), + GrammarToken( + "class-name", + compileHighlightPattern( + "(\\b(?:enum|extend|message|service)\\s+)[A-Za-z_]\\w*(?=\\s*\\{)"), + lookbehind: true), + GrammarToken( + "class-name", + compileHighlightPattern( + "(\\b(?:rpc\\s+\\w+|returns)\\s*\\(\\s*(?:stream\\s+)?)\\.?[A-Za-z_]\\w*(?:\\.[A-Za-z_]\\w*)*(?=\\s*\\))"), + lookbehind: true), + GrammarToken( + "keyword", + compileHighlightPattern( + "\\b(?:enum|extend|extensions|import|message|oneof|option|optional|package|public|repeated|required|reserved|returns|rpc(?=\\s+\\w)|service|stream|syntax|to)\\b(?!\\s*=\\s*\\d)")), + GrammarToken("boolean", compileHighlightPattern("\\b(?:false|true)\\b")), + GrammarToken( + "function", + compileHighlightPattern("\\b[a-z_]\\w*(?=\\s*\\()", + caseSensitive: false)), + GrammarToken( + "number", + compileHighlightPattern( + "\\b0x[\\da-f]+\\b|(?:\\b\\d+(?:\\.\\d*)?|\\B\\.\\d+)(?:e[+-]?\\d+)?", + caseSensitive: false)), + GrammarToken( + "map", + compileHighlightPattern( + "\\bmap<\\s*[\\w.]+\\s*,\\s*[\\w.]+\\s*>(?=\\s+[a-z_]\\w*\\s*[=;])", + caseSensitive: false), + alias: "class-name", + inside: () => _g1), + GrammarToken( + "builtin", + compileHighlightPattern( + "\\b(?:bool|bytes|double|s?fixed(?:32|64)|float|[su]?int(?:32|64)|string)\\b")), + GrammarToken( + "positional-class-name", + compileHighlightPattern( + "(?:\\b|\\B\\.)[a-z_]\\w*(?:\\.[a-z_]\\w*)*(?=\\s+[a-z_]\\w*\\s*[=;])", + caseSensitive: false), + alias: "class-name", + inside: () => _g2), + GrammarToken( + "annotation", + compileHighlightPattern("(\\[\\s*)[a-z_]\\w*(?=\\s*=)", + caseSensitive: false), + lookbehind: true), + GrammarToken( + "operator", + compileHighlightPattern( + "[<>]=?|[!=]=?=?|--?|\\+\\+?|&&?|\\|\\|?|[?*/~^%]")), + GrammarToken("punctuation", compileHighlightPattern("[{}[\\];(),.:]")), +]); + +final Grammar _g1 = Grammar([ + GrammarToken("punctuation", compileHighlightPattern("[<>.,]")), + GrammarToken( + "builtin", + compileHighlightPattern( + "\\b(?:bool|bytes|double|s?fixed(?:32|64)|float|[su]?int(?:32|64)|string)\\b")), +]); + +final Grammar _g2 = Grammar([ + GrammarToken("punctuation", compileHighlightPattern("\\.")), +]); diff --git a/lib/highlight/python.dart b/lib/highlight/python.dart new file mode 100644 index 0000000..dda6687 --- /dev/null +++ b/lib/highlight/python.dart @@ -0,0 +1,87 @@ +// GENERATED CODE — do not modify by hand. Regenerate with tool/highlight_codegen. +// ignore_for_file: lines_longer_than_80_chars, public_member_api_docs +// ignore_for_file: prefer_single_quotes, require_trailing_commas, directives_ordering + +import '../highlight.dart'; + +/// Syntax grammar for `python`. +/// +/// Import this library only when you need `python` highlighting; unused +/// languages are dropped from the build. +abstract final class HighlightPython { + /// The grammar for `python`. + static final Grammar grammar = _g0; +} + +final Grammar _g0 = Grammar([ + GrammarToken("comment", compileHighlightPattern("(^|[^\\\\])#.*"), + lookbehind: true, greedy: true), + GrammarToken( + "string-interpolation", + compileHighlightPattern( + "(?:f|fr|rf)(?:(\"\"\"|''')[\\s\\S]*?\\1|(\"|')(?:\\\\.|(?!\\2)[^\\\\\\r\\n])*\\2)", + caseSensitive: false), + greedy: true, + inside: () => _g1), + GrammarToken( + "triple-quoted-string", + compileHighlightPattern("(?:[rub]|br|rb)?(\"\"\"|''')[\\s\\S]*?\\1", + caseSensitive: false), + greedy: true, + alias: "string"), + GrammarToken( + "string", + compileHighlightPattern( + "(?:[rub]|br|rb)?(\"|')(?:\\\\.|(?!\\1)[^\\\\\\r\\n])*\\1", + caseSensitive: false), + greedy: true), + GrammarToken("function", + compileHighlightPattern("((?:^|\\s)def[ \\t]+)[a-zA-Z_]\\w*(?=\\s*\\()"), + lookbehind: true), + GrammarToken("class-name", + compileHighlightPattern("(\\bclass\\s+)\\w+", caseSensitive: false), + lookbehind: true), + GrammarToken("decorator", + compileHighlightPattern("(^[\\t ]*)@\\w+(?:\\.\\w+)*", multiLine: true), + lookbehind: true, alias: "annotation", inside: () => _g3), + GrammarToken( + "keyword", + compileHighlightPattern( + "\\b(?:_(?=\\s*:)|and|as|assert|async|await|break|case|class|continue|def|del|elif|else|except|exec|finally|for|from|global|if|import|in|is|lambda|match|nonlocal|not|or|pass|print|raise|return|try|while|with|yield)\\b")), + GrammarToken( + "builtin", + compileHighlightPattern( + "\\b(?:__import__|abs|all|any|apply|ascii|basestring|bin|bool|buffer|bytearray|bytes|callable|chr|classmethod|cmp|coerce|compile|complex|delattr|dict|dir|divmod|enumerate|eval|execfile|file|filter|float|format|frozenset|getattr|globals|hasattr|hash|help|hex|id|input|int|intern|isinstance|issubclass|iter|len|list|locals|long|map|max|memoryview|min|next|object|oct|open|ord|pow|property|range|raw_input|reduce|reload|repr|reversed|round|set|setattr|slice|sorted|staticmethod|str|sum|super|tuple|type|unichr|unicode|vars|xrange|zip)\\b")), + GrammarToken("boolean", compileHighlightPattern("\\b(?:False|None|True)\\b")), + GrammarToken( + "number", + compileHighlightPattern( + "\\b0(?:b(?:_?[01])+|o(?:_?[0-7])+|x(?:_?[a-f0-9])+)\\b|(?:\\b\\d+(?:_\\d+)*(?:\\.(?:\\d+(?:_\\d+)*)?)?|\\B\\.\\d+(?:_\\d+)*)(?:e[+-]?\\d+(?:_\\d+)*)?j?(?!\\w)", + caseSensitive: false)), + GrammarToken( + "operator", + compileHighlightPattern( + "[-+%=]=?|!=|:=|\\*\\*?=?|\\/\\/?=?|<[<=>]?|>[=>]?|[&|^~]")), + GrammarToken("punctuation", compileHighlightPattern("[{}[\\];(),.:]")), +]); + +final Grammar _g1 = Grammar([ + GrammarToken( + "interpolation", + compileHighlightPattern( + "((?:^|[^{])(?:\\{\\{)*)\\{(?!\\{)(?:[^{}]|\\{(?!\\{)(?:[^{}]|\\{(?!\\{)(?:[^{}])+\\})+\\})+\\}"), + lookbehind: true, + inside: () => _g2), + GrammarToken("string", compileHighlightPattern("[\\s\\S]+")), +]); + +final Grammar _g2 = Grammar([ + GrammarToken("format-spec", compileHighlightPattern("(:)[^:(){}]+(?=\\}\$)"), + lookbehind: true), + GrammarToken("conversion-option", compileHighlightPattern("![sra](?=[:}]\$)"), + alias: "punctuation"), +], rest: () => _g0); + +final Grammar _g3 = Grammar([ + GrammarToken("punctuation", compileHighlightPattern("\\.")), +]); diff --git a/lib/highlight/r.dart b/lib/highlight/r.dart new file mode 100644 index 0000000..1337b89 --- /dev/null +++ b/lib/highlight/r.dart @@ -0,0 +1,39 @@ +// GENERATED CODE — do not modify by hand. Regenerate with tool/highlight_codegen. +// ignore_for_file: lines_longer_than_80_chars, public_member_api_docs +// ignore_for_file: prefer_single_quotes, require_trailing_commas, directives_ordering + +import '../highlight.dart'; + +/// Syntax grammar for `r`. +/// +/// Import this library only when you need `r` highlighting; unused +/// languages are dropped from the build. +abstract final class HighlightR { + /// The grammar for `r`. + static final Grammar grammar = _g0; +} + +final Grammar _g0 = Grammar([ + GrammarToken("comment", compileHighlightPattern("#.*")), + GrammarToken("string", + compileHighlightPattern("(['\"])(?:\\\\.|(?!\\1)[^\\\\\\r\\n])*\\1"), + greedy: true), + GrammarToken("percent-operator", compileHighlightPattern("%[^%\\s]*%"), + alias: "operator"), + GrammarToken("boolean", compileHighlightPattern("\\b(?:FALSE|TRUE)\\b")), + GrammarToken("ellipsis", compileHighlightPattern("\\.\\.(?:\\.|\\d+)")), + GrammarToken("number", compileHighlightPattern("\\b(?:Inf|NaN)\\b")), + GrammarToken( + "number", + compileHighlightPattern( + "(?:\\b0x[\\dA-Fa-f]+(?:\\.\\d*)?|\\b\\d+(?:\\.\\d*)?|\\B\\.\\d+)(?:[EePp][+-]?\\d+)?[iL]?")), + GrammarToken( + "keyword", + compileHighlightPattern( + "\\b(?:NA|NA_character_|NA_complex_|NA_integer_|NA_real_|NULL|break|else|for|function|if|in|next|repeat|while)\\b")), + GrammarToken( + "operator", + compileHighlightPattern( + "->?>?|<(?:=|=!]=?|::?|&&?|\\|\\|?|[+*\\/^\$@~]")), + GrammarToken("punctuation", compileHighlightPattern("[(){}\\[\\],;]")), +]); diff --git a/lib/highlight/regex.dart b/lib/highlight/regex.dart new file mode 100644 index 0000000..9948974 --- /dev/null +++ b/lib/highlight/regex.dart @@ -0,0 +1,96 @@ +// GENERATED CODE — do not modify by hand. Regenerate with tool/highlight_codegen. +// ignore_for_file: lines_longer_than_80_chars, public_member_api_docs +// ignore_for_file: prefer_single_quotes, require_trailing_commas, directives_ordering + +import '../highlight.dart'; + +/// Syntax grammar for `regex`. +/// +/// Import this library only when you need `regex` highlighting; unused +/// languages are dropped from the build. +abstract final class HighlightRegex { + /// The grammar for `regex`. + static final Grammar grammar = _g0; +} + +final Grammar _g0 = Grammar([ + GrammarToken( + "char-class", + compileHighlightPattern( + "((?:^|[^\\\\])(?:\\\\\\\\)*)\\[(?:[^\\\\\\]]|\\\\[\\s\\S])*\\]"), + lookbehind: true, + inside: () => _g1), + GrammarToken( + "special-escape", compileHighlightPattern("\\\\[\\\\(){}[\\]^\$+*?|.]"), + alias: "escape"), + GrammarToken( + "char-set", + compileHighlightPattern("\\.|\\\\[wsd]|\\\\p\\{[^{}]+\\}", + caseSensitive: false), + alias: "class-name"), + GrammarToken( + "backreference", compileHighlightPattern("\\\\(?![123][0-7]{2})[1-9]"), + alias: "keyword"), + GrammarToken("backreference", compileHighlightPattern("\\\\k<[^<>']+>"), + alias: "keyword", inside: () => _g3), + GrammarToken("anchor", compileHighlightPattern("[\$^]|\\\\[ABbGZz]"), + alias: "function"), + GrammarToken( + "escape", + compileHighlightPattern( + "\\\\(?:x[\\da-fA-F]{2}|u[\\da-fA-F]{4}|u\\{[\\da-fA-F]+\\}|0[0-7]{0,2}|[123][0-7]{2}|c[a-zA-Z]|.)")), + GrammarToken( + "group", + compileHighlightPattern( + "\\((?:\\?(?:<[^<>']+>|'[^<>']+'|[>:]| _g4), + GrammarToken("group", compileHighlightPattern("\\)"), alias: "punctuation"), + GrammarToken("quantifier", + compileHighlightPattern("(?:[+*?]|\\{\\d+(?:,\\d*)?\\})[?+]?"), + alias: "number"), + GrammarToken("alternation", compileHighlightPattern("\\|"), alias: "keyword"), +]); + +final Grammar _g1 = Grammar([ + GrammarToken("char-class-negation", compileHighlightPattern("(^\\[)\\^"), + lookbehind: true, alias: "operator"), + GrammarToken("char-class-punctuation", compileHighlightPattern("^\\[|\\]\$"), + alias: "punctuation"), + GrammarToken( + "range", + compileHighlightPattern( + "(?:[^\\\\-]|\\\\(?:x[\\da-fA-F]{2}|u[\\da-fA-F]{4}|u\\{[\\da-fA-F]+\\}|0[0-7]{0,2}|[123][0-7]{2}|c[a-zA-Z]|.))-(?:[^\\\\-]|\\\\(?:x[\\da-fA-F]{2}|u[\\da-fA-F]{4}|u\\{[\\da-fA-F]+\\}|0[0-7]{0,2}|[123][0-7]{2}|c[a-zA-Z]|.))"), + inside: () => _g2), + GrammarToken( + "special-escape", compileHighlightPattern("\\\\[\\\\(){}[\\]^\$+*?|.]"), + alias: "escape"), + GrammarToken( + "char-set", + compileHighlightPattern("\\\\[wsd]|\\\\p\\{[^{}]+\\}", + caseSensitive: false), + alias: "class-name"), + GrammarToken( + "escape", + compileHighlightPattern( + "\\\\(?:x[\\da-fA-F]{2}|u[\\da-fA-F]{4}|u\\{[\\da-fA-F]+\\}|0[0-7]{0,2}|[123][0-7]{2}|c[a-zA-Z]|.)")), +]); + +final Grammar _g2 = Grammar([ + GrammarToken( + "escape", + compileHighlightPattern( + "\\\\(?:x[\\da-fA-F]{2}|u[\\da-fA-F]{4}|u\\{[\\da-fA-F]+\\}|0[0-7]{0,2}|[123][0-7]{2}|c[a-zA-Z]|.)")), + GrammarToken("range-punctuation", compileHighlightPattern("-"), + alias: "operator"), +]); + +final Grammar _g3 = Grammar([ + GrammarToken("group-name", compileHighlightPattern("(<|')[^<>']+(?=[>']\$)"), + lookbehind: true, alias: "variable"), +]); + +final Grammar _g4 = Grammar([ + GrammarToken("group-name", compileHighlightPattern("(<|')[^<>']+(?=[>']\$)"), + lookbehind: true, alias: "variable"), +]); diff --git a/lib/highlight/ruby.dart b/lib/highlight/ruby.dart new file mode 100644 index 0000000..c97078e --- /dev/null +++ b/lib/highlight/ruby.dart @@ -0,0 +1,233 @@ +// GENERATED CODE — do not modify by hand. Regenerate with tool/highlight_codegen. +// ignore_for_file: lines_longer_than_80_chars, public_member_api_docs +// ignore_for_file: prefer_single_quotes, require_trailing_commas, directives_ordering + +import '../highlight.dart'; + +/// Syntax grammar for `ruby`. +/// +/// Import this library only when you need `ruby` highlighting; unused +/// languages are dropped from the build. +abstract final class HighlightRuby { + /// The grammar for `ruby`. + static final Grammar grammar = _g0; +} + +final Grammar _g0 = Grammar([ + GrammarToken("comment", + compileHighlightPattern("#.*|^=begin\\s[\\s\\S]*?^=end", multiLine: true), + greedy: true), + GrammarToken( + "string-literal", + compileHighlightPattern( + "%[qQiIwWs]?(?:([^a-zA-Z0-9\\s{(\\[<=])(?:(?!\\1)[^\\\\]|\\\\[\\s\\S])*\\1|\\((?:[^()\\\\]|\\\\[\\s\\S]|\\((?:[^()\\\\]|\\\\[\\s\\S])*\\))*\\)|\\{(?:[^{}\\\\]|\\\\[\\s\\S]|\\{(?:[^{}\\\\]|\\\\[\\s\\S])*\\})*\\}|\\[(?:[^\\[\\]\\\\]|\\\\[\\s\\S]|\\[(?:[^\\[\\]\\\\]|\\\\[\\s\\S])*\\])*\\]|<(?:[^<>\\\\]|\\\\[\\s\\S]|<(?:[^<>\\\\]|\\\\[\\s\\S])*>)*>)"), + greedy: true, + inside: () => _g1), + GrammarToken( + "string-literal", + compileHighlightPattern( + "(\"|')(?:#\\{[^}]+\\}|#(?!\\{)|\\\\(?:\\r\\n|[\\s\\S])|(?!\\1)[^\\\\#\\r\\n])*\\1"), + greedy: true, + inside: () => _g3), + GrammarToken( + "string-literal", + compileHighlightPattern( + "<<[-~]?([a-z_]\\w*)[\\r\\n](?:.*[\\r\\n])*?[\\t ]*\\1", + caseSensitive: false), + greedy: true, + alias: "heredoc-string", + inside: () => _g4), + GrammarToken( + "string-literal", + compileHighlightPattern( + "<<[-~]?'([a-z_]\\w*)'[\\r\\n](?:.*[\\r\\n])*?[\\t ]*\\1", + caseSensitive: false), + greedy: true, + alias: "heredoc-string", + inside: () => _g6), + GrammarToken( + "command-literal", + compileHighlightPattern( + "%x(?:([^a-zA-Z0-9\\s{(\\[<=])(?:(?!\\1)[^\\\\]|\\\\[\\s\\S])*\\1|\\((?:[^()\\\\]|\\\\[\\s\\S]|\\((?:[^()\\\\]|\\\\[\\s\\S])*\\))*\\)|\\{(?:[^{}\\\\]|\\\\[\\s\\S]|\\{(?:[^{}\\\\]|\\\\[\\s\\S])*\\})*\\}|\\[(?:[^\\[\\]\\\\]|\\\\[\\s\\S]|\\[(?:[^\\[\\]\\\\]|\\\\[\\s\\S])*\\])*\\]|<(?:[^<>\\\\]|\\\\[\\s\\S]|<(?:[^<>\\\\]|\\\\[\\s\\S])*>)*>)"), + greedy: true, + inside: () => _g8), + GrammarToken( + "command-literal", + compileHighlightPattern( + "`(?:#\\{[^}]+\\}|#(?!\\{)|\\\\(?:\\r\\n|[\\s\\S])|[^\\\\`#\\r\\n])*`"), + greedy: true, + inside: () => _g9), + GrammarToken( + "class-name", + compileHighlightPattern( + "(\\b(?:class|module)\\s+|\\bcatch\\s+\\()[\\w.\\\\]+|\\b[A-Z_]\\w*(?=\\s*\\.\\s*new\\b)"), + lookbehind: true, + inside: () => _g10), + GrammarToken( + "regex-literal", + compileHighlightPattern( + "%r(?:([^a-zA-Z0-9\\s{(\\[<=])(?:(?!\\1)[^\\\\]|\\\\[\\s\\S])*\\1|\\((?:[^()\\\\]|\\\\[\\s\\S]|\\((?:[^()\\\\]|\\\\[\\s\\S])*\\))*\\)|\\{(?:[^{}\\\\]|\\\\[\\s\\S]|\\{(?:[^{}\\\\]|\\\\[\\s\\S])*\\})*\\}|\\[(?:[^\\[\\]\\\\]|\\\\[\\s\\S]|\\[(?:[^\\[\\]\\\\]|\\\\[\\s\\S])*\\])*\\]|<(?:[^<>\\\\]|\\\\[\\s\\S]|<(?:[^<>\\\\]|\\\\[\\s\\S])*>)*>)[egimnosux]{0,6}"), + greedy: true, + inside: () => _g11), + GrammarToken( + "regex-literal", + compileHighlightPattern( + "(^|[^/])\\/(?!\\/)(?:\\[[^\\r\\n\\]]+\\]|\\\\.|[^[/\\\\\\r\\n])+\\/[egimnosux]{0,6}(?=\\s*(?:\$|[\\r\\n,.;})#]))"), + lookbehind: true, + greedy: true, + inside: () => _g12), + GrammarToken( + "variable", compileHighlightPattern("[@\$]+[a-zA-Z_]\\w*(?:[?!]|\\b)")), + GrammarToken( + "symbol", + compileHighlightPattern( + "(^|[^:]):(?:\"(?:\\\\.|[^\"\\\\\\r\\n])*\"|(?:\\b[a-zA-Z_]\\w*|[^\\s\\0-\\x7F]+)[?!]?|\\\$.)"), + lookbehind: true, + greedy: true), + GrammarToken( + "symbol", + compileHighlightPattern( + "([\\r\\n{(,][ \\t]*)(?:\"(?:\\\\.|[^\"\\\\\\r\\n])*\"|(?:\\b[a-zA-Z_]\\w*|[^\\s\\0-\\x7F]+)[?!]?|\\\$.)(?=:(?!:))"), + lookbehind: true, + greedy: true), + GrammarToken("method-definition", + compileHighlightPattern("(\\bdef\\s+)\\w+(?:\\s*\\.\\s*\\w+)?"), + lookbehind: true, inside: () => _g13), + GrammarToken( + "keyword", + compileHighlightPattern( + "\\b(?:BEGIN|END|alias|and|begin|break|case|class|def|define_method|defined|do|each|else|elsif|end|ensure|extend|for|if|in|include|module|new|next|nil|not|or|prepend|private|protected|public|raise|redo|require|rescue|retry|return|self|super|then|throw|undef|unless|until|when|while|yield)\\b")), + GrammarToken("boolean", compileHighlightPattern("\\b(?:false|true)\\b")), + GrammarToken( + "builtin", + compileHighlightPattern( + "\\b(?:Array|Bignum|Binding|Class|Continuation|Dir|Exception|FalseClass|File|Fixnum|Float|Hash|IO|Integer|MatchData|Method|Module|NilClass|Numeric|Object|Proc|Range|Regexp|Stat|String|Struct|Symbol|TMS|Thread|ThreadGroup|Time|TrueClass)\\b")), + GrammarToken( + "constant", compileHighlightPattern("\\b[A-Z][A-Z0-9_]*(?:[?!]|\\b)")), + GrammarToken( + "number", + compileHighlightPattern( + "\\b0x[\\da-f]+\\b|(?:\\b\\d+(?:\\.\\d*)?|\\B\\.\\d+)(?:e[+-]?\\d+)?", + caseSensitive: false)), + GrammarToken("double-colon", compileHighlightPattern("::"), + alias: "punctuation"), + GrammarToken( + "operator", + compileHighlightPattern( + "\\.{2,3}|&\\.|===||[!=]?~|(?:&&|\\|\\||<<|>>|\\*\\*|[+\\-*/%<>!^&|=])=?|[?:]")), + GrammarToken("punctuation", compileHighlightPattern("[(){}[\\].,;]")), +]); + +final Grammar _g1 = Grammar([ + GrammarToken( + "interpolation", + compileHighlightPattern( + "((?:^|[^\\\\])(?:\\\\{2})*)#\\{(?:[^{}]|\\{[^{}]*\\})*\\}"), + lookbehind: true, + inside: () => _g2), + GrammarToken("string", compileHighlightPattern("[\\s\\S]+")), +]); + +final Grammar _g2 = Grammar([ + GrammarToken("content", compileHighlightPattern("^(#\\{)[\\s\\S]+(?=\\}\$)"), + lookbehind: true, inside: () => _g0), + GrammarToken("delimiter", compileHighlightPattern("^#\\{|\\}\$"), + alias: "punctuation"), +]); + +final Grammar _g3 = Grammar([ + GrammarToken( + "interpolation", + compileHighlightPattern( + "((?:^|[^\\\\])(?:\\\\{2})*)#\\{(?:[^{}]|\\{[^{}]*\\})*\\}"), + lookbehind: true, + inside: () => _g2), + GrammarToken("string", compileHighlightPattern("[\\s\\S]+")), +]); + +final Grammar _g4 = Grammar([ + GrammarToken( + "delimiter", + compileHighlightPattern("^<<[-~]?[a-z_]\\w*|\\b[a-z_]\\w*\$", + caseSensitive: false), + inside: () => _g5), + GrammarToken( + "interpolation", + compileHighlightPattern( + "((?:^|[^\\\\])(?:\\\\{2})*)#\\{(?:[^{}]|\\{[^{}]*\\})*\\}"), + lookbehind: true, + inside: () => _g2), + GrammarToken("string", compileHighlightPattern("[\\s\\S]+")), +]); + +final Grammar _g5 = Grammar([ + GrammarToken("symbol", compileHighlightPattern("\\b\\w+")), + GrammarToken("punctuation", compileHighlightPattern("^<<[-~]?")), +]); + +final Grammar _g6 = Grammar([ + GrammarToken( + "delimiter", + compileHighlightPattern("^<<[-~]?'[a-z_]\\w*'|\\b[a-z_]\\w*\$", + caseSensitive: false), + inside: () => _g7), + GrammarToken("string", compileHighlightPattern("[\\s\\S]+")), +]); + +final Grammar _g7 = Grammar([ + GrammarToken("symbol", compileHighlightPattern("\\b\\w+")), + GrammarToken("punctuation", compileHighlightPattern("^<<[-~]?'|'\$")), +]); + +final Grammar _g8 = Grammar([ + GrammarToken( + "interpolation", + compileHighlightPattern( + "((?:^|[^\\\\])(?:\\\\{2})*)#\\{(?:[^{}]|\\{[^{}]*\\})*\\}"), + lookbehind: true, + inside: () => _g2), + GrammarToken("command", compileHighlightPattern("[\\s\\S]+"), + alias: "string"), +]); + +final Grammar _g9 = Grammar([ + GrammarToken( + "interpolation", + compileHighlightPattern( + "((?:^|[^\\\\])(?:\\\\{2})*)#\\{(?:[^{}]|\\{[^{}]*\\})*\\}"), + lookbehind: true, + inside: () => _g2), + GrammarToken("command", compileHighlightPattern("[\\s\\S]+"), + alias: "string"), +]); + +final Grammar _g10 = Grammar([ + GrammarToken("punctuation", compileHighlightPattern("[.\\\\]")), +]); + +final Grammar _g11 = Grammar([ + GrammarToken( + "interpolation", + compileHighlightPattern( + "((?:^|[^\\\\])(?:\\\\{2})*)#\\{(?:[^{}]|\\{[^{}]*\\})*\\}"), + lookbehind: true, + inside: () => _g2), + GrammarToken("regex", compileHighlightPattern("[\\s\\S]+")), +]); + +final Grammar _g12 = Grammar([ + GrammarToken( + "interpolation", + compileHighlightPattern( + "((?:^|[^\\\\])(?:\\\\{2})*)#\\{(?:[^{}]|\\{[^{}]*\\})*\\}"), + lookbehind: true, + inside: () => _g2), + GrammarToken("regex", compileHighlightPattern("[\\s\\S]+")), +]); + +final Grammar _g13 = Grammar([ + GrammarToken("function", compileHighlightPattern("\\b\\w+\$")), + GrammarToken("keyword", compileHighlightPattern("^self\\b")), + GrammarToken("class-name", compileHighlightPattern("^\\w+")), + GrammarToken("punctuation", compileHighlightPattern("\\.")), +]); diff --git a/lib/highlight/rust.dart b/lib/highlight/rust.dart new file mode 100644 index 0000000..0cc8d43 --- /dev/null +++ b/lib/highlight/rust.dart @@ -0,0 +1,121 @@ +// GENERATED CODE — do not modify by hand. Regenerate with tool/highlight_codegen. +// ignore_for_file: lines_longer_than_80_chars, public_member_api_docs +// ignore_for_file: prefer_single_quotes, require_trailing_commas, directives_ordering + +import '../highlight.dart'; + +/// Syntax grammar for `rust`. +/// +/// Import this library only when you need `rust` highlighting; unused +/// languages are dropped from the build. +abstract final class HighlightRust { + /// The grammar for `rust`. + static final Grammar grammar = _g0; +} + +final Grammar _g0 = Grammar([ + GrammarToken( + "comment", + compileHighlightPattern( + "(^|[^\\\\])\\/\\*(?:[^*/]|\\*(?!\\/)|\\/(?!\\*)|\\/\\*(?:[^*/]|\\*(?!\\/)|\\/(?!\\*)|\\/\\*(?:[^*/]|\\*(?!\\/)|\\/(?!\\*)|\\/\\*(?:[^*/]|\\*(?!\\/)|\\/(?!\\*)|[^\\s\\S])*\\*\\/)*\\*\\/)*\\*\\/)*\\*\\/"), + lookbehind: true, + greedy: true), + GrammarToken("comment", compileHighlightPattern("(^|[^\\\\:])\\/\\/.*"), + lookbehind: true, greedy: true), + GrammarToken( + "string", + compileHighlightPattern( + "b?\"(?:\\\\[\\s\\S]|[^\\\\\"])*\"|b?r(#*)\"(?:[^\"]|\"(?!\\1))*\"\\1"), + greedy: true), + GrammarToken( + "char", + compileHighlightPattern( + "b?'(?:\\\\(?:x[0-7][\\da-fA-F]|u\\{(?:[\\da-fA-F]_*){1,6}\\}|.)|[^\\\\\\r\\n\\t'])'"), + greedy: true), + GrammarToken( + "attribute", + compileHighlightPattern( + "#!?\\[(?:[^\\[\\]\"]|\"(?:\\\\[\\s\\S]|[^\\\\\"])*\")*\\]"), + greedy: true, + alias: "attr-name", + inside: () => _g1), + GrammarToken( + "closure-params", + compileHighlightPattern( + "([=(,:]\\s*|\\bmove\\s*)\\|[^|]*\\||\\|[^|]*\\|(?=\\s*(?:\\{|->))"), + lookbehind: true, + greedy: true, + inside: () => _g2), + GrammarToken("lifetime-annotation", compileHighlightPattern("'\\w+"), + alias: "symbol"), + GrammarToken( + "fragment-specifier", compileHighlightPattern("(\\\$\\w+:)[a-z]+"), + lookbehind: true, alias: "punctuation"), + GrammarToken("variable", compileHighlightPattern("\\\$\\w+")), + GrammarToken( + "function-definition", compileHighlightPattern("(\\bfn\\s+)\\w+"), + lookbehind: true, alias: "function"), + GrammarToken("type-definition", + compileHighlightPattern("(\\b(?:enum|struct|trait|type|union)\\s+)\\w+"), + lookbehind: true, alias: "class-name"), + GrammarToken("module-declaration", + compileHighlightPattern("(\\b(?:crate|mod)\\s+)[a-z][a-z_\\d]*"), + lookbehind: true, alias: "namespace"), + GrammarToken( + "module-declaration", + compileHighlightPattern( + "(\\b(?:crate|self|super)\\s*)::\\s*[a-z][a-z_\\d]*\\b(?:\\s*::(?:\\s*[a-z][a-z_\\d]*\\s*::)*)?"), + lookbehind: true, + alias: "namespace", + inside: () => _g3), + GrammarToken( + "keyword", + compileHighlightPattern( + "\\b(?:Self|abstract|as|async|await|become|box|break|const|continue|crate|do|dyn|else|enum|extern|final|fn|for|if|impl|in|let|loop|macro|match|mod|move|mut|override|priv|pub|ref|return|self|static|struct|super|trait|try|type|typeof|union|unsafe|unsized|use|virtual|where|while|yield)\\b")), + GrammarToken( + "keyword", + compileHighlightPattern( + "\\b(?:bool|char|f(?:32|64)|[ui](?:8|16|32|64|128|size)|str)\\b")), + GrammarToken("function", + compileHighlightPattern("\\b[a-z_]\\w*(?=\\s*(?:::\\s*<|\\())")), + GrammarToken("macro", compileHighlightPattern("\\b\\w+!"), alias: "property"), + GrammarToken("constant", compileHighlightPattern("\\b[A-Z_][A-Z_\\d]+\\b")), + GrammarToken("class-name", compileHighlightPattern("\\b[A-Z]\\w*\\b")), + GrammarToken( + "namespace", + compileHighlightPattern( + "(?:\\b[a-z][a-z_\\d]*\\s*::\\s*)*\\b[a-z][a-z_\\d]*\\s*::(?!\\s*<)"), + inside: () => _g4), + GrammarToken( + "number", + compileHighlightPattern( + "\\b(?:0x[\\dA-Fa-f](?:_?[\\dA-Fa-f])*|0o[0-7](?:_?[0-7])*|0b[01](?:_?[01])*|(?:(?:\\d(?:_?\\d)*)?\\.)?\\d(?:_?\\d)*(?:[Ee][+-]?\\d+)?)(?:_?(?:f32|f64|[iu](?:8|16|32|64|size)?))?\\b")), + GrammarToken("boolean", compileHighlightPattern("\\b(?:false|true)\\b")), + GrammarToken("punctuation", + compileHighlightPattern("->|\\.\\.=|\\.{1,3}|::|[{}[\\];(),:]")), + GrammarToken( + "operator", + compileHighlightPattern( + "[-+*\\/%!^]=?|=[=>]?|&[&=]?|\\|[|=]?|<>?=?|[@?]")), +]); + +final Grammar _g1 = Grammar([ + GrammarToken( + "string", + compileHighlightPattern( + "b?\"(?:\\\\[\\s\\S]|[^\\\\\"])*\"|b?r(#*)\"(?:[^\"]|\"(?!\\1))*\"\\1"), + greedy: true), +]); + +final Grammar _g2 = Grammar([ + GrammarToken("closure-punctuation", compileHighlightPattern("^\\||\\|\$"), + alias: "punctuation"), +], rest: () => _g0); + +final Grammar _g3 = Grammar([ + GrammarToken("punctuation", compileHighlightPattern("::")), +]); + +final Grammar _g4 = Grammar([ + GrammarToken("punctuation", compileHighlightPattern("::")), +]); diff --git a/lib/highlight/sass.dart b/lib/highlight/sass.dart new file mode 100644 index 0000000..907b466 --- /dev/null +++ b/lib/highlight/sass.dart @@ -0,0 +1,101 @@ +// GENERATED CODE — do not modify by hand. Regenerate with tool/highlight_codegen. +// ignore_for_file: lines_longer_than_80_chars, public_member_api_docs +// ignore_for_file: prefer_single_quotes, require_trailing_commas, directives_ordering + +import '../highlight.dart'; + +/// Syntax grammar for `sass`. +/// +/// Import this library only when you need `sass` highlighting; unused +/// languages are dropped from the build. +abstract final class HighlightSass { + /// The grammar for `sass`. + static final Grammar grammar = _g0; +} + +final Grammar _g0 = Grammar([ + GrammarToken( + "comment", + compileHighlightPattern( + "^([ \\t]*)\\/[\\/*].*(?:(?:\\r?\\n|\\r)\\1[ \\t].+)*", + multiLine: true), + lookbehind: true, + greedy: true), + GrammarToken("atrule-line", + compileHighlightPattern("^(?:[ \\t]*)[@+=].+", multiLine: true), + greedy: true, inside: () => _g1), + GrammarToken( + "url", + compileHighlightPattern( + "\\burl\\((?:(?:\"(?:\\\\(?:\\r\\n|[\\s\\S])|[^\"\\\\\\r\\n])*\"|'(?:\\\\(?:\\r\\n|[\\s\\S])|[^'\\\\\\r\\n])*')|(?:[^\\\\\\r\\n()\"']|\\\\[\\s\\S])*)\\)", + caseSensitive: false), + greedy: true, + inside: () => _g2), + GrammarToken( + "string", + compileHighlightPattern( + "(?:\"(?:\\\\(?:\\r\\n|[\\s\\S])|[^\"\\\\\\r\\n])*\"|'(?:\\\\(?:\\r\\n|[\\s\\S])|[^'\\\\\\r\\n])*')"), + greedy: true), + GrammarToken("variable-line", + compileHighlightPattern("^[ \\t]*\\\$.+", multiLine: true), + greedy: true, inside: () => _g3), + GrammarToken( + "property-line", + compileHighlightPattern("^[ \\t]*(?:[^:\\s]+ *:.*|:[^:\\s].*)", + multiLine: true), + greedy: true, + inside: () => _g4), + GrammarToken( + "function", + compileHighlightPattern("(^|[^-a-z0-9])[-a-z0-9]+(?=\\()", + caseSensitive: false), + lookbehind: true), + GrammarToken( + "selector", + compileHighlightPattern( + "^([ \\t]*)\\S(?:,[^,\\r\\n]+|[^,\\r\\n]*)(?:,[^,\\r\\n]+)*(?:,(?:\\r?\\n|\\r)\\1[ \\t]+\\S(?:,[^,\\r\\n]+|[^,\\r\\n]*)(?:,[^,\\r\\n]+)*)*", + multiLine: true), + lookbehind: true, + greedy: true), + GrammarToken("punctuation", compileHighlightPattern("[(){};:,]")), +]); + +final Grammar _g1 = Grammar([ + GrammarToken("atrule", compileHighlightPattern("(?:@[\\w-]+|[+=])")), +]); + +final Grammar _g2 = Grammar([ + GrammarToken( + "function", compileHighlightPattern("^url", caseSensitive: false)), + GrammarToken("punctuation", compileHighlightPattern("^\\(|\\)\$")), + GrammarToken( + "string", + compileHighlightPattern( + "^(?:\"(?:\\\\(?:\\r\\n|[\\s\\S])|[^\"\\\\\\r\\n])*\"|'(?:\\\\(?:\\r\\n|[\\s\\S])|[^'\\\\\\r\\n])*')\$"), + alias: "url"), +]); + +final Grammar _g3 = Grammar([ + GrammarToken("punctuation", compileHighlightPattern(":")), + GrammarToken( + "variable", compileHighlightPattern("\\\$[-\\w]+|#\\{\\\$[-\\w]+\\}")), + GrammarToken("operator", + compileHighlightPattern("[+*\\/%]|[=!]=|<=?|>=?|\\b(?:and|not|or)\\b")), + GrammarToken("operator", compileHighlightPattern("(\\s)-(?=\\s)"), + lookbehind: true), +]); + +final Grammar _g4 = Grammar([ + GrammarToken("property", compileHighlightPattern("[^:\\s]+(?=\\s*:)")), + GrammarToken("property", compileHighlightPattern("(:)[^:\\s]+"), + lookbehind: true), + GrammarToken("punctuation", compileHighlightPattern(":")), + GrammarToken( + "variable", compileHighlightPattern("\\\$[-\\w]+|#\\{\\\$[-\\w]+\\}")), + GrammarToken("operator", + compileHighlightPattern("[+*\\/%]|[=!]=|<=?|>=?|\\b(?:and|not|or)\\b")), + GrammarToken("operator", compileHighlightPattern("(\\s)-(?=\\s)"), + lookbehind: true), + GrammarToken("important", + compileHighlightPattern("!important\\b", caseSensitive: false)), +]); diff --git a/lib/highlight/scala.dart b/lib/highlight/scala.dart new file mode 100644 index 0000000..517681f --- /dev/null +++ b/lib/highlight/scala.dart @@ -0,0 +1,159 @@ +// GENERATED CODE — do not modify by hand. Regenerate with tool/highlight_codegen. +// ignore_for_file: lines_longer_than_80_chars, public_member_api_docs +// ignore_for_file: prefer_single_quotes, require_trailing_commas, directives_ordering + +import '../highlight.dart'; + +/// Syntax grammar for `scala`. +/// +/// Import this library only when you need `scala` highlighting; unused +/// languages are dropped from the build. +abstract final class HighlightScala { + /// The grammar for `scala`. + static final Grammar grammar = _g0; +} + +final Grammar _g0 = Grammar([ + GrammarToken("comment", + compileHighlightPattern("(^|[^\\\\])\\/\\*[\\s\\S]*?(?:\\*\\/|\$)"), + lookbehind: true, greedy: true), + GrammarToken("comment", compileHighlightPattern("(^|[^\\\\:])\\/\\/.*"), + lookbehind: true, greedy: true), + GrammarToken( + "string-interpolation", + compileHighlightPattern( + "\\b[a-z]\\w*(?:\"\"\"(?:[^\$]|\\\$(?:[^{]|\\{(?:[^{}]|\\{[^{}]*\\})*\\}))*?\"\"\"|\"(?:[^\$\"\\r\\n]|\\\$(?:[^{]|\\{(?:[^{}]|\\{[^{}]*\\})*\\}))*\")", + caseSensitive: false), + greedy: true, + inside: () => _g1), + GrammarToken( + "triple-quoted-string", compileHighlightPattern("\"\"\"[\\s\\S]*?\"\"\""), + greedy: true, alias: "string"), + GrammarToken( + "char", compileHighlightPattern("'(?:\\\\.|[^'\\\\\\r\\n]){1,6}'"), + greedy: true), + GrammarToken("string", + compileHighlightPattern("(\"|')(?:\\\\.|(?!\\1)[^\\\\\\r\\n])*\\1"), + greedy: true), + GrammarToken("annotation", + compileHighlightPattern("(^|[^.])@\\w+(?:\\s*\\.\\s*\\w+)*"), + lookbehind: true, alias: "punctuation"), + GrammarToken( + "generics", + compileHighlightPattern( + "<(?:[\\w\\s,.?]|&(?!&)|<(?:[\\w\\s,.?]|&(?!&)|<(?:[\\w\\s,.?]|&(?!&)|<(?:[\\w\\s,.?]|&(?!&))*>)*>)*>)*>"), + inside: () => _g3), + GrammarToken( + "import", + compileHighlightPattern( + "(\\bimport\\s+)(?:[a-z]\\w*\\s*\\.\\s*)*(?:[A-Z]\\w*\\s*\\.\\s*)*(?:[A-Z]\\w*|\\*)(?=\\s*;)"), + lookbehind: true, + inside: () => _g6), + GrammarToken( + "import", + compileHighlightPattern( + "(\\bimport\\s+static\\s+)(?:[a-z]\\w*\\s*\\.\\s*)*(?:[A-Z]\\w*\\s*\\.\\s*)*(?:\\w+|\\*)(?=\\s*;)"), + lookbehind: true, + alias: "static", + inside: () => _g7), + GrammarToken( + "namespace", + compileHighlightPattern( + "(\\b(?:exports|import(?:\\s+static)?|module|open|opens|package|provides|requires|to|transitive|uses|with)\\s+)(?!\\b(?:abstract|assert|boolean|break|byte|case|catch|char|class|const|continue|default|do|double|else|enum|exports|extends|final|finally|float|for|goto|if|implements|import|instanceof|int|interface|long|module|native|new|non-sealed|null|open|opens|package|permits|private|protected|provides|public|record(?!\\s*[(){}[\\]<>=%~.:,;?+\\-*/&|^])|requires|return|sealed|short|static|strictfp|super|switch|synchronized|this|throw|throws|to|transient|transitive|try|uses|var|void|volatile|while|with|yield)\\b)[a-z]\\w*(?:\\.[a-z]\\w*)*\\.?"), + lookbehind: true, + inside: () => _g8), + GrammarToken( + "keyword", + compileHighlightPattern( + "<-|=>|\\b(?:abstract|case|catch|class|def|derives|do|else|enum|extends|extension|final|finally|for|forSome|given|if|implicit|import|infix|inline|lazy|match|new|null|object|opaque|open|override|package|private|protected|return|sealed|self|super|this|throw|trait|transparent|try|type|using|val|var|while|with|yield)\\b")), + GrammarToken("boolean", compileHighlightPattern("\\b(?:false|true)\\b")), + GrammarToken( + "number", + compileHighlightPattern( + "\\b0x(?:[\\da-f]*\\.)?[\\da-f]+|(?:\\b\\d+(?:\\.\\d*)?|\\B\\.\\d+)(?:e\\d+)?[dfl]?", + caseSensitive: false)), + GrammarToken( + "operator", + compileHighlightPattern( + "(^|[^.])(?:<<=?|>>>?=?|->|--|\\+\\+|&&|\\|\\||::|[?:~]|[-+*/%&|^!=<>]=?)", + multiLine: true), + lookbehind: true), + GrammarToken("punctuation", compileHighlightPattern("[{}[\\];(),.:]")), + GrammarToken( + "builtin", + compileHighlightPattern( + "\\b(?:Any|AnyRef|AnyVal|Boolean|Byte|Char|Double|Float|Int|Long|Nothing|Short|String|Unit)\\b")), + GrammarToken("symbol", compileHighlightPattern("'[^\\d\\s\\\\]\\w*")), +]); + +final Grammar _g1 = Grammar([ + GrammarToken("id", compileHighlightPattern("^\\w+"), + greedy: true, alias: "function"), + GrammarToken("escape", compileHighlightPattern("\\\\\\\$\"|\\\$[\$\"]"), + greedy: true, alias: "symbol"), + GrammarToken("interpolation", + compileHighlightPattern("\\\$(?:\\w+|\\{(?:[^{}]|\\{[^{}]*\\})*\\})"), + greedy: true, inside: () => _g2), + GrammarToken("string", compileHighlightPattern("[\\s\\S]+")), +]); + +final Grammar _g2 = Grammar([ + GrammarToken("punctuation", compileHighlightPattern("^\\\$\\{?|\\}\$")), + GrammarToken("expression", compileHighlightPattern("[\\s\\S]+"), + inside: () => _g0), +]); + +final Grammar _g3 = Grammar([ + GrammarToken( + "class-name", + compileHighlightPattern( + "(^|[^\\w.])(?:[a-z]\\w*\\s*\\.\\s*)*(?:[A-Z]\\w*\\s*\\.\\s*)*[A-Z](?:[\\d_A-Z]*[a-z]\\w*)?\\b"), + lookbehind: true, + inside: () => _g4), + GrammarToken( + "keyword", + compileHighlightPattern( + "\\b(?:abstract|assert|boolean|break|byte|case|catch|char|class|const|continue|default|do|double|else|enum|exports|extends|final|finally|float|for|goto|if|implements|import|instanceof|int|interface|long|module|native|new|non-sealed|null|open|opens|package|permits|private|protected|provides|public|record(?!\\s*[(){}[\\]<>=%~.:,;?+\\-*/&|^])|requires|return|sealed|short|static|strictfp|super|switch|synchronized|this|throw|throws|to|transient|transitive|try|uses|var|void|volatile|while|with|yield)\\b")), + GrammarToken("punctuation", compileHighlightPattern("[<>(),.:]")), + GrammarToken("operator", compileHighlightPattern("[?&|]")), +]); + +final Grammar _g4 = Grammar([ + GrammarToken( + "namespace", + compileHighlightPattern( + "^[a-z]\\w*(?:\\s*\\.\\s*[a-z]\\w*)*(?:\\s*\\.)?"), + inside: () => _g5), + GrammarToken("punctuation", compileHighlightPattern("\\.")), +]); + +final Grammar _g5 = Grammar([ + GrammarToken("punctuation", compileHighlightPattern("\\.")), +]); + +final Grammar _g6 = Grammar([ + GrammarToken( + "namespace", + compileHighlightPattern( + "^[a-z]\\w*(?:\\s*\\.\\s*[a-z]\\w*)*(?:\\s*\\.)?"), + inside: () => _g5), + GrammarToken("punctuation", compileHighlightPattern("\\.")), + GrammarToken("operator", compileHighlightPattern("\\*")), + GrammarToken("class-name", compileHighlightPattern("\\w+")), +]); + +final Grammar _g7 = Grammar([ + GrammarToken( + "namespace", + compileHighlightPattern( + "^[a-z]\\w*(?:\\s*\\.\\s*[a-z]\\w*)*(?:\\s*\\.)?"), + inside: () => _g5), + GrammarToken("static", compileHighlightPattern("\\b\\w+\$")), + GrammarToken("punctuation", compileHighlightPattern("\\.")), + GrammarToken("operator", compileHighlightPattern("\\*")), + GrammarToken("class-name", compileHighlightPattern("\\w+")), +]); + +final Grammar _g8 = Grammar([ + GrammarToken("punctuation", compileHighlightPattern("\\.")), +]); diff --git a/lib/highlight/scss.dart b/lib/highlight/scss.dart new file mode 100644 index 0000000..7470521 --- /dev/null +++ b/lib/highlight/scss.dart @@ -0,0 +1,94 @@ +// GENERATED CODE — do not modify by hand. Regenerate with tool/highlight_codegen. +// ignore_for_file: lines_longer_than_80_chars, public_member_api_docs +// ignore_for_file: prefer_single_quotes, require_trailing_commas, directives_ordering + +import '../highlight.dart'; + +/// Syntax grammar for `scss`. +/// +/// Import this library only when you need `scss` highlighting; unused +/// languages are dropped from the build. +abstract final class HighlightScss { + /// The grammar for `scss`. + static final Grammar grammar = _g0; +} + +final Grammar _g0 = Grammar([ + GrammarToken("comment", + compileHighlightPattern("(^|[^\\\\])(?:\\/\\*[\\s\\S]*?\\*\\/|\\/\\/.*)"), + lookbehind: true), + GrammarToken( + "keyword", + compileHighlightPattern( + "@(?:content|debug|each|else(?: if)?|extend|for|forward|function|if|import|include|mixin|return|use|warn|while)\\b", + caseSensitive: false)), + GrammarToken("keyword", compileHighlightPattern("( )(?:from|through)(?= )"), + lookbehind: true), + GrammarToken( + "atrule", + compileHighlightPattern( + "@[\\w-](?:\\([^()]+\\)|[^()\\s]|\\s+(?!\\s))*?(?=\\s+[{;])"), + inside: () => _g1), + GrammarToken("url", + compileHighlightPattern("(?:[-a-z]+-)?url(?=\\()", caseSensitive: false)), + GrammarToken( + "selector", + compileHighlightPattern( + "(?=\\S)[^@;{}()]?(?:[^@;{}()\\s]|\\s+(?!\\s)|#\\{\\\$[-\\w]+\\})+(?=\\s*\\{(?:\\}|\\s|[^}][^:{}]*[:{][^}]))"), + inside: () => _g2), + GrammarToken( + "string", + compileHighlightPattern( + "(?:\"(?:\\\\(?:\\r\\n|[\\s\\S])|[^\"\\\\\\r\\n])*\"|'(?:\\\\(?:\\r\\n|[\\s\\S])|[^'\\\\\\r\\n])*')"), + greedy: true), + GrammarToken( + "property", + compileHighlightPattern( + "(?:[-\\w]|\\\$[-\\w]|#\\{\\\$[-\\w]+\\})+(?=\\s*:)"), + inside: () => _g3), + GrammarToken( + "variable", compileHighlightPattern("\\\$[-\\w]+|#\\{\\\$[-\\w]+\\}")), + GrammarToken("important", + compileHighlightPattern("!important\\b", caseSensitive: false)), + GrammarToken( + "module-modifier", + compileHighlightPattern("\\b(?:as|hide|show|with)\\b", + caseSensitive: false), + alias: "keyword"), + GrammarToken("placeholder", compileHighlightPattern("%[-\\w]+"), + alias: "selector"), + GrammarToken( + "statement", + compileHighlightPattern("\\B!(?:default|optional)\\b", + caseSensitive: false), + alias: "keyword"), + GrammarToken("boolean", compileHighlightPattern("\\b(?:false|true)\\b")), + GrammarToken("null", compileHighlightPattern("\\bnull\\b"), alias: "keyword"), + GrammarToken( + "operator", + compileHighlightPattern( + "(\\s)(?:[-+*\\/%]|[=!]=|<=?|>=?|and|not|or)(?=\\s)"), + lookbehind: true), + GrammarToken( + "function", + compileHighlightPattern("(^|[^-a-z0-9])[-a-z0-9]+(?=\\()", + caseSensitive: false), + lookbehind: true), + GrammarToken("punctuation", compileHighlightPattern("[(){};:,]")), +]); + +final Grammar _g1 = Grammar([ + GrammarToken("rule", compileHighlightPattern("@[\\w-]+")), +], rest: () => _g0); + +final Grammar _g2 = Grammar([ + GrammarToken("parent", compileHighlightPattern("&"), alias: "important"), + GrammarToken("placeholder", compileHighlightPattern("%[-\\w]+")), + GrammarToken( + "variable", compileHighlightPattern("\\\$[-\\w]+|#\\{\\\$[-\\w]+\\}")), +]); + +final Grammar _g3 = Grammar([ + GrammarToken( + "variable", compileHighlightPattern("\\\$[-\\w]+|#\\{\\\$[-\\w]+\\}")), +]); diff --git a/lib/highlight/solidity.dart b/lib/highlight/solidity.dart new file mode 100644 index 0000000..ed1c149 --- /dev/null +++ b/lib/highlight/solidity.dart @@ -0,0 +1,55 @@ +// GENERATED CODE — do not modify by hand. Regenerate with tool/highlight_codegen. +// ignore_for_file: lines_longer_than_80_chars, public_member_api_docs +// ignore_for_file: prefer_single_quotes, require_trailing_commas, directives_ordering + +import '../highlight.dart'; + +/// Syntax grammar for `solidity`. +/// +/// Import this library only when you need `solidity` highlighting; unused +/// languages are dropped from the build. +abstract final class HighlightSolidity { + /// The grammar for `solidity`. + static final Grammar grammar = _g0; +} + +final Grammar _g0 = Grammar([ + GrammarToken("comment", + compileHighlightPattern("(^|[^\\\\])\\/\\*[\\s\\S]*?(?:\\*\\/|\$)"), + lookbehind: true, greedy: true), + GrammarToken("comment", compileHighlightPattern("(^|[^\\\\:])\\/\\/.*"), + lookbehind: true, greedy: true), + GrammarToken( + "string", + compileHighlightPattern( + "([\"'])(?:\\\\(?:\\r\\n|[\\s\\S])|(?!\\1)[^\\\\\\r\\n])*\\1"), + greedy: true), + GrammarToken( + "class-name", + compileHighlightPattern( + "(\\b(?:contract|enum|interface|library|new|struct|using)\\s+)(?!\\d)[\\w\$]+"), + lookbehind: true), + GrammarToken( + "builtin", + compileHighlightPattern( + "\\b(?:address|bool|byte|u?int(?:8|16|24|32|40|48|56|64|72|80|88|96|104|112|120|128|136|144|152|160|168|176|184|192|200|208|216|224|232|240|248|256)?|string|bytes(?:[1-9]|[12]\\d|3[0-2])?)\\b")), + GrammarToken( + "keyword", + compileHighlightPattern( + "\\b(?:_|anonymous|as|assembly|assert|break|calldata|case|constant|constructor|continue|contract|default|delete|do|else|emit|enum|event|external|for|from|function|if|import|indexed|inherited|interface|internal|is|let|library|mapping|memory|modifier|new|payable|pragma|private|public|pure|require|returns?|revert|selfdestruct|solidity|storage|struct|suicide|switch|this|throw|using|var|view|while)\\b")), + GrammarToken("boolean", compileHighlightPattern("\\b(?:false|true)\\b")), + GrammarToken("function", compileHighlightPattern("\\b\\w+(?=\\()")), + GrammarToken( + "version", compileHighlightPattern("([<>]=?|\\^)\\d+\\.\\d+\\.\\d+\\b"), + lookbehind: true, alias: "number"), + GrammarToken( + "number", + compileHighlightPattern( + "\\b0x[\\da-f]+\\b|(?:\\b\\d+(?:\\.\\d*)?|\\B\\.\\d+)(?:e[+-]?\\d+)?", + caseSensitive: false)), + GrammarToken( + "operator", + compileHighlightPattern( + "=>|->|:=|=:|\\*\\*|\\+\\+|--|\\|\\||&&|<<=?|>>=?|[-+*/%^&|<>!=]=?|[~?]")), + GrammarToken("punctuation", compileHighlightPattern("[{}[\\];(),.:]")), +]); diff --git a/lib/highlight/sql.dart b/lib/highlight/sql.dart new file mode 100644 index 0000000..eb4e7fb --- /dev/null +++ b/lib/highlight/sql.dart @@ -0,0 +1,64 @@ +// GENERATED CODE — do not modify by hand. Regenerate with tool/highlight_codegen. +// ignore_for_file: lines_longer_than_80_chars, public_member_api_docs +// ignore_for_file: prefer_single_quotes, require_trailing_commas, directives_ordering + +import '../highlight.dart'; + +/// Syntax grammar for `sql`. +/// +/// Import this library only when you need `sql` highlighting; unused +/// languages are dropped from the build. +abstract final class HighlightSql { + /// The grammar for `sql`. + static final Grammar grammar = _g0; +} + +final Grammar _g0 = Grammar([ + GrammarToken( + "comment", + compileHighlightPattern( + "(^|[^\\\\])(?:\\/\\*[\\s\\S]*?\\*\\/|(?:--|\\/\\/|#).*)"), + lookbehind: true), + GrammarToken("variable", + compileHighlightPattern("@([\"'`])(?:\\\\[\\s\\S]|(?!\\1)[^\\\\])+\\1"), + greedy: true), + GrammarToken("variable", compileHighlightPattern("@[\\w.\$]+")), + GrammarToken( + "string", + compileHighlightPattern( + "(^|[^@\\\\])(\"|')(?:\\\\[\\s\\S]|(?!\\2)[^\\\\]|\\2\\2)*\\2"), + lookbehind: true, + greedy: true), + GrammarToken("identifier", + compileHighlightPattern("(^|[^@\\\\])`(?:\\\\[\\s\\S]|[^`\\\\]|``)*`"), + lookbehind: true, greedy: true, inside: () => _g1), + GrammarToken( + "function", + compileHighlightPattern( + "\\b(?:AVG|COUNT|FIRST|FORMAT|LAST|LCASE|LEN|MAX|MID|MIN|MOD|NOW|ROUND|SUM|UCASE)(?=\\s*\\()", + caseSensitive: false)), + GrammarToken( + "keyword", + compileHighlightPattern( + "\\b(?:ACTION|ADD|AFTER|ALGORITHM|ALL|ALTER|ANALYZE|ANY|APPLY|AS|ASC|AUTHORIZATION|AUTO_INCREMENT|BACKUP|BDB|BEGIN|BERKELEYDB|BIGINT|BINARY|BIT|BLOB|BOOL|BOOLEAN|BREAK|BROWSE|BTREE|BULK|BY|CALL|CASCADED?|CASE|CHAIN|CHAR(?:ACTER|SET)?|CHECK(?:POINT)?|CLOSE|CLUSTERED|COALESCE|COLLATE|COLUMNS?|COMMENT|COMMIT(?:TED)?|COMPUTE|CONNECT|CONSISTENT|CONSTRAINT|CONTAINS(?:TABLE)?|CONTINUE|CONVERT|CREATE|CROSS|CURRENT(?:_DATE|_TIME|_TIMESTAMP|_USER)?|CURSOR|CYCLE|DATA(?:BASES?)?|DATE(?:TIME)?|DAY|DBCC|DEALLOCATE|DEC|DECIMAL|DECLARE|DEFAULT|DEFINER|DELAYED|DELETE|DELIMITERS?|DENY|DESC|DESCRIBE|DETERMINISTIC|DISABLE|DISCARD|DISK|DISTINCT|DISTINCTROW|DISTRIBUTED|DO|DOUBLE|DROP|DUMMY|DUMP(?:FILE)?|DUPLICATE|ELSE(?:IF)?|ENABLE|ENCLOSED|END|ENGINE|ENUM|ERRLVL|ERRORS|ESCAPED?|EXCEPT|EXEC(?:UTE)?|EXISTS|EXIT|EXPLAIN|EXTENDED|FETCH|FIELDS|FILE|FILLFACTOR|FIRST|FIXED|FLOAT|FOLLOWING|FOR(?: EACH ROW)?|FORCE|FOREIGN|FREETEXT(?:TABLE)?|FROM|FULL|FUNCTION|GEOMETRY(?:COLLECTION)?|GLOBAL|GOTO|GRANT|GROUP|HANDLER|HASH|HAVING|HOLDLOCK|HOUR|IDENTITY(?:COL|_INSERT)?|IF|IGNORE|IMPORT|INDEX|INFILE|INNER|INNODB|INOUT|INSERT|INT|INTEGER|INTERSECT|INTERVAL|INTO|INVOKER|ISOLATION|ITERATE|JOIN|KEYS?|KILL|LANGUAGE|LAST|LEAVE|LEFT|LEVEL|LIMIT|LINENO|LINES|LINESTRING|LOAD|LOCAL|LOCK|LONG(?:BLOB|TEXT)|LOOP|MATCH(?:ED)?|MEDIUM(?:BLOB|INT|TEXT)|MERGE|MIDDLEINT|MINUTE|MODE|MODIFIES|MODIFY|MONTH|MULTI(?:LINESTRING|POINT|POLYGON)|NATIONAL|NATURAL|NCHAR|NEXT|NO|NONCLUSTERED|NULLIF|NUMERIC|OFF?|OFFSETS?|ON|OPEN(?:DATASOURCE|QUERY|ROWSET)?|OPTIMIZE|OPTION(?:ALLY)?|ORDER|OUT(?:ER|FILE)?|OVER|PARTIAL|PARTITION|PERCENT|PIVOT|PLAN|POINT|POLYGON|PRECEDING|PRECISION|PREPARE|PREV|PRIMARY|PRINT|PRIVILEGES|PROC(?:EDURE)?|PUBLIC|PURGE|QUICK|RAISERROR|READS?|REAL|RECONFIGURE|REFERENCES|RELEASE|RENAME|REPEAT(?:ABLE)?|REPLACE|REPLICATION|REQUIRE|RESIGNAL|RESTORE|RESTRICT|RETURN(?:ING|S)?|REVOKE|RIGHT|ROLLBACK|ROUTINE|ROW(?:COUNT|GUIDCOL|S)?|RTREE|RULE|SAVE(?:POINT)?|SCHEMA|SECOND|SELECT|SERIAL(?:IZABLE)?|SESSION(?:_USER)?|SET(?:USER)?|SHARE|SHOW|SHUTDOWN|SIMPLE|SMALLINT|SNAPSHOT|SOME|SONAME|SQL|START(?:ING)?|STATISTICS|STATUS|STRIPED|SYSTEM_USER|TABLES?|TABLESPACE|TEMP(?:ORARY|TABLE)?|TERMINATED|TEXT(?:SIZE)?|THEN|TIME(?:STAMP)?|TINY(?:BLOB|INT|TEXT)|TOP?|TRAN(?:SACTIONS?)?|TRIGGER|TRUNCATE|TSEQUAL|TYPES?|UNBOUNDED|UNCOMMITTED|UNDEFINED|UNION|UNIQUE|UNLOCK|UNPIVOT|UNSIGNED|UPDATE(?:TEXT)?|USAGE|USE|USER|USING|VALUES?|VAR(?:BINARY|CHAR|CHARACTER|YING)|VIEW|WAITFOR|WARNINGS|WHEN|WHERE|WHILE|WITH(?: ROLLUP|IN)?|WORK|WRITE(?:TEXT)?|YEAR)\\b", + caseSensitive: false)), + GrammarToken( + "boolean", + compileHighlightPattern("\\b(?:FALSE|NULL|TRUE)\\b", + caseSensitive: false)), + GrammarToken( + "number", + compileHighlightPattern( + "\\b0x[\\da-f]+\\b|\\b\\d+(?:\\.\\d*)?|\\B\\.\\d+\\b", + caseSensitive: false)), + GrammarToken( + "operator", + compileHighlightPattern( + "[-+*\\/=%^~]|&&?|\\|\\|?|!=?|<(?:=>?|<|>)?|>[>=]?|\\b(?:AND|BETWEEN|DIV|ILIKE|IN|IS|LIKE|NOT|OR|REGEXP|RLIKE|SOUNDS LIKE|XOR)\\b", + caseSensitive: false)), + GrammarToken("punctuation", compileHighlightPattern("[;[\\]()`,.]")), +]); + +final Grammar _g1 = Grammar([ + GrammarToken("punctuation", compileHighlightPattern("^`|`\$")), +]); diff --git a/lib/highlight/swift.dart b/lib/highlight/swift.dart new file mode 100644 index 0000000..f3faf42 --- /dev/null +++ b/lib/highlight/swift.dart @@ -0,0 +1,113 @@ +// GENERATED CODE — do not modify by hand. Regenerate with tool/highlight_codegen. +// ignore_for_file: lines_longer_than_80_chars, public_member_api_docs +// ignore_for_file: prefer_single_quotes, require_trailing_commas, directives_ordering + +import '../highlight.dart'; + +/// Syntax grammar for `swift`. +/// +/// Import this library only when you need `swift` highlighting; unused +/// languages are dropped from the build. +abstract final class HighlightSwift { + /// The grammar for `swift`. + static final Grammar grammar = _g0; +} + +final Grammar _g0 = Grammar([ + GrammarToken( + "comment", + compileHighlightPattern( + "(^|[^\\\\:])(?:\\/\\/.*|\\/\\*(?:[^/*]|\\/(?!\\*)|\\*(?!\\/)|\\/\\*(?:[^*]|\\*(?!\\/))*\\*\\/)*\\*\\/)"), + lookbehind: true, + greedy: true), + GrammarToken( + "string-literal", + compileHighlightPattern( + "(^|[^\"#])(?:\"(?:\\\\(?:\\((?:[^()]|\\([^()]*\\))*\\)|\\r\\n|[^(])|[^\\\\\\r\\n\"])*\"|\"\"\"(?:\\\\(?:\\((?:[^()]|\\([^()]*\\))*\\)|[^(])|[^\\\\\"]|\"(?!\"\"))*\"\"\")(?![\"#])"), + lookbehind: true, + greedy: true, + inside: () => _g1), + GrammarToken( + "string-literal", + compileHighlightPattern( + "(^|[^\"#])(#+)(?:\"(?:\\\\(?:#+\\((?:[^()]|\\([^()]*\\))*\\)|\\r\\n|[^#])|[^\\\\\\r\\n])*?\"|\"\"\"(?:\\\\(?:#+\\((?:[^()]|\\([^()]*\\))*\\)|[^#])|[^\\\\])*?\"\"\")\\2"), + lookbehind: true, + greedy: true, + inside: () => _g2), + GrammarToken( + "directive", + compileHighlightPattern( + "#(?:(?:elseif|if)\\b(?:[ \t]*(?:![ \\t]*)?(?:\\b\\w+\\b(?:[ \\t]*\\((?:[^()]|\\([^()]*\\))*\\))?|\\((?:[^()]|\\([^()]*\\))*\\))(?:[ \\t]*(?:&&|\\|\\|))?)+|(?:else|endif)\\b)"), + alias: "property", + inside: () => _g3), + GrammarToken( + "literal", + compileHighlightPattern( + "#(?:colorLiteral|column|dsohandle|file(?:ID|Literal|Path)?|function|imageLiteral|line)\\b"), + alias: "constant"), + GrammarToken("other-directive", compileHighlightPattern("#\\w+\\b"), + alias: "property"), + GrammarToken("attribute", compileHighlightPattern("@\\w+"), alias: "atrule"), + GrammarToken( + "function-definition", compileHighlightPattern("(\\bfunc\\s+)\\w+"), + lookbehind: true, alias: "function"), + GrammarToken( + "label", + compileHighlightPattern( + "\\b(break|continue)\\s+\\w+|\\b[a-zA-Z_]\\w*(?=\\s*:\\s*(?:for|repeat|while)\\b)"), + lookbehind: true, + alias: "important"), + GrammarToken( + "keyword", + compileHighlightPattern( + "\\b(?:Any|Protocol|Self|Type|actor|as|assignment|associatedtype|associativity|async|await|break|case|catch|class|continue|convenience|default|defer|deinit|didSet|do|dynamic|else|enum|extension|fallthrough|fileprivate|final|for|func|get|guard|higherThan|if|import|in|indirect|infix|init|inout|internal|is|isolated|lazy|left|let|lowerThan|mutating|none|nonisolated|nonmutating|open|operator|optional|override|postfix|precedencegroup|prefix|private|protocol|public|repeat|required|rethrows|return|right|safe|self|set|some|static|struct|subscript|super|switch|throw|throws|try|typealias|unowned|unsafe|var|weak|where|while|willSet)\\b")), + GrammarToken("boolean", compileHighlightPattern("\\b(?:false|true)\\b")), + GrammarToken("nil", compileHighlightPattern("\\bnil\\b"), alias: "constant"), + GrammarToken("short-argument", compileHighlightPattern("\\\$\\d+\\b")), + GrammarToken("omit", compileHighlightPattern("\\b_\\b"), alias: "keyword"), + GrammarToken( + "number", + compileHighlightPattern( + "\\b(?:[\\d_]+(?:\\.[\\de_]+)?|0x[a-f0-9_]+(?:\\.[a-f0-9p_]+)?|0b[01_]+|0o[0-7_]+)\\b", + caseSensitive: false)), + GrammarToken("class-name", + compileHighlightPattern("\\b[A-Z](?:[A-Z_\\d]*[a-z]\\w*)?\\b")), + GrammarToken( + "function", + compileHighlightPattern("\\b[a-z_]\\w*(?=\\s*\\()", + caseSensitive: false)), + GrammarToken("constant", + compileHighlightPattern("\\b(?:[A-Z_]{2,}|k[A-Z][A-Za-z_]+)\\b")), + GrammarToken("operator", + compileHighlightPattern("[-+*/%=!<>&|^~?]+|\\.[.\\-+*/%=!<>&|^~?]+")), + GrammarToken("punctuation", compileHighlightPattern("[{}[\\]();,.:\\\\]")), +]); + +final Grammar _g1 = Grammar([ + GrammarToken("interpolation", + compileHighlightPattern("(\\\\\\()(?:[^()]|\\([^()]*\\))*(?=\\))"), + lookbehind: true, inside: () => _g0), + GrammarToken( + "interpolation-punctuation", compileHighlightPattern("^\\)|\\\\\\(\$"), + alias: "punctuation"), + GrammarToken("punctuation", compileHighlightPattern("\\\\(?=[\\r\\n])")), + GrammarToken("string", compileHighlightPattern("[\\s\\S]+")), +]); + +final Grammar _g2 = Grammar([ + GrammarToken("interpolation", + compileHighlightPattern("(\\\\#+\\()(?:[^()]|\\([^()]*\\))*(?=\\))"), + lookbehind: true, inside: () => _g0), + GrammarToken( + "interpolation-punctuation", compileHighlightPattern("^\\)|\\\\#+\\(\$"), + alias: "punctuation"), + GrammarToken("string", compileHighlightPattern("[\\s\\S]+")), +]); + +final Grammar _g3 = Grammar([ + GrammarToken("directive-name", compileHighlightPattern("^#\\w+")), + GrammarToken("boolean", compileHighlightPattern("\\b(?:false|true)\\b")), + GrammarToken("number", compileHighlightPattern("\\b\\d+(?:\\.\\d+)*\\b")), + GrammarToken("operator", compileHighlightPattern("!|&&|\\|\\||[<>]=?")), + GrammarToken("punctuation", compileHighlightPattern("[(),]")), +]); diff --git a/lib/highlight/themes.dart b/lib/highlight/themes.dart new file mode 100644 index 0000000..4df6e94 --- /dev/null +++ b/lib/highlight/themes.dart @@ -0,0 +1,136 @@ +import 'package:flutter/painting.dart'; + +import '../highlight.dart'; + +/// Ready-made GitHub code themes for [MarkdownHighlighter]. +/// +/// Each theme is a separate `const` value; referencing one never retains the +/// other, so the theme you don't use is dropped from the build. +abstract final class HighlightThemes { + /// GitHub "dark" code theme (background `#0D1117`). + static const CodeHighlightTheme githubDark = _GithubTheme( + background: Color(0xFF0D1117), + foreground: Color(0xFFC9D1D9), + comment: Color(0xFF8B949E), + keyword: Color(0xFFFF7B72), + string: Color(0xFFA5D6FF), + number: Color(0xFF79C0FF), + function: Color(0xFFD2A8FF), + className: Color(0xFFFFA657), + variable: Color(0xFF79C0FF), + punctuation: Color(0xFFC9D1D9), + ); + + /// GitHub "light" code theme (background `#F6F8FA`). + static const CodeHighlightTheme githubLight = _GithubTheme( + background: Color(0xFFF6F8FA), + foreground: Color(0xFF24292F), + comment: Color(0xFF6E7781), + keyword: Color(0xFFCF222E), + string: Color(0xFF0A3069), + number: Color(0xFF0550AE), + function: Color(0xFF8250DF), + className: Color(0xFF953800), + variable: Color(0xFF0550AE), + punctuation: Color(0xFF24292F), + ); +} + +/// A GitHub-style theme parameterized by a small palette. Token classes are +/// resolved with a `switch` (no shared map), so each instance is self-contained +/// and tree-shakeable. +final class _GithubTheme implements CodeHighlightTheme { + const _GithubTheme({ + required Color background, + required Color foreground, + required this.comment, + required this.keyword, + required this.string, + required this.number, + required this.function, + required this.className, + required this.variable, + required this.punctuation, + }) : _background = background, + _foreground = foreground; + + final Color _background; + final Color _foreground; + + /// Color for comments (also italicized). + final Color comment; + + /// Color for keywords and operators. + final Color keyword; + + /// Color for strings and characters. + final Color string; + + /// Color for numbers, booleans and constants. + final Color number; + + /// Color for function names. + final Color function; + + /// Color for class names, builtins and namespaces. + final Color className; + + /// Color for variables, properties and parameters. + final Color variable; + + /// Color for punctuation. + final Color punctuation; + + @override + Color? get background => _background; + + @override + Color? get foreground => _foreground; + + @override + TextStyle? styleFor(String tokenType) => switch (tokenType) { + 'comment' || + 'prolog' || + 'doctype' || + 'cdata' => + TextStyle(color: comment, fontStyle: FontStyle.italic), + 'keyword' || + 'operator' || + 'rule' || + 'atrule' || + 'selector' => + TextStyle(color: keyword), + 'string' || + 'string-literal' || + 'char' || + 'attr-value' || + 'regex' => + TextStyle(color: string), + 'number' || + 'boolean' || + 'constant' || + 'null' || + 'symbol' || + 'unit' => + TextStyle(color: number), + 'function' || 'function-name' => TextStyle(color: function), + 'class-name' || + 'builtin' || + 'namespace' || + 'important' || + 'tag' => + TextStyle(color: className), + 'variable' || + 'property' || + 'parameter' || + 'attr-name' || + 'shebang' || + 'environment' || + 'file-descriptor' || + 'for-or-select' || + 'assign-left' => + TextStyle(color: variable), + 'punctuation' => TextStyle(color: punctuation), + _ => null, + }; +} diff --git a/lib/highlight/toml.dart b/lib/highlight/toml.dart new file mode 100644 index 0000000..ba95a54 --- /dev/null +++ b/lib/highlight/toml.dart @@ -0,0 +1,54 @@ +// GENERATED CODE — do not modify by hand. Regenerate with tool/highlight_codegen. +// ignore_for_file: lines_longer_than_80_chars, public_member_api_docs +// ignore_for_file: prefer_single_quotes, require_trailing_commas, directives_ordering + +import '../highlight.dart'; + +/// Syntax grammar for `toml`. +/// +/// Import this library only when you need `toml` highlighting; unused +/// languages are dropped from the build. +abstract final class HighlightToml { + /// The grammar for `toml`. + static final Grammar grammar = _g0; +} + +final Grammar _g0 = Grammar([ + GrammarToken("comment", compileHighlightPattern("#.*"), greedy: true), + GrammarToken( + "table", + compileHighlightPattern( + "(^[\\t ]*\\[\\s*(?:\\[\\s*)?)(?:[\\w-]+|'[^'\\n\\r]*'|\"(?:\\\\.|[^\\\\\"\\r\\n])*\")(?:\\s*\\.\\s*(?:[\\w-]+|'[^'\\n\\r]*'|\"(?:\\\\.|[^\\\\\"\\r\\n])*\"))*(?=\\s*\\])", + multiLine: true), + lookbehind: true, + greedy: true, + alias: "class-name"), + GrammarToken( + "key", + compileHighlightPattern( + "(^[\\t ]*|[{,]\\s*)(?:[\\w-]+|'[^'\\n\\r]*'|\"(?:\\\\.|[^\\\\\"\\r\\n])*\")(?:\\s*\\.\\s*(?:[\\w-]+|'[^'\\n\\r]*'|\"(?:\\\\.|[^\\\\\"\\r\\n])*\"))*(?=\\s*=)", + multiLine: true), + lookbehind: true, + greedy: true, + alias: "property"), + GrammarToken( + "string", + compileHighlightPattern( + "\"\"\"(?:\\\\[\\s\\S]|[^\\\\])*?\"\"\"|'''[\\s\\S]*?'''|'[^'\\n\\r]*'|\"(?:\\\\.|[^\\\\\"\\r\\n])*\""), + greedy: true), + GrammarToken( + "date", + compileHighlightPattern( + "\\b\\d{4}-\\d{2}-\\d{2}(?:[T\\s]\\d{2}:\\d{2}:\\d{2}(?:\\.\\d+)?(?:Z|[+-]\\d{2}:\\d{2})?)?\\b", + caseSensitive: false), + alias: "number"), + GrammarToken( + "date", compileHighlightPattern("\\b\\d{2}:\\d{2}:\\d{2}(?:\\.\\d+)?\\b"), + alias: "number"), + GrammarToken( + "number", + compileHighlightPattern( + "(?:\\b0(?:x[\\da-zA-Z]+(?:_[\\da-zA-Z]+)*|o[0-7]+(?:_[0-7]+)*|b[10]+(?:_[10]+)*))\\b|[-+]?\\b\\d+(?:_\\d+)*(?:\\.\\d+(?:_\\d+)*)?(?:[eE][+-]?\\d+(?:_\\d+)*)?\\b|[-+]?\\b(?:inf|nan)\\b")), + GrammarToken("boolean", compileHighlightPattern("\\b(?:false|true)\\b")), + GrammarToken("punctuation", compileHighlightPattern("[.,=[\\]{}]")), +]); diff --git a/lib/highlight/tsx.dart b/lib/highlight/tsx.dart new file mode 100644 index 0000000..52b70e6 --- /dev/null +++ b/lib/highlight/tsx.dart @@ -0,0 +1,996 @@ +// GENERATED CODE — do not modify by hand. Regenerate with tool/highlight_codegen. +// ignore_for_file: lines_longer_than_80_chars, public_member_api_docs +// ignore_for_file: prefer_single_quotes, require_trailing_commas, directives_ordering + +import '../highlight.dart'; + +/// Syntax grammar for `tsx`. +/// +/// Import this library only when you need `tsx` highlighting; unused +/// languages are dropped from the build. +abstract final class HighlightTsx { + /// The grammar for `tsx`. + static final Grammar grammar = _g0; +} + +final Grammar _g0 = Grammar([ + GrammarToken("comment", + compileHighlightPattern("(^|[^\\\\])\\/\\*[\\s\\S]*?(?:\\*\\/|\$)"), + lookbehind: true, greedy: true), + GrammarToken("comment", compileHighlightPattern("(^|[^\\\\:])\\/\\/.*"), + lookbehind: true, greedy: true), + GrammarToken("prolog", compileHighlightPattern("<\\?[\\s\\S]+?\\?>"), + greedy: true), + GrammarToken( + "doctype", + compileHighlightPattern( + "\"'[\\]]|\"[^\"]*\"|'[^']*')+(?:\\[(?:[^<\"'\\]]|\"[^\"]*\"|'[^']*'|<(?!!--)|)*\\]\\s*)?>", + caseSensitive: false), + greedy: true, + inside: () => _g1), + GrammarToken( + "style", + compileHighlightPattern( + "(]*>)(?:))*\\]\\]>|(?!)", + caseSensitive: false), + lookbehind: true, + greedy: true, + inside: () => _g2), + GrammarToken( + "script", + compileHighlightPattern( + "(]*>)(?:))*\\]\\]>|(?!)", + caseSensitive: false), + lookbehind: true, + greedy: true, + inside: () => _g7), + GrammarToken( + "cdata", + compileHighlightPattern("", + caseSensitive: false), + greedy: true), + GrammarToken( + "tag", + compileHighlightPattern( + "(^|[^\\w\$]|(?=<\\/))(?:<\\/?(?:[\\w.:-]+(?:(?:\\s|\\/\\/.*(?!.)|\\/\\*(?:[^*]|\\*(?!\\/))\\*\\/)+(?:[\\w.:\$-]+(?:=(?:\"(?:\\\\[\\s\\S]|[^\\\\\"])*\"|'(?:\\\\[\\s\\S]|[^\\\\'])*'|[^\\s{'\"/>=]+|(?:\\{(?:\\{(?:\\{[^{}]*\\}|[^{}])*\\}|[^{}])*\\})))?|(?:\\{(?:\\s|\\/\\/.*(?!.)|\\/\\*(?:[^*]|\\*(?!\\/))\\*\\/)*\\.{3}(?:[^{}]|(?:\\{(?:\\{(?:\\{[^{}]*\\}|[^{}])*\\}|[^{}])*\\}))*\\})))*(?:\\s|\\/\\/.*(?!.)|\\/\\*(?:[^*]|\\*(?!\\/))\\*\\/)*\\/?)?>)"), + lookbehind: true, + greedy: true, + inside: () => _g19), + GrammarToken("entity", + compileHighlightPattern("&[\\da-z]{1,8};", caseSensitive: false), + alias: "named-entity"), + GrammarToken("entity", + compileHighlightPattern("&#x?[\\da-f]{1,8};", caseSensitive: false)), + GrammarToken("hashbang", compileHighlightPattern("^#!.*"), + greedy: true, alias: "comment"), + GrammarToken( + "template-string", + compileHighlightPattern( + "`(?:\\\\[\\s\\S]|\\\$\\{(?:[^{}]|\\{(?:[^{}]|\\{[^}]*\\})*\\})+\\}|(?!\\\$\\{)[^\\\\`])*`"), + greedy: true, + inside: () => _g28), + GrammarToken( + "string-property", + compileHighlightPattern( + "((?:^|[,{])[ \\t]*)([\"'])(?:\\\\(?:\\r\\n|[\\s\\S])|(?!\\2)[^\\\\\\r\\n])*\\2(?=\\s*:)", + multiLine: true), + lookbehind: true, + greedy: true, + alias: "property"), + GrammarToken( + "string", + compileHighlightPattern( + "([\"'])(?:\\\\(?:\\r\\n|[\\s\\S])|(?!\\1)[^\\\\\\r\\n])*\\1"), + greedy: true), + GrammarToken( + "class-name", + compileHighlightPattern( + "(\\b(?:class|extends|implements|instanceof|interface|new|type)\\s+)(?!keyof\\b)(?!\\s)[_\$a-zA-Z\\xA0-\\uFFFF](?:(?!\\s)[\$\\w\\xA0-\\uFFFF])*(?:\\s*<(?:[^<>]|<(?:[^<>]|<[^<>]*>)*>)*>)?"), + lookbehind: true, + greedy: true, + inside: () => _g31), + GrammarToken( + "regex", + compileHighlightPattern( + "((?:^|[^\$\\w\\xA0-\\uFFFF.\"'\\])\\s]|\\b(?:return|yield))\\s*)\\/(?:(?:\\[(?:[^\\]\\\\\\r\\n]|\\\\.)*\\]|\\\\.|[^/\\\\\\[\\r\\n])+\\/[dgimyus]{0,7}|(?:\\[(?:[^[\\]\\\\\\r\\n]|\\\\.|\\[(?:[^[\\]\\\\\\r\\n]|\\\\.|\\[(?:[^[\\]\\\\\\r\\n]|\\\\.)*\\])*\\])*\\]|\\\\.|[^/\\\\\\[\\r\\n])+\\/[dgimyus]{0,7}v[dgimyus]{0,7})(?=(?:\\s|\\/\\*(?:[^*]|\\*(?!\\/))*\\*\\/)*(?:\$|[\\r\\n,.;:})\\]]|\\/\\/))"), + lookbehind: true, + greedy: true, + inside: () => _g40), + GrammarToken( + "function-variable", + compileHighlightPattern( + "#?(?!\\s)[_\$a-zA-Z\\xA0-\\uFFFF](?:(?!\\s)[\$\\w\\xA0-\\uFFFF])*(?=\\s*[=:]\\s*(?:async\\s*)?(?:\\bfunction\\b|(?:\\((?:[^()]|\\([^()]*\\))*\\)|(?!\\s)[_\$a-zA-Z\\xA0-\\uFFFF](?:(?!\\s)[\$\\w\\xA0-\\uFFFF])*)\\s*=>))"), + alias: "function"), + GrammarToken( + "constant", compileHighlightPattern("\\b[A-Z](?:[A-Z_]|\\dx?)*\\b")), + GrammarToken("keyword", compileHighlightPattern("((?:^|\\})\\s*)catch\\b"), + lookbehind: true), + GrammarToken( + "keyword", + compileHighlightPattern( + "(^|[^.]|\\.\\.\\.\\s*)\\b(?:as|assert(?=\\s*\\{)|async(?=\\s*(?:function\\b|\\(|[\$\\w\\xA0-\\uFFFF]|\$))|await|break|case|class|const|continue|debugger|default|delete|do|else|enum|export|extends|finally(?=\\s*(?:\\{|\$))|for|from(?=\\s*(?:['\"]|\$))|function|(?:get|set)(?=\\s*(?:[#\\[\$\\w\\xA0-\\uFFFF]|\$))|if|implements|import|in|instanceof|interface|let|new|null|of|package|private|protected|public|return|static|super|switch|this|throw|try|typeof|undefined|var|void|while|with|yield)\\b"), + lookbehind: true), + GrammarToken( + "keyword", + compileHighlightPattern( + "\\b(?:abstract|declare|is|keyof|readonly|require)\\b")), + GrammarToken( + "keyword", + compileHighlightPattern( + "\\b(?:asserts|infer|interface|module|namespace|type)\\b(?=\\s*(?:[{_\$a-zA-Z\\xA0-\\uFFFF]|\$))")), + GrammarToken( + "keyword", compileHighlightPattern("\\btype\\b(?=\\s*(?:[\\{*]|\$))")), + GrammarToken("boolean", compileHighlightPattern("\\b(?:false|true)\\b")), + GrammarToken( + "function", + compileHighlightPattern( + "#?(?!\\s)[_\$a-zA-Z\\xA0-\\uFFFF](?:(?!\\s)[\$\\w\\xA0-\\uFFFF])*(?=\\s*(?:\\.\\s*(?:apply|bind|call)\\s*)?\\()")), + GrammarToken( + "number", + compileHighlightPattern( + "(^|[^\\w\$])(?:NaN|Infinity|0[bB][01]+(?:_[01]+)*n?|0[oO][0-7]+(?:_[0-7]+)*n?|0[xX][\\dA-Fa-f]+(?:_[\\dA-Fa-f]+)*n?|\\d+(?:_\\d+)*n|(?:\\d+(?:_\\d+)*(?:\\.(?:\\d+(?:_\\d+)*)?)?|\\.\\d+(?:_\\d+)*)(?:[Ee][+-]?\\d+(?:_\\d+)*)?)(?![\\w\$])"), + lookbehind: true), + GrammarToken( + "operator", + compileHighlightPattern( + "--|\\+\\+|\\*\\*=?|=>|&&=?|\\|\\|=?|[!=]==|<<=?|>>>?=?|[-+*/%&|^!=<>]=?|\\.{3}|\\?\\?=?|\\?\\.?|[~:]")), + GrammarToken("punctuation", compileHighlightPattern("[{}[\\];(),.:]")), + GrammarToken("decorator", compileHighlightPattern("@[\$\\w\\xA0-\\uFFFF]+"), + inside: () => _g46), + GrammarToken( + "generic-function", + compileHighlightPattern( + "#?(?!\\s)[_\$a-zA-Z\\xA0-\\uFFFF](?:(?!\\s)[\$\\w\\xA0-\\uFFFF])*\\s*<(?:[^<>]|<(?:[^<>]|<[^<>]*>)*>)*>(?=\\s*\\()"), + greedy: true, + inside: () => _g47), + GrammarToken( + "builtin", + compileHighlightPattern( + "\\b(?:Array|Function|Promise|any|boolean|console|never|number|string|symbol|unknown)\\b")), +]); + +final Grammar _g1 = Grammar([ + GrammarToken("internal-subset", + compileHighlightPattern("(^[^\\[]*\\[)[\\s\\S]+(?=\\]>\$)"), + lookbehind: true, greedy: true, inside: () => _g0), + GrammarToken("string", compileHighlightPattern("\"[^\"]*\"|'[^']*'"), + greedy: true), + GrammarToken("punctuation", compileHighlightPattern("^\$|[[\\]]")), + GrammarToken( + "doctype-tag", compileHighlightPattern("^DOCTYPE", caseSensitive: false)), + GrammarToken("name", compileHighlightPattern("[^\\s<>'\"]+")), +]); + +final Grammar _g2 = Grammar([ + GrammarToken( + "included-cdata", + compileHighlightPattern("", + caseSensitive: false), + inside: () => _g3), + GrammarToken("language-css", compileHighlightPattern("[\\s\\S]+"), + inside: () => _g4), +]); + +final Grammar _g3 = Grammar([ + GrammarToken( + "language-css", + compileHighlightPattern("(^\$)", + caseSensitive: false), + lookbehind: true, + inside: () => _g4), + GrammarToken( + "cdata", + compileHighlightPattern("^\$", + caseSensitive: false)), +]); + +final Grammar _g4 = Grammar([ + GrammarToken("comment", compileHighlightPattern("\\/\\*[\\s\\S]*?\\*\\/")), + GrammarToken( + "atrule", + compileHighlightPattern( + "@[\\w-](?:[^;{\\s\"']|\\s+(?!\\s)|(?:\"(?:\\\\(?:\\r\\n|[\\s\\S])|[^\"\\\\\\r\\n])*\"|'(?:\\\\(?:\\r\\n|[\\s\\S])|[^'\\\\\\r\\n])*'))*?(?:;|(?=\\s*\\{))"), + inside: () => _g5), + GrammarToken( + "url", + compileHighlightPattern( + "\\burl\\((?:(?:\"(?:\\\\(?:\\r\\n|[\\s\\S])|[^\"\\\\\\r\\n])*\"|'(?:\\\\(?:\\r\\n|[\\s\\S])|[^'\\\\\\r\\n])*')|(?:[^\\\\\\r\\n()\"']|\\\\[\\s\\S])*)\\)", + caseSensitive: false), + greedy: true, + inside: () => _g6), + GrammarToken( + "selector", + compileHighlightPattern( + "(^|[{}\\s])[^{}\\s](?:[^{};\"'\\s]|\\s+(?![\\s{])|(?:\"(?:\\\\(?:\\r\\n|[\\s\\S])|[^\"\\\\\\r\\n])*\"|'(?:\\\\(?:\\r\\n|[\\s\\S])|[^'\\\\\\r\\n])*'))*(?=\\s*\\{)"), + lookbehind: true), + GrammarToken( + "string", + compileHighlightPattern( + "(?:\"(?:\\\\(?:\\r\\n|[\\s\\S])|[^\"\\\\\\r\\n])*\"|'(?:\\\\(?:\\r\\n|[\\s\\S])|[^'\\\\\\r\\n])*')"), + greedy: true), + GrammarToken( + "property", + compileHighlightPattern( + "(^|[^-\\w\\xA0-\\uFFFF])(?!\\s)[-_a-z\\xA0-\\uFFFF](?:(?!\\s)[-\\w\\xA0-\\uFFFF])*(?=\\s*:)", + caseSensitive: false), + lookbehind: true), + GrammarToken("important", + compileHighlightPattern("!important\\b", caseSensitive: false)), + GrammarToken( + "function", + compileHighlightPattern("(^|[^-a-z0-9])[-a-z0-9]+(?=\\()", + caseSensitive: false), + lookbehind: true), + GrammarToken("punctuation", compileHighlightPattern("[(){};:,]")), +]); + +final Grammar _g5 = Grammar([ + GrammarToken("rule", compileHighlightPattern("^@[\\w-]+")), + GrammarToken( + "selector-function-argument", + compileHighlightPattern( + "(\\bselector\\s*\\(\\s*(?![\\s)]))(?:[^()\\s]|\\s+(?![\\s)])|\\((?:[^()]|\\([^()]*\\))*\\))+(?=\\s*\\))"), + lookbehind: true, + alias: "selector"), + GrammarToken("keyword", + compileHighlightPattern("(^|[^\\w-])(?:and|not|only|or)(?![\\w-])"), + lookbehind: true), +], rest: () => _g4); + +final Grammar _g6 = Grammar([ + GrammarToken( + "function", compileHighlightPattern("^url", caseSensitive: false)), + GrammarToken("punctuation", compileHighlightPattern("^\\(|\\)\$")), + GrammarToken( + "string", + compileHighlightPattern( + "^(?:\"(?:\\\\(?:\\r\\n|[\\s\\S])|[^\"\\\\\\r\\n])*\"|'(?:\\\\(?:\\r\\n|[\\s\\S])|[^'\\\\\\r\\n])*')\$"), + alias: "url"), +]); + +final Grammar _g7 = Grammar([ + GrammarToken( + "included-cdata", + compileHighlightPattern("", + caseSensitive: false), + inside: () => _g8), + GrammarToken("language-javascript", compileHighlightPattern("[\\s\\S]+"), + inside: () => _g9), +]); + +final Grammar _g8 = Grammar([ + GrammarToken( + "language-javascript", + compileHighlightPattern("(^\$)", + caseSensitive: false), + lookbehind: true, + inside: () => _g9), + GrammarToken( + "cdata", + compileHighlightPattern("^\$", + caseSensitive: false)), +]); + +final Grammar _g9 = Grammar([ + GrammarToken("comment", + compileHighlightPattern("(^|[^\\\\])\\/\\*[\\s\\S]*?(?:\\*\\/|\$)"), + lookbehind: true, greedy: true), + GrammarToken("comment", compileHighlightPattern("(^|[^\\\\:])\\/\\/.*"), + lookbehind: true, greedy: true), + GrammarToken("hashbang", compileHighlightPattern("^#!.*"), + greedy: true, alias: "comment"), + GrammarToken( + "template-string", + compileHighlightPattern( + "`(?:\\\\[\\s\\S]|\\\$\\{(?:[^{}]|\\{(?:[^{}]|\\{[^}]*\\})*\\})+\\}|(?!\\\$\\{)[^\\\\`])*`"), + greedy: true, + inside: () => _g10), + GrammarToken( + "string-property", + compileHighlightPattern( + "((?:^|[,{])[ \\t]*)([\"'])(?:\\\\(?:\\r\\n|[\\s\\S])|(?!\\2)[^\\\\\\r\\n])*\\2(?=\\s*:)", + multiLine: true), + lookbehind: true, + greedy: true, + alias: "property"), + GrammarToken( + "string", + compileHighlightPattern( + "([\"'])(?:\\\\(?:\\r\\n|[\\s\\S])|(?!\\1)[^\\\\\\r\\n])*\\1"), + greedy: true), + GrammarToken( + "class-name", + compileHighlightPattern( + "(\\b(?:class|extends|implements|instanceof|interface|new)\\s+)[\\w.\\\\]+"), + lookbehind: true, + inside: () => _g12), + GrammarToken( + "class-name", + compileHighlightPattern( + "(^|[^\$\\w\\xA0-\\uFFFF])(?!\\s)[_\$A-Z\\xA0-\\uFFFF](?:(?!\\s)[\$\\w\\xA0-\\uFFFF])*(?=\\.(?:constructor|prototype))"), + lookbehind: true), + GrammarToken( + "regex", + compileHighlightPattern( + "((?:^|[^\$\\w\\xA0-\\uFFFF.\"'\\])\\s]|\\b(?:return|yield))\\s*)\\/(?:(?:\\[(?:[^\\]\\\\\\r\\n]|\\\\.)*\\]|\\\\.|[^/\\\\\\[\\r\\n])+\\/[dgimyus]{0,7}|(?:\\[(?:[^[\\]\\\\\\r\\n]|\\\\.|\\[(?:[^[\\]\\\\\\r\\n]|\\\\.|\\[(?:[^[\\]\\\\\\r\\n]|\\\\.)*\\])*\\])*\\]|\\\\.|[^/\\\\\\[\\r\\n])+\\/[dgimyus]{0,7}v[dgimyus]{0,7})(?=(?:\\s|\\/\\*(?:[^*]|\\*(?!\\/))*\\*\\/)*(?:\$|[\\r\\n,.;:})\\]]|\\/\\/))"), + lookbehind: true, + greedy: true, + inside: () => _g13), + GrammarToken( + "function-variable", + compileHighlightPattern( + "#?(?!\\s)[_\$a-zA-Z\\xA0-\\uFFFF](?:(?!\\s)[\$\\w\\xA0-\\uFFFF])*(?=\\s*[=:]\\s*(?:async\\s*)?(?:\\bfunction\\b|(?:\\((?:[^()]|\\([^()]*\\))*\\)|(?!\\s)[_\$a-zA-Z\\xA0-\\uFFFF](?:(?!\\s)[\$\\w\\xA0-\\uFFFF])*)\\s*=>))"), + alias: "function"), + GrammarToken( + "parameter", + compileHighlightPattern( + "(function(?:\\s+(?!\\s)[_\$a-zA-Z\\xA0-\\uFFFF](?:(?!\\s)[\$\\w\\xA0-\\uFFFF])*)?\\s*\\(\\s*)(?!\\s)(?:[^()\\s]|\\s+(?![\\s)])|\\([^()]*\\))+(?=\\s*\\))"), + lookbehind: true, + inside: () => _g9), + GrammarToken( + "parameter", + compileHighlightPattern( + "(^|[^\$\\w\\xA0-\\uFFFF])(?!\\s)[_\$a-z\\xA0-\\uFFFF](?:(?!\\s)[\$\\w\\xA0-\\uFFFF])*(?=\\s*=>)", + caseSensitive: false), + lookbehind: true, + inside: () => _g9), + GrammarToken( + "parameter", + compileHighlightPattern( + "(\\(\\s*)(?!\\s)(?:[^()\\s]|\\s+(?![\\s)])|\\([^()]*\\))+(?=\\s*\\)\\s*=>)"), + lookbehind: true, + inside: () => _g9), + GrammarToken( + "parameter", + compileHighlightPattern( + "((?:\\b|\\s|^)(?!(?:as|async|await|break|case|catch|class|const|continue|debugger|default|delete|do|else|enum|export|extends|finally|for|from|function|get|if|implements|import|in|instanceof|interface|let|new|null|of|package|private|protected|public|return|set|static|super|switch|this|throw|try|typeof|undefined|var|void|while|with|yield)(?![\$\\w\\xA0-\\uFFFF]))(?:(?!\\s)[_\$a-zA-Z\\xA0-\\uFFFF](?:(?!\\s)[\$\\w\\xA0-\\uFFFF])*\\s*)\\(\\s*|\\]\\s*\\(\\s*)(?!\\s)(?:[^()\\s]|\\s+(?![\\s)])|\\([^()]*\\))+(?=\\s*\\)\\s*\\{)"), + lookbehind: true, + inside: () => _g9), + GrammarToken( + "constant", compileHighlightPattern("\\b[A-Z](?:[A-Z_]|\\dx?)*\\b")), + GrammarToken("keyword", compileHighlightPattern("((?:^|\\})\\s*)catch\\b"), + lookbehind: true), + GrammarToken( + "keyword", + compileHighlightPattern( + "(^|[^.]|\\.\\.\\.\\s*)\\b(?:as|assert(?=\\s*\\{)|async(?=\\s*(?:function\\b|\\(|[\$\\w\\xA0-\\uFFFF]|\$))|await|break|case|class|const|continue|debugger|default|delete|do|else|enum|export|extends|finally(?=\\s*(?:\\{|\$))|for|from(?=\\s*(?:['\"]|\$))|function|(?:get|set)(?=\\s*(?:[#\\[\$\\w\\xA0-\\uFFFF]|\$))|if|implements|import|in|instanceof|interface|let|new|null|of|package|private|protected|public|return|static|super|switch|this|throw|try|typeof|undefined|var|void|while|with|yield)\\b"), + lookbehind: true), + GrammarToken("boolean", compileHighlightPattern("\\b(?:false|true)\\b")), + GrammarToken( + "function", + compileHighlightPattern( + "#?(?!\\s)[_\$a-zA-Z\\xA0-\\uFFFF](?:(?!\\s)[\$\\w\\xA0-\\uFFFF])*(?=\\s*(?:\\.\\s*(?:apply|bind|call)\\s*)?\\()")), + GrammarToken( + "number", + compileHighlightPattern( + "(^|[^\\w\$])(?:NaN|Infinity|0[bB][01]+(?:_[01]+)*n?|0[oO][0-7]+(?:_[0-7]+)*n?|0[xX][\\dA-Fa-f]+(?:_[\\dA-Fa-f]+)*n?|\\d+(?:_\\d+)*n|(?:\\d+(?:_\\d+)*(?:\\.(?:\\d+(?:_\\d+)*)?)?|\\.\\d+(?:_\\d+)*)(?:[Ee][+-]?\\d+(?:_\\d+)*)?)(?![\\w\$])"), + lookbehind: true), + GrammarToken( + "literal-property", + compileHighlightPattern( + "((?:^|[,{])[ \\t]*)(?!\\s)[_\$a-zA-Z\\xA0-\\uFFFF](?:(?!\\s)[\$\\w\\xA0-\\uFFFF])*(?=\\s*:)", + multiLine: true), + lookbehind: true, + alias: "property"), + GrammarToken( + "operator", + compileHighlightPattern( + "--|\\+\\+|\\*\\*=?|=>|&&=?|\\|\\|=?|[!=]==|<<=?|>>>?=?|[-+*/%&|^!=<>]=?|\\.{3}|\\?\\?=?|\\?\\.?|[~:]")), + GrammarToken("punctuation", compileHighlightPattern("[{}[\\];(),.:]")), +]); + +final Grammar _g10 = Grammar([ + GrammarToken("template-punctuation", compileHighlightPattern("^`|`\$"), + alias: "string"), + GrammarToken( + "interpolation", + compileHighlightPattern( + "((?:^|[^\\\\])(?:\\\\{2})*)\\\$\\{(?:[^{}]|\\{(?:[^{}]|\\{[^}]*\\})*\\})+\\}"), + lookbehind: true, + inside: () => _g11), + GrammarToken("string", compileHighlightPattern("[\\s\\S]+")), +]); + +final Grammar _g11 = Grammar([ + GrammarToken( + "interpolation-punctuation", compileHighlightPattern("^\\\$\\{|\\}\$"), + alias: "punctuation"), +], rest: () => _g9); + +final Grammar _g12 = Grammar([ + GrammarToken("punctuation", compileHighlightPattern("[.\\\\]")), +]); + +final Grammar _g13 = Grammar([ + GrammarToken( + "regex-source", compileHighlightPattern("^(\\/)[\\s\\S]+(?=\\/[a-z]*\$)"), + lookbehind: true, alias: "language-regex", inside: () => _g14), + GrammarToken("regex-delimiter", compileHighlightPattern("^\\/|\\/\$")), + GrammarToken("regex-flags", compileHighlightPattern("^[a-z]+\$")), +]); + +final Grammar _g14 = Grammar([ + GrammarToken( + "char-class", + compileHighlightPattern( + "((?:^|[^\\\\])(?:\\\\\\\\)*)\\[(?:[^\\\\\\]]|\\\\[\\s\\S])*\\]"), + lookbehind: true, + inside: () => _g15), + GrammarToken( + "special-escape", compileHighlightPattern("\\\\[\\\\(){}[\\]^\$+*?|.]"), + alias: "escape"), + GrammarToken( + "char-set", + compileHighlightPattern("\\.|\\\\[wsd]|\\\\p\\{[^{}]+\\}", + caseSensitive: false), + alias: "class-name"), + GrammarToken( + "backreference", compileHighlightPattern("\\\\(?![123][0-7]{2})[1-9]"), + alias: "keyword"), + GrammarToken("backreference", compileHighlightPattern("\\\\k<[^<>']+>"), + alias: "keyword", inside: () => _g17), + GrammarToken("anchor", compileHighlightPattern("[\$^]|\\\\[ABbGZz]"), + alias: "function"), + GrammarToken( + "escape", + compileHighlightPattern( + "\\\\(?:x[\\da-fA-F]{2}|u[\\da-fA-F]{4}|u\\{[\\da-fA-F]+\\}|0[0-7]{0,2}|[123][0-7]{2}|c[a-zA-Z]|.)")), + GrammarToken( + "group", + compileHighlightPattern( + "\\((?:\\?(?:<[^<>']+>|'[^<>']+'|[>:]| _g18), + GrammarToken("group", compileHighlightPattern("\\)"), alias: "punctuation"), + GrammarToken("quantifier", + compileHighlightPattern("(?:[+*?]|\\{\\d+(?:,\\d*)?\\})[?+]?"), + alias: "number"), + GrammarToken("alternation", compileHighlightPattern("\\|"), alias: "keyword"), +]); + +final Grammar _g15 = Grammar([ + GrammarToken("char-class-negation", compileHighlightPattern("(^\\[)\\^"), + lookbehind: true, alias: "operator"), + GrammarToken("char-class-punctuation", compileHighlightPattern("^\\[|\\]\$"), + alias: "punctuation"), + GrammarToken( + "range", + compileHighlightPattern( + "(?:[^\\\\-]|\\\\(?:x[\\da-fA-F]{2}|u[\\da-fA-F]{4}|u\\{[\\da-fA-F]+\\}|0[0-7]{0,2}|[123][0-7]{2}|c[a-zA-Z]|.))-(?:[^\\\\-]|\\\\(?:x[\\da-fA-F]{2}|u[\\da-fA-F]{4}|u\\{[\\da-fA-F]+\\}|0[0-7]{0,2}|[123][0-7]{2}|c[a-zA-Z]|.))"), + inside: () => _g16), + GrammarToken( + "special-escape", compileHighlightPattern("\\\\[\\\\(){}[\\]^\$+*?|.]"), + alias: "escape"), + GrammarToken( + "char-set", + compileHighlightPattern("\\\\[wsd]|\\\\p\\{[^{}]+\\}", + caseSensitive: false), + alias: "class-name"), + GrammarToken( + "escape", + compileHighlightPattern( + "\\\\(?:x[\\da-fA-F]{2}|u[\\da-fA-F]{4}|u\\{[\\da-fA-F]+\\}|0[0-7]{0,2}|[123][0-7]{2}|c[a-zA-Z]|.)")), +]); + +final Grammar _g16 = Grammar([ + GrammarToken( + "escape", + compileHighlightPattern( + "\\\\(?:x[\\da-fA-F]{2}|u[\\da-fA-F]{4}|u\\{[\\da-fA-F]+\\}|0[0-7]{0,2}|[123][0-7]{2}|c[a-zA-Z]|.)")), + GrammarToken("range-punctuation", compileHighlightPattern("-"), + alias: "operator"), +]); + +final Grammar _g17 = Grammar([ + GrammarToken("group-name", compileHighlightPattern("(<|')[^<>']+(?=[>']\$)"), + lookbehind: true, alias: "variable"), +]); + +final Grammar _g18 = Grammar([ + GrammarToken("group-name", compileHighlightPattern("(<|')[^<>']+(?=[>']\$)"), + lookbehind: true, alias: "variable"), +]); + +final Grammar _g19 = Grammar([ + GrammarToken("tag", compileHighlightPattern("^<\\/?[^\\s>\\/]*"), + inside: () => _g20), + GrammarToken( + "script", + compileHighlightPattern( + "=(?:\\{(?:\\{(?:\\{[^{}]*\\}|[^{}])*\\}|[^{}])*\\})"), + alias: "language-javascript", + inside: () => _g21), + GrammarToken( + "special-attr", + compileHighlightPattern( + "(^|[\"'\\s])(?:style)\\s*=\\s*(?:\"[^\"]*\"|'[^']*'|[^\\s'\">=]+(?=[\\s>]))", + caseSensitive: false), + lookbehind: true, + inside: () => _g22), + GrammarToken( + "special-attr", + compileHighlightPattern( + "(^|[\"'\\s])(?:on(?:abort|blur|change|click|composition(?:end|start|update)|dblclick|error|focus(?:in|out)?|key(?:down|up)|load|mouse(?:down|enter|leave|move|out|over|up)|reset|resize|scroll|select|slotchange|submit|unload|wheel))\\s*=\\s*(?:\"[^\"]*\"|'[^']*'|[^\\s'\">=]+(?=[\\s>]))", + caseSensitive: false), + lookbehind: true, + inside: () => _g24), + GrammarToken( + "attr-value", + compileHighlightPattern( + "=(?!\\{)(?:\"(?:\\\\[\\s\\S]|[^\\\\\"])*\"|'(?:\\\\[\\s\\S]|[^\\\\'])*'|[^\\s'\">]+)"), + inside: () => _g26), + GrammarToken("punctuation", compileHighlightPattern("\\/?>")), + GrammarToken( + "spread", + compileHighlightPattern( + "(?:\\{(?:\\s|\\/\\/.*(?!.)|\\/\\*(?:[^*]|\\*(?!\\/))\\*\\/)*\\.{3}(?:[^{}]|(?:\\{(?:\\{(?:\\{[^{}]*\\}|[^{}])*\\}|[^{}])*\\}))*\\})"), + inside: () => _g0), + GrammarToken("attr-name", compileHighlightPattern("[^\\s>\\/]+"), + inside: () => _g27), + GrammarToken("comment", + compileHighlightPattern("(^|[^\\\\])\\/\\*[\\s\\S]*?(?:\\*\\/|\$)"), + lookbehind: true, greedy: true), + GrammarToken("comment", compileHighlightPattern("(^|[^\\\\:])\\/\\/.*"), + lookbehind: true, greedy: true), +]); + +final Grammar _g20 = Grammar([ + GrammarToken("punctuation", compileHighlightPattern("^<\\/?")), + GrammarToken("namespace", compileHighlightPattern("^[^\\s>\\/:]+:")), + GrammarToken( + "class-name", compileHighlightPattern("^[A-Z]\\w*(?:\\.[A-Z]\\w*)*\$")), +]); + +final Grammar _g21 = Grammar([ + GrammarToken("script-punctuation", compileHighlightPattern("^=(?=\\{)"), + alias: "punctuation"), +], rest: () => _g0); + +final Grammar _g22 = Grammar([ + GrammarToken("attr-name", compileHighlightPattern("^[^\\s=]+")), + GrammarToken("attr-value", compileHighlightPattern("=[\\s\\S]+"), + inside: () => _g23), +]); + +final Grammar _g23 = Grammar([ + GrammarToken("value", + compileHighlightPattern("(^=\\s*([\"']|(?![\"'])))\\S[\\s\\S]*(?=\\2\$)"), + lookbehind: true, alias: "css", inside: () => _g4), + GrammarToken("punctuation", compileHighlightPattern("^="), + alias: "attr-equals"), + GrammarToken("punctuation", compileHighlightPattern("\"|'")), +]); + +final Grammar _g24 = Grammar([ + GrammarToken("attr-name", compileHighlightPattern("^[^\\s=]+")), + GrammarToken("attr-value", compileHighlightPattern("=[\\s\\S]+"), + inside: () => _g25), +]); + +final Grammar _g25 = Grammar([ + GrammarToken("value", + compileHighlightPattern("(^=\\s*([\"']|(?![\"'])))\\S[\\s\\S]*(?=\\2\$)"), + lookbehind: true, alias: "javascript", inside: () => _g9), + GrammarToken("punctuation", compileHighlightPattern("^="), + alias: "attr-equals"), + GrammarToken("punctuation", compileHighlightPattern("\"|'")), +]); + +final Grammar _g26 = Grammar([ + GrammarToken("punctuation", compileHighlightPattern("^="), + alias: "attr-equals"), + GrammarToken("punctuation", compileHighlightPattern("^(\\s*)[\"']|[\"']\$"), + lookbehind: true), + GrammarToken("entity", + compileHighlightPattern("&[\\da-z]{1,8};", caseSensitive: false), + alias: "named-entity"), + GrammarToken("entity", + compileHighlightPattern("&#x?[\\da-f]{1,8};", caseSensitive: false)), +]); + +final Grammar _g27 = Grammar([ + GrammarToken("namespace", compileHighlightPattern("^[^\\s>\\/:]+:")), +]); + +final Grammar _g28 = Grammar([ + GrammarToken("template-punctuation", compileHighlightPattern("^`|`\$"), + alias: "string"), + GrammarToken( + "interpolation", + compileHighlightPattern( + "((?:^|[^\\\\])(?:\\\\{2})*)\\\$\\{(?:[^{}]|\\{(?:[^{}]|\\{[^}]*\\})*\\})+\\}"), + lookbehind: true, + inside: () => _g29), + GrammarToken("string", compileHighlightPattern("[\\s\\S]+")), +]); + +final Grammar _g29 = Grammar([ + GrammarToken( + "interpolation-punctuation", compileHighlightPattern("^\\\$\\{|\\}\$"), + alias: "punctuation"), +], rest: () => _g30); + +final Grammar _g30 = Grammar([ + GrammarToken("comment", + compileHighlightPattern("(^|[^\\\\])\\/\\*[\\s\\S]*?(?:\\*\\/|\$)"), + lookbehind: true, greedy: true), + GrammarToken("comment", compileHighlightPattern("(^|[^\\\\:])\\/\\/.*"), + lookbehind: true, greedy: true), + GrammarToken("hashbang", compileHighlightPattern("^#!.*"), + greedy: true, alias: "comment"), + GrammarToken( + "template-string", + compileHighlightPattern( + "`(?:\\\\[\\s\\S]|\\\$\\{(?:[^{}]|\\{(?:[^{}]|\\{[^}]*\\})*\\})+\\}|(?!\\\$\\{)[^\\\\`])*`"), + greedy: true, + inside: () => _g28), + GrammarToken( + "string-property", + compileHighlightPattern( + "((?:^|[,{])[ \\t]*)([\"'])(?:\\\\(?:\\r\\n|[\\s\\S])|(?!\\2)[^\\\\\\r\\n])*\\2(?=\\s*:)", + multiLine: true), + lookbehind: true, + greedy: true, + alias: "property"), + GrammarToken( + "string", + compileHighlightPattern( + "([\"'])(?:\\\\(?:\\r\\n|[\\s\\S])|(?!\\1)[^\\\\\\r\\n])*\\1"), + greedy: true), + GrammarToken( + "class-name", + compileHighlightPattern( + "(\\b(?:class|extends|implements|instanceof|interface|new|type)\\s+)(?!keyof\\b)(?!\\s)[_\$a-zA-Z\\xA0-\\uFFFF](?:(?!\\s)[\$\\w\\xA0-\\uFFFF])*(?:\\s*<(?:[^<>]|<(?:[^<>]|<[^<>]*>)*>)*>)?"), + lookbehind: true, + greedy: true, + inside: () => _g31), + GrammarToken( + "regex", + compileHighlightPattern( + "((?:^|[^\$\\w\\xA0-\\uFFFF.\"'\\])\\s]|\\b(?:return|yield))\\s*)\\/(?:(?:\\[(?:[^\\]\\\\\\r\\n]|\\\\.)*\\]|\\\\.|[^/\\\\\\[\\r\\n])+\\/[dgimyus]{0,7}|(?:\\[(?:[^[\\]\\\\\\r\\n]|\\\\.|\\[(?:[^[\\]\\\\\\r\\n]|\\\\.|\\[(?:[^[\\]\\\\\\r\\n]|\\\\.)*\\])*\\])*\\]|\\\\.|[^/\\\\\\[\\r\\n])+\\/[dgimyus]{0,7}v[dgimyus]{0,7})(?=(?:\\s|\\/\\*(?:[^*]|\\*(?!\\/))*\\*\\/)*(?:\$|[\\r\\n,.;:})\\]]|\\/\\/))"), + lookbehind: true, + greedy: true, + inside: () => _g40), + GrammarToken( + "function-variable", + compileHighlightPattern( + "#?(?!\\s)[_\$a-zA-Z\\xA0-\\uFFFF](?:(?!\\s)[\$\\w\\xA0-\\uFFFF])*(?=\\s*[=:]\\s*(?:async\\s*)?(?:\\bfunction\\b|(?:\\((?:[^()]|\\([^()]*\\))*\\)|(?!\\s)[_\$a-zA-Z\\xA0-\\uFFFF](?:(?!\\s)[\$\\w\\xA0-\\uFFFF])*)\\s*=>))"), + alias: "function"), + GrammarToken( + "constant", compileHighlightPattern("\\b[A-Z](?:[A-Z_]|\\dx?)*\\b")), + GrammarToken("keyword", compileHighlightPattern("((?:^|\\})\\s*)catch\\b"), + lookbehind: true), + GrammarToken( + "keyword", + compileHighlightPattern( + "(^|[^.]|\\.\\.\\.\\s*)\\b(?:as|assert(?=\\s*\\{)|async(?=\\s*(?:function\\b|\\(|[\$\\w\\xA0-\\uFFFF]|\$))|await|break|case|class|const|continue|debugger|default|delete|do|else|enum|export|extends|finally(?=\\s*(?:\\{|\$))|for|from(?=\\s*(?:['\"]|\$))|function|(?:get|set)(?=\\s*(?:[#\\[\$\\w\\xA0-\\uFFFF]|\$))|if|implements|import|in|instanceof|interface|let|new|null|of|package|private|protected|public|return|static|super|switch|this|throw|try|typeof|undefined|var|void|while|with|yield)\\b"), + lookbehind: true), + GrammarToken( + "keyword", + compileHighlightPattern( + "\\b(?:abstract|declare|is|keyof|readonly|require)\\b")), + GrammarToken( + "keyword", + compileHighlightPattern( + "\\b(?:asserts|infer|interface|module|namespace|type)\\b(?=\\s*(?:[{_\$a-zA-Z\\xA0-\\uFFFF]|\$))")), + GrammarToken( + "keyword", compileHighlightPattern("\\btype\\b(?=\\s*(?:[\\{*]|\$))")), + GrammarToken("boolean", compileHighlightPattern("\\b(?:false|true)\\b")), + GrammarToken("decorator", compileHighlightPattern("@[\$\\w\\xA0-\\uFFFF]+"), + inside: () => _g46), + GrammarToken( + "generic-function", + compileHighlightPattern( + "#?(?!\\s)[_\$a-zA-Z\\xA0-\\uFFFF](?:(?!\\s)[\$\\w\\xA0-\\uFFFF])*\\s*<(?:[^<>]|<(?:[^<>]|<[^<>]*>)*>)*>(?=\\s*\\()"), + greedy: true, + inside: () => _g47), + GrammarToken( + "function", + compileHighlightPattern( + "#?(?!\\s)[_\$a-zA-Z\\xA0-\\uFFFF](?:(?!\\s)[\$\\w\\xA0-\\uFFFF])*(?=\\s*(?:\\.\\s*(?:apply|bind|call)\\s*)?\\()")), + GrammarToken( + "number", + compileHighlightPattern( + "(^|[^\\w\$])(?:NaN|Infinity|0[bB][01]+(?:_[01]+)*n?|0[oO][0-7]+(?:_[0-7]+)*n?|0[xX][\\dA-Fa-f]+(?:_[\\dA-Fa-f]+)*n?|\\d+(?:_\\d+)*n|(?:\\d+(?:_\\d+)*(?:\\.(?:\\d+(?:_\\d+)*)?)?|\\.\\d+(?:_\\d+)*)(?:[Ee][+-]?\\d+(?:_\\d+)*)?)(?![\\w\$])"), + lookbehind: true), + GrammarToken( + "operator", + compileHighlightPattern( + "--|\\+\\+|\\*\\*=?|=>|&&=?|\\|\\|=?|[!=]==|<<=?|>>>?=?|[-+*/%&|^!=<>]=?|\\.{3}|\\?\\?=?|\\?\\.?|[~:]")), + GrammarToken("punctuation", compileHighlightPattern("[{}[\\];(),.:]")), + GrammarToken( + "builtin", + compileHighlightPattern( + "\\b(?:Array|Function|Promise|any|boolean|console|never|number|string|symbol|unknown)\\b")), +]); + +final Grammar _g31 = Grammar([ + GrammarToken("comment", + compileHighlightPattern("(^|[^\\\\])\\/\\*[\\s\\S]*?(?:\\*\\/|\$)"), + lookbehind: true, greedy: true), + GrammarToken("comment", compileHighlightPattern("(^|[^\\\\:])\\/\\/.*"), + lookbehind: true, greedy: true), + GrammarToken("hashbang", compileHighlightPattern("^#!.*"), + greedy: true, alias: "comment"), + GrammarToken( + "template-string", + compileHighlightPattern( + "`(?:\\\\[\\s\\S]|\\\$\\{(?:[^{}]|\\{(?:[^{}]|\\{[^}]*\\})*\\})+\\}|(?!\\\$\\{)[^\\\\`])*`"), + greedy: true, + inside: () => _g32), + GrammarToken( + "string-property", + compileHighlightPattern( + "((?:^|[,{])[ \\t]*)([\"'])(?:\\\\(?:\\r\\n|[\\s\\S])|(?!\\2)[^\\\\\\r\\n])*\\2(?=\\s*:)", + multiLine: true), + lookbehind: true, + greedy: true, + alias: "property"), + GrammarToken( + "string", + compileHighlightPattern( + "([\"'])(?:\\\\(?:\\r\\n|[\\s\\S])|(?!\\1)[^\\\\\\r\\n])*\\1"), + greedy: true), + GrammarToken( + "regex", + compileHighlightPattern( + "((?:^|[^\$\\w\\xA0-\\uFFFF.\"'\\])\\s]|\\b(?:return|yield))\\s*)\\/(?:(?:\\[(?:[^\\]\\\\\\r\\n]|\\\\.)*\\]|\\\\.|[^/\\\\\\[\\r\\n])+\\/[dgimyus]{0,7}|(?:\\[(?:[^[\\]\\\\\\r\\n]|\\\\.|\\[(?:[^[\\]\\\\\\r\\n]|\\\\.|\\[(?:[^[\\]\\\\\\r\\n]|\\\\.)*\\])*\\])*\\]|\\\\.|[^/\\\\\\[\\r\\n])+\\/[dgimyus]{0,7}v[dgimyus]{0,7})(?=(?:\\s|\\/\\*(?:[^*]|\\*(?!\\/))*\\*\\/)*(?:\$|[\\r\\n,.;:})\\]]|\\/\\/))"), + lookbehind: true, + greedy: true, + inside: () => _g34), + GrammarToken( + "function-variable", + compileHighlightPattern( + "#?(?!\\s)[_\$a-zA-Z\\xA0-\\uFFFF](?:(?!\\s)[\$\\w\\xA0-\\uFFFF])*(?=\\s*[=:]\\s*(?:async\\s*)?(?:\\bfunction\\b|(?:\\((?:[^()]|\\([^()]*\\))*\\)|(?!\\s)[_\$a-zA-Z\\xA0-\\uFFFF](?:(?!\\s)[\$\\w\\xA0-\\uFFFF])*)\\s*=>))"), + alias: "function"), + GrammarToken( + "constant", compileHighlightPattern("\\b[A-Z](?:[A-Z_]|\\dx?)*\\b")), + GrammarToken("keyword", compileHighlightPattern("((?:^|\\})\\s*)catch\\b"), + lookbehind: true), + GrammarToken( + "keyword", + compileHighlightPattern( + "(^|[^.]|\\.\\.\\.\\s*)\\b(?:as|assert(?=\\s*\\{)|async(?=\\s*(?:function\\b|\\(|[\$\\w\\xA0-\\uFFFF]|\$))|await|break|case|class|const|continue|debugger|default|delete|do|else|enum|export|extends|finally(?=\\s*(?:\\{|\$))|for|from(?=\\s*(?:['\"]|\$))|function|(?:get|set)(?=\\s*(?:[#\\[\$\\w\\xA0-\\uFFFF]|\$))|if|implements|import|in|instanceof|interface|let|new|null|of|package|private|protected|public|return|static|super|switch|this|throw|try|typeof|undefined|var|void|while|with|yield)\\b"), + lookbehind: true), + GrammarToken( + "keyword", + compileHighlightPattern( + "\\b(?:abstract|declare|is|keyof|readonly|require)\\b")), + GrammarToken( + "keyword", + compileHighlightPattern( + "\\b(?:asserts|infer|interface|module|namespace|type)\\b(?=\\s*(?:[{_\$a-zA-Z\\xA0-\\uFFFF]|\$))")), + GrammarToken( + "keyword", compileHighlightPattern("\\btype\\b(?=\\s*(?:[\\{*]|\$))")), + GrammarToken("boolean", compileHighlightPattern("\\b(?:false|true)\\b")), + GrammarToken( + "function", + compileHighlightPattern( + "#?(?!\\s)[_\$a-zA-Z\\xA0-\\uFFFF](?:(?!\\s)[\$\\w\\xA0-\\uFFFF])*(?=\\s*(?:\\.\\s*(?:apply|bind|call)\\s*)?\\()")), + GrammarToken( + "number", + compileHighlightPattern( + "(^|[^\\w\$])(?:NaN|Infinity|0[bB][01]+(?:_[01]+)*n?|0[oO][0-7]+(?:_[0-7]+)*n?|0[xX][\\dA-Fa-f]+(?:_[\\dA-Fa-f]+)*n?|\\d+(?:_\\d+)*n|(?:\\d+(?:_\\d+)*(?:\\.(?:\\d+(?:_\\d+)*)?)?|\\.\\d+(?:_\\d+)*)(?:[Ee][+-]?\\d+(?:_\\d+)*)?)(?![\\w\$])"), + lookbehind: true), + GrammarToken( + "operator", + compileHighlightPattern( + "--|\\+\\+|\\*\\*=?|=>|&&=?|\\|\\|=?|[!=]==|<<=?|>>>?=?|[-+*/%&|^!=<>]=?|\\.{3}|\\?\\?=?|\\?\\.?|[~:]")), + GrammarToken("punctuation", compileHighlightPattern("[{}[\\];(),.:]")), + GrammarToken( + "builtin", + compileHighlightPattern( + "\\b(?:Array|Function|Promise|any|boolean|console|never|number|string|symbol|unknown)\\b")), +]); + +final Grammar _g32 = Grammar([ + GrammarToken("template-punctuation", compileHighlightPattern("^`|`\$"), + alias: "string"), + GrammarToken( + "interpolation", + compileHighlightPattern( + "((?:^|[^\\\\])(?:\\\\{2})*)\\\$\\{(?:[^{}]|\\{(?:[^{}]|\\{[^}]*\\})*\\})+\\}"), + lookbehind: true, + inside: () => _g33), + GrammarToken("string", compileHighlightPattern("[\\s\\S]+")), +]); + +final Grammar _g33 = Grammar([ + GrammarToken( + "interpolation-punctuation", compileHighlightPattern("^\\\$\\{|\\}\$"), + alias: "punctuation"), +], rest: () => _g31); + +final Grammar _g34 = Grammar([ + GrammarToken( + "regex-source", compileHighlightPattern("^(\\/)[\\s\\S]+(?=\\/[a-z]*\$)"), + lookbehind: true, alias: "language-regex", inside: () => _g35), + GrammarToken("regex-delimiter", compileHighlightPattern("^\\/|\\/\$")), + GrammarToken("regex-flags", compileHighlightPattern("^[a-z]+\$")), +]); + +final Grammar _g35 = Grammar([ + GrammarToken( + "char-class", + compileHighlightPattern( + "((?:^|[^\\\\])(?:\\\\\\\\)*)\\[(?:[^\\\\\\]]|\\\\[\\s\\S])*\\]"), + lookbehind: true, + inside: () => _g36), + GrammarToken( + "special-escape", compileHighlightPattern("\\\\[\\\\(){}[\\]^\$+*?|.]"), + alias: "escape"), + GrammarToken( + "char-set", + compileHighlightPattern("\\.|\\\\[wsd]|\\\\p\\{[^{}]+\\}", + caseSensitive: false), + alias: "class-name"), + GrammarToken( + "backreference", compileHighlightPattern("\\\\(?![123][0-7]{2})[1-9]"), + alias: "keyword"), + GrammarToken("backreference", compileHighlightPattern("\\\\k<[^<>']+>"), + alias: "keyword", inside: () => _g38), + GrammarToken("anchor", compileHighlightPattern("[\$^]|\\\\[ABbGZz]"), + alias: "function"), + GrammarToken( + "escape", + compileHighlightPattern( + "\\\\(?:x[\\da-fA-F]{2}|u[\\da-fA-F]{4}|u\\{[\\da-fA-F]+\\}|0[0-7]{0,2}|[123][0-7]{2}|c[a-zA-Z]|.)")), + GrammarToken( + "group", + compileHighlightPattern( + "\\((?:\\?(?:<[^<>']+>|'[^<>']+'|[>:]| _g39), + GrammarToken("group", compileHighlightPattern("\\)"), alias: "punctuation"), + GrammarToken("quantifier", + compileHighlightPattern("(?:[+*?]|\\{\\d+(?:,\\d*)?\\})[?+]?"), + alias: "number"), + GrammarToken("alternation", compileHighlightPattern("\\|"), alias: "keyword"), +]); + +final Grammar _g36 = Grammar([ + GrammarToken("char-class-negation", compileHighlightPattern("(^\\[)\\^"), + lookbehind: true, alias: "operator"), + GrammarToken("char-class-punctuation", compileHighlightPattern("^\\[|\\]\$"), + alias: "punctuation"), + GrammarToken( + "range", + compileHighlightPattern( + "(?:[^\\\\-]|\\\\(?:x[\\da-fA-F]{2}|u[\\da-fA-F]{4}|u\\{[\\da-fA-F]+\\}|0[0-7]{0,2}|[123][0-7]{2}|c[a-zA-Z]|.))-(?:[^\\\\-]|\\\\(?:x[\\da-fA-F]{2}|u[\\da-fA-F]{4}|u\\{[\\da-fA-F]+\\}|0[0-7]{0,2}|[123][0-7]{2}|c[a-zA-Z]|.))"), + inside: () => _g37), + GrammarToken( + "special-escape", compileHighlightPattern("\\\\[\\\\(){}[\\]^\$+*?|.]"), + alias: "escape"), + GrammarToken( + "char-set", + compileHighlightPattern("\\\\[wsd]|\\\\p\\{[^{}]+\\}", + caseSensitive: false), + alias: "class-name"), + GrammarToken( + "escape", + compileHighlightPattern( + "\\\\(?:x[\\da-fA-F]{2}|u[\\da-fA-F]{4}|u\\{[\\da-fA-F]+\\}|0[0-7]{0,2}|[123][0-7]{2}|c[a-zA-Z]|.)")), +]); + +final Grammar _g37 = Grammar([ + GrammarToken( + "escape", + compileHighlightPattern( + "\\\\(?:x[\\da-fA-F]{2}|u[\\da-fA-F]{4}|u\\{[\\da-fA-F]+\\}|0[0-7]{0,2}|[123][0-7]{2}|c[a-zA-Z]|.)")), + GrammarToken("range-punctuation", compileHighlightPattern("-"), + alias: "operator"), +]); + +final Grammar _g38 = Grammar([ + GrammarToken("group-name", compileHighlightPattern("(<|')[^<>']+(?=[>']\$)"), + lookbehind: true, alias: "variable"), +]); + +final Grammar _g39 = Grammar([ + GrammarToken("group-name", compileHighlightPattern("(<|')[^<>']+(?=[>']\$)"), + lookbehind: true, alias: "variable"), +]); + +final Grammar _g40 = Grammar([ + GrammarToken( + "regex-source", compileHighlightPattern("^(\\/)[\\s\\S]+(?=\\/[a-z]*\$)"), + lookbehind: true, alias: "language-regex", inside: () => _g41), + GrammarToken("regex-delimiter", compileHighlightPattern("^\\/|\\/\$")), + GrammarToken("regex-flags", compileHighlightPattern("^[a-z]+\$")), +]); + +final Grammar _g41 = Grammar([ + GrammarToken( + "char-class", + compileHighlightPattern( + "((?:^|[^\\\\])(?:\\\\\\\\)*)\\[(?:[^\\\\\\]]|\\\\[\\s\\S])*\\]"), + lookbehind: true, + inside: () => _g42), + GrammarToken( + "special-escape", compileHighlightPattern("\\\\[\\\\(){}[\\]^\$+*?|.]"), + alias: "escape"), + GrammarToken( + "char-set", + compileHighlightPattern("\\.|\\\\[wsd]|\\\\p\\{[^{}]+\\}", + caseSensitive: false), + alias: "class-name"), + GrammarToken( + "backreference", compileHighlightPattern("\\\\(?![123][0-7]{2})[1-9]"), + alias: "keyword"), + GrammarToken("backreference", compileHighlightPattern("\\\\k<[^<>']+>"), + alias: "keyword", inside: () => _g44), + GrammarToken("anchor", compileHighlightPattern("[\$^]|\\\\[ABbGZz]"), + alias: "function"), + GrammarToken( + "escape", + compileHighlightPattern( + "\\\\(?:x[\\da-fA-F]{2}|u[\\da-fA-F]{4}|u\\{[\\da-fA-F]+\\}|0[0-7]{0,2}|[123][0-7]{2}|c[a-zA-Z]|.)")), + GrammarToken( + "group", + compileHighlightPattern( + "\\((?:\\?(?:<[^<>']+>|'[^<>']+'|[>:]| _g45), + GrammarToken("group", compileHighlightPattern("\\)"), alias: "punctuation"), + GrammarToken("quantifier", + compileHighlightPattern("(?:[+*?]|\\{\\d+(?:,\\d*)?\\})[?+]?"), + alias: "number"), + GrammarToken("alternation", compileHighlightPattern("\\|"), alias: "keyword"), +]); + +final Grammar _g42 = Grammar([ + GrammarToken("char-class-negation", compileHighlightPattern("(^\\[)\\^"), + lookbehind: true, alias: "operator"), + GrammarToken("char-class-punctuation", compileHighlightPattern("^\\[|\\]\$"), + alias: "punctuation"), + GrammarToken( + "range", + compileHighlightPattern( + "(?:[^\\\\-]|\\\\(?:x[\\da-fA-F]{2}|u[\\da-fA-F]{4}|u\\{[\\da-fA-F]+\\}|0[0-7]{0,2}|[123][0-7]{2}|c[a-zA-Z]|.))-(?:[^\\\\-]|\\\\(?:x[\\da-fA-F]{2}|u[\\da-fA-F]{4}|u\\{[\\da-fA-F]+\\}|0[0-7]{0,2}|[123][0-7]{2}|c[a-zA-Z]|.))"), + inside: () => _g43), + GrammarToken( + "special-escape", compileHighlightPattern("\\\\[\\\\(){}[\\]^\$+*?|.]"), + alias: "escape"), + GrammarToken( + "char-set", + compileHighlightPattern("\\\\[wsd]|\\\\p\\{[^{}]+\\}", + caseSensitive: false), + alias: "class-name"), + GrammarToken( + "escape", + compileHighlightPattern( + "\\\\(?:x[\\da-fA-F]{2}|u[\\da-fA-F]{4}|u\\{[\\da-fA-F]+\\}|0[0-7]{0,2}|[123][0-7]{2}|c[a-zA-Z]|.)")), +]); + +final Grammar _g43 = Grammar([ + GrammarToken( + "escape", + compileHighlightPattern( + "\\\\(?:x[\\da-fA-F]{2}|u[\\da-fA-F]{4}|u\\{[\\da-fA-F]+\\}|0[0-7]{0,2}|[123][0-7]{2}|c[a-zA-Z]|.)")), + GrammarToken("range-punctuation", compileHighlightPattern("-"), + alias: "operator"), +]); + +final Grammar _g44 = Grammar([ + GrammarToken("group-name", compileHighlightPattern("(<|')[^<>']+(?=[>']\$)"), + lookbehind: true, alias: "variable"), +]); + +final Grammar _g45 = Grammar([ + GrammarToken("group-name", compileHighlightPattern("(<|')[^<>']+(?=[>']\$)"), + lookbehind: true, alias: "variable"), +]); + +final Grammar _g46 = Grammar([ + GrammarToken("at", compileHighlightPattern("^@"), alias: "operator"), + GrammarToken("function", compileHighlightPattern("^[\\s\\S]+")), +]); + +final Grammar _g47 = Grammar([ + GrammarToken( + "function", + compileHighlightPattern( + "^#?(?!\\s)[_\$a-zA-Z\\xA0-\\uFFFF](?:(?!\\s)[\$\\w\\xA0-\\uFFFF])*")), + GrammarToken("generic", compileHighlightPattern("<[\\s\\S]+"), + alias: "class-name", inside: () => _g31), +]); diff --git a/lib/highlight/typescript.dart b/lib/highlight/typescript.dart new file mode 100644 index 0000000..e50f2b0 --- /dev/null +++ b/lib/highlight/typescript.dart @@ -0,0 +1,417 @@ +// GENERATED CODE — do not modify by hand. Regenerate with tool/highlight_codegen. +// ignore_for_file: lines_longer_than_80_chars, public_member_api_docs +// ignore_for_file: prefer_single_quotes, require_trailing_commas, directives_ordering + +import '../highlight.dart'; + +/// Syntax grammar for `typescript`. +/// +/// Import this library only when you need `typescript` highlighting; unused +/// languages are dropped from the build. +abstract final class HighlightTypescript { + /// The grammar for `typescript`. + static final Grammar grammar = _g0; +} + +final Grammar _g0 = Grammar([ + GrammarToken("comment", + compileHighlightPattern("(^|[^\\\\])\\/\\*[\\s\\S]*?(?:\\*\\/|\$)"), + lookbehind: true, greedy: true), + GrammarToken("comment", compileHighlightPattern("(^|[^\\\\:])\\/\\/.*"), + lookbehind: true, greedy: true), + GrammarToken("hashbang", compileHighlightPattern("^#!.*"), + greedy: true, alias: "comment"), + GrammarToken( + "template-string", + compileHighlightPattern( + "`(?:\\\\[\\s\\S]|\\\$\\{(?:[^{}]|\\{(?:[^{}]|\\{[^}]*\\})*\\})+\\}|(?!\\\$\\{)[^\\\\`])*`"), + greedy: true, + inside: () => _g1), + GrammarToken( + "string-property", + compileHighlightPattern( + "((?:^|[,{])[ \\t]*)([\"'])(?:\\\\(?:\\r\\n|[\\s\\S])|(?!\\2)[^\\\\\\r\\n])*\\2(?=\\s*:)", + multiLine: true), + lookbehind: true, + greedy: true, + alias: "property"), + GrammarToken( + "string", + compileHighlightPattern( + "([\"'])(?:\\\\(?:\\r\\n|[\\s\\S])|(?!\\1)[^\\\\\\r\\n])*\\1"), + greedy: true), + GrammarToken( + "class-name", + compileHighlightPattern( + "(\\b(?:class|extends|implements|instanceof|interface|new|type)\\s+)(?!keyof\\b)(?!\\s)[_\$a-zA-Z\\xA0-\\uFFFF](?:(?!\\s)[\$\\w\\xA0-\\uFFFF])*(?:\\s*<(?:[^<>]|<(?:[^<>]|<[^<>]*>)*>)*>)?"), + lookbehind: true, + greedy: true, + inside: () => _g3), + GrammarToken( + "regex", + compileHighlightPattern( + "((?:^|[^\$\\w\\xA0-\\uFFFF.\"'\\])\\s]|\\b(?:return|yield))\\s*)\\/(?:(?:\\[(?:[^\\]\\\\\\r\\n]|\\\\.)*\\]|\\\\.|[^/\\\\\\[\\r\\n])+\\/[dgimyus]{0,7}|(?:\\[(?:[^[\\]\\\\\\r\\n]|\\\\.|\\[(?:[^[\\]\\\\\\r\\n]|\\\\.|\\[(?:[^[\\]\\\\\\r\\n]|\\\\.)*\\])*\\])*\\]|\\\\.|[^/\\\\\\[\\r\\n])+\\/[dgimyus]{0,7}v[dgimyus]{0,7})(?=(?:\\s|\\/\\*(?:[^*]|\\*(?!\\/))*\\*\\/)*(?:\$|[\\r\\n,.;:})\\]]|\\/\\/))"), + lookbehind: true, + greedy: true, + inside: () => _g12), + GrammarToken( + "function-variable", + compileHighlightPattern( + "#?(?!\\s)[_\$a-zA-Z\\xA0-\\uFFFF](?:(?!\\s)[\$\\w\\xA0-\\uFFFF])*(?=\\s*[=:]\\s*(?:async\\s*)?(?:\\bfunction\\b|(?:\\((?:[^()]|\\([^()]*\\))*\\)|(?!\\s)[_\$a-zA-Z\\xA0-\\uFFFF](?:(?!\\s)[\$\\w\\xA0-\\uFFFF])*)\\s*=>))"), + alias: "function"), + GrammarToken( + "constant", compileHighlightPattern("\\b[A-Z](?:[A-Z_]|\\dx?)*\\b")), + GrammarToken("keyword", compileHighlightPattern("((?:^|\\})\\s*)catch\\b"), + lookbehind: true), + GrammarToken( + "keyword", + compileHighlightPattern( + "(^|[^.]|\\.\\.\\.\\s*)\\b(?:as|assert(?=\\s*\\{)|async(?=\\s*(?:function\\b|\\(|[\$\\w\\xA0-\\uFFFF]|\$))|await|break|case|class|const|continue|debugger|default|delete|do|else|enum|export|extends|finally(?=\\s*(?:\\{|\$))|for|from(?=\\s*(?:['\"]|\$))|function|(?:get|set)(?=\\s*(?:[#\\[\$\\w\\xA0-\\uFFFF]|\$))|if|implements|import|in|instanceof|interface|let|new|null|of|package|private|protected|public|return|static|super|switch|this|throw|try|typeof|undefined|var|void|while|with|yield)\\b"), + lookbehind: true), + GrammarToken( + "keyword", + compileHighlightPattern( + "\\b(?:abstract|declare|is|keyof|readonly|require)\\b")), + GrammarToken( + "keyword", + compileHighlightPattern( + "\\b(?:asserts|infer|interface|module|namespace|type)\\b(?=\\s*(?:[{_\$a-zA-Z\\xA0-\\uFFFF]|\$))")), + GrammarToken( + "keyword", compileHighlightPattern("\\btype\\b(?=\\s*(?:[\\{*]|\$))")), + GrammarToken("boolean", compileHighlightPattern("\\b(?:false|true)\\b")), + GrammarToken("decorator", compileHighlightPattern("@[\$\\w\\xA0-\\uFFFF]+"), + inside: () => _g18), + GrammarToken( + "generic-function", + compileHighlightPattern( + "#?(?!\\s)[_\$a-zA-Z\\xA0-\\uFFFF](?:(?!\\s)[\$\\w\\xA0-\\uFFFF])*\\s*<(?:[^<>]|<(?:[^<>]|<[^<>]*>)*>)*>(?=\\s*\\()"), + greedy: true, + inside: () => _g19), + GrammarToken( + "function", + compileHighlightPattern( + "#?(?!\\s)[_\$a-zA-Z\\xA0-\\uFFFF](?:(?!\\s)[\$\\w\\xA0-\\uFFFF])*(?=\\s*(?:\\.\\s*(?:apply|bind|call)\\s*)?\\()")), + GrammarToken( + "number", + compileHighlightPattern( + "(^|[^\\w\$])(?:NaN|Infinity|0[bB][01]+(?:_[01]+)*n?|0[oO][0-7]+(?:_[0-7]+)*n?|0[xX][\\dA-Fa-f]+(?:_[\\dA-Fa-f]+)*n?|\\d+(?:_\\d+)*n|(?:\\d+(?:_\\d+)*(?:\\.(?:\\d+(?:_\\d+)*)?)?|\\.\\d+(?:_\\d+)*)(?:[Ee][+-]?\\d+(?:_\\d+)*)?)(?![\\w\$])"), + lookbehind: true), + GrammarToken( + "operator", + compileHighlightPattern( + "--|\\+\\+|\\*\\*=?|=>|&&=?|\\|\\|=?|[!=]==|<<=?|>>>?=?|[-+*/%&|^!=<>]=?|\\.{3}|\\?\\?=?|\\?\\.?|[~:]")), + GrammarToken("punctuation", compileHighlightPattern("[{}[\\];(),.:]")), + GrammarToken( + "builtin", + compileHighlightPattern( + "\\b(?:Array|Function|Promise|any|boolean|console|never|number|string|symbol|unknown)\\b")), +]); + +final Grammar _g1 = Grammar([ + GrammarToken("template-punctuation", compileHighlightPattern("^`|`\$"), + alias: "string"), + GrammarToken( + "interpolation", + compileHighlightPattern( + "((?:^|[^\\\\])(?:\\\\{2})*)\\\$\\{(?:[^{}]|\\{(?:[^{}]|\\{[^}]*\\})*\\})+\\}"), + lookbehind: true, + inside: () => _g2), + GrammarToken("string", compileHighlightPattern("[\\s\\S]+")), +]); + +final Grammar _g2 = Grammar([ + GrammarToken( + "interpolation-punctuation", compileHighlightPattern("^\\\$\\{|\\}\$"), + alias: "punctuation"), +], rest: () => _g0); + +final Grammar _g3 = Grammar([ + GrammarToken("comment", + compileHighlightPattern("(^|[^\\\\])\\/\\*[\\s\\S]*?(?:\\*\\/|\$)"), + lookbehind: true, greedy: true), + GrammarToken("comment", compileHighlightPattern("(^|[^\\\\:])\\/\\/.*"), + lookbehind: true, greedy: true), + GrammarToken("hashbang", compileHighlightPattern("^#!.*"), + greedy: true, alias: "comment"), + GrammarToken( + "template-string", + compileHighlightPattern( + "`(?:\\\\[\\s\\S]|\\\$\\{(?:[^{}]|\\{(?:[^{}]|\\{[^}]*\\})*\\})+\\}|(?!\\\$\\{)[^\\\\`])*`"), + greedy: true, + inside: () => _g4), + GrammarToken( + "string-property", + compileHighlightPattern( + "((?:^|[,{])[ \\t]*)([\"'])(?:\\\\(?:\\r\\n|[\\s\\S])|(?!\\2)[^\\\\\\r\\n])*\\2(?=\\s*:)", + multiLine: true), + lookbehind: true, + greedy: true, + alias: "property"), + GrammarToken( + "string", + compileHighlightPattern( + "([\"'])(?:\\\\(?:\\r\\n|[\\s\\S])|(?!\\1)[^\\\\\\r\\n])*\\1"), + greedy: true), + GrammarToken( + "regex", + compileHighlightPattern( + "((?:^|[^\$\\w\\xA0-\\uFFFF.\"'\\])\\s]|\\b(?:return|yield))\\s*)\\/(?:(?:\\[(?:[^\\]\\\\\\r\\n]|\\\\.)*\\]|\\\\.|[^/\\\\\\[\\r\\n])+\\/[dgimyus]{0,7}|(?:\\[(?:[^[\\]\\\\\\r\\n]|\\\\.|\\[(?:[^[\\]\\\\\\r\\n]|\\\\.|\\[(?:[^[\\]\\\\\\r\\n]|\\\\.)*\\])*\\])*\\]|\\\\.|[^/\\\\\\[\\r\\n])+\\/[dgimyus]{0,7}v[dgimyus]{0,7})(?=(?:\\s|\\/\\*(?:[^*]|\\*(?!\\/))*\\*\\/)*(?:\$|[\\r\\n,.;:})\\]]|\\/\\/))"), + lookbehind: true, + greedy: true, + inside: () => _g6), + GrammarToken( + "function-variable", + compileHighlightPattern( + "#?(?!\\s)[_\$a-zA-Z\\xA0-\\uFFFF](?:(?!\\s)[\$\\w\\xA0-\\uFFFF])*(?=\\s*[=:]\\s*(?:async\\s*)?(?:\\bfunction\\b|(?:\\((?:[^()]|\\([^()]*\\))*\\)|(?!\\s)[_\$a-zA-Z\\xA0-\\uFFFF](?:(?!\\s)[\$\\w\\xA0-\\uFFFF])*)\\s*=>))"), + alias: "function"), + GrammarToken( + "constant", compileHighlightPattern("\\b[A-Z](?:[A-Z_]|\\dx?)*\\b")), + GrammarToken("keyword", compileHighlightPattern("((?:^|\\})\\s*)catch\\b"), + lookbehind: true), + GrammarToken( + "keyword", + compileHighlightPattern( + "(^|[^.]|\\.\\.\\.\\s*)\\b(?:as|assert(?=\\s*\\{)|async(?=\\s*(?:function\\b|\\(|[\$\\w\\xA0-\\uFFFF]|\$))|await|break|case|class|const|continue|debugger|default|delete|do|else|enum|export|extends|finally(?=\\s*(?:\\{|\$))|for|from(?=\\s*(?:['\"]|\$))|function|(?:get|set)(?=\\s*(?:[#\\[\$\\w\\xA0-\\uFFFF]|\$))|if|implements|import|in|instanceof|interface|let|new|null|of|package|private|protected|public|return|static|super|switch|this|throw|try|typeof|undefined|var|void|while|with|yield)\\b"), + lookbehind: true), + GrammarToken( + "keyword", + compileHighlightPattern( + "\\b(?:abstract|declare|is|keyof|readonly|require)\\b")), + GrammarToken( + "keyword", + compileHighlightPattern( + "\\b(?:asserts|infer|interface|module|namespace|type)\\b(?=\\s*(?:[{_\$a-zA-Z\\xA0-\\uFFFF]|\$))")), + GrammarToken( + "keyword", compileHighlightPattern("\\btype\\b(?=\\s*(?:[\\{*]|\$))")), + GrammarToken("boolean", compileHighlightPattern("\\b(?:false|true)\\b")), + GrammarToken( + "function", + compileHighlightPattern( + "#?(?!\\s)[_\$a-zA-Z\\xA0-\\uFFFF](?:(?!\\s)[\$\\w\\xA0-\\uFFFF])*(?=\\s*(?:\\.\\s*(?:apply|bind|call)\\s*)?\\()")), + GrammarToken( + "number", + compileHighlightPattern( + "(^|[^\\w\$])(?:NaN|Infinity|0[bB][01]+(?:_[01]+)*n?|0[oO][0-7]+(?:_[0-7]+)*n?|0[xX][\\dA-Fa-f]+(?:_[\\dA-Fa-f]+)*n?|\\d+(?:_\\d+)*n|(?:\\d+(?:_\\d+)*(?:\\.(?:\\d+(?:_\\d+)*)?)?|\\.\\d+(?:_\\d+)*)(?:[Ee][+-]?\\d+(?:_\\d+)*)?)(?![\\w\$])"), + lookbehind: true), + GrammarToken( + "operator", + compileHighlightPattern( + "--|\\+\\+|\\*\\*=?|=>|&&=?|\\|\\|=?|[!=]==|<<=?|>>>?=?|[-+*/%&|^!=<>]=?|\\.{3}|\\?\\?=?|\\?\\.?|[~:]")), + GrammarToken("punctuation", compileHighlightPattern("[{}[\\];(),.:]")), + GrammarToken( + "builtin", + compileHighlightPattern( + "\\b(?:Array|Function|Promise|any|boolean|console|never|number|string|symbol|unknown)\\b")), +]); + +final Grammar _g4 = Grammar([ + GrammarToken("template-punctuation", compileHighlightPattern("^`|`\$"), + alias: "string"), + GrammarToken( + "interpolation", + compileHighlightPattern( + "((?:^|[^\\\\])(?:\\\\{2})*)\\\$\\{(?:[^{}]|\\{(?:[^{}]|\\{[^}]*\\})*\\})+\\}"), + lookbehind: true, + inside: () => _g5), + GrammarToken("string", compileHighlightPattern("[\\s\\S]+")), +]); + +final Grammar _g5 = Grammar([ + GrammarToken( + "interpolation-punctuation", compileHighlightPattern("^\\\$\\{|\\}\$"), + alias: "punctuation"), +], rest: () => _g3); + +final Grammar _g6 = Grammar([ + GrammarToken( + "regex-source", compileHighlightPattern("^(\\/)[\\s\\S]+(?=\\/[a-z]*\$)"), + lookbehind: true, alias: "language-regex", inside: () => _g7), + GrammarToken("regex-delimiter", compileHighlightPattern("^\\/|\\/\$")), + GrammarToken("regex-flags", compileHighlightPattern("^[a-z]+\$")), +]); + +final Grammar _g7 = Grammar([ + GrammarToken( + "char-class", + compileHighlightPattern( + "((?:^|[^\\\\])(?:\\\\\\\\)*)\\[(?:[^\\\\\\]]|\\\\[\\s\\S])*\\]"), + lookbehind: true, + inside: () => _g8), + GrammarToken( + "special-escape", compileHighlightPattern("\\\\[\\\\(){}[\\]^\$+*?|.]"), + alias: "escape"), + GrammarToken( + "char-set", + compileHighlightPattern("\\.|\\\\[wsd]|\\\\p\\{[^{}]+\\}", + caseSensitive: false), + alias: "class-name"), + GrammarToken( + "backreference", compileHighlightPattern("\\\\(?![123][0-7]{2})[1-9]"), + alias: "keyword"), + GrammarToken("backreference", compileHighlightPattern("\\\\k<[^<>']+>"), + alias: "keyword", inside: () => _g10), + GrammarToken("anchor", compileHighlightPattern("[\$^]|\\\\[ABbGZz]"), + alias: "function"), + GrammarToken( + "escape", + compileHighlightPattern( + "\\\\(?:x[\\da-fA-F]{2}|u[\\da-fA-F]{4}|u\\{[\\da-fA-F]+\\}|0[0-7]{0,2}|[123][0-7]{2}|c[a-zA-Z]|.)")), + GrammarToken( + "group", + compileHighlightPattern( + "\\((?:\\?(?:<[^<>']+>|'[^<>']+'|[>:]| _g11), + GrammarToken("group", compileHighlightPattern("\\)"), alias: "punctuation"), + GrammarToken("quantifier", + compileHighlightPattern("(?:[+*?]|\\{\\d+(?:,\\d*)?\\})[?+]?"), + alias: "number"), + GrammarToken("alternation", compileHighlightPattern("\\|"), alias: "keyword"), +]); + +final Grammar _g8 = Grammar([ + GrammarToken("char-class-negation", compileHighlightPattern("(^\\[)\\^"), + lookbehind: true, alias: "operator"), + GrammarToken("char-class-punctuation", compileHighlightPattern("^\\[|\\]\$"), + alias: "punctuation"), + GrammarToken( + "range", + compileHighlightPattern( + "(?:[^\\\\-]|\\\\(?:x[\\da-fA-F]{2}|u[\\da-fA-F]{4}|u\\{[\\da-fA-F]+\\}|0[0-7]{0,2}|[123][0-7]{2}|c[a-zA-Z]|.))-(?:[^\\\\-]|\\\\(?:x[\\da-fA-F]{2}|u[\\da-fA-F]{4}|u\\{[\\da-fA-F]+\\}|0[0-7]{0,2}|[123][0-7]{2}|c[a-zA-Z]|.))"), + inside: () => _g9), + GrammarToken( + "special-escape", compileHighlightPattern("\\\\[\\\\(){}[\\]^\$+*?|.]"), + alias: "escape"), + GrammarToken( + "char-set", + compileHighlightPattern("\\\\[wsd]|\\\\p\\{[^{}]+\\}", + caseSensitive: false), + alias: "class-name"), + GrammarToken( + "escape", + compileHighlightPattern( + "\\\\(?:x[\\da-fA-F]{2}|u[\\da-fA-F]{4}|u\\{[\\da-fA-F]+\\}|0[0-7]{0,2}|[123][0-7]{2}|c[a-zA-Z]|.)")), +]); + +final Grammar _g9 = Grammar([ + GrammarToken( + "escape", + compileHighlightPattern( + "\\\\(?:x[\\da-fA-F]{2}|u[\\da-fA-F]{4}|u\\{[\\da-fA-F]+\\}|0[0-7]{0,2}|[123][0-7]{2}|c[a-zA-Z]|.)")), + GrammarToken("range-punctuation", compileHighlightPattern("-"), + alias: "operator"), +]); + +final Grammar _g10 = Grammar([ + GrammarToken("group-name", compileHighlightPattern("(<|')[^<>']+(?=[>']\$)"), + lookbehind: true, alias: "variable"), +]); + +final Grammar _g11 = Grammar([ + GrammarToken("group-name", compileHighlightPattern("(<|')[^<>']+(?=[>']\$)"), + lookbehind: true, alias: "variable"), +]); + +final Grammar _g12 = Grammar([ + GrammarToken( + "regex-source", compileHighlightPattern("^(\\/)[\\s\\S]+(?=\\/[a-z]*\$)"), + lookbehind: true, alias: "language-regex", inside: () => _g13), + GrammarToken("regex-delimiter", compileHighlightPattern("^\\/|\\/\$")), + GrammarToken("regex-flags", compileHighlightPattern("^[a-z]+\$")), +]); + +final Grammar _g13 = Grammar([ + GrammarToken( + "char-class", + compileHighlightPattern( + "((?:^|[^\\\\])(?:\\\\\\\\)*)\\[(?:[^\\\\\\]]|\\\\[\\s\\S])*\\]"), + lookbehind: true, + inside: () => _g14), + GrammarToken( + "special-escape", compileHighlightPattern("\\\\[\\\\(){}[\\]^\$+*?|.]"), + alias: "escape"), + GrammarToken( + "char-set", + compileHighlightPattern("\\.|\\\\[wsd]|\\\\p\\{[^{}]+\\}", + caseSensitive: false), + alias: "class-name"), + GrammarToken( + "backreference", compileHighlightPattern("\\\\(?![123][0-7]{2})[1-9]"), + alias: "keyword"), + GrammarToken("backreference", compileHighlightPattern("\\\\k<[^<>']+>"), + alias: "keyword", inside: () => _g16), + GrammarToken("anchor", compileHighlightPattern("[\$^]|\\\\[ABbGZz]"), + alias: "function"), + GrammarToken( + "escape", + compileHighlightPattern( + "\\\\(?:x[\\da-fA-F]{2}|u[\\da-fA-F]{4}|u\\{[\\da-fA-F]+\\}|0[0-7]{0,2}|[123][0-7]{2}|c[a-zA-Z]|.)")), + GrammarToken( + "group", + compileHighlightPattern( + "\\((?:\\?(?:<[^<>']+>|'[^<>']+'|[>:]| _g17), + GrammarToken("group", compileHighlightPattern("\\)"), alias: "punctuation"), + GrammarToken("quantifier", + compileHighlightPattern("(?:[+*?]|\\{\\d+(?:,\\d*)?\\})[?+]?"), + alias: "number"), + GrammarToken("alternation", compileHighlightPattern("\\|"), alias: "keyword"), +]); + +final Grammar _g14 = Grammar([ + GrammarToken("char-class-negation", compileHighlightPattern("(^\\[)\\^"), + lookbehind: true, alias: "operator"), + GrammarToken("char-class-punctuation", compileHighlightPattern("^\\[|\\]\$"), + alias: "punctuation"), + GrammarToken( + "range", + compileHighlightPattern( + "(?:[^\\\\-]|\\\\(?:x[\\da-fA-F]{2}|u[\\da-fA-F]{4}|u\\{[\\da-fA-F]+\\}|0[0-7]{0,2}|[123][0-7]{2}|c[a-zA-Z]|.))-(?:[^\\\\-]|\\\\(?:x[\\da-fA-F]{2}|u[\\da-fA-F]{4}|u\\{[\\da-fA-F]+\\}|0[0-7]{0,2}|[123][0-7]{2}|c[a-zA-Z]|.))"), + inside: () => _g15), + GrammarToken( + "special-escape", compileHighlightPattern("\\\\[\\\\(){}[\\]^\$+*?|.]"), + alias: "escape"), + GrammarToken( + "char-set", + compileHighlightPattern("\\\\[wsd]|\\\\p\\{[^{}]+\\}", + caseSensitive: false), + alias: "class-name"), + GrammarToken( + "escape", + compileHighlightPattern( + "\\\\(?:x[\\da-fA-F]{2}|u[\\da-fA-F]{4}|u\\{[\\da-fA-F]+\\}|0[0-7]{0,2}|[123][0-7]{2}|c[a-zA-Z]|.)")), +]); + +final Grammar _g15 = Grammar([ + GrammarToken( + "escape", + compileHighlightPattern( + "\\\\(?:x[\\da-fA-F]{2}|u[\\da-fA-F]{4}|u\\{[\\da-fA-F]+\\}|0[0-7]{0,2}|[123][0-7]{2}|c[a-zA-Z]|.)")), + GrammarToken("range-punctuation", compileHighlightPattern("-"), + alias: "operator"), +]); + +final Grammar _g16 = Grammar([ + GrammarToken("group-name", compileHighlightPattern("(<|')[^<>']+(?=[>']\$)"), + lookbehind: true, alias: "variable"), +]); + +final Grammar _g17 = Grammar([ + GrammarToken("group-name", compileHighlightPattern("(<|')[^<>']+(?=[>']\$)"), + lookbehind: true, alias: "variable"), +]); + +final Grammar _g18 = Grammar([ + GrammarToken("at", compileHighlightPattern("^@"), alias: "operator"), + GrammarToken("function", compileHighlightPattern("^[\\s\\S]+")), +]); + +final Grammar _g19 = Grammar([ + GrammarToken( + "function", + compileHighlightPattern( + "^#?(?!\\s)[_\$a-zA-Z\\xA0-\\uFFFF](?:(?!\\s)[\$\\w\\xA0-\\uFFFF])*")), + GrammarToken("generic", compileHighlightPattern("<[\\s\\S]+"), + alias: "class-name", inside: () => _g3), +]); diff --git a/lib/highlight/vim.dart b/lib/highlight/vim.dart new file mode 100644 index 0000000..64fc3f1 --- /dev/null +++ b/lib/highlight/vim.dart @@ -0,0 +1,40 @@ +// GENERATED CODE — do not modify by hand. Regenerate with tool/highlight_codegen. +// ignore_for_file: lines_longer_than_80_chars, public_member_api_docs +// ignore_for_file: prefer_single_quotes, require_trailing_commas, directives_ordering + +import '../highlight.dart'; + +/// Syntax grammar for `vim`. +/// +/// Import this library only when you need `vim` highlighting; unused +/// languages are dropped from the build. +abstract final class HighlightVim { + /// The grammar for `vim`. + static final Grammar grammar = _g0; +} + +final Grammar _g0 = Grammar([ + GrammarToken( + "string", + compileHighlightPattern( + "\"(?:[^\"\\\\\\r\\n]|\\\\.)*\"|'(?:[^'\\r\\n]|'')*'")), + GrammarToken("comment", compileHighlightPattern("\".*")), + GrammarToken("function", compileHighlightPattern("\\b\\w+(?=\\()")), + GrammarToken( + "keyword", + compileHighlightPattern( + "\\b(?:N|Next|P|Print|X|XMLent|XMLns|ab|abbreviate|abc|abclear|abo|aboveleft|al|all|ar|arga|argadd|argd|argdelete|argdo|arge|argedit|argg|argglobal|argl|arglocal|args|argu|argument|as|ascii|b|bN|bNext|ba|bad|badd|ball|bd|bdelete|be|bel|belowright|bf|bfirst|bl|blast|bm|bmodified|bn|bnext|bo|botright|bp|bprevious|br|brea|break|breaka|breakadd|breakd|breakdel|breakl|breaklist|brewind|bro|browse|bufdo|buffer|buffers|bun|bunload|bw|bwipeout|c|cN|cNext|cNfcNfile|ca|cabbrev|cabc|cabclear|cad|caddb|caddbuffer|caddexpr|caddf|caddfile|cal|call|cat|catch|cb|cbuffer|cc|ccl|cclose|cd|ce|center|cex|cexpr|cf|cfile|cfir|cfirst|cg|cgetb|cgetbuffer|cgete|cgetexpr|cgetfile|change|changes|chd|chdir|che|checkpath|checkt|checktime|cl|cla|clast|clist|clo|close|cmapc|cmapclear|cn|cnew|cnewer|cnext|cnf|cnfile|cnorea|cnoreabbrev|co|col|colder|colo|colorscheme|comc|comclear|comp|compiler|con|conf|confirm|continue|cope|copen|copy|cp|cpf|cpfile|cprevious|cq|cquit|cr|crewind|cu|cuna|cunabbrev|cunmap|cw|cwindow|d|debugg|debuggreedy|delc|delcommand|delete|delf|delfunction|delm|delmarks|di|diffg|diffget|diffoff|diffpatch|diffpu|diffput|diffsplit|diffthis|diffu|diffupdate|dig|digraphs|display|dj|djump|dl|dlist|dr|drop|ds|dsearch|dsp|dsplit|e|earlier|echoe|echoerr|echom|echomsg|echon|edit|el|else|elsei|elseif|em|emenu|en|endf|endfo|endfor|endfun|endfunction|endif|endt|endtry|endw|endwhile|ene|enew|ex|exi|exit|exu|exusage|f|file|files|filetype|fin|fina|finally|find|fini|finish|fir|first|fix|fixdel|fo|fold|foldc|foldclose|foldd|folddoc|folddoclosed|folddoopen|foldo|foldopen|for|fu|fun|function|go|goto|gr|grep|grepa|grepadd|h|ha|hardcopy|help|helpf|helpfind|helpg|helpgrep|helpt|helptags|hid|hide|his|history|ia|iabbrev|iabc|iabclear|if|ij|ijump|il|ilist|imapc|imapclear|in|inorea|inoreabbrev|isearch|isp|isplit|iu|iuna|iunabbrev|iunmap|j|join|ju|jumps|k|kee|keepalt|keepj|keepjumps|keepmarks|l|lN|lNext|lNf|lNfile|la|lad|laddb|laddbuffer|laddexpr|laddf|laddfile|lan|language|last|later|lb|lbuffer|lc|lcd|lch|lchdir|lcl|lclose|left|lefta|leftabove|let|lex|lexpr|lf|lfile|lfir|lfirst|lg|lgetb|lgetbuffer|lgete|lgetexpr|lgetfile|lgr|lgrep|lgrepa|lgrepadd|lh|lhelpgrep|list|ll|lla|llast|lli|llist|lm|lmak|lmake|lmap|lmapc|lmapclear|ln|lne|lnew|lnewer|lnext|lnf|lnfile|lnoremap|lo|loadview|loc|lockmarks|lockv|lockvar|lol|lolder|lop|lopen|lp|lpf|lpfile|lprevious|lr|lrewind|ls|lt|ltag|lu|lunmap|lv|lvimgrep|lvimgrepa|lvimgrepadd|lw|lwindow|m|ma|mak|make|mark|marks|mat|match|menut|menutranslate|mk|mkexrc|mks|mksession|mksp|mkspell|mkv|mkvie|mkview|mkvimrc|mod|mode|move|mz|mzf|mzfile|mzscheme|n|nbkey|new|next|nmapc|nmapclear|noh|nohlsearch|norea|noreabbrev|nu|number|nun|nunmap|o|omapc|omapclear|on|only|open|opt|options|ou|ounmap|p|pc|pclose|pe|ped|pedit|perl|perld|perldo|po|pop|popu|popup|pp|ppop|pre|preserve|prev|previous|print|prof|profd|profdel|profile|promptf|promptfind|promptr|promptrepl|ps|psearch|ptN|ptNext|pta|ptag|ptf|ptfirst|ptj|ptjump|ptl|ptlast|ptn|ptnext|ptp|ptprevious|ptr|ptrewind|pts|ptselect|pu|put|pw|pwd|py|pyf|pyfile|python|q|qa|qall|quit|quita|quitall|r|read|rec|recover|red|redi|redir|redo|redr|redraw|redraws|redrawstatus|reg|registers|res|resize|ret|retab|retu|return|rew|rewind|ri|right|rightb|rightbelow|ru|rub|ruby|rubyd|rubydo|rubyf|rubyfile|runtime|rv|rviminfo|sN|sNext|sa|sal|sall|san|sandbox|sargument|sav|saveas|sb|sbN|sbNext|sba|sball|sbf|sbfirst|sbl|sblast|sbm|sbmodified|sbn|sbnext|sbp|sbprevious|sbr|sbrewind|sbuffer|scrip|scripte|scriptencoding|scriptnames|se|set|setf|setfiletype|setg|setglobal|setl|setlocal|sf|sfind|sfir|sfirst|sh|shell|sign|sil|silent|sim|simalt|sl|sla|slast|sleep|sm|smagic|smap|smapc|smapclear|sme|smenu|sn|snext|sni|sniff|sno|snomagic|snor|snoremap|snoreme|snoremenu|so|sor|sort|source|sp|spe|spelld|spelldump|spellgood|spelli|spellinfo|spellr|spellrepall|spellu|spellundo|spellw|spellwrong|split|spr|sprevious|sre|srewind|st|sta|stag|star|startg|startgreplace|startinsert|startr|startreplace|stj|stjump|stop|stopi|stopinsert|sts|stselect|sun|sunhide|sunm|sunmap|sus|suspend|sv|sview|syncbind|t|tN|tNext|ta|tab|tabN|tabNext|tabc|tabclose|tabd|tabdo|tabe|tabedit|tabf|tabfind|tabfir|tabfirst|tabl|tablast|tabm|tabmove|tabn|tabnew|tabnext|tabo|tabonly|tabp|tabprevious|tabr|tabrewind|tabs|tag|tags|tc|tcl|tcld|tcldo|tclf|tclfile|te|tearoff|tf|tfirst|th|throw|tj|tjump|tl|tlast|tm|tmenu|tn|tnext|to|topleft|tp|tprevious|tr|trewind|try|ts|tselect|tu|tunmenu|u|una|unabbreviate|undo|undoj|undojoin|undol|undolist|unh|unhide|unlet|unlo|unlockvar|unm|unmap|up|update|ve|verb|verbose|version|vert|vertical|vi|vie|view|vim|vimgrep|vimgrepa|vimgrepadd|visual|viu|viusage|vmapc|vmapclear|vne|vnew|vs|vsplit|vu|vunmap|w|wN|wNext|wa|wall|wh|while|win|winc|wincmd|windo|winp|winpos|winsize|wn|wnext|wp|wprevious|wq|wqa|wqall|write|ws|wsverb|wv|wviminfo|x|xa|xall|xit|xm|xmap|xmapc|xmapclear|xme|xmenu|xn|xnoremap|xnoreme|xnoremenu|xu|xunmap|y|yank)\\b")), + GrammarToken( + "builtin", + compileHighlightPattern( + "\\b(?:acd|ai|akm|aleph|allowrevins|altkeymap|ambiwidth|ambw|anti|antialias|arab|arabic|arabicshape|ari|arshape|autochdir|autocmd|autoindent|autoread|autowrite|autowriteall|aw|awa|background|backspace|backup|backupcopy|backupdir|backupext|backupskip|balloondelay|ballooneval|balloonexpr|bdir|bdlay|beval|bex|bexpr|bg|bh|bin|binary|biosk|bioskey|bk|bkc|bomb|breakat|brk|browsedir|bs|bsdir|bsk|bt|bufhidden|buflisted|buftype|casemap|ccv|cdpath|cedit|cfu|ch|charconvert|ci|cin|cindent|cink|cinkeys|cino|cinoptions|cinw|cinwords|clipboard|cmdheight|cmdwinheight|cmp|cms|columns|com|comments|commentstring|compatible|complete|completefunc|completeopt|consk|conskey|copyindent|cot|cpo|cpoptions|cpt|cscopepathcomp|cscopeprg|cscopequickfix|cscopetag|cscopetagorder|cscopeverbose|cspc|csprg|csqf|cst|csto|csverb|cuc|cul|cursorcolumn|cursorline|cwh|debug|deco|def|define|delcombine|dex|dg|dict|dictionary|diff|diffexpr|diffopt|digraph|dip|dir|directory|dy|ea|ead|eadirection|eb|ed|edcompatible|ef|efm|ei|ek|enc|encoding|endofline|eol|ep|equalalways|equalprg|errorbells|errorfile|errorformat|esckeys|et|eventignore|expandtab|exrc|fcl|fcs|fdc|fde|fdi|fdl|fdls|fdm|fdn|fdo|fdt|fen|fenc|fencs|fex|ff|ffs|fileencoding|fileencodings|fileformat|fileformats|fillchars|fk|fkmap|flp|fml|fmr|foldcolumn|foldenable|foldexpr|foldignore|foldlevel|foldlevelstart|foldmarker|foldmethod|foldminlines|foldnestmax|foldtext|formatexpr|formatlistpat|formatoptions|formatprg|fp|fs|fsync|ft|gcr|gd|gdefault|gfm|gfn|gfs|gfw|ghr|gp|grepformat|grepprg|gtl|gtt|guicursor|guifont|guifontset|guifontwide|guiheadroom|guioptions|guipty|guitablabel|guitabtooltip|helpfile|helpheight|helplang|hf|hh|hi|hidden|highlight|hk|hkmap|hkmapp|hkp|hl|hlg|hls|hlsearch|ic|icon|iconstring|ignorecase|im|imactivatekey|imak|imc|imcmdline|imd|imdisable|imi|iminsert|ims|imsearch|inc|include|includeexpr|incsearch|inde|indentexpr|indentkeys|indk|inex|inf|infercase|insertmode|invacd|invai|invakm|invallowrevins|invaltkeymap|invanti|invantialias|invar|invarab|invarabic|invarabicshape|invari|invarshape|invautochdir|invautoindent|invautoread|invautowrite|invautowriteall|invaw|invawa|invbackup|invballooneval|invbeval|invbin|invbinary|invbiosk|invbioskey|invbk|invbl|invbomb|invbuflisted|invcf|invci|invcin|invcindent|invcompatible|invconfirm|invconsk|invconskey|invcopyindent|invcp|invcscopetag|invcscopeverbose|invcst|invcsverb|invcuc|invcul|invcursorcolumn|invcursorline|invdeco|invdelcombine|invdg|invdiff|invdigraph|invdisable|invea|inveb|inved|invedcompatible|invek|invendofline|inveol|invequalalways|inverrorbells|invesckeys|invet|invex|invexpandtab|invexrc|invfen|invfk|invfkmap|invfoldenable|invgd|invgdefault|invguipty|invhid|invhidden|invhk|invhkmap|invhkmapp|invhkp|invhls|invhlsearch|invic|invicon|invignorecase|invim|invimc|invimcmdline|invimd|invincsearch|invinf|invinfercase|invinsertmode|invis|invjoinspaces|invjs|invlazyredraw|invlbr|invlinebreak|invlisp|invlist|invloadplugins|invlpl|invlz|invma|invmacatsui|invmagic|invmh|invml|invmod|invmodeline|invmodifiable|invmodified|invmore|invmousef|invmousefocus|invmousehide|invnu|invnumber|invodev|invopendevice|invpaste|invpi|invpreserveindent|invpreviewwindow|invprompt|invpvw|invreadonly|invremap|invrestorescreen|invrevins|invri|invrightleft|invrightleftcmd|invrl|invrlc|invro|invrs|invru|invruler|invsb|invsc|invscb|invscrollbind|invscs|invsecure|invsft|invshellslash|invshelltemp|invshiftround|invshortname|invshowcmd|invshowfulltag|invshowmatch|invshowmode|invsi|invsm|invsmartcase|invsmartindent|invsmarttab|invsmd|invsn|invsol|invspell|invsplitbelow|invsplitright|invspr|invsr|invssl|invsta|invstartofline|invstmp|invswapfile|invswf|invta|invtagbsearch|invtagrelative|invtagstack|invtbi|invtbidi|invtbs|invtermbidi|invterse|invtextauto|invtextmode|invtf|invtgst|invtildeop|invtimeout|invtitle|invto|invtop|invtr|invttimeout|invttybuiltin|invttyfast|invtx|invvb|invvisualbell|invwa|invwarn|invwb|invweirdinvert|invwfh|invwfw|invwildmenu|invwinfixheight|invwinfixwidth|invwiv|invwmnu|invwrap|invwrapscan|invwrite|invwriteany|invwritebackup|invws|isf|isfname|isi|isident|isk|iskeyword|isprint|joinspaces|js|key|keymap|keymodel|keywordprg|km|kmp|kp|langmap|langmenu|laststatus|lazyredraw|lbr|lcs|linebreak|lines|linespace|lisp|lispwords|listchars|loadplugins|lpl|lsp|lz|macatsui|magic|makeef|makeprg|matchpairs|matchtime|maxcombine|maxfuncdepth|maxmapdepth|maxmem|maxmempattern|maxmemtot|mco|mef|menuitems|mfd|mh|mis|mkspellmem|ml|mls|mm|mmd|mmp|mmt|modeline|modelines|modifiable|modified|more|mouse|mousef|mousefocus|mousehide|mousem|mousemodel|mouses|mouseshape|mouset|mousetime|mp|mps|msm|mzq|mzquantum|nf|noacd|noai|noakm|noallowrevins|noaltkeymap|noanti|noantialias|noar|noarab|noarabic|noarabicshape|noari|noarshape|noautochdir|noautoindent|noautoread|noautowrite|noautowriteall|noaw|noawa|nobackup|noballooneval|nobeval|nobin|nobinary|nobiosk|nobioskey|nobk|nobl|nobomb|nobuflisted|nocf|noci|nocin|nocindent|nocompatible|noconfirm|noconsk|noconskey|nocopyindent|nocp|nocscopetag|nocscopeverbose|nocst|nocsverb|nocuc|nocul|nocursorcolumn|nocursorline|nodeco|nodelcombine|nodg|nodiff|nodigraph|nodisable|noea|noeb|noed|noedcompatible|noek|noendofline|noeol|noequalalways|noerrorbells|noesckeys|noet|noex|noexpandtab|noexrc|nofen|nofk|nofkmap|nofoldenable|nogd|nogdefault|noguipty|nohid|nohidden|nohk|nohkmap|nohkmapp|nohkp|nohls|noic|noicon|noignorecase|noim|noimc|noimcmdline|noimd|noincsearch|noinf|noinfercase|noinsertmode|nois|nojoinspaces|nojs|nolazyredraw|nolbr|nolinebreak|nolisp|nolist|noloadplugins|nolpl|nolz|noma|nomacatsui|nomagic|nomh|noml|nomod|nomodeline|nomodifiable|nomodified|nomore|nomousef|nomousefocus|nomousehide|nonu|nonumber|noodev|noopendevice|nopaste|nopi|nopreserveindent|nopreviewwindow|noprompt|nopvw|noreadonly|noremap|norestorescreen|norevins|nori|norightleft|norightleftcmd|norl|norlc|noro|nors|noru|noruler|nosb|nosc|noscb|noscrollbind|noscs|nosecure|nosft|noshellslash|noshelltemp|noshiftround|noshortname|noshowcmd|noshowfulltag|noshowmatch|noshowmode|nosi|nosm|nosmartcase|nosmartindent|nosmarttab|nosmd|nosn|nosol|nospell|nosplitbelow|nosplitright|nospr|nosr|nossl|nosta|nostartofline|nostmp|noswapfile|noswf|nota|notagbsearch|notagrelative|notagstack|notbi|notbidi|notbs|notermbidi|noterse|notextauto|notextmode|notf|notgst|notildeop|notimeout|notitle|noto|notop|notr|nottimeout|nottybuiltin|nottyfast|notx|novb|novisualbell|nowa|nowarn|nowb|noweirdinvert|nowfh|nowfw|nowildmenu|nowinfixheight|nowinfixwidth|nowiv|nowmnu|nowrap|nowrapscan|nowrite|nowriteany|nowritebackup|nows|nrformats|numberwidth|nuw|odev|oft|ofu|omnifunc|opendevice|operatorfunc|opfunc|osfiletype|pa|para|paragraphs|paste|pastetoggle|patchexpr|patchmode|path|pdev|penc|pex|pexpr|pfn|ph|pheader|pi|pm|pmbcs|pmbfn|popt|preserveindent|previewheight|previewwindow|printdevice|printencoding|printexpr|printfont|printheader|printmbcharset|printmbfont|printoptions|prompt|pt|pumheight|pvh|pvw|qe|quoteescape|readonly|remap|report|restorescreen|revins|rightleft|rightleftcmd|rl|rlc|ro|rs|rtp|ruf|ruler|rulerformat|runtimepath|sbo|sc|scb|scr|scroll|scrollbind|scrolljump|scrolloff|scrollopt|scs|sect|sections|secure|sel|selection|selectmode|sessionoptions|sft|shcf|shellcmdflag|shellpipe|shellquote|shellredir|shellslash|shelltemp|shelltype|shellxquote|shiftround|shiftwidth|shm|shortmess|shortname|showbreak|showcmd|showfulltag|showmatch|showmode|showtabline|shq|si|sidescroll|sidescrolloff|siso|sj|slm|smartcase|smartindent|smarttab|smc|smd|softtabstop|sol|spc|spell|spellcapcheck|spellfile|spelllang|spellsuggest|spf|spl|splitbelow|splitright|sps|sr|srr|ss|ssl|ssop|stal|startofline|statusline|stl|stmp|su|sua|suffixes|suffixesadd|sw|swapfile|swapsync|swb|swf|switchbuf|sws|sxq|syn|synmaxcol|syntax|t_AB|t_AF|t_AL|t_CS|t_CV|t_Ce|t_Co|t_Cs|t_DL|t_EI|t_F1|t_F2|t_F3|t_F4|t_F5|t_F6|t_F7|t_F8|t_F9|t_IE|t_IS|t_K1|t_K3|t_K4|t_K5|t_K6|t_K7|t_K8|t_K9|t_KA|t_KB|t_KC|t_KD|t_KE|t_KF|t_KG|t_KH|t_KI|t_KJ|t_KK|t_KL|t_RI|t_RV|t_SI|t_Sb|t_Sf|t_WP|t_WS|t_ZH|t_ZR|t_al|t_bc|t_cd|t_ce|t_cl|t_cm|t_cs|t_da|t_db|t_dl|t_fs|t_k1|t_k2|t_k3|t_k4|t_k5|t_k6|t_k7|t_k8|t_k9|t_kB|t_kD|t_kI|t_kN|t_kP|t_kb|t_kd|t_ke|t_kh|t_kl|t_kr|t_ks|t_ku|t_le|t_mb|t_md|t_me|t_mr|t_ms|t_nd|t_op|t_se|t_so|t_sr|t_te|t_ti|t_ts|t_ue|t_us|t_ut|t_vb|t_ve|t_vi|t_vs|t_xs|tabline|tabpagemax|tabstop|tagbsearch|taglength|tagrelative|tagstack|tal|tb|tbi|tbidi|tbis|tbs|tenc|term|termbidi|termencoding|terse|textauto|textmode|textwidth|tgst|thesaurus|tildeop|timeout|timeoutlen|title|titlelen|titleold|titlestring|toolbar|toolbariconsize|top|tpm|tsl|tsr|ttimeout|ttimeoutlen|ttm|tty|ttybuiltin|ttyfast|ttym|ttymouse|ttyscroll|ttytype|tw|tx|uc|ul|undolevels|updatecount|updatetime|ut|vb|vbs|vdir|verbosefile|vfile|viewdir|viewoptions|viminfo|virtualedit|visualbell|vop|wak|warn|wb|wc|wcm|wd|weirdinvert|wfh|wfw|whichwrap|wi|wig|wildchar|wildcharm|wildignore|wildmenu|wildmode|wildoptions|wim|winaltkeys|window|winfixheight|winfixwidth|winheight|winminheight|winminwidth|winwidth|wiv|wiw|wm|wmh|wmnu|wmw|wop|wrap|wrapmargin|wrapscan|writeany|writebackup|writedelay|ww)\\b")), + GrammarToken( + "number", + compileHighlightPattern("\\b(?:0x[\\da-f]+|\\d+(?:\\.\\d+)?)\\b", + caseSensitive: false)), + GrammarToken( + "operator", + compileHighlightPattern( + "\\|\\||&&|[-+.]=?|[=!](?:[=~][#?]?)?|[<>]=?[#?]?|[*\\/%?]|\\b(?:is(?:not)?)\\b")), + GrammarToken("punctuation", compileHighlightPattern("[{}[\\](),;:]")), +]); diff --git a/lib/highlight/wasm.dart b/lib/highlight/wasm.dart new file mode 100644 index 0000000..405d168 --- /dev/null +++ b/lib/highlight/wasm.dart @@ -0,0 +1,48 @@ +// GENERATED CODE — do not modify by hand. Regenerate with tool/highlight_codegen. +// ignore_for_file: lines_longer_than_80_chars, public_member_api_docs +// ignore_for_file: prefer_single_quotes, require_trailing_commas, directives_ordering + +import '../highlight.dart'; + +/// Syntax grammar for `wasm`. +/// +/// Import this library only when you need `wasm` highlighting; unused +/// languages are dropped from the build. +abstract final class HighlightWasm { + /// The grammar for `wasm`. + static final Grammar grammar = _g0; +} + +final Grammar _g0 = Grammar([ + GrammarToken("comment", compileHighlightPattern("\\(;[\\s\\S]*?;\\)")), + GrammarToken("comment", compileHighlightPattern(";;.*"), greedy: true), + GrammarToken( + "string", compileHighlightPattern("\"(?:\\\\[\\s\\S]|[^\"\\\\])*\""), + greedy: true), + GrammarToken("keyword", compileHighlightPattern("\\b(?:align|offset)="), + inside: () => _g1), + GrammarToken( + "keyword", + compileHighlightPattern( + "\\b(?:(?:f32|f64|i32|i64)(?:\\.(?:abs|add|and|ceil|clz|const|convert_[su]\\/i(?:32|64)|copysign|ctz|demote\\/f64|div(?:_[su])?|eqz?|extend_[su]\\/i32|floor|ge(?:_[su])?|gt(?:_[su])?|le(?:_[su])?|load(?:(?:8|16|32)_[su])?|lt(?:_[su])?|max|min|mul|neg?|nearest|or|popcnt|promote\\/f32|reinterpret\\/[fi](?:32|64)|rem_[su]|rot[lr]|shl|shr_[su]|sqrt|store(?:8|16|32)?|sub|trunc(?:_[su]\\/f(?:32|64))?|wrap\\/i64|xor))?|memory\\.(?:grow|size))\\b"), + inside: () => _g2), + GrammarToken( + "keyword", + compileHighlightPattern( + "\\b(?:anyfunc|block|br(?:_if|_table)?|call(?:_indirect)?|data|drop|elem|else|end|export|func|get_(?:global|local)|global|if|import|local|loop|memory|module|mut|nop|offset|param|result|return|select|set_(?:global|local)|start|table|tee_local|then|type|unreachable)\\b")), + GrammarToken("variable", + compileHighlightPattern("\\\$[\\w!#\$%&'*+\\-./:<=>?@\\\\^`|~]+")), + GrammarToken( + "number", + compileHighlightPattern( + "[+-]?\\b(?:\\d(?:_?\\d)*(?:\\.\\d(?:_?\\d)*)?(?:[eE][+-]?\\d(?:_?\\d)*)?|0x[\\da-fA-F](?:_?[\\da-fA-F])*(?:\\.[\\da-fA-F](?:_?[\\da-fA-D])*)?(?:[pP][+-]?\\d(?:_?\\d)*)?)\\b|\\binf\\b|\\bnan(?::0x[\\da-fA-F](?:_?[\\da-fA-D])*)?\\b")), + GrammarToken("punctuation", compileHighlightPattern("[()]")), +]); + +final Grammar _g1 = Grammar([ + GrammarToken("operator", compileHighlightPattern("=")), +]); + +final Grammar _g2 = Grammar([ + GrammarToken("punctuation", compileHighlightPattern("\\.")), +]); diff --git a/lib/highlight/xml.dart b/lib/highlight/xml.dart new file mode 100644 index 0000000..370355a --- /dev/null +++ b/lib/highlight/xml.dart @@ -0,0 +1,89 @@ +// GENERATED CODE — do not modify by hand. Regenerate with tool/highlight_codegen. +// ignore_for_file: lines_longer_than_80_chars, public_member_api_docs +// ignore_for_file: prefer_single_quotes, require_trailing_commas, directives_ordering + +import '../highlight.dart'; + +/// Syntax grammar for `xml`. +/// +/// Import this library only when you need `xml` highlighting; unused +/// languages are dropped from the build. +abstract final class HighlightXml { + /// The grammar for `xml`. + static final Grammar grammar = _g0; +} + +final Grammar _g0 = Grammar([ + GrammarToken( + "comment", compileHighlightPattern(""), + greedy: true), + GrammarToken("prolog", compileHighlightPattern("<\\?[\\s\\S]+?\\?>"), + greedy: true), + GrammarToken( + "doctype", + compileHighlightPattern( + "\"'[\\]]|\"[^\"]*\"|'[^']*')+(?:\\[(?:[^<\"'\\]]|\"[^\"]*\"|'[^']*'|<(?!!--)|)*\\]\\s*)?>", + caseSensitive: false), + greedy: true, + inside: () => _g1), + GrammarToken( + "cdata", + compileHighlightPattern("", + caseSensitive: false), + greedy: true), + GrammarToken( + "tag", + compileHighlightPattern( + "<\\/?(?!\\d)[^\\s>\\/=\$<%]+(?:\\s(?:\\s*[^\\s>\\/=]+(?:\\s*=\\s*(?:\"[^\"]*\"|'[^']*'|[^\\s'\">=]+(?=[\\s>]))|(?=[\\s/>])))+)?\\s*\\/?>"), + greedy: true, + inside: () => _g2), + GrammarToken("entity", + compileHighlightPattern("&[\\da-z]{1,8};", caseSensitive: false), + alias: "named-entity"), + GrammarToken("entity", + compileHighlightPattern("&#x?[\\da-f]{1,8};", caseSensitive: false)), +]); + +final Grammar _g1 = Grammar([ + GrammarToken("internal-subset", + compileHighlightPattern("(^[^\\[]*\\[)[\\s\\S]+(?=\\]>\$)"), + lookbehind: true, greedy: true, inside: () => _g0), + GrammarToken("string", compileHighlightPattern("\"[^\"]*\"|'[^']*'"), + greedy: true), + GrammarToken("punctuation", compileHighlightPattern("^\$|[[\\]]")), + GrammarToken( + "doctype-tag", compileHighlightPattern("^DOCTYPE", caseSensitive: false)), + GrammarToken("name", compileHighlightPattern("[^\\s<>'\"]+")), +]); + +final Grammar _g2 = Grammar([ + GrammarToken("tag", compileHighlightPattern("^<\\/?[^\\s>\\/]+"), + inside: () => _g3), + GrammarToken("attr-value", + compileHighlightPattern("=\\s*(?:\"[^\"]*\"|'[^']*'|[^\\s'\">=]+)"), + inside: () => _g4), + GrammarToken("punctuation", compileHighlightPattern("\\/?>")), + GrammarToken("attr-name", compileHighlightPattern("[^\\s>\\/]+"), + inside: () => _g5), +]); + +final Grammar _g3 = Grammar([ + GrammarToken("punctuation", compileHighlightPattern("^<\\/?")), + GrammarToken("namespace", compileHighlightPattern("^[^\\s>\\/:]+:")), +]); + +final Grammar _g4 = Grammar([ + GrammarToken("punctuation", compileHighlightPattern("^="), + alias: "attr-equals"), + GrammarToken("punctuation", compileHighlightPattern("^(\\s*)[\"']|[\"']\$"), + lookbehind: true), + GrammarToken("entity", + compileHighlightPattern("&[\\da-z]{1,8};", caseSensitive: false), + alias: "named-entity"), + GrammarToken("entity", + compileHighlightPattern("&#x?[\\da-f]{1,8};", caseSensitive: false)), +]); + +final Grammar _g5 = Grammar([ + GrammarToken("namespace", compileHighlightPattern("^[^\\s>\\/:]+:")), +]); diff --git a/lib/highlight/yaml.dart b/lib/highlight/yaml.dart new file mode 100644 index 0000000..c13ab25 --- /dev/null +++ b/lib/highlight/yaml.dart @@ -0,0 +1,78 @@ +// GENERATED CODE — do not modify by hand. Regenerate with tool/highlight_codegen. +// ignore_for_file: lines_longer_than_80_chars, public_member_api_docs +// ignore_for_file: prefer_single_quotes, require_trailing_commas, directives_ordering + +import '../highlight.dart'; + +/// Syntax grammar for `yaml`. +/// +/// Import this library only when you need `yaml` highlighting; unused +/// languages are dropped from the build. +abstract final class HighlightYaml { + /// The grammar for `yaml`. + static final Grammar grammar = _g0; +} + +final Grammar _g0 = Grammar([ + GrammarToken( + "scalar", + compileHighlightPattern( + "([\\-:]\\s*(?:\\s(?:!(?:<[\\w\\-%#;/?:@&=+\$,.!~*'()[\\]]+>|(?:[a-zA-Z\\d-]*!)?[\\w\\-%#;/?:@&=+\$.~*'()]+)?(?:[ \t]+[*&][^\\s[\\]{},]+)?|[*&][^\\s[\\]{},]+(?:[ \t]+!(?:<[\\w\\-%#;/?:@&=+\$,.!~*'()[\\]]+>|(?:[a-zA-Z\\d-]*!)?[\\w\\-%#;/?:@&=+\$.~*'()]+)?)?)[ \\t]+)?[|>])[ \\t]*(?:((?:\\r?\\n|\\r)[ \\t]+)\\S[^\\r\\n]*(?:\\2[^\\r\\n]+)*)"), + lookbehind: true, + alias: "string"), + GrammarToken("comment", compileHighlightPattern("#.*")), + GrammarToken( + "key", + compileHighlightPattern( + "((?:^|[:\\-,[{\\r\\n?])[ \\t]*(?:(?:!(?:<[\\w\\-%#;/?:@&=+\$,.!~*'()[\\]]+>|(?:[a-zA-Z\\d-]*!)?[\\w\\-%#;/?:@&=+\$.~*'()]+)?(?:[ \t]+[*&][^\\s[\\]{},]+)?|[*&][^\\s[\\]{},]+(?:[ \t]+!(?:<[\\w\\-%#;/?:@&=+\$,.!~*'()[\\]]+>|(?:[a-zA-Z\\d-]*!)?[\\w\\-%#;/?:@&=+\$.~*'()]+)?)?)[ \\t]+)?)(?:(?:[^\\s\\x00-\\x08\\x0e-\\x1f!\"#%&'*,\\-:>?@[\\]`{|}\\x7f-\\x84\\x86-\\x9f\\ud800-\\udfff\\ufffe\\uffff]|[?:-][^\\s\\x00-\\x08\\x0e-\\x1f,[\\]{}\\x7f-\\x84\\x86-\\x9f\\ud800-\\udfff\\ufffe\\uffff])(?:[ \\t]*(?:(?![#:])[^\\s\\x00-\\x08\\x0e-\\x1f,[\\]{}\\x7f-\\x84\\x86-\\x9f\\ud800-\\udfff\\ufffe\\uffff]|:[^\\s\\x00-\\x08\\x0e-\\x1f,[\\]{}\\x7f-\\x84\\x86-\\x9f\\ud800-\\udfff\\ufffe\\uffff]))*|\"(?:[^\"\\\\\\r\\n]|\\\\.)*\"|'(?:[^'\\\\\\r\\n]|\\\\.)*')(?=\\s*:\\s)"), + lookbehind: true, + greedy: true, + alias: "atrule"), + GrammarToken( + "directive", compileHighlightPattern("(^[ \\t]*)%.+", multiLine: true), + lookbehind: true, alias: "important"), + GrammarToken( + "datetime", + compileHighlightPattern( + "([:\\-,[{]\\s*(?:\\s(?:!(?:<[\\w\\-%#;/?:@&=+\$,.!~*'()[\\]]+>|(?:[a-zA-Z\\d-]*!)?[\\w\\-%#;/?:@&=+\$.~*'()]+)?(?:[ \t]+[*&][^\\s[\\]{},]+)?|[*&][^\\s[\\]{},]+(?:[ \t]+!(?:<[\\w\\-%#;/?:@&=+\$,.!~*'()[\\]]+>|(?:[a-zA-Z\\d-]*!)?[\\w\\-%#;/?:@&=+\$.~*'()]+)?)?)[ \\t]+)?)(?:\\d{4}-\\d\\d?-\\d\\d?(?:[tT]|[ \\t]+)\\d\\d?:\\d{2}:\\d{2}(?:\\.\\d*)?(?:[ \\t]*(?:Z|[-+]\\d\\d?(?::\\d{2})?))?|\\d{4}-\\d{2}-\\d{2}|\\d\\d?:\\d{2}(?::\\d{2}(?:\\.\\d*)?)?)(?=[ \\t]*(?:\$|,|\\]|\\}|(?:[\\r\\n]\\s*)?#))", + multiLine: true), + lookbehind: true, + alias: "number"), + GrammarToken( + "boolean", + compileHighlightPattern( + "([:\\-,[{]\\s*(?:\\s(?:!(?:<[\\w\\-%#;/?:@&=+\$,.!~*'()[\\]]+>|(?:[a-zA-Z\\d-]*!)?[\\w\\-%#;/?:@&=+\$.~*'()]+)?(?:[ \t]+[*&][^\\s[\\]{},]+)?|[*&][^\\s[\\]{},]+(?:[ \t]+!(?:<[\\w\\-%#;/?:@&=+\$,.!~*'()[\\]]+>|(?:[a-zA-Z\\d-]*!)?[\\w\\-%#;/?:@&=+\$.~*'()]+)?)?)[ \\t]+)?)(?:false|true)(?=[ \\t]*(?:\$|,|\\]|\\}|(?:[\\r\\n]\\s*)?#))", + caseSensitive: false, + multiLine: true), + lookbehind: true, + alias: "important"), + GrammarToken( + "null", + compileHighlightPattern( + "([:\\-,[{]\\s*(?:\\s(?:!(?:<[\\w\\-%#;/?:@&=+\$,.!~*'()[\\]]+>|(?:[a-zA-Z\\d-]*!)?[\\w\\-%#;/?:@&=+\$.~*'()]+)?(?:[ \t]+[*&][^\\s[\\]{},]+)?|[*&][^\\s[\\]{},]+(?:[ \t]+!(?:<[\\w\\-%#;/?:@&=+\$,.!~*'()[\\]]+>|(?:[a-zA-Z\\d-]*!)?[\\w\\-%#;/?:@&=+\$.~*'()]+)?)?)[ \\t]+)?)(?:null|~)(?=[ \\t]*(?:\$|,|\\]|\\}|(?:[\\r\\n]\\s*)?#))", + caseSensitive: false, + multiLine: true), + lookbehind: true, + alias: "important"), + GrammarToken( + "string", + compileHighlightPattern( + "([:\\-,[{]\\s*(?:\\s(?:!(?:<[\\w\\-%#;/?:@&=+\$,.!~*'()[\\]]+>|(?:[a-zA-Z\\d-]*!)?[\\w\\-%#;/?:@&=+\$.~*'()]+)?(?:[ \t]+[*&][^\\s[\\]{},]+)?|[*&][^\\s[\\]{},]+(?:[ \t]+!(?:<[\\w\\-%#;/?:@&=+\$,.!~*'()[\\]]+>|(?:[a-zA-Z\\d-]*!)?[\\w\\-%#;/?:@&=+\$.~*'()]+)?)?)[ \\t]+)?)(?:\"(?:[^\"\\\\\\r\\n]|\\\\.)*\"|'(?:[^'\\\\\\r\\n]|\\\\.)*')(?=[ \\t]*(?:\$|,|\\]|\\}|(?:[\\r\\n]\\s*)?#))", + multiLine: true), + lookbehind: true, + greedy: true), + GrammarToken( + "number", + compileHighlightPattern( + "([:\\-,[{]\\s*(?:\\s(?:!(?:<[\\w\\-%#;/?:@&=+\$,.!~*'()[\\]]+>|(?:[a-zA-Z\\d-]*!)?[\\w\\-%#;/?:@&=+\$.~*'()]+)?(?:[ \t]+[*&][^\\s[\\]{},]+)?|[*&][^\\s[\\]{},]+(?:[ \t]+!(?:<[\\w\\-%#;/?:@&=+\$,.!~*'()[\\]]+>|(?:[a-zA-Z\\d-]*!)?[\\w\\-%#;/?:@&=+\$.~*'()]+)?)?)[ \\t]+)?)(?:[+-]?(?:0x[\\da-f]+|0o[0-7]+|(?:\\d+(?:\\.\\d*)?|\\.\\d+)(?:e[+-]?\\d+)?|\\.inf|\\.nan))(?=[ \\t]*(?:\$|,|\\]|\\}|(?:[\\r\\n]\\s*)?#))", + caseSensitive: false, + multiLine: true), + lookbehind: true), + GrammarToken( + "tag", + compileHighlightPattern( + "!(?:<[\\w\\-%#;/?:@&=+\$,.!~*'()[\\]]+>|(?:[a-zA-Z\\d-]*!)?[\\w\\-%#;/?:@&=+\$.~*'()]+)?")), + GrammarToken("important", compileHighlightPattern("[*&][^\\s[\\]{},]+")), + GrammarToken( + "punctuation", compileHighlightPattern("---|[:[\\]{}\\-,|>?]|\\.\\.\\.")), +]); diff --git a/lib/src/highlight/engine.dart b/lib/src/highlight/engine.dart new file mode 100644 index 0000000..192d646 --- /dev/null +++ b/lib/src/highlight/engine.dart @@ -0,0 +1,288 @@ +import 'package:flutter/painting.dart'; + +/// {@template highlight_grammar} +/// A syntax grammar: an ordered list of [GrammarToken] rules applied to source +/// text to produce styled spans. +/// +/// Grammars are tree-shakeable: each language lives in its own library (e.g. +/// `highlight/dart.dart`) exposing a single grammar, so importing one never +/// references the others and unused languages are dropped by the compiler. +/// {@endtemplate} +final class Grammar { + /// {@macro highlight_grammar} + Grammar(this.tokens, {Grammar Function()? rest}) : _rest = rest; + + /// The token rules, applied in order. Earlier rules claim their text first + /// (this ordering is what lets strings and comments swallow characters that + /// later rules would otherwise match). + final List tokens; + + final Grammar Function()? _rest; + + /// An additional grammar whose rules are applied after [tokens] (used by + /// templating languages to fall back to a host grammar). Stored as a thunk so + /// it can reference other languages without cycles. + Grammar? get rest => _rest?.call(); +} + +/// Compiles a highlighting pattern. If the source uses a regex feature Dart's +/// engine rejects, returns a never-matching pattern so a single bad rule +/// disables itself instead of breaking the whole language. +RegExp compileHighlightPattern( + String source, { + bool caseSensitive = true, + bool multiLine = false, + bool dotAll = false, + bool unicode = false, +}) { + try { + return RegExp( + source, + caseSensitive: caseSensitive, + multiLine: multiLine, + dotAll: dotAll, + unicode: unicode, + ); + } on FormatException { + return _never; + } +} + +/// A pattern that matches nothing (no character is both `\s` and `\S`). +final RegExp _never = RegExp(r'[^\s\S]'); + +/// {@template highlight_grammar_token} +/// One rule of a [Grammar]: a [pattern] whose matches are tagged with [type] +/// (and optionally re-tagged via [alias]), with optional nested highlighting +/// via [inside]. +/// {@endtemplate} +final class GrammarToken { + /// {@macro highlight_grammar_token} + GrammarToken( + this.type, + this.pattern, { + this.lookbehind = false, + this.greedy = false, + this.alias, + Grammar Function()? inside, + }) : _inside = inside; + + /// The token type (the grammar key), used to look up a style in the theme. + final String type; + + /// The pattern whose matches become tokens of this [type]. + final RegExp pattern; + + /// A "lookbehind" flag: when `true`, capture group 1 of [pattern] is treated + /// as an unstyled prefix and excluded from the emitted token (it is *not* a + /// regex look-behind assertion). + final bool lookbehind; + + /// A "greedy" flag, retained for fidelity. The current tokenizer relies on + /// grammar ordering rather than greedy re-scanning, so this is advisory. + final bool greedy; + + /// An alternative type name that takes precedence over [type] when resolving + /// a style (e.g. Dart's `metadata` is aliased to `function`). + final String? alias; + + final Grammar Function()? _inside; + + /// The nested grammar used to tokenize this rule's matched text, or `null`. + /// + /// Stored as a thunk so grammars can reference themselves or each other + /// without initialization cycles. + Grammar? get inside => _inside?.call(); +} + +/// A theme that maps token types to text styles for code highlighting. +/// +/// Implemented as a `switch` per theme (rather than a shared map or enum) so an +/// unused theme is dropped entirely by the compiler. +abstract interface class CodeHighlightTheme { + /// The background color for the code block surface, or `null` to keep the + /// ambient Markdown surface color. + Color? get background; + + /// The default foreground color for untokenized code, or `null` to keep the + /// ambient text color. Needed so a dark theme stays legible when the ambient + /// Markdown text color is dark. + Color? get foreground; + + /// The style for a given [tokenType], or `null` to inherit the base style. + TextStyle? styleFor(String tokenType); +} + +/// Turns the text of a fenced code block into styled [InlineSpan]s. +/// +/// Assign an instance to [MarkdownThemeData.highlighter] to enable syntax +/// highlighting. The default (no highlighter) paints code as plain text. +abstract interface class SyntaxHighlighter { + /// Returns the spans for [code], tagged for [language]. Implementations must + /// preserve text exactly: the concatenation of the returned spans' text must + /// equal [code], so selection and copy stay aligned. [baseStyle] is the code + /// block's base (monospace) style, already applied to the enclosing span. + List highlight( + String code, String? language, TextStyle baseStyle); + + /// The background color to use for [language]'s code block, or `null` to keep + /// the ambient surface color. + Color? backgroundFor(String? language); + + /// The base style for [language]'s code, derived from [fallback] (the code + /// block's monospace style). Used to apply the theme's default foreground so + /// untokenized code stays legible on the theme background. + TextStyle baseStyleFor(String? language, TextStyle fallback); +} + +/// A [SyntaxHighlighter] driven by [Grammar]s. +/// +/// The caller supplies the exact set of [languages] to support, so only the +/// grammars named here are retained; every other language is dropped by +/// tree-shaking. +/// +/// ```dart +/// MarkdownHighlighter( +/// languages: {'dart': HighlightDart.grammar, 'json': HighlightJson.grammar}, +/// theme: HighlightThemes.githubDark, +/// ) +/// ``` +final class MarkdownHighlighter implements SyntaxHighlighter { + /// Creates a highlighter for the given [languages], styled by [theme]. + /// + /// [languages] keys are matched case-insensitively against the fenced code + /// block's language tag; unknown languages fall back to plain text. + MarkdownHighlighter({ + required Map languages, + required this.theme, + }) : _languages = { + for (final MapEntry(:key, :value) + in languages.entries) + key.toLowerCase(): value, + }; + + final Map _languages; + + /// The theme used to color tokens. + final CodeHighlightTheme theme; + + @override + Color? backgroundFor(String? language) => theme.background; + + @override + TextStyle baseStyleFor(String? language, TextStyle fallback) => + theme.foreground == null + ? fallback + : fallback.copyWith(color: theme.foreground); + + @override + List highlight( + String code, + String? language, + TextStyle baseStyle, + ) { + final grammar = + language == null ? null : _languages[language.toLowerCase()]; + if (grammar == null) return [TextSpan(text: code)]; + return _spansFor(_tokenize(code, grammar), theme); + } +} + +// =========================================================================== +// Tokenizer: an ordered-rule matcher. Each rule partitions the remaining plain +// text into typed spans; earlier rules (strings, comments) claim their text +// first, and nested `inside`/`rest` grammars are applied recursively. +// =========================================================================== + +/// A node of the tokenized tree: either raw [_Text] or a typed [_Span]. +sealed class _Node {} + +final class _Text extends _Node { + _Text(this.text); + final String text; +} + +final class _Span extends _Node { + _Span(this.type, this.alias, this.children); + final String type; + final String? alias; + final List<_Node> children; +} + +/// Guards against pathological self-referential grammars. +const int _maxDepth = 24; + +/// Tokenizes [text] against [grammar], returning a flat/nested node list whose +/// concatenated text equals [text] (it only partitions, never edits). +List<_Node> _tokenize(String text, Grammar grammar, [int depth = 0]) { + final nodes = <_Node>[_Text(text)]; + if (depth < _maxDepth) _applyGrammar(nodes, grammar, depth); + return nodes; +} + +/// Applies [grammar]'s rules (then its [Grammar.rest]) across [nodes] in place. +void _applyGrammar(List<_Node> nodes, Grammar grammar, int depth) { + for (final rule in grammar.tokens) { + for (var i = 0; i < nodes.length; i++) { + final node = nodes[i]; + if (node is! _Text) continue; + final replacement = _applyRule(node.text, rule, depth); + if (replacement == null) continue; + nodes.replaceRange(i, i + 1, replacement); + // Skip the freshly inserted nodes; they must not be re-scanned by the + // same rule (already exhaustively matched) but will be seen by the next. + i += replacement.length - 1; + } + } + final rest = grammar.rest; + if (rest != null && depth < _maxDepth) _applyGrammar(nodes, rest, depth + 1); +} + +/// Applies a single [rule] to a plain [text] segment. Returns `null` when the +/// rule does not match (so the caller can leave the segment untouched). +List<_Node>? _applyRule(String text, GrammarToken rule, int depth) { + List<_Node>? out; + var pos = 0; + for (final match in rule.pattern.allMatches(text)) { + var start = match.start; + var matched = match.group(0)!; + if (rule.lookbehind) { + final lead = (match.groupCount >= 1 ? match.group(1) : null)?.length ?? 0; + start += lead; + matched = matched.substring(lead); + } + if (matched.isEmpty) continue; + out ??= <_Node>[]; + if (start > pos) out.add(_Text(text.substring(pos, start))); + final inside = rule.inside; + out.add(_Span( + rule.type, + rule.alias, + inside == null + ? <_Node>[_Text(matched)] + : _tokenize(matched, inside, depth + 1), + )); + pos = start + matched.length; + } + if (out == null) return null; + if (pos < text.length) out.add(_Text(text.substring(pos))); + return out; +} + +/// Converts the tokenized tree into [InlineSpan]s, resolving each token's style +/// from [theme]. Container spans carry no text of their own; children inherit +/// the enclosing style, so token colors compose down the tree. +List _spansFor(List<_Node> nodes, CodeHighlightTheme theme) { + final out = []; + for (final node in nodes) { + switch (node) { + case _Text(:final text): + out.add(TextSpan(text: text)); + case _Span(:final type, :final alias, :final children): + final style = (alias == null ? null : theme.styleFor(alias)) ?? + theme.styleFor(type); + out.add(TextSpan(style: style, children: _spansFor(children, theme))); + } + } + return out; +} diff --git a/lib/src/render/blocks/code.dart b/lib/src/render/blocks/code.dart index f14e180..d44733c 100644 --- a/lib/src/render/blocks/code.dart +++ b/lib/src/render/blocks/code.dart @@ -22,25 +22,46 @@ class BlockPainter$Code with SelectableTextBlock implements BlockPainter { required String text, required String? language, required this.theme, - }) : painter = TextPainter( - text: TextSpan( - text: text, - style: theme.textStyle.copyWith( - fontFamily: 'monospace', - fontSize: theme.textStyle.fontSize ?? kDefaultFontSize, - ), - ), + }) : _background = theme.highlighter?.backgroundFor(language) ?? + theme.surfaceColor ?? + const Color.fromARGB(255, 235, 235, 235), + painter = TextPainter( + text: _buildSpan(text, language, theme), textAlign: TextAlign.start, textDirection: theme.textDirection, textScaler: theme.textScaler, ); + /// Builds the code span: plain monospace text, or, when the theme carries a + /// [MarkdownThemeData.highlighter], a tree of colored token spans whose + /// concatenated text still equals [text] (so selection stays aligned). + static TextSpan _buildSpan( + String text, + String? language, + MarkdownThemeData theme, + ) { + final baseStyle = theme.textStyle.copyWith( + fontFamily: 'monospace', + fontSize: theme.textStyle.fontSize ?? kDefaultFontSize, + ); + final highlighter = theme.highlighter; + if (highlighter == null) return TextSpan(text: text, style: baseStyle); + final effectiveBase = highlighter.baseStyleFor(language, baseStyle); + return TextSpan( + style: effectiveBase, + children: highlighter.highlight(text, language, effectiveBase), + ); + } + /// Padding around the code text, inside its rounded background. static const double padding = 8.0; /// The theme used to style the code block. final MarkdownThemeData theme; + /// Background color of the code block surface. + final Color _background; + /// The text painter that owns the code's glyphs. final TextPainter painter; @@ -81,7 +102,7 @@ class BlockPainter$Code with SelectableTextBlock implements BlockPainter { const Radius.circular(padding), ), Paint() - ..color = theme.surfaceColor ?? const Color.fromARGB(255, 235, 235, 235) + ..color = _background ..isAntiAlias = false ..style = PaintingStyle.fill, ); diff --git a/lib/src/theme.dart b/lib/src/theme.dart index 60be404..bca6016 100644 --- a/lib/src/theme.dart +++ b/lib/src/theme.dart @@ -3,6 +3,7 @@ import 'dart:collection'; import 'package:flutter/material.dart'; import '../flutter_md.dart'; +import 'highlight/engine.dart'; /// {@template markdown_theme_data} /// Theme data for Markdown widgets. @@ -32,6 +33,7 @@ class MarkdownThemeData implements ThemeExtension { this.spanFilter, this.builder, this.onLinkTap, + this.highlighter, }) : _headingStyles = List.filled(8, null), _textStyles = HashMap(); @@ -59,6 +61,7 @@ class MarkdownThemeData implements ThemeExtension { bool Function(MD$Span span)? spanFilter, BlockPainter? Function(MD$Block block, MarkdownThemeData theme)? builder, void Function(String title, String url)? onLinkTap, + SyntaxHighlighter? highlighter, }) { return MarkdownThemeData( textStyle: textStyle ?? @@ -89,6 +92,7 @@ class MarkdownThemeData implements ThemeExtension { spanFilter: spanFilter, builder: builder, onLinkTap: onLinkTap, + highlighter: highlighter, ); } @@ -193,6 +197,13 @@ class MarkdownThemeData implements ThemeExtension { /// It receives the link title and URL as parameters. final void Function(String title, String url)? onLinkTap; + /// An optional syntax highlighter for fenced code blocks. When `null`, code + /// is painted as plain monospace text. + /// + /// See `package:flutter_md/highlight.dart` and [MarkdownHighlighter]. Assign + /// the exact set of languages you support so unused grammars tree-shake away. + final SyntaxHighlighter? highlighter; + final List _headingStyles; /// Returns a [TextStyle] for the given heading level. @@ -300,6 +311,7 @@ class MarkdownThemeData implements ThemeExtension { bool Function(MD$Span span)? spanFilter, BlockPainter? Function(MD$Block block, MarkdownThemeData theme)? builder, void Function(String title, String url)? onLinkTap, + SyntaxHighlighter? highlighter, }) => MarkdownThemeData( textDirection: textDirection ?? this.textDirection, @@ -325,6 +337,7 @@ class MarkdownThemeData implements ThemeExtension { spanFilter: spanFilter ?? this.spanFilter, builder: builder ?? this.builder, onLinkTap: onLinkTap ?? this.onLinkTap, + highlighter: highlighter ?? this.highlighter, ); @override @@ -360,6 +373,7 @@ class MarkdownThemeData implements ThemeExtension { spanFilter: t < 0.5 ? spanFilter : other?.spanFilter, builder: t < 0.5 ? builder : other?.builder, onLinkTap: t < 0.5 ? onLinkTap : other?.onLinkTap, + highlighter: t < 0.5 ? highlighter : other?.highlighter, ); } diff --git a/test/highlight/highlight_test.dart b/test/highlight/highlight_test.dart new file mode 100644 index 0000000..1149e31 --- /dev/null +++ b/test/highlight/highlight_test.dart @@ -0,0 +1,452 @@ +import 'dart:math'; + +import 'package:flutter/painting.dart'; +import 'package:flutter_md/flutter_md.dart'; +import 'package:flutter_md/highlight.dart'; +import 'package:flutter_md/highlight/all.dart'; +import 'package:flutter_md/highlight/bash.dart'; +import 'package:flutter_md/highlight/dart.dart'; +import 'package:flutter_md/highlight/json.dart'; +import 'package:flutter_md/highlight/python.dart'; +import 'package:flutter_md/highlight/themes.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// Flattens a span tree into (text, effectiveStyle) fragments, resolving the +/// inherited style down the tree exactly as the text engine would. +List<(String, TextStyle)> _fragments(InlineSpan span, [TextStyle? inherited]) { + final out = <(String, TextStyle)>[]; + void walk(InlineSpan node, TextStyle acc) { + if (node is! TextSpan) return; + final merged = node.style == null ? acc : acc.merge(node.style); + final text = node.text; + if (text != null && text.isNotEmpty) out.add((text, merged)); + for (final child in node.children ?? const []) { + walk(child, merged); + } + } + + walk(span, inherited ?? const TextStyle()); + return out; +} + +/// The effective color of the first fragment whose text equals [needle]. +Color? _colorOf(List<(String, TextStyle)> frags, String needle) { + for (final (text, style) in frags) { + if (text == needle) return style.color; + } + return null; +} + +void main() { + const base = TextStyle(fontFamily: 'monospace', fontSize: 14); + + MarkdownHighlighter highlighterWith(Map langs) => + MarkdownHighlighter(languages: langs, theme: HighlightThemes.githubDark); + + // Tokenizes [src] under the grammar registered for [tag] and returns the + // concatenated span text (which must equal [src] for a lossless highlighter). + String roundTrip(String tag, String src) { + final grammar = allHighlightLanguages[tag]!; + final highlighter = highlighterWith({tag: grammar}); + return TextSpan(children: highlighter.highlight(src, tag, base)) + .toPlainText(); + } + + group('Highlight › losslessness (selection stays aligned)', () { + final cases = { + 'dart': ( + HighlightDart.grammar, + '// a comment\n' + 'class Foo extends Bar {\n' + ' final String name = "hi \$world and \${a.b}";\n' + ' int n = 0x1F + 42; // trailing\n' + ' @override void f() => print(r\'raw\');\n' + '}\n', + ), + 'json': ( + HighlightJson.grammar, + '{"a": 1, "b": "x", "c": [true, null], "d": 3.14e2}', + ), + 'bash': ( + HighlightBash.grammar, + '#!/usr/bin/env bash\n' + '# comment\n' + 'name="world"\n' + 'echo "hello \$name \${name:-def} \$(date)"\n' + 'for i in 1 2 3; do echo "\$i"; done\n', + ), + }; + + for (final MapEntry(key: lang, value: (grammar, source)) in cases.entries) { + test('$lang round-trips to identical text', () { + final highlighter = highlighterWith({lang: grammar}); + final spans = highlighter.highlight(source, lang, base); + final joined = TextSpan(children: spans).toPlainText(); + expect(joined, source, reason: 'highlighting must not alter text'); + }); + } + }); + + group('Highlight › tokens are colored', () { + test('dart keyword / comment / number use the theme palette', () { + final highlighter = highlighterWith({'dart': HighlightDart.grammar}); + const code = '// hi\nclass Foo {}\nvar n = 42;'; + final frags = _fragments(TextSpan( + style: base, + children: highlighter.highlight(code, 'dart', base), + )); + + expect(_colorOf(frags, 'class'), const Color(0xFFFF7B72)); // keyword + expect(_colorOf(frags, '42'), const Color(0xFF79C0FF)); // number + + final comment = frags.firstWhere((f) => f.$1.startsWith('//')); + expect(comment.$2.color, const Color(0xFF8B949E)); + expect(comment.$2.fontStyle, FontStyle.italic); + }); + + test('python keyword / string / comment use the theme palette', () { + final highlighter = MarkdownHighlighter( + languages: {'python': HighlightPython.grammar}, + theme: HighlightThemes.githubDark); + const code = '# c\ndef greet(name):\n return "hi"'; + final frags = _fragments(TextSpan( + style: base, + children: highlighter.highlight(code, 'python', base), + )); + expect(_colorOf(frags, 'def'), const Color(0xFFFF7B72)); // keyword + expect(_colorOf(frags, '"hi"'), const Color(0xFFA5D6FF)); // string + final comment = frags.firstWhere((f) => f.$1.startsWith('#')); + expect(comment.$2.color, const Color(0xFF8B949E)); + }); + + test('json string vs number are distinguished', () { + final highlighter = highlighterWith({'json': HighlightJson.grammar}); + const code = '{"k": "v", "n": 7}'; + final frags = _fragments(TextSpan( + style: base, + children: highlighter.highlight(code, 'json', base), + )); + expect(_colorOf(frags, '"v"'), const Color(0xFFA5D6FF)); // string + expect(_colorOf(frags, '7'), const Color(0xFF79C0FF)); // number + }); + }); + + group('Highlight › all bundled grammars', () { + // A varied polyglot blob: tags, comments, interpolated strings, numbers, + // template markers, keywords. Tokenizing it under every grammar exercises + // regex compilation, nested/rest recursion, and lossless partitioning. + const sample = r''' +link +/* block comment */ // line comment +const greeting = "hi $name and ${a.b}"; +let n = 0xFF + 42 - 3.14e2; + +name: value # yaml-ish comment +SELECT * FROM t WHERE id = 1; +def f(x): return x +'''; + + test('every grammar builds, tokenizes and round-trips losslessly', () { + final failures = []; + for (final MapEntry(key: tag, value: grammar) + in allHighlightLanguages.entries) { + final highlighter = MarkdownHighlighter( + languages: {tag: grammar}, + theme: HighlightThemes.githubDark, + ); + final joined = + TextSpan(children: highlighter.highlight(sample, tag, base)) + .toPlainText(); + if (joined != sample) failures.add(tag); + } + expect(failures, isEmpty, reason: 'text altered for: $failures'); + }); + + test('registry covers the popular languages and their aliases', () { + // Spot-check that common fence tags resolve. + for (final tag in const [ + 'js', + 'javascript', + 'ts', + 'typescript', + 'py', + 'python', + 'rb', + 'ruby', + 'html', + 'css', + 'scss', + 'go', + 'rust', + 'java', + 'kotlin', + 'swift', + 'c', + 'cpp', + 'csharp', + 'php', + 'sql', + 'yaml', + 'json', + 'bash', + 'sh', + ]) { + expect(allHighlightLanguages.containsKey(tag), isTrue, + reason: 'missing $tag'); + } + }); + }); + + group('Highlight › fallbacks', () { + test('unknown language yields a single plain span equal to the source', () { + final highlighter = highlighterWith({'dart': HighlightDart.grammar}); + const code = 'nothing to see here'; + final spans = highlighter.highlight(code, 'ruby', base); + expect(spans, hasLength(1)); + expect(TextSpan(children: spans).toPlainText(), code); + }); + + test('null language yields plain text', () { + final highlighter = highlighterWith({'dart': HighlightDart.grammar}); + final spans = highlighter.highlight('x = 1', null, base); + expect(TextSpan(children: spans).toPlainText(), 'x = 1'); + }); + + test('language tag is matched case-insensitively', () { + final highlighter = highlighterWith({'dart': HighlightDart.grammar}); + final frags = _fragments(TextSpan( + style: base, + children: highlighter.highlight('class X {}', 'DART', base), + )); + expect(_colorOf(frags, 'class'), const Color(0xFFFF7B72)); + }); + }); + + group('Highlight › BlockPainter\$Code integration', () { + test('renderedText equals source (selection offset space preserved)', () { + final theme = MarkdownThemeData( + textStyle: base, + highlighter: highlighterWith({'dart': HighlightDart.grammar}), + ); + const code = 'void main() => print("hi"); // c'; + final painter = BlockPainter$Code( + text: code, + language: 'dart', + theme: theme, + ); + expect(painter.renderedText, code); + painter.dispose(); + }); + + test('no highlighter keeps plain-text behavior', () { + final theme = MarkdownThemeData(textStyle: base); + const code = 'class Foo {}'; + final painter = BlockPainter$Code( + text: code, + language: 'dart', + theme: theme, + ); + expect(painter.renderedText, code); + painter.dispose(); + }); + + test('delegates to the theme highlighter for the block language', () { + final spy = _SpyHighlighter(); + final theme = MarkdownThemeData(textStyle: base, highlighter: spy); + final painter = + BlockPainter$Code(text: 'x = 1', language: 'dart', theme: theme); + expect(spy.seenLanguages, contains('dart')); + expect(painter.renderedText, 'x = 1'); + painter.dispose(); + }); + }); + + group('Highlight › corner cases', () { + test('empty and whitespace-only inputs round-trip and do not throw', () { + for (final src in const ['', ' ', '\n', '\n\n', '\t \n ', ' ']) { + for (final tag in const ['dart', 'js', 'html', 'json', 'bash']) { + expect(roundTrip(tag, src), src, reason: 'tag=$tag'); + } + } + }); + + test('empty code yields an empty (or plain) span with no text', () { + final highlighter = highlighterWith({'dart': HighlightDart.grammar}); + final joined = TextSpan(children: highlighter.highlight('', 'dart', base)) + .toPlainText(); + expect(joined, isEmpty); + }); + + test('unicode, emoji (surrogate pairs) and CJK are preserved', () { + const src = '// π ≈ 3.14 你好, 世界 👍🏽 家族\n' + 'const s = "café — naïve — 🚀"; // ✅'; + for (final tag in const ['dart', 'python', 'js', 'json', 'yaml']) { + expect(roundTrip(tag, src), src, reason: 'tag=$tag'); + } + }); + + test('CRLF and mixed line endings are preserved verbatim', () { + const src = 'a = 1\r\n// c\r\nb = 2\rlast'; + for (final tag in const ['dart', 'js', 'bash', 'sql']) { + expect(roundTrip(tag, src), src, reason: 'tag=$tag'); + } + }); + + test('deeply nested interpolation does not overflow and round-trips', () { + final buffer = StringBuffer('"'); + for (var i = 0; i < 200; i++) { + buffer.write(r'${x + '); + } + buffer.write('1'); + for (var i = 0; i < 200; i++) { + buffer.write('}'); + } + buffer.write('"'); + final src = buffer.toString(); + expect(roundTrip('dart', src), src); + }); + + test('templating language with a rest grammar (php+html) round-trips', () { + const src = '
\n' + ' \n' + '
'; + expect(roundTrip('php', src), src); + }); + + test('a very long input with many tokens round-trips', () { + final src = ('final x = "s"; // comment ${'ab12 ' * 4}\n' * 500); + expect(roundTrip('dart', src), src); + }); + + test('custom grammar exercises lookbehind, nested inside and rest', () { + final inner = + Grammar([GrammarToken('number', compileHighlightPattern(r'\d+'))]); + final rest = + Grammar([GrammarToken('keyword', compileHighlightPattern('foo'))]); + final grammar = Grammar( + [ + GrammarToken('comment', compileHighlightPattern('//.*')), + // Lookbehind: group 1 ('@') is excluded from the emitted token. + GrammarToken('function', compileHighlightPattern(r'(@)\w+'), + lookbehind: true), + GrammarToken('string', compileHighlightPattern(r'\{[^}]*\}'), + inside: () => inner), + ], + rest: () => rest, + ); + final highlighter = highlighterWith({'x': grammar}); + const src = '// c\n@name {42} foo'; + final spans = highlighter.highlight(src, 'x', base); + final frags = _fragments(TextSpan(style: base, children: spans)); + + expect(TextSpan(children: spans).toPlainText(), src); + expect(_colorOf(frags, 'name'), const Color(0xFFD2A8FF)); // function + // Lookbehind excluded '@', so the token is 'name', never '@name'. + expect(_colorOf(frags, '@name'), isNull); + expect(_colorOf(frags, '42'), const Color(0xFF79C0FF)); // inside → number + expect(_colorOf(frags, 'foo'), const Color(0xFFFF7B72)); // rest → keyword + }); + + test( + 'compileHighlightPattern disables an invalid pattern instead of throwing', + () { + final bad = compileHighlightPattern('(unclosed'); + expect(bad.hasMatch('an (unclosed group'), isFalse); + final good = compileHighlightPattern('abc', caseSensitive: false); + expect(good.hasMatch('xxABCxx'), isTrue); + }); + + test('a rule whose regex Dart rejects is skipped, text still round-trips', + () { + final grammar = Grammar([ + GrammarToken( + 'bad', compileHighlightPattern(r'(?<= )\K')), // unsupported → never + GrammarToken('number', compileHighlightPattern(r'\d+')), + ]); + final highlighter = highlighterWith({'x': grammar}); + const src = 'value 42 end'; + expect( + TextSpan(children: highlighter.highlight(src, 'x', base)).toPlainText(), + src, + ); + }); + + test('empty languages map falls back to plain text for any tag', () { + final highlighter = MarkdownHighlighter( + languages: const {}, theme: HighlightThemes.githubDark); + const code = 'class Foo {}'; + final spans = highlighter.highlight(code, 'dart', base); + expect(spans, hasLength(1)); + expect(TextSpan(children: spans).toPlainText(), code); + }); + + test('styleFor returns null for an unknown token type', () { + expect(HighlightThemes.githubDark.styleFor('no-such-token'), isNull); + expect( + HighlightThemes.githubLight.styleFor('definitely-unknown'), isNull); + }); + + test('theme background/foreground surface through the highlighter', () { + final highlighter = highlighterWith({'dart': HighlightDart.grammar}); + expect(highlighter.backgroundFor('dart'), const Color(0xFF0D1117)); + final derived = highlighter.baseStyleFor('dart', base); + expect(derived.color, const Color(0xFFC9D1D9)); + expect(derived.fontFamily, 'monospace'); // fallback preserved + }); + + test('deterministic fuzz: random sources round-trip under many grammars', + () { + final rng = Random(20240805); + // Chars that stress grammars: quotes, braces, comment/interp markers, + // escapes, newlines, tabs. + const alphabet = "abc 09{}()[]<>\"'/*#\$\\\n\t.:;=|&-_`~"; + const tags = [ + 'dart', + 'js', + 'ts', + 'html', + 'php', + 'python', + 'ruby', + 'bash', + 'json', + 'yaml', + 'sql', + 'css', + 'go', + 'rust', + 'markdown', + ]; + for (var i = 0; i < 300; i++) { + final len = rng.nextInt(96); + final sb = StringBuffer(); + for (var j = 0; j < len; j++) { + sb.write(alphabet[rng.nextInt(alphabet.length)]); + } + final src = sb.toString(); + for (final tag in tags) { + expect(roundTrip(tag, src), src, reason: 'tag=$tag len=$len iter=$i'); + } + } + }); + }); +} + +/// Records which languages it was asked to highlight; otherwise a pass-through +/// (lossless) highlighter, to verify [BlockPainter$Code] delegation. +class _SpyHighlighter implements SyntaxHighlighter { + final List seenLanguages = []; + + @override + List highlight(String code, String? language, TextStyle base) { + seenLanguages.add(language); + return [TextSpan(text: code)]; + } + + @override + Color? backgroundFor(String? language) => null; + + @override + TextStyle baseStyleFor(String? language, TextStyle fallback) => fallback; +} diff --git a/test/unit_test.dart b/test/unit_test.dart index 295af46..f6dce08 100644 --- a/test/unit_test.dart +++ b/test/unit_test.dart @@ -15,6 +15,7 @@ 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; @@ -30,6 +31,7 @@ void main() => group('Unit', () { streaming_test.main(); golden_test.main(); nodes_test.main(); + highlight_test.main(); theme_test.main(); selection_test.main(); markup_formatter_test.main(); diff --git a/tool/highlight_codegen/README.md b/tool/highlight_codegen/README.md new file mode 100644 index 0000000..d1d940a --- /dev/null +++ b/tool/highlight_codegen/README.md @@ -0,0 +1,61 @@ +# Syntax-highlight grammar codegen + +Generates the tree-shakeable Dart grammars under `lib/highlight/` (consumed by +`package:flutter_md/highlight.dart`) from upstream +[Prism](https://prismjs.com/) grammar definitions. Dev-only tooling — not part +of the published package. + +## Regenerate + +```sh +cd tool/highlight_codegen +npm install # pins prismjs (see package.json) +node dump.cjs # snapshot grammars -> grammars.json +node codegen.cjs # emit lib/highlight/.dart (+ all.dart) +cd ../.. && dart format lib/highlight +``` + +The set of languages lives in `languages.json`. `dump.cjs` loads them via +Prism's `loadLanguages`, which pulls in every transitive dependency (e.g. `tsx` +drags in `jsx`, `typescript`, `javascript`, `markup`), and snapshots the **full +closure** — Prism resolves `extend` / `insertBefore` at load time, so the +snapshot is the fully built grammar. Each distinct grammar object becomes one +file named by its canonical name; aliases (js→javascript, sh→bash, …) are +recorded and surface only in `all.dart`. + +To add languages, append to `languages.json` and re-run. + +## Outputs + +- `lib/highlight/.dart` — one `Highlight.grammar` per language. +- `lib/highlight/all.dart` — a convenience `allHighlightLanguages` map (canonical + names + aliases) that references **every** grammar. Referencing it prevents + unused languages from tree-shaking; it's for demos/tooling (the example's + Highlight tab). + +## How it stays tree-shakeable + +- Each language becomes its own library (`lib/highlight/.dart`) exposing a + single `Highlight.grammar`. Importing one never references the others. +- Grammars are hoisted into per-object lazy `final`s and referenced through + thunks (`inside: () => _gN`), which handles self/cross-references and cyclic + sub-grammars (e.g. bash) without a central registry. +- There is deliberately **no** `Map`/`enum`/`switch` that enumerates all + languages: the app assembles the `{ 'lang': grammar }` map at its own call + site, so only the grammars it names survive compilation. + +## Notes / known limitations + +- Regex sources are emitted as escaped Dart string literals. Dart's `RegExp` + (Irregexp) is JS-compatible, so most patterns port verbatim; only the `i` + flag appears in the current language set. +- The tokenizer relies on grammar ordering rather than a cross-segment `greedy` + re-scan (the `greedy` flag is retained but advisory). This can mis-highlight + rare pathological cases; it is correct for typical code. +- Grammar keys with `undefined` values (e.g. Dart's inherited `string` hole) + are skipped. + +## Attribution + +Grammar definitions are adapted from [PrismJS](https://github.com/PrismJS/prism), +which is distributed under the MIT License. diff --git a/tool/highlight_codegen/codegen.cjs b/tool/highlight_codegen/codegen.cjs new file mode 100644 index 0000000..5516526 --- /dev/null +++ b/tool/highlight_codegen/codegen.cjs @@ -0,0 +1,150 @@ +// Emit tree-shakeable Dart grammar files from grammars.json. +const fs = require('fs'); +const path = require('path'); + +const { languages, aliases } = require('./grammars.json'); +const OUT_DIR = path.resolve(__dirname, '../../lib/highlight'); +fs.mkdirSync(OUT_DIR, { recursive: true }); + +const pascal = (name) => + name.split(/[-_]/).map((s) => (s ? s[0].toUpperCase() + s.slice(1) : '')).join(''); +const cls = (name) => `Highlight${pascal(name)}`; +// Dart file names must be lower_case_with_underscores. +const fileName = (name) => name.replace(/-/g, '_'); + +// Dart double-quoted string literal for an arbitrary regex source. +function dartStr(s) { + let r = ''; + for (const ch of s) { + if (ch === '\\') r += '\\\\'; + else if (ch === '"') r += '\\"'; + else if (ch === '$') r += '\\$'; + else if (ch === '\n') r += '\\n'; + else if (ch === '\r') r += '\\r'; + else if (ch === '\t') r += '\\t'; + else r += ch; + } + return `"${r}"`; +} + +function regexExpr(node) { + const args = [dartStr(node.s)]; + const f = node.f || ''; + if (f.includes('i')) args.push('caseSensitive: false'); + if (f.includes('m')) args.push('multiLine: true'); + if (f.includes('s')) args.push('dotAll: true'); + if (f.includes('u')) args.push('unicode: true'); + return `compileHighlightPattern(${args.join(', ')})`; +} + +function refExpr(ref, rootName, externals) { + if (!ref) return null; + if (ref.ref !== undefined) return `() => _g${ref.ref}`; + if (ref.langref !== undefined) { + if (ref.langref === rootName) return '() => _g0'; + externals.add(ref.langref); + return `() => ${cls(ref.langref)}.grammar`; + } + return null; +} + +function tokensForEntry(name, value, rootName, externals) { + const items = value.t === 'arr' ? value.items : [value]; + const out = []; + for (const it of items) { + if (it.t === 're') { + out.push(`GrammarToken(${dartStr(name)}, ${regexExpr(it)}),`); + continue; + } + if (it.t !== 'tok') continue; + const parts = [dartStr(name), regexExpr(it)]; + if (it.lb) parts.push('lookbehind: true'); + if (it.g) parts.push('greedy: true'); + if (it.alias != null) { + const a = Array.isArray(it.alias) ? it.alias[0] : it.alias; + if (typeof a === 'string') parts.push(`alias: ${dartStr(a)}`); + } + const inside = refExpr(it.inside, rootName, externals); + if (inside) parts.push(`inside: ${inside}`); + out.push(`GrammarToken(${parts.join(', ')}),`); + } + return out; +} + +function generate(lang) { + const { rootName, defs } = languages[lang]; + const externals = new Set(); + const decls = defs.map((def, id) => { + const lines = []; + for (const e of def.entries) { + for (const l of tokensForEntry(e.name, e.value, rootName, externals)) { + lines.push(` ${l}`); + } + } + const rest = refExpr(def.rest, rootName, externals); + const restArg = rest ? `, rest: ${rest}` : ''; + return `final Grammar _g${id} = Grammar([\n${lines.join('\n')}\n]${restArg});`; + }); + + externals.delete(lang); + const imports = ["import '../highlight.dart';"]; + for (const ext of [...externals].sort()) { + imports.push(`import '${fileName(ext)}.dart';`); + } + + const header = +`// GENERATED CODE — do not modify by hand. Regenerate with tool/highlight_codegen. +// ignore_for_file: lines_longer_than_80_chars, public_member_api_docs +// ignore_for_file: prefer_single_quotes, require_trailing_commas, directives_ordering +`; + const body = +`${imports.join('\n')} + +/// Syntax grammar for \`${lang}\`. +/// +/// Import this library only when you need \`${lang}\` highlighting; unused +/// languages are dropped from the build. +abstract final class ${cls(lang)} { + /// The grammar for \`${lang}\`. + static final Grammar grammar = _g0; +} + +${decls.join('\n\n')} +`; + fs.writeFileSync(path.join(OUT_DIR, `${fileName(lang)}.dart`), `${header}\n${body}`); +} + +const names = Object.keys(languages); +for (const lang of names) generate(lang); + +// Convenience registry — references EVERY grammar (defeats tree-shaking on +// purpose). For demos and tools only; production code should build its own map. +const sorted = [...names].sort(); +const importLines = sorted.map((n) => `import '${fileName(n)}.dart';`).join('\n'); +const exportLines = sorted.map((n) => `export '${fileName(n)}.dart';`).join('\n'); +const entries = []; +for (const n of sorted) entries.push(` '${n}': ${cls(n)}.grammar,`); +for (const [alias, target] of Object.entries(aliases).sort()) { + entries.push(` '${alias}': ${cls(target)}.grammar,`); +} +const allDart = +`// GENERATED CODE — do not modify by hand. Regenerate with tool/highlight_codegen. +// ignore_for_file: lines_longer_than_80_chars, public_member_api_docs +// ignore_for_file: prefer_single_quotes, require_trailing_commas, directives_ordering + +import '../highlight.dart'; +${importLines} +${exportLines} + +/// Every bundled syntax grammar, keyed by language tag and common aliases. +/// +/// Referencing this map pulls in ALL grammars, so unused languages can no longer +/// be removed by tree-shaking — use it for demos or tooling. Production code +/// should assemble a map with only the languages it needs. +final Map allHighlightLanguages = { +${entries.join('\n')} +}; +`; +fs.writeFileSync(path.join(OUT_DIR, 'all.dart'), allDart); + +console.log(`wrote ${names.length} language files + all.dart to lib/highlight/`); diff --git a/tool/highlight_codegen/dump.cjs b/tool/highlight_codegen/dump.cjs new file mode 100644 index 0000000..3239123 --- /dev/null +++ b/tool/highlight_codegen/dump.cjs @@ -0,0 +1,95 @@ +// Snapshot Prism grammars (the full dependency closure of the requested +// languages) into grammars.json for the Dart codegen. +const fs = require('fs'); +const Prism = require('prismjs'); +const loadLanguages = require('prismjs/components/'); + +const requested = require('./languages.json'); +loadLanguages(requested); + +// Canonical name per distinct grammar object (first key wins), plus aliases. +const canonical = new Map(); // object -> canonical name +const aliases = {}; // alias name -> canonical name +for (const name of Object.keys(Prism.languages)) { + const g = Prism.languages[name]; + if (!g || typeof g !== 'object' || Array.isArray(g)) continue; // skip fns + if (!canonical.has(g)) canonical.set(g, name); + else aliases[name] = canonical.get(g); +} + +function isRegExp(o) { return o instanceof RegExp; } +function isToken(o) { + return o && typeof o === 'object' && !Array.isArray(o) && !isRegExp(o) && + 'pattern' in o; +} +function isGrammar(o) { + return o && typeof o === 'object' && !Array.isArray(o) && !isRegExp(o) && + !('pattern' in o); +} + +let skippedFns = 0; + +// Build one language's id-hoisted grammar table. +function build(rootName) { + const root = Prism.languages[rootName]; + const ids = new Map(); + const defs = []; + + function grammarId(g) { + if (ids.has(g)) return ids.get(g); + const id = defs.length; + ids.set(g, id); + defs.push(null); + const entries = []; + let rest = null; + for (const key of Object.keys(g)) { + if (key === 'rest') { rest = grammarRef(g[key]); continue; } + const v = ser(g[key]); + if (v.t === 'skip') continue; + entries.push({ name: key, value: v }); + } + defs[id] = { entries, rest }; + return id; + } + + // A reference to a whole grammar (used by `inside` and `rest`). + function grammarRef(g) { + if (!isGrammar(g)) return null; + if (canonical.has(g)) return { langref: canonical.get(g) }; + return { ref: grammarId(g) }; + } + + function ser(node) { + if (isRegExp(node)) return { t: 're', s: node.source, f: node.flags }; + if (Array.isArray(node)) { + return { + t: 'arr', + items: node.map((x) => ser(x)).filter((x) => x.t !== 'skip'), + }; + } + if (isToken(node)) { + if (!isRegExp(node.pattern)) { skippedFns++; return { t: 'skip' }; } + return { + t: 'tok', s: node.pattern.source, f: node.pattern.flags, + lb: !!node.lookbehind, g: !!node.greedy, + alias: node.alias ?? null, + inside: grammarRef(node.inside), + }; + } + if (isGrammar(node)) return grammarRef(node) ?? { t: 'skip' }; + if (typeof node === 'function') { skippedFns++; return { t: 'skip' }; } + return { t: 'skip' }; // primitive/undefined hole + } + + grammarId(root); + return { rootName, defs }; +} + +const languages = {}; +for (const g of canonical.values()) languages[g] = build(g); + +fs.writeFileSync('grammars.json', JSON.stringify({ languages, aliases })); + +const count = Object.keys(languages).length; +console.log(`requested ${requested.length}; closure of ${count} languages`); +console.log(`aliases: ${Object.keys(aliases).length}; skipped fn/primitive: ${skippedFns}`); diff --git a/tool/highlight_codegen/languages.json b/tool/highlight_codegen/languages.json new file mode 100644 index 0000000..a1f657f --- /dev/null +++ b/tool/highlight_codegen/languages.json @@ -0,0 +1,64 @@ +[ + "markup", + "css", + "clike", + "javascript", + "typescript", + "jsx", + "tsx", + "coffeescript", + "json", + "json5", + "yaml", + "toml", + "ini", + "markdown", + "python", + "ruby", + "php", + "java", + "kotlin", + "scala", + "groovy", + "clojure", + "dart", + "go", + "rust", + "swift", + "objectivec", + "c", + "cpp", + "csharp", + "fsharp", + "haskell", + "elixir", + "erlang", + "elm", + "lua", + "perl", + "r", + "julia", + "ocaml", + "bash", + "powershell", + "batch", + "sql", + "graphql", + "docker", + "makefile", + "nginx", + "apacheconf", + "diff", + "git", + "regex", + "protobuf", + "wasm", + "solidity", + "scss", + "sass", + "less", + "latex", + "vim", + "http", + "handlebars" +] diff --git a/tool/highlight_codegen/package.json b/tool/highlight_codegen/package.json new file mode 100644 index 0000000..0995cfd --- /dev/null +++ b/tool/highlight_codegen/package.json @@ -0,0 +1,7 @@ +{ + "name": "prism_codegen", + "private": true, + "version": "0.0.0", + "type": "commonjs", + "dependencies": { "prismjs": "1.30.0" } +} From 7b0f87bce061de28fb645896a15a8d898a173f80 Mon Sep 17 00:00:00 2001 From: Mike Matiunin Date: Wed, 5 Aug 2026 18:47:07 +0400 Subject: [PATCH 29/30] style: simplify code formatting in StreamingMarkdownParser and related tests --- lib/src/parser.dart | 13 +++++-------- test/parser/streaming_test.dart | 3 +-- 2 files changed, 6 insertions(+), 10 deletions(-) diff --git a/lib/src/parser.dart b/lib/src/parser.dart index 129d44e..a52cdb0 100644 --- a/lib/src/parser.dart +++ b/lib/src/parser.dart @@ -1228,9 +1228,8 @@ class StreamingMarkdownParser { if (chunk.isNotEmpty) { _tail = _tail.isEmpty ? chunk : '$_tail$chunk'; _freezeCompletedPrefix(); - _tailBlocks = _tail.isEmpty - ? const [] - : _decoder.convert(_tail).blocks; + _tailBlocks = + _tail.isEmpty ? const [] : _decoder.convert(_tail).blocks; } return current; } @@ -1294,7 +1293,8 @@ int _safeCutOffset(String tail) { final len = tail.length; var cut = 0; var inFence = false; - var fence = 0; // active fence marker code unit (0x60 ``` / 0x7E ~~~), 0 = none + var fence = + 0; // active fence marker code unit (0x60 ``` / 0x7E ~~~), 0 = none var prevBlank = false; // the previous *complete* line was blank var havePrev = false; // there is a previous complete line var start = 0; @@ -1307,10 +1307,7 @@ int _safeCutOffset(String tail) { // Evaluate a cut at this line's start, using the fence/blank state that // holds *before* this line is folded in. - if (havePrev && - prevBlank && - !inFence && - !_segIsBlank(tail, start, i)) { + if (havePrev && prevBlank && !inFence && !_segIsBlank(tail, start, i)) { cut = start; } diff --git a/test/parser/streaming_test.dart b/test/parser/streaming_test.dart index 64797ac..b2bf590 100644 --- a/test/parser/streaming_test.dart +++ b/test/parser/streaming_test.dart @@ -277,8 +277,7 @@ void main() => group('StreamingMarkdownParser', () { final acc = StringBuffer(); for (var i = 0; i < chunks.length; i++) { acc.write(chunks[i]); - expect( - _sig(results[i]), _sig(Markdown.fromString(acc.toString()))); + expect(_sig(results[i]), _sig(Markdown.fromString(acc.toString()))); } }); From dbc4b307a505f29da3b83b1cc658f55de040287f Mon Sep 17 00:00:00 2001 From: Mike Matiunin Date: Wed, 5 Aug 2026 18:51:09 +0400 Subject: [PATCH 30/30] refactor: remove unused highlight imports and improve test description --- test/highlight/highlight_test.dart | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/test/highlight/highlight_test.dart b/test/highlight/highlight_test.dart index 1149e31..821f9ae 100644 --- a/test/highlight/highlight_test.dart +++ b/test/highlight/highlight_test.dart @@ -4,10 +4,6 @@ import 'package:flutter/painting.dart'; import 'package:flutter_md/flutter_md.dart'; import 'package:flutter_md/highlight.dart'; import 'package:flutter_md/highlight/all.dart'; -import 'package:flutter_md/highlight/bash.dart'; -import 'package:flutter_md/highlight/dart.dart'; -import 'package:flutter_md/highlight/json.dart'; -import 'package:flutter_md/highlight/python.dart'; import 'package:flutter_md/highlight/themes.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -348,9 +344,7 @@ def f(x): return x expect(_colorOf(frags, 'foo'), const Color(0xFFFF7B72)); // rest → keyword }); - test( - 'compileHighlightPattern disables an invalid pattern instead of throwing', - () { + test('compileHighlightPattern: invalid source disables the rule', () { final bad = compileHighlightPattern('(unclosed'); expect(bad.hasMatch('an (unclosed group'), isFalse); final good = compileHighlightPattern('abc', caseSensitive: false);