diff --git a/packages/flterm/CHANGELOG.md b/packages/flterm/CHANGELOG.md index d07fffbb..84146c13 100644 --- a/packages/flterm/CHANGELOG.md +++ b/packages/flterm/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## Unreleased + +### Added + +- **Tracked touch policy**: `TerminalGestureSettings.touchMouseTracking` can + preserve terminal taps while routing touch and stylus drags to scrolling. + ## 0.0.4 ### Breaking diff --git a/packages/flterm/lib/flterm.dart b/packages/flterm/lib/flterm.dart index 12421baf..2f3e0a67 100644 --- a/packages/flterm/lib/flterm.dart +++ b/packages/flterm/lib/flterm.dart @@ -37,7 +37,8 @@ export 'src/foundation/terminal_gesture_settings.dart' GestureModifier, LineSelectMode, TerminalGestureSettings, - TerminalSelectionShape; + TerminalSelectionShape, + TouchMouseTracking; export 'src/foundation/terminal_theme.dart' show CursorTheme, diff --git a/packages/flterm/lib/src/foundation/callbacks.dart b/packages/flterm/lib/src/foundation/callbacks.dart index af7bcd4d..87f92813 100644 --- a/packages/flterm/lib/src/foundation/callbacks.dart +++ b/packages/flterm/lib/src/foundation/callbacks.dart @@ -28,7 +28,7 @@ typedef OnResize = void Function(int cols, int rows); /// ``` typedef TerminalMouseEvent = ({ MouseAction action, - MouseButton button, + MouseButton? button, double pixelX, double pixelY, }); diff --git a/packages/flterm/lib/src/foundation/terminal_gesture_settings.dart b/packages/flterm/lib/src/foundation/terminal_gesture_settings.dart index fddfbabe..c5105db9 100644 --- a/packages/flterm/lib/src/foundation/terminal_gesture_settings.dart +++ b/packages/flterm/lib/src/foundation/terminal_gesture_settings.dart @@ -19,12 +19,9 @@ enum LineSelectMode { full, } -/// Controls which terminal selection affordances are enabled and how press -/// gestures behave. +/// Controls terminal selection and tracked-pointer gesture behavior. /// -/// Passed to [TerminalView.gestureSettings]. Only affects selection -/// behavior: mouse tracking (for terminal programs) and focus gestures -/// work regardless of these settings. +/// Passed to [TerminalView.gestureSettings]. /// /// ```dart /// TerminalView( @@ -61,6 +58,14 @@ final class TerminalGestureSettings { /// [TerminalController.selectAll] still selects programmatically. final bool selectAllShortcut; + /// How touch-like pointers interact with terminal mouse tracking. + /// + /// [TouchMouseTracking.direct] (default) forwards touch down, motion, and up + /// events directly to the terminal program. [TouchMouseTracking.tapAndScroll] + /// forwards recognized taps as clicks while leaving drags to terminal + /// scrolling. + final TouchMouseTracking touchMouseTracking; + /// How triple-click line selection determines the end column. /// /// [LineSelectMode.content] (default) trims trailing empty cells. @@ -103,6 +108,7 @@ final class TerminalGestureSettings { this.dragSelection = true, this.selectAllShortcut = true, this.longPressSelection = true, + this.touchMouseTracking = .direct, this.lineSelectMode = .content, this.blockSelectionModifier = .alt, this.selectionBehaviors = .standard, @@ -121,6 +127,7 @@ final class TerminalGestureSettings { dragSelection, longPressSelection, selectAllShortcut, + touchMouseTracking, wordBoundaries, ); @@ -140,9 +147,19 @@ final class TerminalGestureSettings { dragSelection == other.dragSelection && longPressSelection == other.longPressSelection && selectAllShortcut == other.selectAllShortcut && + touchMouseTracking == other.touchMouseTracking && wordBoundaries == other.wordBoundaries; } +/// How touch-like pointers interact with terminal mouse tracking. +enum TouchMouseTracking { + /// Forwards touch down, motion, and up as terminal mouse events. + direct, + + /// Forwards recognized taps as clicks and leaves drags to scrolling. + tapAndScroll, +} + /// Selection shape used for gestures that start without a keyboard modifier. enum TerminalSelectionShape { /// Selects contiguous terminal text. diff --git a/packages/flterm/lib/src/widgets/terminal_controller_impl.dart b/packages/flterm/lib/src/widgets/terminal_controller_impl.dart index f3b66f15..143559a1 100644 --- a/packages/flterm/lib/src/widgets/terminal_controller_impl.dart +++ b/packages/flterm/lib/src/widgets/terminal_controller_impl.dart @@ -35,7 +35,6 @@ class TerminalControllerImpl extends TerminalController @override final Terminal terminal; - final _renderState = RenderState(); final _keyEncoder = KeyEncoder(); final _mouseEncoder = MouseEncoder(); late final SelectionGestureDriver _selectionGesture; @@ -52,6 +51,7 @@ class TerminalControllerImpl extends TerminalController var _cursorKeyApplication = false; Brightness _brightness = .dark; var _cursorBlinking = true; + var _mouseButtonPressed = false; var _wasFocused = false; var _selectionMutationDepth = 0; @@ -78,6 +78,8 @@ class TerminalControllerImpl extends TerminalController maxScrollback: config.scrollbackLimit, ), super.base() { + _lastCols = config.cols; + _lastRows = config.rows; _selectionGesture = SelectionGestureDriver(terminal); installDefaultKittyPngDecoder(); _textInput @@ -258,7 +260,6 @@ class TerminalControllerImpl extends TerminalController _selectionGesture.dispose(); _keyEncoder.dispose(); _mouseEncoder.dispose(); - _renderState.dispose(); terminal.dispose(); super.dispose(); } @@ -332,16 +333,32 @@ class TerminalControllerImpl extends TerminalController @override void handleMouseEvent(TerminalMouseEvent event) { + final button = event.button; _mouseEvent ..action = event.action - ..button = event.button ..mods = _currentMods() ..setPosition( x: event.pixelX * _lastDevicePixelRatio, y: event.pixelY * _lastDevicePixelRatio, ); + if (button == null) { + _mouseEvent.clearButton(); + } else { + _mouseEvent.button = button; + } + if (event.action == .press && + button != .four && + button != .five && + button != null) { + _mouseButtonPressed = true; + } _mouseEncoder.sync(terminal); + _mouseEncoder.setAnyButtonPressed(pressed: _mouseButtonPressed); final result = _mouseEncoder.encode(_mouseEvent); + if (event.action == .release) { + _mouseButtonPressed = false; + _mouseEncoder.setAnyButtonPressed(pressed: false); + } if (result.isEmpty) return; _emitOutput(utf8.encode(result)); } @@ -386,10 +403,11 @@ class TerminalControllerImpl extends TerminalController } @override - void handleScroll(int lines) { + void handleScroll(int lines, {Offset? localPosition}) { if (_activeScreen != .alternate || lines == 0) return; if (_mouseTracking != .none) { + if (localPosition == null) return; final button = lines < 0 ? MouseButton.four : MouseButton.five; final count = lines.abs(); @@ -400,7 +418,10 @@ class TerminalControllerImpl extends TerminalController ..action = .press ..button = button ..mods = _currentMods() - ..setPosition(x: 0, y: 0); + ..setPosition( + x: localPosition.dx * _lastDevicePixelRatio, + y: localPosition.dy * _lastDevicePixelRatio, + ); final result = _mouseEncoder.encode(_mouseEvent); if (result.isNotEmpty) _emitOutput(utf8.encode(result)); } @@ -716,10 +737,8 @@ class TerminalControllerImpl extends TerminalController void _emitOutput(Uint8List bytes) => onOutput?.call(bytes); void _ensureGridSize() { - if (_lastRows > 0 && _lastCols > 0) return; - _renderState.update(terminal); - _lastRows = _renderState.rows; - _lastCols = _renderState.cols; + if (_lastRows <= 0) _lastRows = _config.rows; + if (_lastCols <= 0) _lastCols = _config.cols; } bool _extendSelection(LogicalKeyboardKey arrowKey) { @@ -791,10 +810,10 @@ class TerminalControllerImpl extends TerminalController } TerminalSizeInfo _handleSizeQuery() { - _renderState.update(terminal); + _ensureGridSize(); return TerminalSizeInfo( - rows: _renderState.rows, - columns: _renderState.cols, + rows: _lastRows, + columns: _lastCols, cellWidth: (_lastMetrics.cellWidth * _lastDevicePixelRatio).round(), cellHeight: (_lastMetrics.cellHeight * _lastDevicePixelRatio).round(), ); diff --git a/packages/flterm/lib/src/widgets/terminal_gesture_detector.dart b/packages/flterm/lib/src/widgets/terminal_gesture_detector.dart index ffcdbc13..804e3221 100644 --- a/packages/flterm/lib/src/widgets/terminal_gesture_detector.dart +++ b/packages/flterm/lib/src/widgets/terminal_gesture_detector.dart @@ -4,7 +4,7 @@ import 'package:flutter/gestures.dart'; import 'package:flutter/services.dart'; import 'package:flutter/widgets.dart'; import 'package:libghostty/libghostty.dart' - show MouseAction, MouseTracking, Position; + show MouseAction, MouseButton, MouseTracking, Position; import 'package:meta/meta.dart'; import '../foundation.dart'; @@ -51,6 +51,8 @@ class _TerminalGestureDetectorState extends State { Position? _pressCell; var _linkPressActive = false; Timer? _autoScrollTimer; + final Map _trackedButtons = {}; + double _wheelRemainder = 0; TerminalViewBinding get _binding => widget.binding; @@ -63,6 +65,9 @@ class _TerminalGestureDetectorState extends State { onPointerDown: tracked ? _handleTrackedDown : null, onPointerMove: tracked ? _handleTrackedMove : null, onPointerUp: tracked ? _handleTrackedUp : null, + onPointerCancel: tracked ? _handleTrackedCancel : null, + onPointerHover: tracked ? _handleTrackedHover : null, + onPointerSignal: tracked ? _handleTrackedSignal : null, child: TerminalRawGestureDetector( onTapDown: _handleTapDown, onTapUp: _handleTapUp, @@ -93,6 +98,7 @@ class _TerminalGestureDetectorState extends State { @override void dispose() { _autoScrollTimer?.cancel(); + _trackedButtons.clear(); super.dispose(); } @@ -158,7 +164,6 @@ class _TerminalGestureDetectorState extends State { } void _handleDragUpdate(DragUpdateDetails details) { - if (_isMouseTracked(HardwareKeyboard.instance.isShiftPressed)) return; if (_drag != null) _updateDrag(details.localPosition); } @@ -168,6 +173,11 @@ class _TerminalGestureDetectorState extends State { void _handleLongPressStart(LongPressStartDetails details) { _binding.requestFocus(); + if (_isMouseTracked(false) && + widget.settings.touchMouseTracking == TouchMouseTracking.direct) { + _cancelSelectionPress(); + return; + } if (!widget.settings.longPressSelection) { _cancelSelectionPress(); return; @@ -177,6 +187,7 @@ class _TerminalGestureDetectorState extends State { rectangle: widget.settings.longPressSelectionShape == .rectangle, beginPress: _pressCell == null, ); + unawaited(Feedback.forLongPress(context)); } void _handleLongPressUp() => _endDrag(); @@ -208,6 +219,12 @@ class _TerminalGestureDetectorState extends State { } void _handleTapUp(TapUpDetails details) { + if (_defersTrackedPointer(details.kind) && + _isMouseTracked(HardwareKeyboard.instance.isShiftPressed)) { + _sendMouseEvent(.press, details.localPosition, button: .left); + _sendMouseEvent(.release, details.localPosition, button: .left); + return; + } if (_linkPressActive) { _linkPressActive = false; final link = widget.links.handleRelease( @@ -225,21 +242,64 @@ class _TerminalGestureDetectorState extends State { } void _handleTrackedDown(PointerDownEvent event) { - final shift = - event.buttons & kSecondaryButton != 0 || - HardwareKeyboard.instance.isShiftPressed; - if (!_isMouseTracked(shift)) return; - _sendMouseEvent(.press, event.localPosition); + if (_defersTrackedPointer(event.kind)) return; + if (!_isMouseTracked(HardwareKeyboard.instance.isShiftPressed)) return; + final button = _mouseButton(event.buttons); + _trackedButtons[event.pointer] = button; + _sendMouseEvent(.press, event.localPosition, button: button); } void _handleTrackedMove(PointerMoveEvent event) { + if (_defersTrackedPointer(event.kind)) return; if (!_isMouseTracked(HardwareKeyboard.instance.isShiftPressed)) return; - _sendMouseEvent(.motion, event.localPosition); + _sendMouseEvent( + .motion, + event.localPosition, + button: _trackedButtons[event.pointer] ?? _mouseButton(event.buttons), + ); } void _handleTrackedUp(PointerUpEvent event) { - if (!_isMouseTracked(HardwareKeyboard.instance.isShiftPressed)) return; - _sendMouseEvent(.release, event.localPosition); + if (_defersTrackedPointer(event.kind)) return; + final button = _trackedButtons.remove(event.pointer); + if (button == null) return; + _sendMouseEvent(.release, event.localPosition, button: button); + } + + void _handleTrackedCancel(PointerCancelEvent event) { + if (_defersTrackedPointer(event.kind)) return; + final button = _trackedButtons.remove(event.pointer); + if (button == null) return; + _sendMouseEvent(.release, event.localPosition, button: button); + } + + void _handleTrackedHover(PointerHoverEvent event) { + if (event.kind == .touch || + !_isMouseTracked(HardwareKeyboard.instance.isShiftPressed)) { + return; + } + _sendMouseEvent(.motion, event.localPosition, button: null); + } + + void _handleTrackedSignal(PointerSignalEvent event) { + if (event is! PointerScrollEvent || + event.kind == .touch || + !_isMouseTracked(HardwareKeyboard.instance.isShiftPressed)) { + return; + } + GestureBinding.instance.pointerSignalResolver.register(event, (resolved) { + final scroll = resolved as PointerScrollEvent; + final cellHeight = widget.metrics.cellHeight; + if (cellHeight <= 0) return; + _wheelRemainder += scroll.scrollDelta.dy / cellHeight; + final lines = _wheelRemainder.truncate(); + _wheelRemainder -= lines; + if (lines == 0) return; + final button = lines < 0 ? MouseButton.four : MouseButton.five; + for (var i = 0; i < lines.abs(); i++) { + _sendMouseEvent(.press, scroll.localPosition, button: button); + } + }); } bool _isBlockModifierPressed() { @@ -255,6 +315,14 @@ class _TerminalGestureDetectorState extends State { }; } + bool _defersTrackedPointer(PointerDeviceKind kind) { + if (widget.settings.touchMouseTracking != .tapAndScroll) return false; + return switch (kind) { + .touch || .stylus || .invertedStylus => true, + _ => false, + }; + } + bool _isMouseTracked(bool shift) { return _binding.mouseTracking != .none && !shift && @@ -268,10 +336,23 @@ class _TerminalGestureDetectorState extends State { _pressCell = null; } - void _sendMouseEvent(MouseAction action, Offset position) { + MouseButton _mouseButton(int buttons) { + if (buttons & kSecondaryButton != 0) return .right; + if (buttons & kMiddleMouseButton != 0) return .middle; + if (buttons & kBackMouseButton != 0) return .four; + if (buttons & kForwardMouseButton != 0) return .five; + if (buttons & kPrimaryButton != 0) return .left; + return .unknown; + } + + void _sendMouseEvent( + MouseAction action, + Offset position, { + required MouseButton? button, + }) { _binding.handleMouseEvent(( action: action, - button: .left, + button: button, pixelX: position.dx, pixelY: position.dy, )); diff --git a/packages/flterm/lib/src/widgets/terminal_view.dart b/packages/flterm/lib/src/widgets/terminal_view.dart index 3aa30ecf..07611b53 100644 --- a/packages/flterm/lib/src/widgets/terminal_view.dart +++ b/packages/flterm/lib/src/widgets/terminal_view.dart @@ -81,8 +81,8 @@ class TerminalView extends StatefulWidget { /// Scroll physics for scrollback navigation. /// - /// Disabled automatically when the terminal program requests mouse - /// tracking, so gestures are forwarded as mouse events instead. + /// With [TouchMouseTracking.tapAndScroll], touch-like drags continue to use + /// these physics while a terminal program requests mouse tracking. final ScrollPhysics? scrollPhysics; /// Scroll controller for programmatic scrollback access. @@ -142,6 +142,7 @@ class _TerminalViewState extends State { var _ownsFocusNode = false; var _ownsScrollController = false; var _mouseCursorHidden = false; + Offset? _lastPointerPosition; var _lastAlternatePixels = 0.0; var _visibleCols = 0; var _visibleRows = 0; @@ -310,33 +311,39 @@ class _TerminalViewState extends State { onHover: _handleMouseHover, onExit: _handleMouseExit, cursor: _effectiveMouseCursor(), - child: Focus( - focusNode: _focusNode, - autofocus: widget.autofocus, - onFocusChange: _handleFocusChange, - child: TerminalGestureDetector( - links: _links, - metrics: _metrics, - binding: _binding, - visibleRows: _visibleRows, - settings: widget.gestureSettings, - scrollController: _scrollController, - onLinkActivate: widget.linkSettings.onActivate, - child: Scrollable( - controller: _scrollController, - physics: widget.scrollPhysics, - viewportBuilder: (_, offset) => TerminalRenderer( - key: _rendererKey, - theme: _theme, - offset: offset, - metrics: _metrics, - renderObserver: _controller, - terminal: _binding.terminal, - renderCache: cache, - preeditText: _binding.preeditText, - blinkVisible: _blinkVisible, - linkSnapshot: _links.snapshot(), - onResize: _handleResize, + child: Listener( + onPointerDown: _recordPointerPosition, + onPointerMove: _recordPointerPosition, + onPointerUp: _recordPointerPosition, + onPointerSignal: _recordPointerPosition, + child: Focus( + focusNode: _focusNode, + autofocus: widget.autofocus, + onFocusChange: _handleFocusChange, + child: TerminalGestureDetector( + links: _links, + metrics: _metrics, + binding: _binding, + visibleRows: _visibleRows, + settings: widget.gestureSettings, + scrollController: _scrollController, + onLinkActivate: widget.linkSettings.onActivate, + child: Scrollable( + controller: _scrollController, + physics: widget.scrollPhysics, + viewportBuilder: (_, offset) => TerminalRenderer( + key: _rendererKey, + theme: _theme, + offset: offset, + metrics: _metrics, + renderObserver: _controller, + terminal: _binding.terminal, + renderCache: cache, + preeditText: _binding.preeditText, + blinkVisible: _blinkVisible, + linkSnapshot: _links.snapshot(), + onResize: _handleResize, + ), ), ), ), @@ -382,12 +389,14 @@ class _TerminalViewState extends State { } void _handleMouseExit(PointerExitEvent event) { + _lastPointerPosition = null; final previous = _links.highlighted; _links.cancelHover(); if (previous != null) setState(() {}); } void _handleMouseHover(PointerHoverEvent event) { + _lastPointerPosition = event.localPosition; final previous = _links.highlighted; _links.handleHover( localPosition: event.localPosition, @@ -399,6 +408,10 @@ class _TerminalViewState extends State { } } + void _recordPointerPosition(PointerEvent event) { + _lastPointerPosition = event.localPosition; + } + void _syncHoveredLink() { final previous = _links.highlighted; _links.refreshHover(metrics: _metrics, virtualMods: _binding.virtualMods); @@ -470,7 +483,7 @@ class _TerminalViewState extends State { final lines = (delta / cellHeight).truncate(); if (lines == 0) return; _lastAlternatePixels += lines * cellHeight; - _binding.handleScroll(lines); + _binding.handleScroll(lines, localPosition: _lastPointerPosition); _links.invalidateContent(); _syncLinkInteraction(); _updateTextInputGeometry(); diff --git a/packages/flterm/lib/src/widgets/terminal_view_binding.dart b/packages/flterm/lib/src/widgets/terminal_view_binding.dart index 78a3959d..cd0a4875 100644 --- a/packages/flterm/lib/src/widgets/terminal_view_binding.dart +++ b/packages/flterm/lib/src/widgets/terminal_view_binding.dart @@ -66,8 +66,9 @@ abstract interface class TerminalViewBinding { required double devicePixelRatio, }); - /// Reports scroll by line count. - void handleScroll(int lines); + /// Reports alternate-screen scrolling by line count. [localPosition] is + /// required when mouse tracking converts it into a terminal wheel report. + void handleScroll(int lines, {Offset? localPosition}); /// Applies a press selection gesture. void handleSelectionPress({ diff --git a/packages/flterm/test/foundation/terminal_gesture_settings_test.dart b/packages/flterm/test/foundation/terminal_gesture_settings_test.dart index 99bb1638..e41b59cd 100644 --- a/packages/flterm/test/foundation/terminal_gesture_settings_test.dart +++ b/packages/flterm/test/foundation/terminal_gesture_settings_test.dart @@ -10,6 +10,7 @@ void main() { expect(settings.dragSelection, isTrue); expect(settings.longPressSelection, isTrue); expect(settings.selectAllShortcut, isTrue); + expect(settings.touchMouseTracking, TouchMouseTracking.direct); expect(settings.blockSelectionModifier, GestureModifier.alt); expect(settings.longPressSelectionShape, TerminalSelectionShape.normal); expect(settings.lineSelectMode, LineSelectMode.content); @@ -22,12 +23,14 @@ void main() { dragSelection: false, longPressSelection: false, selectAllShortcut: false, + touchMouseTracking: TouchMouseTracking.tapAndScroll, blockSelectionModifier: null, ); expect(settings.dragSelection, isFalse); expect(settings.longPressSelection, isFalse); expect(settings.selectAllShortcut, isFalse); + expect(settings.touchMouseTracking, TouchMouseTracking.tapAndScroll); expect(settings.blockSelectionModifier, isNull); }); @@ -49,6 +52,9 @@ void main() { const differentSelectAll = TerminalGestureSettings( selectAllShortcut: false, ); + const differentTouchTracking = TerminalGestureSettings( + touchMouseTracking: TouchMouseTracking.tapAndScroll, + ); const differentModifier = TerminalGestureSettings( blockSelectionModifier: GestureModifier.meta, ); @@ -74,6 +80,7 @@ void main() { expect(a, isNot(equals(differentDrag))); expect(a, isNot(equals(differentLongPressSelection))); expect(a, isNot(equals(differentSelectAll))); + expect(a, isNot(equals(differentTouchTracking))); expect(a, isNot(equals(differentModifier))); expect(a, isNot(equals(differentLongPress))); expect(a, isNot(equals(differentLineSelect))); diff --git a/packages/flterm/test/widgets/terminal_controller_test.dart b/packages/flterm/test/widgets/terminal_controller_test.dart index 9138ea35..3824e1bf 100644 --- a/packages/flterm/test/widgets/terminal_controller_test.dart +++ b/packages/flterm/test/widgets/terminal_controller_test.dart @@ -7,6 +7,7 @@ import 'package:flterm/src/foundation.dart'; import 'package:flterm/src/widgets/terminal_controller_impl.dart'; import 'package:flterm/src/widgets/terminal_view_binding.dart'; import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart' show EdgeInsets; import 'package:flutter_test/flutter_test.dart'; import 'package:libghostty/libghostty.dart' hide KeyEvent; @@ -47,6 +48,30 @@ void main() { }); }); + test('size query preserves renderer dirty state', () { + final renderState = RenderState(); + final output = []; + addTearDown(renderState.dispose); + controller.onOutput = output.add; + controller.handleResize( + cols: 91, + rows: 37, + metrics: const CellMetrics(cellWidth: 9, cellHeight: 18, baseline: 14), + padding: EdgeInsets.zero, + devicePixelRatio: 2, + ); + renderState.update(controller.terminal); + renderState.dirty = DirtyState.clean; + + writeControllerUtf8(controller, 'visible\x1b[18t\x1b[16t\x1b[14t'); + + expect( + utf8.decode(output.expand((chunk) => chunk).toList()), + '\x1b[8;37;91t\x1b[6;36;18t\x1b[4;1332;1638t', + ); + expect(renderState.update(controller.terminal), isNot(DirtyState.clean)); + }); + group('sendText', () { test('emits UTF-8 bytes via onOutput', () { final output = []; diff --git a/packages/flterm/test/widgets/terminal_gesture_detector_test.dart b/packages/flterm/test/widgets/terminal_gesture_detector_test.dart index bf6b9248..8dcafcf8 100644 --- a/packages/flterm/test/widgets/terminal_gesture_detector_test.dart +++ b/packages/flterm/test/widgets/terminal_gesture_detector_test.dart @@ -2,13 +2,15 @@ library; import 'dart:convert'; -import 'dart:typed_data'; import 'package:flterm/src/foundation.dart'; import 'package:flterm/src/links/link_settings.dart'; import 'package:flterm/src/widgets.dart'; import 'package:flterm/src/widgets/link_interaction.dart'; +import 'package:flutter/foundation.dart' + show TargetPlatform, debugDefaultTargetPlatformOverride; import 'package:flutter/gestures.dart'; +import 'package:flutter/services.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:libghostty/libghostty.dart' @@ -403,6 +405,18 @@ void main() { testWidgets('touch long press starts normal selection by default', ( tester, ) async { + final platformCalls = []; + debugDefaultTargetPlatformOverride = TargetPlatform.android; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(SystemChannels.platform, (call) async { + platformCalls.add(call); + return null; + }); + addTearDown(() { + debugDefaultTargetPlatformOverride = null; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(SystemChannels.platform, null); + }); await tester.pumpWidget(buildHandler(controller: controller)); final gesture = await tester.startGesture(const Offset(40, 16)); @@ -410,12 +424,17 @@ void main() { await tester.pump(const Duration(milliseconds: 550)); expect(terminalFor(controller).selection, isNull); + expect( + platformCalls.where((call) => call.method == 'HapticFeedback.vibrate'), + hasLength(1), + ); await gesture.moveTo(const Offset(80, 32)); final sel = terminalFor(controller).selection!; expect(sel.mode, TerminalSelectionShape.normal); await gesture.up(); + debugDefaultTargetPlatformOverride = null; }); testWidgets('touch move cancels long press if distance exceeds threshold', ( @@ -839,6 +858,22 @@ void main() { }); group('mouse tracking', () { + void enableSgrMouse(String mode, {String format = '1006'}) { + writeToTerminal( + controller, + '\x1b[?$mode' + 'h\x1b[?$format' + 'h', + ); + bindingFor(controller).handleResize( + cols: 80, + rows: 24, + metrics: defaultMetrics, + padding: EdgeInsets.zero, + devicePixelRatio: 1, + ); + } + testWidgets('click fires press and release when mode is normal', ( tester, ) async { @@ -880,6 +915,207 @@ void main() { expect(events, isEmpty); }); + + testWidgets('preserves secondary and middle mouse buttons', ( + tester, + ) async { + enableSgrMouse('1000'); + final events = []; + controller.onOutput = events.add; + await tester.pumpWidget(buildHandler(controller: controller)); + + final right = await mouseDown( + tester, + const Offset(24, 16), + buttons: kSecondaryButton, + ); + await right.up(); + final middle = await mouseDown( + tester, + const Offset(24, 16), + buttons: kMiddleMouseButton, + ); + await middle.up(); + + expect(utf8.decode(events[0]), '\x1b[<2;4;2M'); + expect(utf8.decode(events[1]), '\x1b[<2;4;2m'); + expect(utf8.decode(events[2]), '\x1b[<1;4;2M'); + expect(utf8.decode(events[3]), '\x1b[<1;4;2m'); + }); + + testWidgets('reports hover motion in any-event mode', (tester) async { + enableSgrMouse('1003'); + expect(bindingFor(controller).mouseTracking, MouseTracking.any); + final events = []; + controller.onOutput = events.add; + await tester.pumpWidget(buildHandler(controller: controller)); + + final mouse = await tester.createGesture(kind: .mouse); + await mouse.addPointer(location: const Offset(16, 16)); + await mouse.moveTo(const Offset(24, 16)); + + expect(events, hasLength(1)); + expect(utf8.decode(events.single), '\x1b[<35;4;2M'); + await mouse.removePointer(); + }); + + testWidgets('touch emits left-button SGR cell input when tracked', ( + tester, + ) async { + enableSgrMouse('1002'); + final events = []; + controller.onOutput = events.add; + await tester.pumpWidget(buildHandler(controller: controller)); + + final touch = await tester.startGesture(const Offset(24, 16)); + await touch.up(); + + expect(events, hasLength(2)); + expect(utf8.decode(events[0]), '\x1b[<0;4;2M'); + expect(utf8.decode(events[1]), '\x1b[<0;4;2m'); + }); + + testWidgets('touch emits left-button SGR-pixel input in mode 1016', ( + tester, + ) async { + enableSgrMouse('1000', format: '1016'); + final events = []; + controller.onOutput = events.add; + await tester.pumpWidget(buildHandler(controller: controller)); + + final touch = await tester.startGesture(const Offset(24, 16)); + await touch.up(); + + expect(events, hasLength(2)); + expect(utf8.decode(events[0]), '\x1b[<0;24;16M'); + expect(utf8.decode(events[1]), '\x1b[<0;24;16m'); + }); + + for (final kind in [ + PointerDeviceKind.touch, + PointerDeviceKind.stylus, + PointerDeviceKind.invertedStylus, + ]) { + testWidgets('$kind tap-and-scroll forwards recognized taps only', ( + tester, + ) async { + enableSgrMouse('1002'); + final events = []; + controller.onOutput = events.add; + await tester.pumpWidget( + buildHandler( + controller: controller, + gestureSettings: const TerminalGestureSettings( + touchMouseTracking: TouchMouseTracking.tapAndScroll, + ), + ), + ); + + final pointer = await tester.startGesture( + const Offset(24, 16), + kind: kind, + ); + expect(events, isEmpty); + + await pointer.up(); + + expect(events, hasLength(2)); + expect(utf8.decode(events[0]), '\x1b[<0;4;2M'); + expect(utf8.decode(events[1]), '\x1b[<0;4;2m'); + }); + + testWidgets('$kind tap-and-scroll does not forward drags', ( + tester, + ) async { + enableSgrMouse('1002'); + final events = []; + controller.onOutput = events.add; + await tester.pumpWidget( + buildHandler( + controller: controller, + gestureSettings: const TerminalGestureSettings( + touchMouseTracking: TouchMouseTracking.tapAndScroll, + ), + ), + ); + + final pointer = await tester.startGesture( + const Offset(24, 16), + kind: kind, + ); + await pointer.moveTo(const Offset(24, 80)); + await pointer.up(); + + expect(events, isEmpty); + }); + } + + testWidgets('tap-and-scroll permits tracked touch long-press selection', ( + tester, + ) async { + writeToTerminal(controller, 'hello world'); + enableSgrMouse('1002'); + final events = []; + controller.onOutput = events.add; + await tester.pumpWidget( + buildHandler( + controller: controller, + gestureSettings: const TerminalGestureSettings( + touchMouseTracking: TouchMouseTracking.tapAndScroll, + ), + ), + ); + + final touch = await tester.startGesture(const Offset(8, 0)); + await tester.pump(kLongPressTimeout + const Duration(milliseconds: 1)); + await touch.moveTo(const Offset(40, 0)); + await touch.up(); + + expect(controller.hasSelection, isTrue); + expect(events, isEmpty); + }); + + testWidgets('disabled tracked touch long press remains inert', ( + tester, + ) async { + writeToTerminal(controller, 'hello world'); + enableSgrMouse('1002'); + final events = []; + controller.onOutput = events.add; + await tester.pumpWidget( + buildHandler( + controller: controller, + gestureSettings: const TerminalGestureSettings( + longPressSelection: false, + touchMouseTracking: TouchMouseTracking.tapAndScroll, + ), + ), + ); + + final touch = await tester.startGesture(const Offset(8, 0)); + await tester.pump(kLongPressTimeout + const Duration(milliseconds: 1)); + await touch.up(); + + expect(controller.hasSelection, isFalse); + expect(events, isEmpty); + }); + + testWidgets('wheel reports its pointer position', (tester) async { + enableSgrMouse('1000'); + final events = []; + controller.onOutput = events.add; + await tester.pumpWidget(buildHandler(controller: controller)); + + await tester.sendEventToBinding( + const PointerScrollEvent( + position: Offset(24, 16), + scrollDelta: Offset(0, 16), + ), + ); + + expect(events, hasLength(1)); + expect(utf8.decode(events.single), '\x1b[<65;4;2M'); + }); }); }); } diff --git a/packages/flterm/test/widgets/terminal_view_binding_test.dart b/packages/flterm/test/widgets/terminal_view_binding_test.dart index 65204c36..c0d5be82 100644 --- a/packages/flterm/test/widgets/terminal_view_binding_test.dart +++ b/packages/flterm/test/widgets/terminal_view_binding_test.dart @@ -119,6 +119,37 @@ void main() { expect(output, isEmpty); }); + + test('tracked scroll preserves the supplied pointer position', () { + writeUtf8(controller.terminal, '\x1b[?1049h\x1b[?1000h\x1b[?1006h'); + binding.handleResize( + cols: 80, + rows: 24, + metrics: const CellMetrics( + cellWidth: 8, + cellHeight: 16, + baseline: 12, + ), + padding: EdgeInsets.zero, + devicePixelRatio: 1, + ); + final output = []; + controller.onOutput = output.add; + + binding.handleScroll(1, localPosition: const Offset(24, 16)); + + expect(utf8.decode(output.single), '\x1b[<65;4;2M'); + }); + + test('tracked scroll without a pointer position emits nothing', () { + writeUtf8(controller.terminal, '\x1b[?1049h\x1b[?1000h\x1b[?1006h'); + final output = []; + controller.onOutput = output.add; + + binding.handleScroll(1); + + expect(output, isEmpty); + }); }); group('selection drag', () { diff --git a/packages/flterm/test/widgets/terminal_view_test.dart b/packages/flterm/test/widgets/terminal_view_test.dart index 86a64c20..34e3f27e 100644 --- a/packages/flterm/test/widgets/terminal_view_test.dart +++ b/packages/flterm/test/widgets/terminal_view_test.dart @@ -1327,6 +1327,69 @@ void main() { fixture.terminal.scrollbackRows, ); }); + + for (final kind in [PointerDeviceKind.touch, PointerDeviceKind.stylus]) { + testWidgets('$kind tracked scroll uses its latest pointer position', ( + tester, + ) async { + final output = []; + final scrollController = TerminalScrollController(); + addTearDown(scrollController.dispose); + controller.onOutput = output.add; + await tester.pumpWidget( + wrapInApp( + controller: controller, + scrollController: scrollController, + autofocus: true, + showKeyboard: false, + gestureSettings: const TerminalGestureSettings( + touchMouseTracking: TouchMouseTracking.tapAndScroll, + ), + width: 400, + height: 320, + ), + ); + await tester.pumpAndSettle(); + writeUtf8(controller, '\x1b[?1049h\x1b[?1000h\x1b[?1016h'); + await tester.pump(); + output.clear(); + + final topLeft = tester.getTopLeft(find.byType(TerminalView)); + await tester.dragFrom( + topLeft + const Offset(120, 240), + const Offset(0, -120), + kind: kind, + ); + await tester.pump(); + + expect( + utf8.decode( + Uint8List.fromList(output.expand((bytes) => bytes).toList()), + ), + isNot(contains('\x1b[<0;')), + ); + expect(controller.hasSelection, isFalse); + output.clear(); + + scrollController.jumpTo(scrollController.offset + 160); + await tester.pump(); + + final reports = utf8 + .decode( + Uint8List.fromList(output.expand((bytes) => bytes).toList()), + ) + .split('\x1b') + .where((report) => report.startsWith('[<65;')) + .toList(); + expect(reports, isNotEmpty); + final expectedPixel = (120 * tester.view.devicePixelRatio).round(); + expect( + reports, + contains(startsWith('[<65;$expectedPixel;${expectedPixel}M')), + ); + expect(reports, everyElement(isNot(contains(';0;0M')))); + }); + } }); testWidgets('selectAll via controller updates view', (tester) async {