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
22 changes: 22 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,27 @@
## Unreleased

### Right-to-left layout
- **FIXED**: With `MarkdownThemeData.textDirection` set to
`TextDirection.rtl`, blocks were still laid out left-to-right: paragraphs
and headings sat against the left edge, list bullets, quote bars and the
alert accent bar were on the left, and table columns ran left to right.
Blocks now mirror: text aligns to the right edge, bullets and bars sit on
the right, and the first table column is on the right (unaligned data
cells start on the right; explicit `:--` / `--:` alignment stays physical).
Link taps and text selection follow the mirrored positions. Left-to-right
layout is unchanged.
- **FIXED**: Selection handles under a right-to-left `Directionality` sit at
the logical start (right edge) and end (left edge) of the selection.
- **FIXED**: Link taps inside quotes account for the quote's indent.
- **CHANGED**: Under tight constraints (for example a fixed-width chat
bubble), right-to-left content aligns to the full width, like `Text`. Under
loose constraints the widget still sizes to its content.
- **CHANGED**: Fenced code blocks always render left-to-right, even in
right-to-left documents.
- **ADDED**: `ParagraphGestureHandler.hitTestInlineSpanWithPointerEvent` takes
an optional `origin`, for painters whose text is not painted at the block
origin.

### Monospace font resolution
- **FIXED**: Inline `` `code` `` and fenced blocks pick a monospace family the
host platform can actually resolve. Flutter hands the family straight to the
Expand Down
7 changes: 7 additions & 0 deletions docs/rendering.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,13 @@ and expose `fragments` + `renderedText`.
> [selection](selection.md)), or hit-testing, highlight, and copied text will
> disagree.

> **Direction rule:** when `theme.textDirection` is `TextDirection.rtl`,
> align content to the right edge of the width passed to `layout` (mirror x
> as `width - x - w`), and use the same positions in `paint`,
> `selectionOrigin` / fragment origins, and tap hit-testing. Under loose
> constraints `MarkdownPainter` lays blocks out a second time at the final
> content width, so a right-aligned block stays flush with the painted edge.

## Default block painters

