diff --git a/lib/chat/widgets/finite_selection_edges.dart b/lib/chat/widgets/finite_selection_edges.dart index 536c5f68..c3428374 100644 --- a/lib/chat/widgets/finite_selection_edges.dart +++ b/lib/chat/widgets/finite_selection_edges.dart @@ -32,7 +32,7 @@ class FiniteSelectionEdges extends StatefulWidget { } class _FiniteSelectionEdgesState extends State { - final _delegate = _FiniteEdgesDelegate(); + final _delegate = FiniteEdgesDelegate(); @override void dispose() { @@ -47,7 +47,9 @@ class _FiniteSelectionEdgesState extends State { } } -class _FiniteEdgesDelegate extends StaticSelectionContainerDelegate +/// [FiniteSelectionEdges]'s: an edge sent at infinity put just past the +/// content. +class FiniteEdgesDelegate extends StaticSelectionContainerDelegate with SafeSelectionIndexes { bool joinsLines = false; diff --git a/lib/chat/widgets/markdown_view.dart b/lib/chat/widgets/markdown_view.dart index 0c46d1bf..ef636084 100644 --- a/lib/chat/widgets/markdown_view.dart +++ b/lib/chat/widgets/markdown_view.dart @@ -15,7 +15,6 @@ import '../composer/composer_embeds.dart' show FileImageChip; import '../side_panel/file_open.dart'; import 'code_citation.dart'; import 'content_image.dart'; -import 'finite_selection_edges.dart'; import 'hover_scrollbar.dart'; import 'inline_code.dart'; import 'lazy_segment.dart'; @@ -23,6 +22,7 @@ import 'markdown_cjk.dart'; import 'markdown_math.dart'; import 'markdown_tags.dart'; import 'mermaid_code_block.dart'; +import 'table_selection.dart'; import 'will_change_paint.dart'; /// GitHub-flavored markdown as plain widgets (so a surrounding @@ -900,16 +900,18 @@ class _TableState extends State<_Table> { vertical: 5, ), child: i < cells.length - ? InlineCodeText( - _inlines( - cells[i].children ?? const [], - header - ? style.copyWith( - fontWeight: FontWeight.w600, - color: AppColors.textPrimary, - ) - : style, - widget.options, + ? TableCellSelection( + child: InlineCodeText( + _inlines( + cells[i].children ?? const [], + header + ? style.copyWith( + fontWeight: FontWeight.w600, + color: AppColors.textPrimary, + ) + : style, + widget.options, + ), ), ) : const SizedBox.shrink(), @@ -930,10 +932,16 @@ class _TableState extends State<_Table> { clipBehavior: Clip.antiAlias, child: HoverScrollbar( controller: _scroll, - child: SingleChildScrollView( - controller: _scroll, - scrollDirection: Axis.horizontal, - child: FiniteSelectionEdges(child: WillChangePaint(child: table)), + // The table's selection is its own, not the scroll view's. + child: SelectionContainer.disabled( + child: SingleChildScrollView( + controller: _scroll, + scrollDirection: Axis.horizontal, + child: TableSelection( + registrar: SelectionContainer.maybeOf(context), + child: WillChangePaint(child: table), + ), + ), ), ), ), diff --git a/lib/chat/widgets/table_selection.dart b/lib/chat/widgets/table_selection.dart new file mode 100644 index 00000000..9040f948 --- /dev/null +++ b/lib/chat/widgets/table_selection.dart @@ -0,0 +1,221 @@ +import 'dart:math' as math; + +import 'package:flutter/rendering.dart'; +import 'package:flutter/scheduler.dart'; +import 'package:flutter/widgets.dart'; + +import 'finite_selection_edges.dart'; + +/// The cells of a table that scrolls sideways, selected as text is, and +/// copied a row to a line, its cells apart by tabs (as a spreadsheet takes +/// them). +/// +/// Put in the scroll view, the scroll view's own selection handling off +/// ([SelectionContainer.disabled] around it), [registrar] the one around +/// that. Flutter's took a drag to the left or right of the table as one +/// past all of it, and it kept, from one drag to the next, whether the +/// first had begun in the table: a drag in a row, after one across the +/// table, out past the edge selected every row below; one from above the +/// table to beside a row, the whole table. Here an edge beside a row is at +/// that row's end (or start): at the edge of what shows if the drag began +/// in the table, which scrolls on toward it, else past all of the row. +class TableSelection extends StatefulWidget { + const TableSelection({ + super.key, + required this.registrar, + required this.child, + }); + + final SelectionRegistrar? registrar; + + /// The table, each cell in a [TableCellSelection]. + final Widget child; + + @override + State createState() => _TableSelectionState(); +} + +class _TableSelectionState extends State { + final _delegate = _TableSelectionDelegate(); + + @override + void dispose() { + _delegate.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + _delegate.scrollable = Scrollable.maybeOf(context); + return SelectionContainer( + registrar: widget.registrar, + delegate: _delegate, + child: widget.child, + ); + } +} + +/// A cell of a [TableSelection]'s table: its text one piece to the table, +/// however many pieces it is in (a file link's icon splits it). +class TableCellSelection extends StatefulWidget { + const TableCellSelection({super.key, required this.child}); + + final Widget child; + + @override + State createState() => _TableCellSelectionState(); +} + +class _TableCellSelectionState extends State { + final _delegate = StaticSelectionContainerDelegate(); + + @override + void dispose() { + _delegate.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) => + SelectionContainer(delegate: _delegate, child: widget.child); +} + +class _TableSelectionDelegate extends FiniteEdgesDelegate { + ScrollableState? scrollable; + + /// The selection began in what of the table shows. + bool _begunWithin = false; + + /// What of the table shows, on screen. + Rect? get _shown { + final box = scrollable?.context.findRenderObject(); + if (box is! RenderBox || !box.hasSize) return null; + return MatrixUtils.transformRect( + box.getTransformTo(null), + Offset.zero & box.size, + ); + } + + @override + SelectionResult dispatchSelectionEvent(SelectionEvent event) { + switch (event) { + case SelectionEdgeUpdateEvent( + :final globalPosition, + :final type, + :final granularity, + ) + when globalPosition.isFinite && hasSize: + final shown = _shown; + if (shown == null) break; + final isEnd = type == SelectionEventType.endEdgeUpdate; + if (!isEnd) _begunWithin = shown.contains(globalPosition); + final table = MatrixUtils.transformRect( + getTransformTo(null), + Offset.zero & containerSize, + ); + final x = globalPosition.dx; + final besideRow = + globalPosition.dy >= table.top && + globalPosition.dy <= table.bottom && + (x < shown.left || x > shown.right); + if (!besideRow) { + _lastStep = null; + break; + } + final position = Offset( + _begunWithin + ? x.clamp(shown.left, shown.right) + : (x > shown.right ? table.right + 1 : table.left - 1), + globalPosition.dy, + ); + final result = super.dispatchSelectionEvent( + isEnd + ? SelectionEdgeUpdateEvent.forEnd( + globalPosition: position, + granularity: granularity, + ) + : SelectionEdgeUpdateEvent.forStart( + globalPosition: position, + granularity: granularity, + ), + ); + // Sent again next frame while it scrolls, to select what came in. + if (_begunWithin && isEnd && _scrollToward(x, shown)) { + return SelectionResult.pending; + } + _lastStep = null; + return result; + case SelectWordSelectionEvent(:final globalPosition) || + SelectParagraphSelectionEvent(:final globalPosition): + _begunWithin = _shown?.contains(globalPosition) ?? false; + case ClearSelectionEvent() || SelectAllSelectionEvent(): + _begunWithin = false; + _lastStep = null; + } + return super.dispatchSelectionEvent(event); + } + + /// The frame the table last scrolled in toward an edge held past it. + Duration? _lastStep; + + /// Scrolls the table a step toward [x], past what [shown]: as Flutter's + /// scroll views do, the further past the faster, up to 600 points a + /// second. Whether it scrolls on: not at the end. + bool _scrollToward(double x, Rect shown) { + final position = scrollable?.position; + if (position == null || !position.hasPixels) return false; + final past = x > shown.right ? x - shown.right : x - shown.left; + if (past > 0 + ? position.pixels >= position.maxScrollExtent + : position.pixels <= position.minScrollExtent) { + return false; + } + final scheduler = SchedulerBinding.instance; + final now = scheduler.currentSystemFrameTimeStamp; + final last = _lastStep; + _lastStep = now; + // The first step as long as a frame; none longer than a few. + final seconds = last == null + ? 1 / 60 + : math.min((now - last).inMicroseconds / 1e6, 0.05); + position.jumpTo( + (position.pixels + past.clamp(-20, 20) * 30 * seconds).clamp( + position.minScrollExtent, + position.maxScrollExtent, + ), + ); + // The next frame, to send the edge again in. + scheduler.ensureVisualUpdate(); + return true; + } + + @override + SelectedContent? getSelectedContent() { + final rows = >[]; + Rect? previous; + for (final cell in selectables) { + final text = cell.getSelectedContent()?.plainText; + if (text == null) continue; + final transform = cell.getTransformTo(null); + final box = cell.boundingBoxes + .map((rect) => MatrixUtils.transformRect(transform, rect)) + .fold(null, (all, rect) => all?.expandToInclude(rect) ?? rect); + // A row's cells all begin at its top. + final sameRow = + previous != null && + box != null && + box.top < previous.bottom && + previous.top < box.bottom; + if (sameRow) { + rows.last.add(text); + } else { + rows.add([text]); + } + previous = box ?? previous; + } + if (rows.isEmpty) return null; + return SelectedContent( + plainText: rows.map((cells) => cells.join('\t')).join('\n'), + ); + } +} diff --git a/test/markdown_test.dart b/test/markdown_test.dart index 212b1633..0770f001 100644 --- a/test/markdown_test.dart +++ b/test/markdown_test.dart @@ -258,6 +258,121 @@ void main() { tester.getSize(find.byType(Table)).width, ); }); + + group('selected', () { + final wide = 'x' * 120; + final data = + 'above\n\n| h | k |\n| - | - |\n| a1 | b1 $wide |\n' + '| a2 | b2 |\n| a3 | b3 |\n\nbelow'; + String? selected; + + /// [data] in a selection area 500 wide, the table wider. + Future pumpTable(WidgetTester tester) => tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Align( + alignment: Alignment.topLeft, + child: SizedBox( + width: 500, + child: SelectionArea( + onSelectionChanged: (content) => + selected = content?.plainText, + child: MarkdownView(data), + ), + ), + ), + ), + ), + ); + + /// Where [text] begins. + Offset at(WidgetTester tester, String text) => + tester.getTopLeft( + find.textContaining(text, findRichText: true).last, + ) + + const Offset(1, 8); + + /// A mouse drag from [from] to [to], held there [frames] frames. + Future drag( + WidgetTester tester, + Offset from, + Offset to, { + int frames = 0, + }) async { + final gesture = await tester.startGesture( + from, + kind: PointerDeviceKind.mouse, + ); + await tester.pump(); + for (var i = 1; i <= 8; i++) { + await gesture.moveTo(Offset.lerp(from, to, i / 8)!); + await tester.pump(); + } + for (var i = 0; i < frames; i++) { + await tester.pump(const Duration(milliseconds: 16)); + } + await gesture.up(); + await tester.pump(); + } + + ScrollPosition table(WidgetTester tester) => tester + .state( + find.descendant( + of: find.byType(MarkdownView), + matching: find.byType(Scrollable), + ), + ) + .position; + + testWidgets('to beside a row, from above: up to that row\'s end, ' + 'a row to a line and its cells apart by tabs', (tester) async { + await pumpTable(tester); + await drag( + tester, + at(tester, 'above'), + Offset(600, at(tester, 'a1').dy), + ); + expect(selected, endsWith('h\tk\na1\tb1 $wide')); + expect(selected, startsWith('above')); + }); + + testWidgets('to beside a row, from below: from that row\'s end', ( + tester, + ) async { + await pumpTable(tester); + await drag( + tester, + at(tester, 'below') + const Offset(20, 0), + Offset(600, at(tester, 'a2').dy), + ); + expect(selected, startsWith('a3\tb3')); + expect(selected, isNot(contains('a2'))); + }); + + testWidgets('from a cell, held past the edge: the table scrolls to its ' + 'end, the row alone selected, after a drag across the table too', ( + tester, + ) async { + await pumpTable(tester); + await drag( + tester, + at(tester, 'above'), + at(tester, 'below') + const Offset(20, 0), + ); + expect(selected, contains('a1\tb1 $wide\na2\tb2\na3\tb3')); + + final from = at(tester, 'a1'); + await drag(tester, from, Offset(600, from.dy), frames: 150); + expect(table(tester).pixels, table(tester).maxScrollExtent); + expect(selected, 'a1\tb1 $wide'); + + table(tester).jumpTo(0); + await tester.pump(); + final row2 = at(tester, 'a2'); + await drag(tester, row2, Offset(600, row2.dy)); + expect(selected, 'a2\tb2'); + }); + }); }); group('math', () {