diff --git a/CHANGELOG.md b/CHANGELOG.md index 0402849..33c6142 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,16 @@ +## Unreleased + +### Line clamp +- **ADDED**: `clampMarkdownToLines` / `MarkdownLineClamp` — measures a document + at a width and reports where its first N visual text lines end, so a host can + size a clipped box. The renderer still has no line budget of its own; this is + a measurement pass a caller runs itself. +- **ADDED**: `MarkdownPainter.textLineBottoms()` (`@meta.internal`) — the + vertical positions where each visual text line ends. Runs that share a bottom + edge (a table row's cells, a bullet and its first line) count as one line; + blocks without text (spacer, divider) add height without spending the budget, + so a clamp never ends on trailing blank space. + ## 0.2.0 > **Upgrading from 0.0.x?** See the diff --git a/docs/rendering.md b/docs/rendering.md index 9a3869b..5a68437 100644 --- a/docs/rendering.md +++ b/docs/rendering.md @@ -198,6 +198,21 @@ strikethrough/monospace + highlight/monospace backgrounds + link color), `alertColors` (+ built-in GitHub palette fallback via `alertColorFor`), `textDirection`, `textScaler`, and `onLinkTap`. +## Measuring a line budget + +The renderer has no `maxLines`. `clampMarkdownToLines(markdown:, theme:, +maxWidth:, maxLines:)` lays the document out on a throwaway `MarkdownPainter` +and returns a `MarkdownLineClamp(height, clampedHeight)`: `clampedHeight` ends +on a line boundary, so a host can clip to it without showing half a line. + +It is a **full layout per call** — cache the result, do not call it from +`build` on every frame. + +Line counting comes from `MarkdownPainter.textLineBottoms()`: runs sharing a +bottom edge (a table row's cells, a list bullet and its first line) count once, +and blocks with no text (`MD$Spacer`, `MD$Divider`) add height without spending +a line. + ## Public vs internal Public (in `flutter_md.dart`'s `show` list): the framework diff --git a/lib/flutter_md.dart b/lib/flutter_md.dart index a301310..00c0e43 100644 --- a/lib/flutter_md.dart +++ b/lib/flutter_md.dart @@ -1,5 +1,6 @@ library; +export 'src/line_clamp.dart'; export 'src/markdown.dart'; export 'src/nodes.dart'; export 'src/parser.dart'; diff --git a/lib/src/line_clamp.dart b/lib/src/line_clamp.dart new file mode 100644 index 0000000..ea6ba99 --- /dev/null +++ b/lib/src/line_clamp.dart @@ -0,0 +1,61 @@ +import 'markdown.dart'; +import 'render/markdown_painter.dart'; +import 'theme.dart'; + +/// Where a markdown document has to be cut to show a limited number of lines. +final class MarkdownLineClamp { + /// Creates a clamp result. + const MarkdownLineClamp({ + required this.height, + required this.clampedHeight, + }); + + /// Full content height at the measured width. + final double height; + + /// Height holding at most the requested lines, ending on a line boundary. + /// Equals [height] when the whole document fits. + final double clampedHeight; + + /// Whether the line budget leaves content out. + bool get overflows => clampedHeight < height; + + @override + String toString() => + 'MarkdownLineClamp(height: $height, clampedHeight: $clampedHeight)'; +} + +/// Measures [markdown] at [maxWidth] and reports where its first [maxLines] +/// text lines end. +/// +/// Lays the document out on its own so a caller can size a clipped box before +/// the renderer runs — the renderer has no line budget of its own. Cutting on +/// a line boundary is what keeps a clipped view from showing half a line. +/// +/// Line counting follows the rules of [MarkdownPainter.textLineBottoms]: blank +/// lines and dividers add height without spending the budget, so a clamp never +/// ends on trailing blank space. +MarkdownLineClamp clampMarkdownToLines({ + required Markdown markdown, + required MarkdownThemeData theme, + required double maxWidth, + required int maxLines, +}) { + final painter = MarkdownPainter(markdown: markdown, theme: theme); + try { + final height = painter.layout(maxWidth: maxWidth).height; + if (maxLines <= 0) { + return MarkdownLineClamp(height: height, clampedHeight: 0); + } + final lines = painter.textLineBottoms(); + if (lines.length <= maxLines) { + return MarkdownLineClamp(height: height, clampedHeight: height); + } + return MarkdownLineClamp( + height: height, + clampedHeight: lines[maxLines - 1], + ); + } finally { + painter.dispose(); + } +} diff --git a/lib/src/render/markdown_painter.dart b/lib/src/render/markdown_painter.dart index 266aa15..1e90f7e 100644 --- a/lib/src/render/markdown_painter.dart +++ b/lib/src/render/markdown_painter.dart @@ -289,6 +289,59 @@ class MarkdownPainter { return _size = Size(width, height); } + /// Vertical positions where each visual text line ends, top-down. + /// + /// Positions are absolute in the laid-out content. Runs sharing a bottom + /// edge — cells of a table row, a list bullet and its first line — count as + /// one line. Blocks without text (spacer, divider) contribute no line of + /// their own; their height still shows in the positions after them. + /// + /// Only valid after [layout]. + List textLineBottoms() { + if (_isEmpty) return const []; + final blocks = _blockPainters; + if (_blockOffsets.length != blocks.length) return const []; + final bottoms = []; + for (var i = 0; i < blocks.length; i++) { + final top = _blockOffsets[i]; + for (final (painter, offset) in _textFragments(blocks[i])) { + var bottom = top + offset.dy; + for (final line in painter.computeLineMetrics()) { + bottom += line.height; + bottoms.add(bottom); + } + } + } + bottoms.sort(); + final lines = []; + for (final bottom in bottoms) { + // Runs of one line round to slightly different bottoms; a cut may only + // land where every run on that line has ended. + if (lines.isNotEmpty && bottom - lines.last < 1) { + lines[lines.length - 1] = bottom; + continue; + } + lines.add(bottom); + } + return lines; + } + + /// Selectable text runs of [block], as `(painter, block-local origin)`. + static Iterable<(TextPainter, Offset)> _textFragments(BlockPainter block) { + if (block is SelectableTextBlock) { + return <(TextPainter, Offset)>[ + ( + block.selectionPainter, + block.selectionOrigin, + ) + ]; + } + if (block is MultiPainterSelectable) { + return block.fragments.map((f) => (f.painter, f.origin)); + } + return const <(TextPainter, Offset)>[]; + } + /// Routes a tap-down / tap-up to the block under the pointer, re-basing the /// event's position into that block's local space (only taps are handled; the /// block painters use it to fire link recognizers). diff --git a/test/line_clamp/line_clamp_test.dart b/test/line_clamp/line_clamp_test.dart new file mode 100644 index 0000000..26575fd --- /dev/null +++ b/test/line_clamp/line_clamp_test.dart @@ -0,0 +1,115 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_md/flutter_md.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() => group('clampMarkdownToLines', () { + // Height 2.0 keeps every line exactly 20px tall, so expectations read as + // line counts. + final theme = MarkdownThemeData( + textStyle: const TextStyle(fontSize: 10, height: 2), + ); + + MarkdownLineClamp clamp(String source, int maxLines, + {double width = 300}) => + clampMarkdownToLines( + markdown: Markdown.fromString(source), + theme: theme, + maxWidth: width, + maxLines: maxLines, + ); + + test('content within the budget is not clamped', () { + final result = clamp('one\n\ntwo', 6); + + expect(result.overflows, isFalse); + expect(result.clampedHeight, result.height); + }); + + test('cuts on the boundary of the last allowed line', () { + final result = clamp('one\ntwo\nthree\nfour', 2); + + expect(result.overflows, isTrue); + expect(result.clampedHeight, 40); + expect(result.height, 80); + }); + + test('counts wrapped lines of a single paragraph', () { + // 10px glyphs at width 100 wrap roughly every 10 characters. + final result = clamp('word ' * 40, 3, width: 100); + + expect(result.overflows, isTrue); + expect(result.clampedHeight, 60); + }); + + test('counts list items as lines', () { + final result = clamp('- one\n- two\n- three', 2); + + expect(result.overflows, isTrue); + expect(result.clampedHeight, 40); + }); + + test('blank lines add height without spending the budget', () { + final withBlank = clamp('one\n\ntwo\nthree', 2); + final withoutBlank = clamp('one\ntwo\nthree', 2); + + expect(withBlank.overflows, isTrue); + // Two text lines plus the blank line between them. + expect( + withBlank.clampedHeight, greaterThan(withoutBlank.clampedHeight)); + }); + + test('dividers add height without spending the budget', () { + final withDivider = clamp('one\n\n---\n\ntwo\nthree', 2); + final withoutDivider = clamp('one\ntwo\nthree', 2); + + expect(withDivider.overflows, isTrue); + expect(withDivider.clampedHeight, + greaterThan(withoutDivider.clampedHeight)); + }); + + test('a clamp of zero lines keeps nothing', () { + expect(clamp('one\ntwo', 0).clampedHeight, 0); + }); + + test('empty markdown has no height', () { + final result = clamp('', 6); + + expect(result.height, 0); + expect(result.overflows, isFalse); + }); + + test('a negative budget keeps nothing', () { + final result = clamp('one\ntwo', -5); + + expect(result.clampedHeight, 0); + expect(result.overflows, isTrue); + }); + + test('table rows count as one line each', () { + const source = '| a | b |\n|---|---|\n| 1 | 2 |\n| 3 | 4 |'; + final all = clamp(source, 100); + final two = clamp(source, 2); + + expect(all.overflows, isFalse); + expect(two.overflows, isTrue); + expect(two.clampedHeight, greaterThan(0)); + expect(two.clampedHeight, lessThan(all.height)); + }); + + test('a quote with a nested fence counts its child lines', () { + const source = '> intro\n> ```\n> one\n> two\n> ```\n> outro'; + final all = clamp(source, 100); + final two = clamp(source, 2); + + expect(all.height, greaterThan(0)); + expect(two.clampedHeight, greaterThan(0)); + expect(two.clampedHeight, lessThan(all.height)); + }); + + test('a zero measuring width does not throw', () { + final result = clamp('hello world', 2, width: 0); + + expect(result.height, greaterThanOrEqualTo(0)); + expect(result.clampedHeight, greaterThanOrEqualTo(0)); + }); + }); diff --git a/test/unit_test.dart b/test/unit_test.dart index f6dce08..2da36c7 100644 --- a/test/unit_test.dart +++ b/test/unit_test.dart @@ -4,6 +4,7 @@ import 'parser/block_test.dart' as block_test; import 'parser/edge_cases_test.dart' as edge_cases_test; import 'parser/gfm_test.dart' as gfm_test; import 'parser/golden_test.dart' as golden_test; +import 'line_clamp/line_clamp_test.dart' as line_clamp_test; import 'nodes/nodes_test.dart' as nodes_test; import 'parser/inline_test.dart' as inline_test; import 'parser/math_test.dart' as math_test; @@ -38,6 +39,7 @@ void main() => group('Unit', () { selection_widget_test.main(); selection_keyboard_test.main(); selection_handles_test.main(); + line_clamp_test.main(); render_test.main(); widget_test.main(); });