Bladeren bron

Supports scrolling in alt buffer #139

xuty 3 jaren geleden
bovenliggende
commit
b9ef30f563

+ 17 - 0
lib/src/terminal_view.dart

@@ -14,6 +14,7 @@ import 'package:xterm/src/ui/input_map.dart';
 import 'package:xterm/src/ui/keyboard_listener.dart';
 import 'package:xterm/src/ui/keyboard_visibility.dart';
 import 'package:xterm/src/ui/render.dart';
+import 'package:xterm/src/ui/scroll_handler.dart';
 import 'package:xterm/src/ui/shortcut/actions.dart';
 import 'package:xterm/src/ui/shortcut/shortcuts.dart';
 import 'package:xterm/src/ui/terminal_text_style.dart';
@@ -46,6 +47,7 @@ class TerminalView extends StatefulWidget {
     this.shortcuts,
     this.readOnly = false,
     this.hardwareKeyboardOnly = false,
+    this.simulateScroll = true,
   }) : super(key: key);
 
   /// The underlying terminal that this widget renders.
@@ -131,6 +133,13 @@ class TerminalView extends StatefulWidget {
   /// also prevent any on-screen keyboard to be shown.
   final bool hardwareKeyboardOnly;
 
+  /// If true, when the terminal is in alternate buffer (for example running
+  /// vim, man, etc), if the application does not declare that it can handle
+  /// scrolling, the terminal will simulate scrolling by sending up/down arrow
+  /// keys to the application. This is standard behavior for most terminal
+  /// emulators. True by default.
+  final bool simulateScroll;
+
   @override
   State<TerminalView> createState() => TerminalViewState();
 }
@@ -231,6 +240,14 @@ class TerminalViewState extends State<TerminalView> {
       },
     );
 