- **`$Paragraph`** — one `TextPainter` from spans, painted at `(0, offset)`;
Expand Down
10 changes: 8 additions & 2 deletions lib/src/render/block_painter.dart
Original file line number Diff line number Diff line change
Expand Up @@ -246,10 +246,16 @@ double _distanceToRect(Offset point, Rect rect) {
/// [BlockPainter.handleTapUp] can match down and up on the same span.
mixin ParagraphGestureHandler {
/// The [InlineSpan] under [event] within [painter], or null if none.
///
/// [origin] is the block-local offset where [painter] is painted (for
/// example, the right-aligned position of right-to-left text).
@protected
InlineSpan? hitTestInlineSpanWithPointerEvent(
PointerEvent event, TextPainter painter) {
final pos = painter.getPositionForOffset(event.localPosition);
PointerEvent event,
TextPainter painter, {
Offset origin = Offset.zero,
}) {
final pos = painter.getPositionForOffset(event.localPosition - origin);
final span = painter.text?.getSpanForPosition(pos);
return span;
}
Expand Down
37 changes: 30 additions & 7 deletions lib/src/render/blocks/alert.dart
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ class BlockPainter$Alert
/// 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.
/// Width of the accent bar on the start edge.
static const double barWidth = 4.0;

/// Gap between the accent bar and the content.
Expand All @@ -74,21 +74,34 @@ class BlockPainter$Alert
/// Vertical gap between the title and the body.
static const double titleGap = 4.0;

/// Left offset where the title/body content begins.
/// Inset from the start edge where the title/body content begins.
double get _contentLeft => padding + barWidth + gap;

@override
Size get size => _size;
Size _size = Size.zero;

/// Whether the last layout mirrored the block for right-to-left text.
bool _rtl = false;

/// The width passed to the last [layout].
double _width = 0;

/// Block-local x where [painter]'s glyphs start: after the bar for
/// left-to-right text, or flush against the right-hand bar for RTL.
double _contentX(TextPainter painter) =>
_rtl ? math.max(_width - _contentLeft - painter.width, 0) : _contentLeft;

/// 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);
Offset get _bodyOrigin => Offset(
_contentX(bodyPainter),
padding + titlePainter.height + titleGap,
);

TextSpan? _spanForPosition(Offset localPosition) {
if (!_hasBody) return null;
Expand Down Expand Up @@ -120,6 +133,8 @@ class BlockPainter$Alert

@override
Size layout(double width) {
_rtl = theme.textDirection == TextDirection.rtl && width.isFinite;
_width = width;
final available = math.max(0.0, width - _contentLeft - padding);
titlePainter.layout(minWidth: 0, maxWidth: available);
bodyPainter.layout(minWidth: 0, maxWidth: available);
Expand Down Expand Up @@ -149,18 +164,26 @@ class BlockPainter$Alert
..color = _accent.withValues(alpha: 0.10)
..style = PaintingStyle.fill,
);
// Accent bar on the left.
// Accent bar on the start edge.
canvas.drawRRect(
RRect.fromRectAndRadius(
Rect.fromLTWH(0, offset, barWidth, _size.height),
Rect.fromLTWH(
_rtl ? size.width - barWidth : 0,
offset,
barWidth,
_size.height,
),
const Radius.circular(barWidth / 2),
),
Paint()
..color = _accent
..style = PaintingStyle.fill,
);

titlePainter.paint(canvas, Offset(_contentLeft, offset + padding));
titlePainter.paint(
canvas,
Offset(_contentX(titlePainter), offset + padding),
);
if (_hasBody) {
bodyPainter.paint(canvas, _bodyOrigin + Offset(0, offset));
}
Expand Down
3 changes: 2 additions & 1 deletion lib/src/render/blocks/code.dart
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,8 @@ class BlockPainter$Code with SelectableTextBlock implements BlockPainter {
painter = TextPainter(
text: _buildSpan(text, language, theme),
textAlign: TextAlign.start,
textDirection: theme.textDirection,
// Code reads left-to-right even inside right-to-left documents.
textDirection: TextDirection.ltr,
textScaler: theme.textScaler,
);

Expand Down
23 changes: 20 additions & 3 deletions lib/src/render/blocks/heading.dart
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ class BlockPainter$Heading
implements BlockPainter {
@override
TextPainter get selectionPainter => painter;
@override
Offset get selectionOrigin => Offset(_dx, 0);

/// Creates a heading painter for [spans] at [level] (1-6), styled by [theme].
BlockPainter$Heading({
Expand Down Expand Up @@ -45,20 +47,32 @@ class BlockPainter$Heading
Size get size => _size;
Size _size = Size.zero;

/// Block-local x where the glyphs are painted: `0` for left-to-right text,
/// or the offset that puts right-to-left text against the right edge.
double _dx = 0;

/// 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);
final span = hitTestInlineSpanWithPointerEvent(
event,
painter,
origin: selectionOrigin,
);
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);
final span = hitTestInlineSpanWithPointerEvent(
event,
painter,
origin: selectionOrigin,
);
if (span != null && _lastSpan == span) {
// If the span is the same as the one hit on tap down,
// call the tap recognizer.
Expand All @@ -74,6 +88,9 @@ class BlockPainter$Heading
minWidth: 0,
maxWidth: width,
);
_dx = theme.textDirection == TextDirection.rtl && width.isFinite
? (width - painter.width).clamp(0.0, width)
: 0.0;
return _size = painter.size;
}

Expand All @@ -83,7 +100,7 @@ class BlockPainter$Heading
if (size.width < _size.width) return;
painter.paint(
canvas,
Offset(0, offset),
Offset(_dx, offset),
);
}

Expand Down
36 changes: 25 additions & 11 deletions lib/src/render/blocks/list.dart
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,18 @@ class _ListItemMetrics {
required this.bulletPainter,
required this.contentPainter,
required this.offset,
required this.contentOffset,
});

final TextPainter bulletPainter;
final TextPainter contentPainter;

/// Block-local top-left of the bullet.
final Offset offset;

/// Block-local top-left of the item content.
final Offset contentOffset;

late final double height =
math.max(bulletPainter.height, contentPainter.height);
late final Size size =
Expand Down Expand Up @@ -76,8 +82,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.contentOffset;
final contentRect = contentOffset & metrics.contentPainter.size;
if (contentRect.contains(localPosition)) {
final painterPosition = localPosition - contentOffset;
Expand Down Expand Up @@ -118,6 +123,8 @@ class BlockPainter$List

double currentHeight = 0;
double maxContentWidth = 0;
// Right-to-left lists put the bullet on the right, content to its left.
final rtl = theme.textDirection == TextDirection.rtl && width.isFinite;

void layoutItems(List<MD$ListItem> items, int level) {
final indent = _baseIndent + level * _levelIndent;
Expand All @@ -143,10 +150,17 @@ class BlockPainter$List
textScaler: theme.textScaler,
)..layout(maxWidth: math.max(0, width - indent - bulletPainter.width));

final bulletX = rtl ? width - indent - bulletPainter.width : indent;
final metrics = _ListItemMetrics(
bulletPainter: bulletPainter,
contentPainter: contentPainter,
offset: Offset(indent, currentHeight),
offset: Offset(bulletX, currentHeight),
contentOffset: Offset(
rtl
? math.max(bulletX - contentPainter.width, 0)
: indent + bulletPainter.width,
currentHeight,
),
);
_painters.add(metrics);

Expand All @@ -172,8 +186,11 @@ class BlockPainter$List
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));
frags.add(SelectableFragment(
metrics.contentPainter,
metrics.contentOffset,
textPos,
));
textPos += metrics.contentPainter.plainText.length;
}
_fragments = frags;
Expand All @@ -182,13 +199,10 @@ class BlockPainter$List

@override
void paint(Canvas canvas, Size size, double offset) {
final dy = Offset(0, 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);
metrics.bulletPainter.paint(canvas, metrics.offset + dy);
metrics.contentPainter.paint(canvas, metrics.contentOffset + dy);
}
}

