Skip to content
Merged
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
6 changes: 4 additions & 2 deletions lib/chat/widgets/finite_selection_edges.dart
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ class FiniteSelectionEdges extends StatefulWidget {
}

class _FiniteSelectionEdgesState extends State<FiniteSelectionEdges> {
final _delegate = _FiniteEdgesDelegate();
final _delegate = FiniteEdgesDelegate();

@override
void dispose() {
Expand All @@ -47,7 +47,9 @@ class _FiniteSelectionEdgesState extends State<FiniteSelectionEdges> {
}
}

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;

Expand Down
38 changes: 23 additions & 15 deletions lib/chat/widgets/markdown_view.dart
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,14 @@ 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';
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
Expand Down Expand Up @@ -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(),
Expand All @@ -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),
),
),
),
),
),
Expand Down
221 changes: 221 additions & 0 deletions lib/chat/widgets/table_selection.dart
Original file line number Diff line number Diff line change
@@ -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<TableSelection> createState() => _TableSelectionState();
}

class _TableSelectionState extends State<TableSelection> {
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<TableCellSelection> createState() => _TableCellSelectionState();
}

class _TableCellSelectionState extends State<TableCellSelection> {
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 = <List<String>>[];
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<Rect?>(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'),
);
}
}
Loading