Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
15 changes: 15 additions & 0 deletions docs/rendering.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions lib/flutter_md.dart
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
library;

export 'src/line_clamp.dart';
export 'src/markdown.dart';
export 'src/nodes.dart';
export 'src/parser.dart';
Expand Down
61 changes: 61 additions & 0 deletions lib/src/line_clamp.dart
Original file line number Diff line number Diff line change
@@ -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();
}
}
53 changes: 53 additions & 0 deletions lib/src/render/markdown_painter.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<double> textLineBottoms() {
if (_isEmpty) return const <double>[];
final blocks = _blockPainters;
if (_blockOffsets.length != blocks.length) return const <double>[];
final bottoms = <double>[];
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 = <double>[];
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).
Expand Down
115 changes: 115 additions & 0 deletions test/line_clamp/line_clamp_test.dart
Original file line number Diff line number Diff line change
@@ -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));
});
});
2 changes: 2 additions & 0 deletions test/unit_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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();
});
Loading