Expand Down
23 changes: 20 additions & 3 deletions lib/src/render/blocks/paragraph.dart
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ class BlockPainter$Paragraph
implements BlockPainter {
@override
TextPainter get selectionPainter => painter;
@override
Offset get selectionOrigin => Offset(_dx, 0);

/// Creates a paragraph painter for [spans], styled by [theme].
BlockPainter$Paragraph({
Expand All @@ -43,20 +45,32 @@ class BlockPainter$Paragraph
Size get size => _size;
Size _size = Size.zero;

/// Block-local x where the glyphs are painted: `0` for left-to-right text,
/// or the offset that puts right-to-left text against the right edge.
double _dx = 0;

/// 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);
final span = hitTestInlineSpanWithPointerEvent(
event,
painter,
origin: selectionOrigin,
);
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);
final span = hitTestInlineSpanWithPointerEvent(
event,
painter,
origin: selectionOrigin,
);
if (span != null && _lastSpan == span) {
// If the span is the same as the one hit on tap down,
// call the tap recognizer.
Expand All @@ -72,6 +86,9 @@ class BlockPainter$Paragraph
minWidth: 0,
maxWidth: width,
);
_dx = theme.textDirection == TextDirection.rtl && width.isFinite
? (width - painter.width).clamp(0.0, width)
: 0.0;
return _size = painter.size;
}

Expand All @@ -81,7 +98,7 @@ class BlockPainter$Paragraph
if (size.width < _size.width) return;
painter.paint(
canvas,
Offset(0, offset),
Offset(_dx, offset),
);
}

Expand Down
Loading