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']); + }); + }); + }); +}