Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions lib/src/terminal_view.dart
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ class TerminalView extends StatefulWidget {
this.focusNode,
this.autofocus = false,
this.onTapUp,
this.onDoubleTapDown,
this.onSecondaryTapDown,
this.onSecondaryTapUp,
this.mouseCursor = SystemMouseCursors.text,
Expand Down Expand Up @@ -87,6 +88,16 @@ class TerminalView extends StatefulWidget {
/// Callback for when the user taps on the terminal.
final void Function(TapUpDetails, CellOffset)? onTapUp;

/// Function called when the user double-taps the terminal.
///
/// Providing this REPLACES the built-in double-tap behaviour, which selects
/// the word under the pointer. Leave it null to keep that default.
///
/// Supplying it lets an embedder define its own double-tap meaning — select
/// the whole line, select a URL, open a file — without the built-in word
/// selection running first and being visibly overwritten a frame later.
final void Function(TapDownDetails, CellOffset)? onDoubleTapDown;

/// Function called when the user taps on the terminal with a secondary
/// button.
final void Function(TapDownDetails, CellOffset)? onSecondaryTapDown;
Expand Down Expand Up @@ -300,6 +311,7 @@ class TerminalViewState extends State<TerminalView> {
terminalController: _controller,
onTapUp: _onTapUp,
onTapDown: _onTapDown,
onDoubleTapDown: widget.onDoubleTapDown != null ? _onDoubleTapDown : null,
onSecondaryTapDown: widget.onSecondaryTapDown != null ? _onSecondaryTapDown : null,
onSecondaryTapUp: widget.onSecondaryTapUp != null ? _onSecondaryTapUp : null,
readOnly: widget.readOnly,
Expand Down Expand Up @@ -353,6 +365,11 @@ class TerminalViewState extends State<TerminalView> {
}
}

void _onDoubleTapDown(TapDownDetails details) {
final offset = renderTerminal.getCellOffset(details.localPosition);
widget.onDoubleTapDown?.call(details, offset);
}

void _onSecondaryTapDown(TapDownDetails details) {
final offset = renderTerminal.getCellOffset(details.localPosition);
widget.onSecondaryTapDown?.call(details, offset);
Expand Down
16 changes: 16 additions & 0 deletions lib/src/ui/gesture/gesture_handler.dart
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ class TerminalGestureHandler extends StatefulWidget {
this.onTapUp,
this.onSingleTapUp,
this.onTapDown,
this.onDoubleTapDown,
this.onSecondaryTapDown,
this.onSecondaryTapUp,
this.onTertiaryTapDown,
Expand All @@ -36,6 +37,9 @@ class TerminalGestureHandler extends StatefulWidget {

final GestureTapDownCallback? onTapDown;

/// Replaces the built-in double-tap word selection when non-null.
final GestureTapDownCallback? onDoubleTapDown;

final GestureTapDownCallback? onSecondaryTapDown;

final GestureTapUpCallback? onSecondaryTapUp;
Expand Down Expand Up @@ -157,6 +161,18 @@ class _TerminalGestureHandlerState extends State<TerminalGestureHandler> {
}

void onDoubleTapDown(TapDownDetails details) {
// An embedder-supplied handler REPLACES the word selection rather than
// running after it. Selecting the word first and letting the embedder
// correct it a frame later is visible to the user as a flicker, and it
// makes the embedder's selection depend on scheduling order.
final handler = widget.onDoubleTapDown;

if (handler != null) {
handler(details);

return;
}

renderTerminal.selectWord(details.localPosition);
}

Expand Down
96 changes: 96 additions & 0 deletions test/src/ui/double_tap_override_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:xterm/xterm.dart';

/// `TerminalView.onDoubleTapDown` REPLACES the built-in word selection.
///
/// Replacing rather than running after it is the whole point: an embedder that
/// wants a different double-tap meaning (select the line, open a file, follow a
/// URL) would otherwise see the word selected first and its own selection land a
/// frame later — visible as a flicker, and dependent on scheduling order.
void main() {
TestWidgetsFlutterBinding.ensureInitialized();

testWidgets('with no handler, double-tap still selects the word', (
tester,
) async {
final terminal = Terminal();
final controller = TerminalController();

await _pumpTerminal(
tester: tester,
terminal: terminal,
controller: controller,
);

terminal.write('hello world');
await tester.pump();

await _doubleTapAt(tester: tester, offset: const Offset(10, 5));

// The default behaviour is untouched for every existing embedder.
expect(controller.selection, isNotNull);
});

testWidgets('a handler REPLACES the word selection, and receives the cell', (
tester,
) async {
final terminal = Terminal();
final controller = TerminalController();

CellOffset? reportedCell;

void onDoubleTapDown(TapDownDetails details, CellOffset cell) {
reportedCell = cell;
}

await _pumpTerminal(
tester: tester,
terminal: terminal,
controller: controller,
onDoubleTapDown: onDoubleTapDown,
);

terminal.write('hello world');
await tester.pump();

await _doubleTapAt(tester: tester, offset: const Offset(10, 5));

// The embedder was told WHERE, in cell coordinates...
expect(reportedCell, isNotNull);

// ...and xterm did NOT select the word behind its back. Without this, the
// embedder's own selection is a correction rather than the only write.
expect(controller.selection, isNull);
});
}

Future<void> _pumpTerminal({
required WidgetTester tester,
required Terminal terminal,
required TerminalController controller,
void Function(TapDownDetails, CellOffset)? onDoubleTapDown,
}) async {
final view = TerminalView(
terminal,
controller: controller,
onDoubleTapDown: onDoubleTapDown,
);

await tester.pumpWidget(MaterialApp(home: Scaffold(body: view)));

await tester.pump();
}

/// Two taps inside the double-tap window and slop, so xterm's detector reports a
/// double tap rather than two singles.
Future<void> _doubleTapAt({
required WidgetTester tester,
required Offset offset,
}) async {
await tester.tapAt(offset);
await tester.pump(const Duration(milliseconds: 50));

await tester.tapAt(offset);
await tester.pump();
}