Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
da2e0da
Add selection spikes, render benchmark, and locked selection core API
PlugFox Jul 30, 2026
16332ef
Wire selection into the render layer: selectable MarkdownWidget + scope
PlugFox Jul 30, 2026
3ee3f36
example: tabbed selection demo (editor, lorem, chat)
PlugFox Jul 30, 2026
4951d77
docs: document text selection; bump to 0.2.0
PlugFox Jul 30, 2026
2417331
fix: crash when dragging near a zero-size selection surface
PlugFox Jul 30, 2026
cd8d8ee
feat: selectable lists & tables; richer, longer example demos
PlugFox Jul 30, 2026
26b0ad6
feat(selection): keyboard shortcuts, context toolbar, customization API
PlugFox Jul 30, 2026
ceabbd7
feat(selection): native handles + magnifier via SelectionOverlay
PlugFox Jul 30, 2026
a56adcc
bench+example+docs: selection_drag tier, custom-menu demo, docs
PlugFox Jul 30, 2026
bf97cf7
fix(selection): guard handles/toolbar when there is no Overlay host
PlugFox Jul 30, 2026
fb6b15e
fix(selection): review findings — list offsets, handle-drag collapse,…
PlugFox Jul 30, 2026
435a26c
Refactor code formatting for consistency across multiple test files a…
PlugFox Aug 3, 2026
c76dc1d
chore: remove unused imports from caching and logical controller tests
PlugFox Aug 3, 2026
6f2ed80
Refactor code formatting and improve readability across multiple test…
PlugFox Aug 3, 2026
022508e
Split render by blocks
PlugFox Aug 3, 2026
afbfbbf
docs: add comprehensive documentation for architecture, development, …
PlugFox Aug 3, 2026
f8271f4
test: add cases for space-flanked closers and ensure proper emphasis …
PlugFox Aug 3, 2026
2dd01b6
Add benchmark package for comparing Markdown libraries
PlugFox Aug 3, 2026
b4991c0
feat: enhance selection functionality with granular gestures and mous…
PlugFox Aug 3, 2026
84de57d
docs: add migration guide for upgrading from 0.0.x to 0.2.x
PlugFox Aug 3, 2026
08bf02b
docs: update table formatting and improve rule syntax examples in README
PlugFox Aug 3, 2026
5c79e75
feat: enhance text selection functionality with example tests and doc…
PlugFox Aug 4, 2026
8a0855b
feat: add MarkdownMarkupFormatter for structured Markdown copying and…
PlugFox Aug 4, 2026
4e91756
style: format two selection test files with dart format (line-length 80)
PlugFox Aug 4, 2026
2d32dfa
test: drop deprecated ListView.cacheExtent from disposal test
PlugFox Aug 4, 2026
198d2f9
Compress pub size
PlugFox Aug 5, 2026
95d742f
feat: add StreamingMarkdownParser for incremental Markdown parsing an…
PlugFox Aug 5, 2026
5b12b49
feat: add syntax highlighting support using Prism.js grammars
PlugFox Aug 5, 2026
7b0f87b
style: simplify code formatting in StreamingMarkdownParser and relate…
PlugFox Aug 5, 2026
dbc4b30
refactor: remove unused highlight imports and improve test description
PlugFox Aug 5, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/actions/setup/action.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ runs:
sparse-checkout: |
lib/
test/
example/
analysis_options.yaml
pubspec.yaml
README.md
Expand Down
8 changes: 8 additions & 0 deletions .github/workflows/checkout.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,10 @@ pubspec.lock
*.exe
# 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
19 changes: 18 additions & 1 deletion .pubignore
Original file line number Diff line number Diff line change
@@ -1,5 +1,22 @@
.dart_tool/
pubspec.lock
benchmark/.baseline.txt
benchmark/.render_baseline.txt
.vscode/
build/
coverage/
credentials.json
*.exe
*.exe
benchmark/
benchmark_compare/
example/lib/experiments/
example/android/
example/ios/
example/windows/
example/macos/
example/linux/
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/
110 changes: 110 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -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<String, Markdown>`); 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
`# <version>` 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<BlockPainter>`; 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.
110 changes: 110 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,113 @@
## 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**: 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/<lang>.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
disposal (e.g. chat scrolling). New public API: `MarkdownSelectionController`,
`MarkdownSelectionScope`, `MarkdownSelectionGroup`, `MarkdownPosition`,
`MarkdownSelection`, `MarkdownDocumentRef`, `MarkdownSelectedContent`
(+ document/block), `MarkdownSelectionFormatter` /
`MarkdownPlainTextFormatter` / `MarkdownMarkupFormatter`,
`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<String>.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,
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.
- **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`.
- **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`.
- **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`.
- **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 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.

## 0.1.0

- **ADDED**: GitHub-style alert blocks (`> [!NOTE]`, `> [!TIP]`, `> [!IMPORTANT]`,
Expand Down
Loading
Loading