+    child = TerminalScrollGestureHandler(
+      terminal: widget.terminal,
+      simulateScroll: widget.simulateScroll,
+      getCellOffset: (offset) => renderTerminal.getCellOffset(offset),
+      getLineHeight: () => renderTerminal.lineHeight,
+      child: child,
+    );
+
     if (!widget.hardwareKeyboardOnly) {
       child = CustomTextEdit(
         key: _customTextEditKey,

+ 115 - 0
lib/src/ui/infinite_scroll_view.dart

@@ -0,0 +1,115 @@
+import 'package:flutter/rendering.dart';
+import 'package:flutter/widgets.dart';
+
+/// The function called when the user scrolls the [InfiniteScrollView]. [offset]
+/// is the current offset of the scroll view, ranging from [double.negativeInfinity]
+/// to [double.infinity].
+typedef ScrollCallback = void Function(double offset);
+
+/// A [Scrollable] that can be scrolled infinitely in both directions. When
+/// scroll happens, the [onScroll] callback is called with the new offset.
+class InfiniteScrollView extends StatelessWidget {
+  const InfiniteScrollView({
+    super.key,
+    required this.onScroll,
+    required this.child,
+  });
+
+  final ScrollCallback onScroll;
+
+  final Widget child;
+
+  @override
+  Widget build(BuildContext context) {
+    return Scrollable(
+      viewportBuilder: (context, position) {
+        return _InfiniteScrollView(
+          position: position,
+          onScroll: onScroll,
+          child: child,
+        );
+      },
+    );
+  }
+}
+
+class _InfiniteScrollView extends SingleChildRenderObjectWidget {
+  const _InfiniteScrollView({
+    // super.key,
+    super.child,
+    required this.position,
+    required this.onScroll,
+  });
+
+  final ViewportOffset position;
+
+  final ScrollCallback onScroll;
+
+  @override
+  RenderObject createRenderObject(BuildContext context) {
+    return _RenderInfiniteScrollView(
+      position: position,
+      onScroll: onScroll,
+    );
+  }
+
+  @override
+  void updateRenderObject(
+    BuildContext context,
+    _RenderInfiniteScrollView renderObject,
+  ) {
+    renderObject
+      ..position = position
+      ..onScroll = onScroll;
+  }
+}
+
+class _RenderInfiniteScrollView extends RenderShiftedBox {
+  _RenderInfiniteScrollView({
+    RenderBox? child,
+    required ViewportOffset position,
+    required ScrollCallback onScroll,
+  })  : _position = position,
+        _scrollCallback = onScroll,
+        super(child);
+
+  ViewportOffset _position;
+  set position(ViewportOffset value) {
+    if (_position == value) return;
+    if (attached) _position.removeListener(markNeedsLayout);
+    _position = value;
+    if (attached) _position.addListener(markNeedsLayout);
+    markNeedsLayout();
+  }
+
+  ScrollCallback _scrollCallback;
+  set onScroll(ScrollCallback value) {
+    if (_scrollCallback == value) return;
+    _scrollCallback = value;
+    markNeedsLayout();
+  }
+
+  void _onScroll() {
+    _scrollCallback(_position.pixels);
+  }
+
+  @override
+  void attach(covariant PipelineOwner owner) {
+    super.attach(owner);
+    _position.addListener(_onScroll);
+  }
+
+  @override
+  void detach() {
+    super.detach();
+    _position.removeListener(_onScroll);
+  }
+
+  @override
+  void performLayout() {
+    child?.layout(constraints, parentUsesSize: true);
+    size = child?.size ?? Size.zero;
+    _position.applyViewportDimension(size.height);
+    _position.applyContentDimensions(double.negativeInfinity, double.infinity);
+  }
+}

+ 4 - 0
lib/src/ui/render.dart

@@ -261,6 +261,10 @@ class RenderTerminal extends RenderBox with RelayoutWhenSystemFontsChangeMixin {
     return _offset.pixels;
   }
 
+  /// The height of a terminal line in pixels. This includes the line spacing.
+  /// Height of the entire terminal is expected to be a multiple of this value.
+  double get lineHeight => _charSize.height;
+
   /// Get the top-left corner of the cell at [cellOffset] in pixels.
   Offset getOffset(CellOffset cellOffset) {
     final row = cellOffset.y;

+ 133 - 0
lib/src/ui/scroll_handler.dart

@@ -0,0 +1,133 @@
+import 'package:flutter/widgets.dart';
+import 'package:xterm/core.dart';
+import 'package:xterm/src/ui/infinite_scroll_view.dart';
+
+/// Handles scrolling gestures in the alternate screen buffer. In alternate
+/// screen buffer, the terminal don't have a scrollback buffer, instead, the
+/// scroll gestures are converted to escape sequences based on the current
+/// report mode declared by the application.
+class TerminalScrollGestureHandler extends StatefulWidget {
+  const TerminalScrollGestureHandler({
+    super.key,
+    required this.terminal,
+    required this.getCellOffset,
+    required this.getLineHeight,
+    this.simulateScroll = true,
+    required this.child,
+  });
+
+  final Terminal terminal;
+
+  /// Returns the cell offset for the pixel offset.
+  final CellOffset Function(Offset) getCellOffset;
+
+  /// Returns the pixel height of lines in the terminal.
+  final double Function() getLineHeight;
+
+  /// Whether to simulate scroll events in the terminal when the application
+  /// doesn't declare it supports mouse wheel events. true by default as it
+  /// is the default behavior of most terminals.
+  final bool simulateScroll;
+
+  final Widget child;
+
+  @override
+  State<TerminalScrollGestureHandler> createState() =>
+      _TerminalScrollGestureHandlerState();
+}
+
+class _TerminalScrollGestureHandlerState
+    extends State<TerminalScrollGestureHandler> {
+  /// Whether the application is in alternate screen buffer. If false, then this
+  /// widget does nothing.
+  var isAltBuffer = false;
+
+  /// The variable that tracks the line offset in last scroll event. Used to
+  /// determine how many the scroll events should be sent to the terminal.
+  var lastLineOffset = 0;
+
+  /// This variable tracks the last offset where the scroll gesture started.
+  /// Used to calculate the cell offset of the terminal mouse event.
+  var lastPointerPosition = Offset.zero;
+
+  @override
+  void initState() {
+    widget.terminal.addListener(_onTerminalUpdated);
+    isAltBuffer = widget.terminal.isUsingAltBuffer;
+    super.initState();
+  }
+
+  @override
+  void dispose() {
+    widget.terminal.removeListener(_onTerminalUpdated);
+    super.dispose();
+  }
+
+  @override
+  void didUpdateWidget(covariant TerminalScrollGestureHandler oldWidget) {
+    if (oldWidget.terminal != widget.terminal) {
+      oldWidget.terminal.removeListener(_onTerminalUpdated);
+      widget.terminal.addListener(_onTerminalUpdated);
+      isAltBuffer = widget.terminal.isUsingAltBuffer;
+    }
+    super.didUpdateWidget(oldWidget);
+  }
+
+  void _onTerminalUpdated() {
+    if (isAltBuffer != widget.terminal.isUsingAltBuffer) {
+      isAltBuffer = widget.terminal.isUsingAltBuffer;
+      setState(() {});
+    }
+  }
+
+  /// Send a single scroll event to the terminal. If [simulateScroll] is true,
+  /// then if the application doesn't recognize mouse wheel events, this method
+  /// will simulate scroll events by sending up/down arrow keys.
+  void _sendScrollEvent(bool up) {
+    final position = widget.getCellOffset(lastPointerPosition);
+
+    final handled = widget.terminal.mouseInput(
+      up ? TerminalMouseButton.wheelUp : TerminalMouseButton.wheelDown,
+      TerminalMouseButtonState.down,
+      position,
+    );
+
+    if (!handled && widget.simulateScroll) {
+      widget.terminal.keyInput(
+        up ? TerminalKey.arrowUp : TerminalKey.arrowDown,
+      );
+    }
+  }
+
+  void _onScroll(double offset) {
+    final currentLineOffset = offset ~/ widget.getLineHeight();
+
+    final delta = currentLineOffset - lastLineOffset;
+
+    for (var i = 0; i < delta.abs(); i++) {
+      _sendScrollEvent(delta < 0);
+    }
+
+    lastLineOffset = currentLineOffset;
+  }
+
+  @override
+  Widget build(BuildContext context) {
+    if (!isAltBuffer) {
+      return widget.child;
+    }
+
+    return Listener(
+      onPointerSignal: (event) {
+        lastPointerPosition = event.position;
+      },
+      onPointerDown: (event) {
+        lastPointerPosition = event.position;
+      },
+      child: InfiniteScrollView(
+        onScroll: _onScroll,
+        child: widget.child,
+      ),
+    );
+  }
+}

+ 30 - 0
test/src/terminal_view_test.dart

@@ -419,4 +419,34 @@ void main() {
       expect(terminalOutput.join(), 'AAA');
     });
   });
+
+  group('TerminalView.simulateScroll', () {
+    testWidgets('works', (tester) async {
+      final terminalOutput = <String>[];
+      final terminal = Terminal(onOutput: terminalOutput.add);
+      terminal.useAltBuffer();
+
+      await tester.pumpWidget(MaterialApp(
+        home: TerminalView(terminal, autofocus: true, simulateScroll: true),
+      ));
+
+      await tester.drag(find.byType(TerminalView), const Offset(0, -100));
+
+      expect(terminalOutput.join(), contains('\x1B[B'));
+    });
+
+    testWidgets('does nothing when disabled', (tester) async {
+      final terminalOutput = <String>[];
+      final terminal = Terminal(onOutput: terminalOutput.add);
+      terminal.useAltBuffer();
+
+      await tester.pumpWidget(MaterialApp(
+        home: TerminalView(terminal, autofocus: true, simulateScroll: false),
+      ));
+
+      await tester.drag(find.byType(TerminalView), const Offset(0, -100));
+
+      expect(terminalOutput.join(), isEmpty);
+    });
+  });
 }