From c3e01e6f1c893c7e9e55599eac39c680f72b2788 Mon Sep 17 00:00:00 2001 From: Hamza Date: Tue, 15 Sep 2026 23:52:24 +0500 Subject: [PATCH] fix(render): lay out right-to-left documents from the right edge With MarkdownThemeData.textDirection set to TextDirection.rtl, every block still positioned its content from the left: short paragraphs and headings hugged the left edge, list bullets and quote/alert bars sat on the left, and table columns ran left to right. Blocks now mirror their x positions for RTL, and link taps and selection use the same mirrored positions. MarkdownPainter honors minWidth, so tight constraints align RTL content to the full width, and under loose constraints it lays blocks out again at the final content width. Code blocks stay LTR. Selection handles follow a right-to-left Directionality. Left-to-right layout is unchanged. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 22 ++ docs/rendering.md | 7 + lib/src/render/block_painter.dart | 10 +- lib/src/render/blocks/alert.dart | 37 +++- lib/src/render/blocks/code.dart | 3 +- lib/src/render/blocks/heading.dart | 23 +- lib/src/render/blocks/list.dart | 36 +++- lib/src/render/blocks/paragraph.dart | 23 +- lib/src/render/blocks/quote.dart | 52 +++-- lib/src/render/blocks/table.dart | 124 ++++++----- lib/src/render/markdown_painter.dart | 32 ++- lib/src/render/markdown_render_object.dart | 11 +- lib/src/selection_scope.dart | 52 +++-- test/unit_test.dart | 2 + test/widget/rtl_test.dart | 233 +++++++++++++++++++++ 15 files changed, 540 insertions(+), 127 deletions(-) create mode 100644 test/widget/rtl_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index de8cac3..78b9b04 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/rendering.md b/docs/rendering.md index c0564ab..0246b70 100644 --- a/docs/rendering.md +++ b/docs/rendering.md @@ -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)`; diff --git a/lib/src/render/block_painter.dart b/lib/src/render/block_painter.dart index c261e09..7f3f304 100644 --- a/lib/src/render/block_painter.dart +++ b/lib/src/render/block_painter.dart @@ -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; } diff --git a/lib/src/render/blocks/alert.dart b/lib/src/render/blocks/alert.dart index e543755..70a0754 100644 --- a/lib/src/render/blocks/alert.dart +++ b/lib/src/render/blocks/alert.dart @@ -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. @@ -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; @@ -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); @@ -149,10 +164,15 @@ 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() @@ -160,7 +180,10 @@ class BlockPainter$Alert ..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)); } diff --git a/lib/src/render/blocks/code.dart b/lib/src/render/blocks/code.dart index d65689c..b2dd1bd 100644 --- a/lib/src/render/blocks/code.dart +++ b/lib/src/render/blocks/code.dart @@ -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, ); diff --git a/lib/src/render/blocks/heading.dart b/lib/src/render/blocks/heading.dart index 8315d10..ce68f95 100644 --- a/lib/src/render/blocks/heading.dart +++ b/lib/src/render/blocks/heading.dart @@ -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({ @@ -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. @@ -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; } @@ -83,7 +100,7 @@ class BlockPainter$Heading if (size.width < _size.width) return; painter.paint( canvas, - Offset(0, offset), + Offset(_dx, offset), ); } diff --git a/lib/src/render/blocks/list.dart b/lib/src/render/blocks/list.dart index fe538db..bf205e8 100644 --- a/lib/src/render/blocks/list.dart +++ b/lib/src/render/blocks/list.dart @@ -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 = @@ -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; @@ -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 items, int level) { final indent = _baseIndent + level * _levelIndent; @@ -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); @@ -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; @@ -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); } } diff --git a/lib/src/render/blocks/paragraph.dart b/lib/src/render/blocks/paragraph.dart index aac47e0..cb930a0 100644 --- a/lib/src/render/blocks/paragraph.dart +++ b/lib/src/render/blocks/paragraph.dart @@ -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({ @@ -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. @@ -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; } @@ -81,7 +98,7 @@ class BlockPainter$Paragraph if (size.width < _size.width) return; painter.paint( canvas, - Offset(0, offset), + Offset(_dx, offset), ); } diff --git a/lib/src/render/blocks/quote.dart b/lib/src/render/blocks/quote.dart index 5a8fb3c..0ddcd04 100644 --- a/lib/src/render/blocks/quote.dart +++ b/lib/src/render/blocks/quote.dart @@ -20,7 +20,7 @@ class BlockPainter$Quote @override TextPainter get selectionPainter => painter; @override - Offset get selectionOrigin => Offset(lineIndent + indent * lineIndent, 0); + Offset get selectionOrigin => Offset(_dx, 0); /// Creates a quote painter for [spans] nested [indent] levels deep, styled /// by [theme]. @@ -57,27 +57,44 @@ class BlockPainter$Quote /// 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. + /// Paint used for the vertical accent bar(s) on the start side. final Paint linePaint; @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 the glyphs are painted. + 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. @@ -89,13 +106,17 @@ class BlockPainter$Quote @override Size layout(double width) { + final textIndent = lineIndent + indent * lineIndent; // Adjust width for indentation. painter.layout( minWidth: 0, - maxWidth: math.max(width - lineIndent - indent * lineIndent, 0), + maxWidth: math.max(width - textIndent, 0), ); + _rtl = theme.textDirection == TextDirection.rtl && width.isFinite; + _width = width; + _dx = _rtl ? math.max(width - textIndent - painter.width, 0) : textIndent; return _size = Size( - painter.size.width + lineIndent + indent * lineIndent, + painter.size.width + textIndent, painter.size.height, ); } @@ -106,25 +127,18 @@ class BlockPainter$Quote if (size.width < _size.width) return; // --- Draw vertical lines --- // - for (var i = 1; i <= indent; i++) + for (var i = 1; i <= indent; i++) { + final x = _rtl ? _width - i * lineIndent : i * lineIndent; canvas.drawLine( - Offset( - i * lineIndent, - offset, - ), - Offset( - i * lineIndent, - offset + _size.height, - ), + Offset(x, offset), + Offset(x, offset + _size.height), linePaint, ); + } painter.paint( canvas, - Offset( - lineIndent + indent * lineIndent, - offset, - ), + Offset(_dx, offset), ); } diff --git a/lib/src/render/blocks/table.dart b/lib/src/render/blocks/table.dart index 8e8b55c..c3c04d3 100644 --- a/lib/src/render/blocks/table.dart +++ b/lib/src/render/blocks/table.dart @@ -58,16 +58,28 @@ class BlockPainter$Table : 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). + /// column alignment (falling back to centered headers and start-aligned + /// data: left for left-to-right text, right for right-to-left). 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, + MD$TableColumnAlign.none => (r == 0) + ? (_columnWidths[c] - painterWidth) / 2 + : (_rtl ? _columnWidths[c] - painterWidth - padding : padding), }; + /// Whether the last layout mirrored the table for right-to-left text: the + /// first column on the right, and the table against the right edge. + bool _rtl = false; + + /// Block-local x of the table's left border. + double _tableLeft = 0; + + /// Block-local x of each column's left edge. + List _columnLefts = const []; + final List _columnWidths; final List _rowHeights; final Paint _borderPaint; @@ -125,45 +137,35 @@ class BlockPainter$Table 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; + final columnLeft = _columnLefts[c]; + if (painter.text == null || + position.dx < columnLeft || + position.dx >= columnLeft + _columnWidths[c]) continue; + + // In this cell. + final verticalPadding = (rowHeight - painter.height) / 2; + final horizontalPadding = _cellHorizontalPadding(r, c, painter.width); + + final painterOffset = Offset( + columnLeft + 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) continue; + + final textPosition = painter.getPositionForOffset(localPosition); + final span = painter.text!.getSpanForPosition(textPosition); + return span is TextSpan ? span : null; } } currentY += rowHeight; @@ -239,6 +241,20 @@ class BlockPainter$Table final totalWidth = _columnWidths.reduce((a, b) => a + b); + // Column positions: left to right, or mirrored for right-to-left text so + // the first column sits on the right and the table hugs the right edge. + _rtl = theme.textDirection == TextDirection.rtl && width.isFinite; + _tableLeft = _rtl ? math.max(width - totalWidth, 0.0) : 0.0; + final lefts = List.filled(columns, 0.0); + var columnStart = 0.0; + for (var c = 0; c < columns; c++) { + lefts[c] = _rtl + ? _tableLeft + totalWidth - columnStart - _columnWidths[c] + : columnStart; + columnStart += _columnWidths[c]; + } + _columnLefts = lefts; + // Layout painters with final widths and calculate row heights double totalHeight = 0.0; @@ -265,15 +281,14 @@ class BlockPainter$Table double lineY = 0; for (int r = 0; r < allRows.length - 1; r++) { lineY += _rowHeights[r]; - points[pointIndex++] = 0; + points[pointIndex++] = _tableLeft; points[pointIndex++] = lineY; - points[pointIndex++] = totalWidth; + points[pointIndex++] = _tableLeft + totalWidth; points[pointIndex++] = lineY; } - // Vertical lines - double lineX = 0; + // Vertical lines, between column c and the next one in reading order. for (int c = 0; c < columns - 1; c++) { - lineX += _columnWidths[c]; + final lineX = _rtl ? lefts[c] : lefts[c] + _columnWidths[c]; points[pointIndex++] = lineX; points[pointIndex++] = 0; points[pointIndex++] = lineX; @@ -296,19 +311,19 @@ class BlockPainter$Table 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); + final origin = Offset( + _columnLefts[c] + horizontalPadding, + rowTop + verticalPadding, + ); frags.add(SelectableFragment(painter, origin, text.length)); text.write(painter.plainText); } - colLeft += _columnWidths[c]; } rowTop += _rowHeights[r]; } @@ -325,22 +340,18 @@ class BlockPainter$Table 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), + Rect.fromLTWH(_tableLeft, 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; - } + if (painter.text == null) continue; final verticalPadding = (rowHeight - painter.height) / 2; final horizontalPadding = _cellHorizontalPadding(r, c, painter.width); @@ -348,11 +359,10 @@ class BlockPainter$Table painter.paint( canvas, Offset( - currentX + horizontalPadding, + _columnLefts[c] + horizontalPadding, currentY + verticalPadding, ), ); - currentX += _columnWidths[c]; } currentY += rowHeight; } @@ -367,11 +377,11 @@ class BlockPainter$Table // Draw outer borders canvas.drawRect( - Rect.fromLTRB( - 0, + Rect.fromLTWH( + _tableLeft, offset, _size.width, - offset + _size.height, + _size.height, ), _borderPaint, ); diff --git a/lib/src/render/markdown_painter.dart b/lib/src/render/markdown_painter.dart index 266aa15..01461ff 100644 --- a/lib/src/render/markdown_painter.dart +++ b/lib/src/render/markdown_painter.dart @@ -264,29 +264,47 @@ class MarkdownPainter { } /// Layouts the markdown content with the given width. - Size layout({required double maxWidth}) { + /// + /// For right-to-left text, blocks align their content to the right edge of + /// the width they are laid out with. When the content ends up narrower than + /// [maxWidth] (loose constraints), the blocks are laid out again at the + /// final content width so that edge matches the painted size. + Size layout({required double maxWidth, double minWidth = 0}) { 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); } + var size = _layoutBlocks(maxWidth); + if (_theme.textDirection == TextDirection.rtl) { + final target = math.max(size.width, minWidth); + // Re-laying out at a width no smaller than every block's content keeps + // the same line breaks; only the right edge moves. + if (target < maxWidth) size = _layoutBlocks(target); + } + _needsLayout = false; // No need to layout if the markdown is empty. + return _size = size; + } + + /// Lays out every block at [width], stacking them vertically, and returns + /// the content size. + Size _layoutBlocks(double width) { + var contentWidth = .0, height = .0; + final blocks = _blockPainters; 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); + final size = blocks[i].layout(width); + contentWidth = math.max(contentWidth, size.width); height += size.height; } - _needsLayout = false; // No need to layout if the markdown is empty. - return _size = Size(width, height); + return Size(contentWidth, height); } /// Routes a tap-down / tap-up to the block under the pointer, re-basing the diff --git a/lib/src/render/markdown_render_object.dart b/lib/src/render/markdown_render_object.dart index 7f4cad2..92443cb 100644 --- a/lib/src/render/markdown_render_object.dart +++ b/lib/src/render/markdown_render_object.dart @@ -217,13 +217,18 @@ class MarkdownRenderObject extends RenderBox // the same layout, so the cached state is always finalized before [paint]. @override Size computeDryLayout(BoxConstraints constraints) => - constraints.constrain(_painter.layout(maxWidth: constraints.maxWidth)); + constraints.constrain(_painter.layout( + maxWidth: constraints.maxWidth, + minWidth: constraints.minWidth, + )); @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, + minWidth: constraints.minWidth, + )); } @override diff --git a/lib/src/selection_scope.dart b/lib/src/selection_scope.dart index 5ab2a5d..13c581c 100644 --- a/lib/src/selection_scope.dart +++ b/lib/src/selection_scope.dart @@ -265,27 +265,37 @@ class MarkdownSelectionScopeState extends State { _applyHandles(endpoints); final box = context.findRenderObject() as RenderBox?; if (box == null || !box.hasSize) return; + final rtl = _isRtl; + final direction = rtl ? TextDirection.rtl : TextDirection.ltr; final startPoint = TextSelectionPoint( - box.globalToLocal( - Offset(endpoints.startGlobal.left, endpoints.startGlobal.bottom)), - TextDirection.ltr, + box.globalToLocal(Offset( + rtl ? endpoints.startGlobal.right : endpoints.startGlobal.left, + endpoints.startGlobal.bottom, + )), + direction, ); final endPoint = TextSelectionPoint( - box.globalToLocal( - Offset(endpoints.endGlobal.right, endpoints.endGlobal.bottom)), - TextDirection.ltr, + box.globalToLocal(Offset( + rtl ? endpoints.endGlobal.left : endpoints.endGlobal.right, + endpoints.endGlobal.bottom, + )), + direction, ); + final startHandleType = + rtl ? TextSelectionHandleType.right : TextSelectionHandleType.left; + final endHandleType = + rtl ? TextSelectionHandleType.left : TextSelectionHandleType.right; final overlay = _selectionOverlay; if (overlay == null) { if (Overlay.maybeOf(context) == null) return; // no host for handles _selectionOverlay = SelectionOverlay( context: context, - startHandleType: TextSelectionHandleType.left, + startHandleType: startHandleType, lineHeightAtStart: endpoints.startLocal.height, onStartHandleDragStart: (d) => _onHandleDragStart(d, isStart: true), onStartHandleDragUpdate: (d) => _onHandleDragUpdate(d, isStart: true), onStartHandleDragEnd: (_) => _onHandleDragEnd(), - endHandleType: TextSelectionHandleType.right, + endHandleType: endHandleType, lineHeightAtEnd: endpoints.endLocal.height, onEndHandleDragStart: (d) => _onHandleDragStart(d, isStart: false), onEndHandleDragUpdate: (d) => _onHandleDragUpdate(d, isStart: false), @@ -301,23 +311,37 @@ class MarkdownSelectionScopeState extends State { )..showHandles(); } else { overlay - ..startHandleType = TextSelectionHandleType.left + ..startHandleType = startHandleType ..lineHeightAtStart = endpoints.startLocal.height - ..endHandleType = TextSelectionHandleType.right + ..endHandleType = endHandleType ..lineHeightAtEnd = endpoints.endLocal.height ..selectionEndpoints = [startPoint, endPoint]; } } + /// Whether the ambient [Directionality] is right-to-left, in which case the + /// selection starts at the right edge of its first box and ends at the left + /// edge of its last one. + bool get _isRtl => Directionality.maybeOf(context) == TextDirection.rtl; + /// 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); + final rtl = e != null && _isRtl; + final startLocal = e == null + ? null + : Offset( + rtl ? e.startLocal.right : e.startLocal.left, + e.startLocal.bottom, + ); + final endLocal = e == null + ? null + : Offset( + rtl ? e.endLocal.left : e.endLocal.right, + e.endLocal.bottom, + ); for (final surface in controller.mountedSurfaces) { final isStart = identical(surface, startSurface); final isEnd = identical(surface, endSurface); diff --git a/test/unit_test.dart b/test/unit_test.dart index f6dce08..e96f334 100644 --- a/test/unit_test.dart +++ b/test/unit_test.dart @@ -18,6 +18,7 @@ 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/rtl_test.dart' as rtl_test; import 'widget/widget_test.dart' as widget_test; void main() => group('Unit', () { @@ -39,5 +40,6 @@ void main() => group('Unit', () { selection_keyboard_test.main(); selection_handles_test.main(); render_test.main(); + rtl_test.main(); widget_test.main(); }); diff --git a/test/widget/rtl_test.dart b/test/widget/rtl_test.dart new file mode 100644 index 0000000..dcf37f8 --- /dev/null +++ b/test/widget/rtl_test.dart @@ -0,0 +1,233 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_md/flutter_md.dart'; +import 'package:flutter_test/flutter_test.dart'; + +const double _width = 400; + +MarkdownThemeData _theme(TextDirection direction) => MarkdownThemeData( + textStyle: const TextStyle(fontSize: 14), + textDirection: direction, + ); + +T _block(String source) => + Markdown.fromString(source).blocks.whereType().first; + +/// The bounding box of the highlight rectangles for `[start, end)`. +Rect _bounds(SelectableBlockPainter painter, int start, int end) => + painter.boxesForRange(start, end).reduce((a, b) => a.expandToInclude(b)); + +Rect _allBounds(SelectableBlockPainter painter) => + _bounds(painter, 0, painter.renderedText.length); + +Future _pumpRtl( + WidgetTester tester, + String source, { + required void Function(String title, String url) onLinkTap, + bool loose = false, +}) async { + final markdown = MarkdownWidget(markdown: Markdown.fromString(source)); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: MarkdownTheme( + data: MarkdownThemeData( + textStyle: const TextStyle(fontSize: 14), + textDirection: TextDirection.rtl, + onLinkTap: onLinkTap, + ), + child: Center( + child: SizedBox( + width: _width, + child: loose + ? Align(alignment: Alignment.centerRight, child: markdown) + : markdown, + ), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('Right-to-left layout', () { + group('paragraph', () { + BlockPainter$Paragraph paint(TextDirection direction) => + BlockPainter$Paragraph( + spans: _block('שלום עולם').spans, + theme: _theme(direction), + )..layout(_width); + + test('aligns RTL text to the right edge', () { + final box = _allBounds(paint(TextDirection.rtl)); + expect(box.right, closeTo(_width, 0.5)); + expect(box.left, greaterThan(0)); + }); + + test('keeps LTR text against the left edge', () { + final box = _allBounds(paint(TextDirection.ltr)); + expect(box.left, closeTo(0, 0.5)); + expect(box.right, lessThan(_width)); + }); + + test('maps the right edge to the start of RTL text', () { + final painter = paint(TextDirection.rtl); + expect(painter.offsetForLocalPosition(const Offset(_width - 1, 7)), 0); + expect( + painter.offsetForLocalPosition(Offset(_allBounds(painter).left, 7)), + painter.renderedText.length, + ); + }); + }); + + test('heading aligns to the right edge', () { + final heading = _block('# כותרת'); + final painter = BlockPainter$Heading( + level: heading.level, + spans: heading.spans, + theme: _theme(TextDirection.rtl), + )..layout(_width); + expect(_allBounds(painter).right, closeTo(_width, 0.5)); + }); + + test('quote text sits left of the right-hand indent', () { + final quote = _block('> ציטוט'); + final painter = BlockPainter$Quote( + spans: quote.spans, + indent: quote.indent, + theme: _theme(TextDirection.rtl), + )..layout(_width); + final indent = BlockPainter$Quote.lineIndent * (quote.indent + 1); + expect(_allBounds(painter).right, closeTo(_width - indent, 0.5)); + }); + + test('alert body sits left of the right-hand accent bar', () { + final alert = _block('> [!NOTE]\n> גוף ההתראה'); + final painter = BlockPainter$Alert( + alert: alert.alert, + spans: alert.spans, + theme: _theme(TextDirection.rtl), + )..layout(_width); + const inset = BlockPainter$Alert.padding + + BlockPainter$Alert.barWidth + + BlockPainter$Alert.gap; + expect(_allBounds(painter).right, closeTo(_width - inset, 0.5)); + }); + + group('list', () { + BlockPainter$List paint(TextDirection direction) => BlockPainter$List( + items: _block('- אחד\n- שתיים').items, + theme: _theme(direction), + )..layout(_width); + + test('puts RTL item content left of a right-hand bullet', () { + final painter = paint(TextDirection.rtl); + final first = _bounds(painter, 0, 3); + // Clear of the base indent and the bullet, but still near the right. + expect(first.right, lessThan(_width - 8)); + expect(first.right, greaterThan(_width - 60)); + }); + + test('keeps LTR item content right of a left-hand bullet', () { + final painter = paint(TextDirection.ltr); + final first = _bounds(painter, 0, 3); + expect(first.left, greaterThan(8)); + expect(first.left, lessThan(60)); + }); + }); + + group('table', () { + const source = '| א | ב |\n|---|---|\n| 1 | 2 |'; + + BlockPainter$Table paint(TextDirection direction) { + final table = _block(source); + return BlockPainter$Table( + header: table.header, + rows: table.rows, + alignments: table.alignments, + theme: _theme(direction), + )..layout(_width); + } + + test('places the first RTL column on the right edge', () { + final painter = paint(TextDirection.rtl); + // Rendered text is "א\tב\n1\t2". + final firstHeader = _bounds(painter, 0, 1); + final secondHeader = _bounds(painter, 2, 3); + expect(firstHeader.center.dx, greaterThan(secondHeader.center.dx)); + expect(_allBounds(painter).right, greaterThan(_width / 2)); + }); + + test('keeps the first LTR column on the left edge', () { + final painter = paint(TextDirection.ltr); + final firstHeader = _bounds(painter, 0, 1); + final secondHeader = _bounds(painter, 2, 3); + expect(firstHeader.center.dx, lessThan(secondHeader.center.dx)); + }); + + test('maps RTL taps back to the mirrored cells', () { + final painter = paint(TextDirection.rtl); + final firstData = _bounds(painter, 4, 5); + expect(painter.offsetForLocalPosition(firstData.center), + inInclusiveRange(4, 5)); + }); + }); + + test('code blocks stay left-to-right', () { + final code = _block('```\nprint(1)\n```'); + final painter = BlockPainter$Code( + text: code.text, + language: code.language, + theme: _theme(TextDirection.rtl), + )..layout(_width); + expect(_allBounds(painter).left, closeTo(BlockPainter$Code.padding, 0.5)); + }); + + group('link taps', () { + // Short enough to fit on one line, so the text only reaches the right + // edge when the block is right-aligned. A tap past either end of a line + // snaps to the nearest glyph, so the taps below aim just inside the + // text: the first link is the rightmost glyph, the second the leftmost. + const source = '[ראשון](https://a.example) ביניים ' + '[שני](https://b.example)'; + + double contentWidth() => BlockPainter$Paragraph( + spans: _block(source).spans, + theme: _theme(TextDirection.rtl), + ).layout(_width).width; + + testWidgets('hit the mirrored links under tight constraints', + (tester) async { + final tapped = []; + await _pumpRtl(tester, source, onLinkTap: (_, url) => tapped.add(url)); + final box = find.byType(MarkdownWidget); + expect(tester.getSize(box).width, _width); + + final textLeft = _width - contentWidth(); + await tester.tapAt(tester.getTopLeft(box) + Offset(textLeft + 4, 7)); + await tester.tapAt(tester.getTopRight(box) + const Offset(-4, 7)); + expect(tapped, ['https://b.example', 'https://a.example']); + }); + + testWidgets('hug the content under loose constraints', (tester) async { + final tapped = []; + await _pumpRtl( + tester, + source, + onLinkTap: (_, url) => tapped.add(url), + loose: true, + ); + final box = find.byType(MarkdownWidget); + expect(tester.takeException(), isNull); + expect(tester.getSize(box).width, closeTo(contentWidth(), 0.5)); + + await tester.tapAt(tester.getTopLeft(box) + const Offset(4, 7)); + await tester.tapAt(tester.getTopRight(box) + const Offset(-4, 7)); + expect(tapped, ['https://b.example', 'https://a.example']); + }); + }); + }); +}