Explorar o código

Merge branch 'master' into pr/tauu/130

xuty %!s(int64=3) %!d(string=hai) anos
pai
achega
295bbd35c7

+ 6 - 3
.github/workflows/ci.yml

@@ -28,8 +28,11 @@ jobs:
         run: flutter format --set-exit-if-changed .
 
       # Consider passing '--fatal-infos' for slightly stricter analysis.
-      # - name: Analyze project source
-      #   run: flutter analyze
+      - name: Analyze project source
+        run: flutter analyze --fatal-infos
 
       - name: Run tests
-        run: flutter test
+        run: flutter test --coverage
+
+      - name: Upload coverage
+        uses: codecov/codecov-action@v3

+ 3 - 1
.gitignore

@@ -75,4 +75,6 @@ build/
 !/packages/flutter_tools/test/data/dart_dependencies_test/**/.packages
 
 .vscode/
-example/lib/debug.dart
+example/lib/debug.dart
+
+coverage/

+ 1 - 1
example/lib/src/platform_menu.dart

@@ -143,7 +143,7 @@ class _AppPlatformMenuState extends State<AppPlatformMenu> {
           ],
         ),
       ],
-      body: widget.child,
+      child: widget.child,
     );
   }
 }

+ 1 - 1
example/macos/Podfile.lock

@@ -21,7 +21,7 @@ EXTERNAL SOURCES:
 SPEC CHECKSUMS:
   flutter_acrylic: c3df24ae52ab6597197837ce59ef2a8542640c17
   flutter_pty: 41b6f848ade294be726a6b94cdd4a67c3bc52f59
-  FlutterMacOS: 57701585bf7de1b3fc2bb61f6378d73bbdea8424
+  FlutterMacOS: ae6af50a8ea7d6103d888583d46bd8328a7e9811
 
 PODFILE CHECKSUM: 6eac6b3292e5142cfc23bdeb71848a40ec51c14c
 

+ 14 - 21
example/pubspec.lock

@@ -56,7 +56,7 @@ packages:
       name: async
       url: "https://pub.dartlang.org"
     source: hosted
-    version: "2.8.2"
+    version: "2.9.0"
   boolean_selector:
     dependency: transitive
     description:
@@ -70,21 +70,14 @@ packages:
       name: characters
       url: "https://pub.dartlang.org"
     source: hosted
-    version: "1.2.0"
-  charcode:
-    dependency: transitive
-    description:
-      name: charcode
-      url: "https://pub.dartlang.org"
-    source: hosted
-    version: "1.3.1"
+    version: "1.2.1"
   clock:
     dependency: transitive
     description:
       name: clock
       url: "https://pub.dartlang.org"
     source: hosted
-    version: "1.1.0"
+    version: "1.1.1"
   collection:
     dependency: transitive
     description:
@@ -161,7 +154,7 @@ packages:
       name: fake_async
       url: "https://pub.dartlang.org"
     source: hosted
-    version: "1.3.0"
+    version: "1.3.1"
   ffi:
     dependency: transitive
     description:
@@ -194,7 +187,7 @@ packages:
       name: flutter_pty
       url: "https://pub.dartlang.org"
     source: hosted
-    version: "0.1.1"
+    version: "0.3.0"
   flutter_test:
     dependency: "direct dev"
     description: flutter
@@ -234,21 +227,21 @@ packages:
       name: matcher
       url: "https://pub.dartlang.org"
     source: hosted
-    version: "0.12.11"
+    version: "0.12.12"
   material_color_utilities:
     dependency: transitive
     description:
       name: material_color_utilities
       url: "https://pub.dartlang.org"
     source: hosted
-    version: "0.1.4"
+    version: "0.1.5"
   meta:
     dependency: transitive
     description:
       name: meta
       url: "https://pub.dartlang.org"
     source: hosted
-    version: "1.7.0"
+    version: "1.8.0"
   package_config:
     dependency: transitive
     description:
@@ -262,7 +255,7 @@ packages:
       name: path
       url: "https://pub.dartlang.org"
     source: hosted
-    version: "1.8.1"
+    version: "1.8.2"
   petitparser:
     dependency: transitive
     description:
@@ -316,7 +309,7 @@ packages:
       name: source_span
       url: "https://pub.dartlang.org"
     source: hosted
-    version: "1.8.2"
+    version: "1.9.0"
   stack_trace:
     dependency: transitive
     description:
@@ -337,21 +330,21 @@ packages:
       name: string_scanner
       url: "https://pub.dartlang.org"
     source: hosted
-    version: "1.1.0"
+    version: "1.1.1"
   term_glyph:
     dependency: transitive
     description:
       name: term_glyph
       url: "https://pub.dartlang.org"
     source: hosted
-    version: "1.2.0"
+    version: "1.2.1"
   test_api:
     dependency: transitive
     description:
       name: test_api
       url: "https://pub.dartlang.org"
     source: hosted
-    version: "0.4.9"
+    version: "0.4.12"
   typed_data:
     dependency: transitive
     description:
@@ -393,7 +386,7 @@ packages:
       path: ".."
       relative: true
     source: path
-    version: "3.2.4-alpha"
+    version: "3.2.7"
   yaml:
     dependency: transitive
     description:

+ 1 - 1
example/pubspec.yaml

@@ -26,7 +26,7 @@ dependencies:
 
   dartssh2: ^2.5.0
 
-  flutter_pty: ^0.1.1
+  flutter_pty: ^0.3.0
 
   flutter_acrylic: ^1.0.0+2
 

+ 2 - 0
lib/core.dart

@@ -3,6 +3,8 @@ export 'src/core/buffer/cell_flags.dart';
 export 'src/core/buffer/cell_offset.dart';
 export 'src/core/buffer/line.dart';
 export 'src/core/buffer/range.dart';
+export 'src/core/buffer/range_block.dart';
+export 'src/core/buffer/range_line.dart';
 export 'src/core/buffer/segment.dart';
 export 'src/core/cell.dart';
 export 'src/core/color.dart';

+ 12 - 17
lib/src/core/buffer/buffer.dart

@@ -2,6 +2,7 @@ import 'dart:math' show max, min;
 
 import 'package:xterm/src/core/buffer/cell_offset.dart';
 import 'package:xterm/src/core/buffer/line.dart';
+import 'package:xterm/src/core/buffer/range_line.dart';
 import 'package:xterm/src/core/buffer/range.dart';
 import 'package:xterm/src/core/charset.dart';
 import 'package:xterm/src/core/cursor.dart';
@@ -464,7 +465,7 @@ class Buffer {
     r'\'.codeUnitAt(0),
   };
 
-  BufferRange? getWordBoundary(CellOffset position) {
+  BufferRangeLine? getWordBoundary(CellOffset position) {
     if (position.y >= lines.length) {
       return null;
     }
@@ -499,7 +500,7 @@ class Buffer {
       return null;
     }
 
-    return BufferRange(
+    return BufferRangeLine(
       CellOffset(start, position.y),
       CellOffset(end, position.y),
     );
@@ -508,7 +509,7 @@ class Buffer {
   /// Get the plain text content of the buffer including the scrollback.
   /// Accepts an optional [range] to get a specific part of the buffer.
   String getText([BufferRange? range]) {
-    range ??= BufferRange(
+    range ??= BufferRangeLine(
       CellOffset(0, 0),
       CellOffset(viewWidth - 1, height - 1),
     );
@@ -517,23 +518,17 @@ class Buffer {
 
     final builder = StringBuffer();
 
-    final firstLine = range.begin.y.clamp(0, height - 1);
-    final lastLine = range.end.y.clamp(firstLine, height - 1);
-
-    for (var i = firstLine; i <= lastLine; i++) {
-      if (i < 0 || i >= lines.length) {
+    for (var segment in range.toSegments()) {
+      if (segment.line < 0 || segment.line >= height) {
         continue;
       }
-
-      final line = lines[i];
-      final start = i == firstLine ? range.begin.x : 0;
-      final end = i == lastLine ? range.end.x : line.length;
-
-      if (!(i == firstLine || line.isWrapped)) {
-        builder.write('\n');
+      final line = lines[segment.line];
+      if (!(segment.line == range.begin.y ||
+          segment.line == 0 ||
+          line.isWrapped)) {
+        builder.write("\n");
       }
-
-      builder.write(line.getText(start, end));
+      builder.write(line.getText(segment.start, segment.end));
     }
 
     return builder.toString();

+ 1 - 1
lib/src/core/buffer/cell_offset.dart

@@ -36,7 +36,7 @@ class CellOffset {
   }
 
   bool isWithin(BufferRange range) {
-    return range.begin.isBeforeOrSame(this) && range.end.isAfterOrSame(this);
+    return range.contains(this);
   }
 
   @override

+ 13 - 37
lib/src/core/buffer/range.dart

@@ -1,12 +1,12 @@
 import 'package:xterm/src/core/buffer/cell_offset.dart';
 import 'package:xterm/src/core/buffer/segment.dart';
 
-class BufferRange {
+abstract class BufferRange {
   final CellOffset begin;
 
   final CellOffset end;
 
-  BufferRange(this.begin, this.end);
+  const BufferRange(this.begin, this.end);
 
   BufferRange.collapsed(this.begin) : end = begin;
 
@@ -18,45 +18,21 @@ class BufferRange {
     return begin.isEqual(end);
   }
 
-  BufferRange get normalized {
-    if (isNormalized) {
-      return this;
-    } else {
-      return BufferRange(end, begin);
-    }
-  }
-
-  Iterable<BufferSegment> toSegments() sync* {
-    var begin = this.begin;
-    var end = this.end;
-
-    if (!isNormalized) {
-      end = this.begin;
-      begin = this.end;
-    }
+  BufferRange get normalized;
 
-    for (var i = begin.y; i <= end.y; i++) {
-      var startX = i == begin.y ? begin.x : null;
-      var endX = i == end.y ? end.x : null;
-      yield BufferSegment(this, i, startX, endX);
-    }
-  }
+  /// Convert this range to segments of single lines.
+  Iterable<BufferSegment> toSegments();
 
-  bool contains(CellOffset position) {
-    return begin.isBeforeOrSame(position) && end.isAfterOrSame(position);
-  }
+  /// Returns true if the given[position] is within this range.
+  bool contains(CellOffset position);
 
-  BufferRange merge(BufferRange range) {
-    final begin = this.begin.isBefore(range.begin) ? this.begin : range.begin;
-    final end = this.end.isAfter(range.end) ? this.end : range.end;
-    return BufferRange(begin, end);
-  }
+  /// Returns the smallest range that contains both this range and the given
+  /// [range].
+  BufferRange merge(BufferRange range);
 
-  BufferRange extend(CellOffset position) {
-    final begin = this.begin.isBefore(position) ? position : this.begin;
-    final end = this.end.isAfter(position) ? position : this.end;
-    return BufferRange(begin, end);
-  }
+  /// Returns the smallest range that contains both this range and the given
+  /// [position].
+  BufferRange extend(CellOffset position);
 
   @override
   operator ==(Object other) {

+ 111 - 0
lib/src/core/buffer/range_block.dart

@@ -0,0 +1,111 @@
+import 'dart:math';
+
+import 'package:xterm/src/core/buffer/cell_offset.dart';
+import 'package:xterm/src/core/buffer/range.dart';
+import 'package:xterm/src/core/buffer/segment.dart';
+
+class BufferRangeBlock extends BufferRange {
+  BufferRangeBlock(super.begin, super.end);
+
+  BufferRangeBlock.collapsed(CellOffset begin) : super.collapsed(begin);
+
+  @override
+  bool get isNormalized {
+    // A block range is normalized if begin is the top left corner of the range
+    // and end the bottom right corner.
+    return (begin.isBefore(end) && begin.x <= end.x) || begin.isEqual(end);
+  }
+
+  @override
+  BufferRangeBlock get normalized {
+    if (isNormalized) {
+      return this;
+    }
+    // Determine new normalized begin and end offset, such that begin is the
+    // top left corner and end is the bottom right corner of the block.
+    final normalBegin = CellOffset(min(begin.x, end.x), min(begin.y, end.y));
+    final normalEnd = CellOffset(max(begin.x, end.x), max(begin.y, end.y));
+    return BufferRangeBlock(normalBegin, normalEnd);
+  }
+
+  @override
+  Iterable<BufferSegment> toSegments() sync* {
+    var begin = this.begin;
+    var end = this.end;
+
+    if (!isNormalized) {
+      end = this.begin;
+      begin = this.end;
+    }
+
+    final startX = min(begin.x, end.x);
+    final endX = max(begin.x, end.x);
+    for (var i = begin.y; i <= end.y; i++) {
+      yield BufferSegment(this, i, startX, endX);
+    }
+  }
+
+  @override
+  bool contains(CellOffset position) {
+    var begin = this.begin;
+    var end = this.end;
+
+    if (!isNormalized) {
+      end = this.begin;
+      begin = this.end;
+    }
+    if (!(begin.y <= position.y && position.y <= end.y)) {
+      return false;
+    }
+
+    final startX = min(begin.x, end.x);
+    final endX = max(begin.x, end.x);
+    return startX <= position.x && position.x <= endX;
+  }
+
+  @override
+  BufferRangeBlock merge(BufferRange range) {
+    // Enlarge the block such that both borders of the range
+    // are within the selected block.
+    return extend(range.begin).extend(range.end);
+  }
+
+  @override
+  BufferRangeBlock extend(CellOffset position) {
+    // If the position is within the block, there is nothing to do.
+    if (contains(position)) {
+      return this;
+    }
+    // Otherwise normalize the block and push the borders outside up to
+    // the position to which the block has to extended.
+    final normal = normalized;
+    final extendBegin = CellOffset(
+      min(normal.begin.x, position.x),
+      min(normal.begin.y, position.y),
+    );
+    final extendEnd = CellOffset(
+      max(normal.end.x, position.x),
+      max(normal.end.y, position.y),
+    );
+    return BufferRangeBlock(extendBegin, extendEnd);
+  }
+
+  @override
+  operator ==(Object other) {
+    if (identical(this, other)) {
+      return true;
+    }
+
+    if (other is! BufferRangeBlock) {
+      return false;
+    }
+
+    return begin == other.begin && end == other.end;
+  }
+
+  @override
+  int get hashCode => begin.hashCode ^ end.hashCode;
+
+  @override
+  String toString() => 'Block Range($begin, $end)';
+}

+ 66 - 0
lib/src/core/buffer/range_line.dart

@@ -0,0 +1,66 @@
+import 'package:xterm/src/core/buffer/cell_offset.dart';
+import 'package:xterm/src/core/buffer/range.dart';
+import 'package:xterm/src/core/buffer/segment.dart';
+
+class BufferRangeLine extends BufferRange {
+  BufferRangeLine(super.begin, super.end);
+
+  BufferRangeLine.collapsed(CellOffset begin) : super.collapsed(begin);
+
+  @override
+  BufferRangeLine get normalized {
+    return isNormalized ? this : BufferRangeLine(end, begin);
+  }
+
+  @override
+  Iterable<BufferSegment> toSegments() sync* {
+    final self = normalized;
+    for (var i = self.begin.y; i <= self.end.y; i++) {
+      var startX = i == self.begin.y ? self.begin.x : null;
+      var endX = i == self.end.y ? self.end.x : null;
+      yield BufferSegment(this, i, startX, endX);
+    }
+  }
+
+  @override
+  bool contains(CellOffset position) {
+    final self = normalized;
+    return self.begin.isBeforeOrSame(position) &&
+        self.end.isAfterOrSame(position);
+  }
+
+  @override
+  BufferRangeLine merge(BufferRange range) {
+    final self = normalized;
+    final begin = self.begin.isBefore(range.begin) ? self.begin : range.begin;
+    final end = self.end.isAfter(range.end) ? self.end : range.end;
+    return BufferRangeLine(begin, end);
+  }
+
+  @override
+  BufferRangeLine extend(CellOffset position) {
+    final self = normalized;
+    final begin = self.begin.isAfter(position) ? position : self.begin;
+    final end = self.end.isBefore(position) ? position : self.end;
+    return BufferRangeLine(begin, end);
+  }
+
+  @override
+  operator ==(Object other) {
+    if (identical(this, other)) {
+      return true;
+    }
+
+    if (other is! BufferRangeLine) {
+      return false;
+    }
+
+    return begin == other.begin && end == other.end;
+  }
+
+  @override
+  int get hashCode => begin.hashCode ^ end.hashCode;
+
+  @override
+  String toString() => 'Line Range($begin, $end)';
+}

+ 10 - 4
lib/src/core/buffer/segment.dart

@@ -8,13 +8,15 @@ class BufferSegment {
   /// The line that this segment resides on.
   final int line;
 
-  /// The start position of this segment.
+  /// The start position of this segment. [null] means the start of the line.
   final int? start;
 
-  /// The end position of this segment. [null] if this segment is not closed.
+  /// The end position of this segment. [null] means the end of the line.
+  /// Should be greater than or equal to [start].
   final int? end;
 
-  const BufferSegment(this.range, this.line, this.start, this.end);
+  const BufferSegment(this.range, this.line, this.start, this.end)
+      : assert((start != null && end != null) ? start <= end : true);
 
   bool isWithin(CellOffset position) {
     if (position.y != line) {
@@ -33,7 +35,11 @@ class BufferSegment {
   }
 
   @override
-  String toString() => 'Segment($line, $start, $end)';
+  String toString() {
+    final start = this.start != null ? this.start.toString() : 'start';
+    final end = this.end != null ? this.end.toString() : 'end';
+    return 'Segment($line, $start -> $end)';
+  }
 
   @override
   int get hashCode =>

+ 4 - 0
lib/src/core/escape/emitter.dart

@@ -26,4 +26,8 @@ class EscapeEmitter {
   String bracketedPaste(String text) {
     return '\x1b[200~$text\x1b[201~';
   }
+
+  String size(int rows, int cols) {
+    return '\x1b[8;$rows;${cols}t';
+  }
 }

+ 4 - 0
lib/src/core/escape/handler.dart

@@ -147,6 +147,10 @@ abstract class EscapeHandler {
 
   void setUnknownDecMode(int mode, bool enabled);
 
+  void resize(int cols, int rows);
+
+  void sendSize();
+
   /* Select Graphic Rendition (SGR) */
 
   void resetCursorStyle();

+ 50 - 1
lib/src/core/escape/parser.dart

@@ -655,7 +655,56 @@ class EscapeParser {
   ///
   /// https://terminalguide.namepad.de/seq/csi_st/
   void _csiWindowManipulation() {
-    // Not supported.
+    // The sequence needs at least one parameter.
+    if (_csi.params.isEmpty) {
+      return;
+    }
+    // Most the commands in this group are either of the scope of this package,
+    // or should be disabled for security risks.
+    switch (_csi.params.first) {
+      // Window handling is currently not in the scope of the package.
+      case 1: // Restore Terminal Window (show window if minimized)
+      case 2: // Minimize Terminal Window
+      case 3: // Set Terminal Window Position
+      case 4: // Set Terminal Window Size in Pixels
+      case 5: // Raise Terminal Window
+      case 6: // Lower Terminal Window
+      case 7: // Refresh/Redraw Terminal Window
+        return;
+      case 8: // Set Terminal Window Size (in characters)
+        // This CSI contains 2 more parameters: width and height.
+        if (_csi.params.length != 3) {
+          return;
+        }
+        final rows = _csi.params[1];
+        final cols = _csi.params[2];
+        handler.resize(cols, rows);
+        return;
+      // Window handling is currently no in the scope of the package.
+      case 9: // Maximize Terminal Window
+      case 10: // Alias: Maximize Terminal Window
+      case 11: // Report Terminal Window State
+      case 13: // Report Terminal Window Position
+      case 14: // Report Terminal Window Size in Pixels
+      case 15: // Report Screen Size in Pixels
+      case 16: // Report Cell Size in Pixels
+        return;
+      case 18: // Report Terminal Size (in characters)
+        handler.sendSize();
+        return;
+      // Screen handling is currently no in the scope of the package.
+      case 19: // Report Screen Size (in characters)
+      // Disabled as these can a security risk.
+      case 20: // Get Icon Title
+      case 21: // Get Terminal Title
+      // Not implemented.
+      case 22: // Push Terminal Title
+      case 23: // Pop Terminal Title
+        return;
+      // Unknown CSI.
+      default:
+        return;
+    }
   }
 
   /// `ESC [ Ps A` Cursor Up (CUU)

+ 6 - 0
lib/src/terminal.dart

@@ -265,6 +265,7 @@ class Terminal with Observable implements TerminalState, EscapeHandler {
   /// Resize the terminal screen. [newWidth] and [newHeight] should be greater
   /// than 0. Text reflow is currently not implemented and will be avaliable in
   /// the future.
+  @override
   void resize(
     int newWidth,
     int newHeight, [
@@ -556,6 +557,11 @@ class Terminal with Observable implements TerminalState, EscapeHandler {
     _buffer.insertBlankChars(amount);
   }
 
+  @override
+  void sendSize() {
+    onOutput?.call(_emitter.size(viewHeight, viewWidth));
+  }
+
   @override
   void unknownCSI(int finalByte) {
     // no-op

+ 62 - 11
lib/src/ui/controller.dart

@@ -1,13 +1,32 @@
 import 'package:flutter/material.dart';
 import 'package:meta/meta.dart';
+import 'package:xterm/src/core/buffer/cell_offset.dart';
 import 'package:xterm/src/core/buffer/range.dart';
+import 'package:xterm/src/core/buffer/range_block.dart';
+import 'package:xterm/src/core/buffer/range_line.dart';
 import 'package:xterm/src/ui/pointer_input.dart';
+import 'package:xterm/src/ui/selection_mode.dart';
 
 class TerminalController with ChangeNotifier {
+  TerminalController({
+    SelectionMode selectionMode = SelectionMode.line,
+    PointerInputs pointerInputs = const PointerInputs.none(),
+    bool suspendPointerInput = false,
+  })  : _selectionMode = selectionMode,
+        _pointerInputs = pointerInputs,
+        _suspendPointerInputs = suspendPointerInput;
+
   BufferRange? _selection;
 
   BufferRange? get selection => _selection;
 
+  SelectionMode _selectionMode;
+
+  SelectionMode get selectionMode => _selectionMode;
+
+  /// Set selection on the terminal to [range]. For now [range] could be either
+  /// a [BufferRangeLine] or a [BufferRangeBlock]. This is not effected by
+  /// [selectionMode].
   PointerInputs _pointerInputs;
 
   /// The set of pointer events which will be used as mouse input for the terminal.
@@ -18,12 +37,6 @@ class TerminalController with ChangeNotifier {
   /// True if sending pointer events to the terminal is suspended.
   bool get suspendedPointerInputs => _suspendPointerInputs;
 
-  TerminalController({
-    PointerInputs pointerInputs = const PointerInputs.none(),
-    bool suspendPointerInput = false,
-  })  : _pointerInputs = pointerInputs,
-        _suspendPointerInputs = suspendPointerInput;
-
   void setSelection(BufferRange? range) {
     range = range?.normalized;
 
@@ -33,6 +46,49 @@ class TerminalController with ChangeNotifier {
     }
   }
 
+  /// Set selection on the terminal to the minimum range that contains both
+  /// [begin] and [end]. The type of range is determined by [selectionMode].
+  void setSelectionRange(CellOffset begin, CellOffset end) {
+    final range = _modeRange(begin, end);
+    setSelection(range);
+  }
+
+  BufferRange _modeRange(CellOffset begin, CellOffset end) {
+    switch (selectionMode) {
+      case SelectionMode.line:
+        return BufferRangeLine(begin, end);
+      case SelectionMode.block:
+        return BufferRangeBlock(begin, end);
+    }
+  }
+
+  /// Controls how the terminal behaves when the user selects a range of text.
+  /// The default is [SelectionMode.line]. Setting this to [SelectionMode.block]
+  /// enables block selection mode.
+  void setSelectionMode(SelectionMode newSelectionMode) {
+    // If the new mode is the same as the old mode,
+    // nothing has to be changed.
+    if (_selectionMode == newSelectionMode) {
+      return;
+    }
+    // Set the new mode.
+    _selectionMode = newSelectionMode;
+    // Check if an active selection exists.
+    final selection = _selection;
+    if (selection == null) {
+      notifyListeners();
+      return;
+    }
+    // Convert the selection into a selection corresponding to the new mode.
+    setSelection(_modeRange(selection.begin, selection.end));
+  }
+
+  /// Clears the current selection.
+  void clearSelection() {
+    _selection = null;
+    notifyListeners();
+  }
+
   // Select which type of pointer events are send to the terminal.
   void setPointerInputs(PointerInputs pointerInput) {
     _pointerInputs = pointerInput;
@@ -54,11 +110,6 @@ class TerminalController with ChangeNotifier {
         : _pointerInputs.inputs.contains(pointerInput);
   }
 
-  void clearSelection() {
-    _selection = null;
-    notifyListeners();
-  }
-
   void addHighlight(BufferRange? range) {
     // TODO: implement addHighlight
   }

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

@@ -282,14 +282,18 @@ class RenderTerminal extends RenderBox with RelayoutWhenSystemFontsChangeMixin {
     }
   }
 
-  /// Selects characters in the terminal that starts from [from] to [to].
+  /// Selects characters in the terminal that starts from [from] to [to]. At
+  /// least one cell is selected even if [from] and [to] are same.
   void selectCharacters(Offset from, [Offset? to]) {
     final fromPosition = getCellOffset(from);
     if (to == null) {
-      _controller.setSelection(BufferRange.collapsed(fromPosition));
+      _controller.setSelectionRange(fromPosition, fromPosition);
     } else {
-      final toPosition = getCellOffset(to);
-      _controller.setSelection(BufferRange(fromPosition, toPosition));
+      var toPosition = getCellOffset(to);
+      if (toPosition.x >= fromPosition.x) {
+        toPosition = CellOffset(toPosition.x + 1, toPosition.y);
+      }
+      _controller.setSelectionRange(fromPosition, toPosition);
     }
   }
 

+ 5 - 0
lib/src/ui/selection_mode.dart

@@ -0,0 +1,5 @@
+enum SelectionMode {
+  line,
+
+  block,
+}

+ 2 - 2
lib/src/ui/shortcut/actions.dart

@@ -1,7 +1,7 @@
 import 'package:flutter/services.dart';
 import 'package:flutter/widgets.dart';
 import 'package:xterm/src/core/buffer/cell_offset.dart';
-import 'package:xterm/src/core/buffer/range.dart';
+import 'package:xterm/src/core/buffer/range_line.dart';
 import 'package:xterm/src/terminal.dart';
 import 'package:xterm/src/ui/controller.dart';
 
@@ -52,7 +52,7 @@ class TerminalActions extends StatelessWidget {
         SelectAllTextIntent: CallbackAction<SelectAllTextIntent>(
           onInvoke: (intent) {
             controller.setSelection(
-              BufferRange(
+              BufferRangeLine(
                 CellOffset(0, terminal.buffer.height - terminal.viewHeight),
                 CellOffset(terminal.viewWidth, terminal.buffer.height - 1),
               ),

+ 10 - 0
lib/src/utils/debugger.dart

@@ -330,6 +330,16 @@ class _TerminalDebuggerHandler implements EscapeHandler {
     onCommand('insertBlankChars($amount)');
   }
 
+  @override
+  void resize(int cols, int rows) {
+    onCommand('resize($cols, $rows)');
+  }
+
+  @override
+  void sendSize() {
+    onCommand('sendSize');
+  }
+
   /* Modes */
 
   @override

+ 1 - 0
lib/ui.dart

@@ -3,6 +3,7 @@ export 'src/ui/controller.dart';
 export 'src/ui/cursor_type.dart';
 export 'src/ui/keyboard_visibility.dart';
 export 'src/ui/pointer_input.dart';
+export 'src/ui/selection_mode.dart';
 export 'src/ui/shortcut/shortcuts.dart';
 export 'src/ui/terminal_text_style.dart';
 export 'src/ui/terminal_theme.dart';

+ 16 - 16
pubspec.lock

@@ -42,7 +42,7 @@ packages:
       name: async
       url: "https://pub.dartlang.org"
     source: hosted
-    version: "2.8.2"
+    version: "2.9.0"
   boolean_selector:
     dependency: transitive
     description:
@@ -112,7 +112,7 @@ packages:
       name: characters
       url: "https://pub.dartlang.org"
     source: hosted
-    version: "1.2.0"
+    version: "1.2.1"
   charcode:
     dependency: transitive
     description:
@@ -133,14 +133,14 @@ packages:
       name: clock
       url: "https://pub.dartlang.org"
     source: hosted
-    version: "1.1.0"
+    version: "1.1.1"
   code_builder:
     dependency: transitive
     description:
       name: code_builder
       url: "https://pub.dartlang.org"
     source: hosted
-    version: "4.1.0"
+    version: "4.3.0"
   collection:
     dependency: transitive
     description:
@@ -203,7 +203,7 @@ packages:
       name: fake_async
       url: "https://pub.dartlang.org"
     source: hosted
-    version: "1.3.0"
+    version: "1.3.1"
   file:
     dependency: transitive
     description:
@@ -311,21 +311,21 @@ packages:
       name: matcher
       url: "https://pub.dartlang.org"
     source: hosted
-    version: "0.12.11"
+    version: "0.12.12"
   material_color_utilities:
     dependency: transitive
     description:
       name: material_color_utilities
       url: "https://pub.dartlang.org"
     source: hosted
-    version: "0.1.4"
+    version: "0.1.5"
   meta:
     dependency: "direct main"
     description:
       name: meta
       url: "https://pub.dartlang.org"
     source: hosted
-    version: "1.7.0"
+    version: "1.8.0"
   mime:
     dependency: transitive
     description:
@@ -339,7 +339,7 @@ packages:
       name: mockito
       url: "https://pub.dartlang.org"
     source: hosted
-    version: "5.3.0"
+    version: "5.3.1"
   node_preamble:
     dependency: transitive
     description:
@@ -360,7 +360,7 @@ packages:
       name: path
       url: "https://pub.dartlang.org"
     source: hosted
-    version: "1.8.1"
+    version: "1.8.2"
   pedantic:
     dependency: transitive
     description:
@@ -470,7 +470,7 @@ packages:
       name: source_span
       url: "https://pub.dartlang.org"
     source: hosted
-    version: "1.8.2"
+    version: "1.9.0"
   stack_trace:
     dependency: transitive
     description:
@@ -498,35 +498,35 @@ packages:
       name: string_scanner
       url: "https://pub.dartlang.org"
     source: hosted
-    version: "1.1.0"
+    version: "1.1.1"
   term_glyph:
     dependency: transitive
     description:
       name: term_glyph
       url: "https://pub.dartlang.org"
     source: hosted
-    version: "1.2.0"
+    version: "1.2.1"
   test:
     dependency: "direct dev"
     description:
       name: test
       url: "https://pub.dartlang.org"
     source: hosted
-    version: "1.21.1"
+    version: "1.21.4"
   test_api:
     dependency: transitive
     description:
       name: test_api
       url: "https://pub.dartlang.org"
     source: hosted
-    version: "0.4.9"
+    version: "0.4.12"
   test_core:
     dependency: transitive
     description:
       name: test_core
       url: "https://pub.dartlang.org"
     source: hosted
-    version: "0.4.13"
+    version: "0.4.16"
   timing:
     dependency: transitive
     description:

+ 1 - 1
pubspec.yaml

@@ -22,7 +22,7 @@ dev_dependencies:
   test: ^1.6.5
   lints: ^2.0.0
   dart_code_metrics: ^4.16.0
-  mockito: ^5.0.15
+  mockito: ^5.3.1
   build_runner: ^2.1.1
 
 # For information on the generic Dart part of this file, see the

+ 17 - 3
test/src/core/buffer/buffer_test.dart

@@ -37,7 +37,7 @@ void main() {
 
       expect(
         terminal.buffer.getText(
-          BufferRange(CellOffset(-100, -100), CellOffset(100, 100)),
+          BufferRangeLine(CellOffset(-100, -100), CellOffset(100, 100)),
         ),
         startsWith('Hello World'),
       );
@@ -50,7 +50,7 @@ void main() {
 
       expect(
         terminal.buffer.getText(
-          BufferRange(CellOffset(0, 0), CellOffset(100, 100)),
+          BufferRangeLine(CellOffset(0, 0), CellOffset(100, 100)),
         ),
         startsWith('Hello World'),
       );
@@ -63,11 +63,25 @@ void main() {
 
       expect(
         terminal.buffer.getText(
-          BufferRange(CellOffset(5, 5), CellOffset(0, 0)),
+          BufferRangeLine(CellOffset(5, 5), CellOffset(0, 0)),
         ),
         startsWith('Hello World'),
       );
     });
+
+    test('can handle block range', () {
+      final terminal = Terminal();
+
+      terminal.write('Hello World\r\n');
+      terminal.write('Nice to meet you\r\n');
+
+      expect(
+        terminal.buffer.getText(
+          BufferRangeBlock(CellOffset(2, 0), CellOffset(5, 1)),
+        ),
+        startsWith('llo\nce '),
+      );
+    });
   });
 
   group('Buffer.resize()', () {

+ 107 - 0
test/src/core/buffer/range_block_test.dart

@@ -0,0 +1,107 @@
+import 'package:test/test.dart';
+import 'package:xterm/xterm.dart';
+
+void main() {
+  group('BufferLineRange', () {
+    test('toSegments() works', () {
+      final range = BufferRangeBlock(CellOffset(10, 10), CellOffset(12, 12));
+      final segments = range.toSegments().toList();
+
+      expect(segments, hasLength(3));
+
+      expect(segments[0].start, equals(10));
+      expect(segments[0].end, equals(12));
+
+      expect(segments[1].start, equals(10));
+      expect(segments[1].end, equals(12));
+
+      expect(segments[2].start, equals(10));
+      expect(segments[2].end, 12);
+    });
+
+    test('toSegments() works with reversed range', () {
+      final range = BufferRangeBlock(CellOffset(12, 12), CellOffset(10, 10));
+      final segments = range.toSegments().toList();
+
+      expect(segments, hasLength(3));
+
+      expect(segments[0].start, equals(10));
+      expect(segments[0].end, equals(12));
+
+      expect(segments[1].start, equals(10));
+      expect(segments[1].end, equals(12));
+
+      expect(segments[2].start, equals(10));
+      expect(segments[2].end, 12);
+    });
+
+    test('contains() works', () {
+      final range = BufferRangeBlock(CellOffset(10, 10), CellOffset(10, 12));
+
+      expect(range.contains(CellOffset(10, 10)), isTrue);
+      expect(range.contains(CellOffset(10, 11)), isTrue);
+      expect(range.contains(CellOffset(10, 12)), isTrue);
+
+      expect(range.contains(CellOffset(10, 9)), isFalse);
+      expect(range.contains(CellOffset(10, 13)), isFalse);
+    });
+
+    test('contains() works with reversed range', () {
+      final range = BufferRangeBlock(CellOffset(10, 12), CellOffset(10, 10));
+
+      expect(range.contains(CellOffset(10, 10)), isTrue);
+      expect(range.contains(CellOffset(10, 11)), isTrue);
+      expect(range.contains(CellOffset(10, 12)), isTrue);
+
+      expect(range.contains(CellOffset(10, 9)), isFalse);
+      expect(range.contains(CellOffset(10, 13)), isFalse);
+    });
+
+    test('merge() works', () {
+      final range1 = BufferRangeBlock(CellOffset(10, 10), CellOffset(10, 12));
+      final range2 = BufferRangeBlock(CellOffset(10, 13), CellOffset(10, 15));
+
+      final merged = range1.merge(range2);
+
+      expect(merged.begin, equals(CellOffset(10, 10)));
+      expect(merged.end, equals(CellOffset(10, 15)));
+    });
+
+    test('merge() works with reversed range', () {
+      final range1 = BufferRangeBlock(CellOffset(10, 12), CellOffset(10, 10));
+      final range2 = BufferRangeBlock(CellOffset(10, 13), CellOffset(10, 15));
+
+      final merged = range1.merge(range2);
+
+      expect(merged.begin, equals(CellOffset(10, 10)));
+      expect(merged.end, equals(CellOffset(10, 15)));
+    });
+
+    test('extend() works', () {
+      final range = BufferRangeBlock(CellOffset(10, 10), CellOffset(10, 12));
+
+      final extended = range.extend(CellOffset(10, 13));
+
+      expect(extended.begin, equals(CellOffset(10, 10)));
+      expect(extended.end, equals(CellOffset(10, 13)));
+    });
+
+    test('extend() works with reversed range', () {
+      final range = BufferRangeBlock(CellOffset(10, 12), CellOffset(10, 10));
+
+      final extended = range.extend(CellOffset(10, 13));
+
+      expect(extended.begin, equals(CellOffset(10, 10)));
+      expect(extended.end, equals(CellOffset(10, 13)));
+    });
+
+    test('extend() works with reversed range and reversed extend', () {
+      final range = BufferRangeBlock(CellOffset(10, 12), CellOffset(10, 10));
+
+      final extended = range.extend(CellOffset(10, 9));
+
+      expect(extended.begin, equals(CellOffset(10, 9)));
+      expect(extended.end, equals(CellOffset(10, 12)));
+    });
+  });
+}

+ 107 - 0
test/src/core/buffer/range_line_test.dart

@@ -0,0 +1,107 @@
+import 'package:test/test.dart';
+import 'package:xterm/xterm.dart';
+
+void main() {
+  group('BufferRangeLine', () {
+    test('toSegments() works', () {
+      final range = BufferRangeLine(CellOffset(10, 10), CellOffset(10, 12));
+      final segments = range.toSegments().toList();
+
+      expect(segments, hasLength(3));
+
+      expect(segments[0].start, equals(10));
+      expect(segments[0].end, null);
+
+      expect(segments[1].start, null);
+      expect(segments[1].end, null);
+
+      expect(segments[2].start, null);
+      expect(segments[2].end, 10);
+    });
+
+    test('toSegments() works with reversed range', () {
+      final range = BufferRangeLine(CellOffset(10, 12), CellOffset(10, 10));
+      final segments = range.toSegments().toList();
+
+      expect(segments, hasLength(3));
+
+      expect(segments[0].start, 10);
+      expect(segments[0].end, null);
+
+      expect(segments[1].start, null);
+      expect(segments[1].end, null);
+
+      expect(segments[2].start, null);
+      expect(segments[2].end, 10);
+    });
+
+    test('contains() works', () {
+      final range = BufferRangeLine(CellOffset(10, 10), CellOffset(10, 12));
+
+      expect(range.contains(CellOffset(10, 10)), isTrue);
+      expect(range.contains(CellOffset(10, 11)), isTrue);
+      expect(range.contains(CellOffset(10, 12)), isTrue);
+
+      expect(range.contains(CellOffset(10, 9)), isFalse);
+      expect(range.contains(CellOffset(10, 13)), isFalse);
+    });
+
+    test('contains() works with reversed range', () {
+      final range = BufferRangeLine(CellOffset(10, 12), CellOffset(10, 10));
+
+      expect(range.contains(CellOffset(10, 10)), isTrue);
+      expect(range.contains(CellOffset(10, 11)), isTrue);
+      expect(range.contains(CellOffset(10, 12)), isTrue);
+
+      expect(range.contains(CellOffset(10, 9)), isFalse);
+      expect(range.contains(CellOffset(10, 13)), isFalse);
+    });
+
+    test('merge() works', () {
+      final range1 = BufferRangeLine(CellOffset(10, 10), CellOffset(10, 12));
+      final range2 = BufferRangeLine(CellOffset(10, 13), CellOffset(10, 15));
+
+      final merged = range1.merge(range2);
+
+      expect(merged.begin, equals(CellOffset(10, 10)));
+      expect(merged.end, equals(CellOffset(10, 15)));
+    });
+
+    test('merge() works with reversed range', () {
+      final range1 = BufferRangeLine(CellOffset(10, 12), CellOffset(10, 10));
+      final range2 = BufferRangeLine(CellOffset(10, 13), CellOffset(10, 15));
+
+      final merged = range1.merge(range2);
+
+      expect(merged.begin, equals(CellOffset(10, 10)));
+      expect(merged.end, equals(CellOffset(10, 15)));
+    });
+
+    test('extend() works', () {
+      final range = BufferRangeLine(CellOffset(10, 10), CellOffset(10, 12));
+
+      final extended = range.extend(CellOffset(10, 13));
+
+      expect(extended.begin, equals(CellOffset(10, 10)));
+      expect(extended.end, equals(CellOffset(10, 13)));
+    });
+
+    test('extend() works with reversed range', () {
+      final range = BufferRangeLine(CellOffset(10, 12), CellOffset(10, 10));
+
+      final extended = range.extend(CellOffset(10, 13));
+
+      expect(extended.begin, equals(CellOffset(10, 10)));
+      expect(extended.end, equals(CellOffset(10, 13)));
+    });
+
+    test('extend() works with reversed range and reversed extend', () {
+      final range = BufferRangeLine(CellOffset(10, 12), CellOffset(10, 10));
+
+      final extended = range.extend(CellOffset(10, 9));
+
+      expect(extended.begin, equals(CellOffset(10, 9)));
+      expect(extended.end, equals(CellOffset(10, 12)));
+    });
+  });
+}

+ 32 - 0
test/src/core/buffer/segment_test.dart

@@ -0,0 +1,32 @@
+import 'package:test/test.dart';
+import 'package:xterm/xterm.dart';
+
+void main() {
+  group('BufferSegment', () {
+    test('isWithin() works', () {
+      final segments = BufferRangeLine(CellOffset(10, 10), CellOffset(10, 12))
+          .toSegments()
+          .toList();
+
+      expect(segments[0].start, equals(10));
+      expect(segments[0].end, null);
+      expect(segments[0].isWithin(CellOffset(10, 10)), isTrue);
+      expect(segments[0].isWithin(CellOffset(11, 10)), isTrue);
+      expect(segments[0].isWithin(CellOffset(100, 10)), isTrue);
+      expect(segments[0].isWithin(CellOffset(9, 10)), isFalse);
+
+      expect(segments[1].start, null);
+      expect(segments[1].end, null);
+      expect(segments[1].isWithin(CellOffset(10, 11)), isTrue);
+      expect(segments[1].isWithin(CellOffset(11, 11)), isTrue);
+      expect(segments[1].isWithin(CellOffset(100, 11)), isTrue);
+      expect(segments[1].isWithin(CellOffset(0, 11)), isTrue);
+
+      expect(segments[2].start, null);
+      expect(segments[2].end, 10);
+      expect(segments[2].isWithin(CellOffset(0, 12)), isTrue);
+      expect(segments[2].isWithin(CellOffset(10, 12)), isTrue);
+      expect(segments[2].isWithin(CellOffset(11, 12)), isFalse);
+    });
+  });
+}

+ 17 - 0
test/src/core/escape/parser_test.dart

@@ -0,0 +1,17 @@
+import 'package:mockito/annotations.dart';
+import 'package:mockito/mockito.dart';
+import 'package:test/test.dart';
+import 'package:xterm/xterm.dart';
+
+@GenerateNiceMocks([MockSpec<EscapeHandler>()])
+import 'parser_test.mocks.dart';
+
+void main() {
+  group('EscapeParser', () {
+    test('can parse window manipulation', () {
+      final parser = EscapeParser(MockEscapeHandler());
+      parser.write('\x1b[8;24;80t');
+      verify(parser.handler.resize(80, 24));
+    });
+  });
+}

+ 870 - 0
test/src/core/escape/parser_test.mocks.dart

@@ -0,0 +1,870 @@
+// Mocks generated by Mockito 5.3.1 from annotations
+// in xterm/test/src/core/escape/parser_test.dart.
+// Do not manually edit this file.
+
+// ignore_for_file: no_leading_underscores_for_library_prefixes
+import 'package:mockito/mockito.dart' as _i1;
+import 'package:xterm/src/core/escape/handler.dart' as _i2;
+import 'package:xterm/src/core/mouse/mode.dart' as _i3;
+
+// ignore_for_file: type=lint
+// ignore_for_file: avoid_redundant_argument_values
+// ignore_for_file: avoid_setters_without_getters
+// ignore_for_file: comment_references
+// ignore_for_file: implementation_imports
+// ignore_for_file: invalid_use_of_visible_for_testing_member
+// ignore_for_file: prefer_const_constructors
+// ignore_for_file: unnecessary_parenthesis
+// ignore_for_file: camel_case_types
+// ignore_for_file: subtype_of_sealed_class
+
+/// A class which mocks [EscapeHandler].
+///
+/// See the documentation for Mockito's code generation for more information.
+class MockEscapeHandler extends _i1.Mock implements _i2.EscapeHandler {
+  @override
+  void writeChar(int? char) => super.noSuchMethod(
+        Invocation.method(
+          #writeChar,
+          [char],
+        ),
+        returnValueForMissingStub: null,
+      );
+  @override
+  void bell() => super.noSuchMethod(
+        Invocation.method(
+          #bell,
+          [],
+        ),
+        returnValueForMissingStub: null,
+      );
+  @override
+  void backspaceReturn() => super.noSuchMethod(
+        Invocation.method(
+          #backspaceReturn,
+          [],
+        ),
+        returnValueForMissingStub: null,
+      );
+  @override
+  void tab() => super.noSuchMethod(
+        Invocation.method(
+          #tab,
+          [],
+        ),
+        returnValueForMissingStub: null,
+      );
+  @override
+  void lineFeed() => super.noSuchMethod(
+        Invocation.method(
+          #lineFeed,
+          [],
+        ),
+        returnValueForMissingStub: null,
+      );
+  @override
+  void carriageReturn() => super.noSuchMethod(
+        Invocation.method(
+          #carriageReturn,
+          [],
+        ),
+        returnValueForMissingStub: null,
+      );
+  @override
+  void shiftOut() => super.noSuchMethod(
+        Invocation.method(
+          #shiftOut,
+          [],
+        ),
+        returnValueForMissingStub: null,
+      );
+  @override
+  void shiftIn() => super.noSuchMethod(
+        Invocation.method(
+          #shiftIn,
+          [],
+        ),
+        returnValueForMissingStub: null,
+      );
+  @override
+  void unknownSBC(int? char) => super.noSuchMethod(
+        Invocation.method(
+          #unknownSBC,
+          [char],
+        ),
+        returnValueForMissingStub: null,
+      );
+  @override
+  void saveCursor() => super.noSuchMethod(
+        Invocation.method(
+          #saveCursor,
+          [],
+        ),
+        returnValueForMissingStub: null,
+      );
+  @override
+  void restoreCursor() => super.noSuchMethod(
+        Invocation.method(
+          #restoreCursor,
+          [],
+        ),
+        returnValueForMissingStub: null,
+      );
+  @override
+  void index() => super.noSuchMethod(
+        Invocation.method(
+          #index,
+          [],
+        ),
+        returnValueForMissingStub: null,
+      );
+  @override
+  void nextLine() => super.noSuchMethod(
+        Invocation.method(
+          #nextLine,
+          [],
+        ),
+        returnValueForMissingStub: null,
+      );
+  @override
+  void setTapStop() => super.noSuchMethod(
+        Invocation.method(
+          #setTapStop,
+          [],
+        ),
+        returnValueForMissingStub: null,
+      );
+  @override
+  void reverseIndex() => super.noSuchMethod(
+        Invocation.method(
+          #reverseIndex,
+          [],
+        ),
+        returnValueForMissingStub: null,
+      );
+  @override
+  void designateCharset(int? charset) => super.noSuchMethod(
+        Invocation.method(
+          #designateCharset,
+          [charset],
+        ),
+        returnValueForMissingStub: null,
+      );
+  @override
+  void unkownEscape(int? char) => super.noSuchMethod(
+        Invocation.method(
+          #unkownEscape,
+          [char],
+        ),
+        returnValueForMissingStub: null,
+      );
+  @override
+  void repeatPreviousCharacter(int? n) => super.noSuchMethod(
+        Invocation.method(
+          #repeatPreviousCharacter,
+          [n],
+        ),
+        returnValueForMissingStub: null,
+      );
+  @override
+  void setCursor(
+    int? x,
+    int? y,
+  ) =>
+      super.noSuchMethod(
+        Invocation.method(
+          #setCursor,
+          [
+            x,
+            y,
+          ],
+        ),
+        returnValueForMissingStub: null,
+      );
+  @override
+  void setCursorX(int? x) => super.noSuchMethod(
+        Invocation.method(
+          #setCursorX,
+          [x],
+        ),
+        returnValueForMissingStub: null,
+      );
+  @override
+  void setCursorY(int? y) => super.noSuchMethod(
+        Invocation.method(
+          #setCursorY,
+          [y],
+        ),
+        returnValueForMissingStub: null,
+      );
+  @override
+  void sendPrimaryDeviceAttributes() => super.noSuchMethod(
+        Invocation.method(
+          #sendPrimaryDeviceAttributes,
+          [],
+        ),
+        returnValueForMissingStub: null,
+      );
+  @override
+  void clearTabStopUnderCursor() => super.noSuchMethod(
+        Invocation.method(
+          #clearTabStopUnderCursor,
+          [],
+        ),
+        returnValueForMissingStub: null,
+      );
+  @override
+  void clearAllTabStops() => super.noSuchMethod(
+        Invocation.method(
+          #clearAllTabStops,
+          [],
+        ),
+        returnValueForMissingStub: null,
+      );
+  @override
+  void moveCursorX(int? offset) => super.noSuchMethod(
+        Invocation.method(
+          #moveCursorX,
+          [offset],
+        ),
+        returnValueForMissingStub: null,
+      );
+  @override
+  void moveCursorY(int? n) => super.noSuchMethod(
+        Invocation.method(
+          #moveCursorY,
+          [n],
+        ),
+        returnValueForMissingStub: null,
+      );
+  @override
+  void sendSecondaryDeviceAttributes() => super.noSuchMethod(
+        Invocation.method(
+          #sendSecondaryDeviceAttributes,
+          [],
+        ),
+        returnValueForMissingStub: null,
+      );
+  @override
+  void sendTertiaryDeviceAttributes() => super.noSuchMethod(
+        Invocation.method(
+          #sendTertiaryDeviceAttributes,
+          [],
+        ),
+        returnValueForMissingStub: null,
+      );
+  @override
+  void sendOperatingStatus() => super.noSuchMethod(
+        Invocation.method(
+          #sendOperatingStatus,
+          [],
+        ),
+        returnValueForMissingStub: null,
+      );
+  @override
+  void sendCursorPosition() => super.noSuchMethod(
+        Invocation.method(
+          #sendCursorPosition,
+          [],
+        ),
+        returnValueForMissingStub: null,
+      );
+  @override
+  void setMargins(
+    int? i, [
+    int? bottom,
+  ]) =>
+      super.noSuchMethod(
+        Invocation.method(
+          #setMargins,
+          [
+            i,
+            bottom,
+          ],
+        ),
+        returnValueForMissingStub: null,
+      );
+  @override
+  void cursorNextLine(int? amount) => super.noSuchMethod(
+        Invocation.method(
+          #cursorNextLine,
+          [amount],
+        ),
+        returnValueForMissingStub: null,
+      );
+  @override
+  void cursorPrecedingLine(int? amount) => super.noSuchMethod(
+        Invocation.method(
+          #cursorPrecedingLine,
+          [amount],
+        ),
+        returnValueForMissingStub: null,
+      );
+  @override
+  void eraseDisplayBelow() => super.noSuchMethod(
+        Invocation.method(
+          #eraseDisplayBelow,
+          [],
+        ),
+        returnValueForMissingStub: null,
+      );
+  @override
+  void eraseDisplayAbove() => super.noSuchMethod(
+        Invocation.method(
+          #eraseDisplayAbove,
+          [],
+        ),
+        returnValueForMissingStub: null,
+      );
+  @override
+  void eraseDisplay() => super.noSuchMethod(
+        Invocation.method(
+          #eraseDisplay,
+          [],
+        ),
+        returnValueForMissingStub: null,
+      );
+  @override
+  void eraseScrollbackOnly() => super.noSuchMethod(
+        Invocation.method(
+          #eraseScrollbackOnly,
+          [],
+        ),
+        returnValueForMissingStub: null,
+      );
+  @override
+  void eraseLineRight() => super.noSuchMethod(
+        Invocation.method(
+          #eraseLineRight,
+          [],
+        ),
+        returnValueForMissingStub: null,
+      );
+  @override
+  void eraseLineLeft() => super.noSuchMethod(
+        Invocation.method(
+          #eraseLineLeft,
+          [],
+        ),
+        returnValueForMissingStub: null,
+      );
+  @override
+  void eraseLine() => super.noSuchMethod(
+        Invocation.method(
+          #eraseLine,
+          [],
+        ),
+        returnValueForMissingStub: null,
+      );
+  @override
+  void insertLines(int? amount) => super.noSuchMethod(
+        Invocation.method(
+          #insertLines,
+          [amount],
+        ),
+        returnValueForMissingStub: null,
+      );
+  @override
+  void deleteLines(int? amount) => super.noSuchMethod(
+        Invocation.method(
+          #deleteLines,
+          [amount],
+        ),
+        returnValueForMissingStub: null,
+      );
+  @override
+  void deleteChars(int? amount) => super.noSuchMethod(
+        Invocation.method(
+          #deleteChars,
+          [amount],
+        ),
+        returnValueForMissingStub: null,
+      );
+  @override
+  void scrollUp(int? amount) => super.noSuchMethod(
+        Invocation.method(
+          #scrollUp,
+          [amount],
+        ),
+        returnValueForMissingStub: null,
+      );
+  @override
+  void scrollDown(int? amount) => super.noSuchMethod(
+        Invocation.method(
+          #scrollDown,
+          [amount],
+        ),
+        returnValueForMissingStub: null,
+      );
+  @override
+  void eraseChars(int? amount) => super.noSuchMethod(
+        Invocation.method(
+          #eraseChars,
+          [amount],
+        ),
+        returnValueForMissingStub: null,
+      );
+  @override
+  void insertBlankChars(int? amount) => super.noSuchMethod(
+        Invocation.method(
+          #insertBlankChars,
+          [amount],
+        ),
+        returnValueForMissingStub: null,
+      );
+  @override
+  void unknownCSI(int? finalByte) => super.noSuchMethod(
+        Invocation.method(
+          #unknownCSI,
+          [finalByte],
+        ),
+        returnValueForMissingStub: null,
+      );
+  @override
+  void setInsertMode(bool? enabled) => super.noSuchMethod(
+        Invocation.method(
+          #setInsertMode,
+          [enabled],
+        ),
+        returnValueForMissingStub: null,
+      );
+  @override
+  void setLineFeedMode(bool? enabled) => super.noSuchMethod(
+        Invocation.method(
+          #setLineFeedMode,
+          [enabled],
+        ),
+        returnValueForMissingStub: null,
+      );
+  @override
+  void setUnknownMode(
+    int? mode,
+    bool? enabled,
+  ) =>
+      super.noSuchMethod(
+        Invocation.method(
+          #setUnknownMode,
+          [
+            mode,
+            enabled,
+          ],
+        ),
+        returnValueForMissingStub: null,
+      );
+  @override
+  void setCursorKeysMode(bool? enabled) => super.noSuchMethod(
+        Invocation.method(
+          #setCursorKeysMode,
+          [enabled],
+        ),
+        returnValueForMissingStub: null,
+      );
+  @override
+  void setReverseDisplayMode(bool? enabled) => super.noSuchMethod(
+        Invocation.method(
+          #setReverseDisplayMode,
+          [enabled],
+        ),
+        returnValueForMissingStub: null,
+      );
+  @override
+  void setOriginMode(bool? enabled) => super.noSuchMethod(
+        Invocation.method(
+          #setOriginMode,
+          [enabled],
+        ),
+        returnValueForMissingStub: null,
+      );
+  @override
+  void setColumnMode(bool? enabled) => super.noSuchMethod(
+        Invocation.method(
+          #setColumnMode,
+          [enabled],
+        ),
+        returnValueForMissingStub: null,
+      );
+  @override
+  void setAutoWrapMode(bool? enabled) => super.noSuchMethod(
+        Invocation.method(
+          #setAutoWrapMode,
+          [enabled],
+        ),
+        returnValueForMissingStub: null,
+      );
+  @override
+  void setMouseMode(_i3.MouseMode? mode) => super.noSuchMethod(
+        Invocation.method(
+          #setMouseMode,
+          [mode],
+        ),
+        returnValueForMissingStub: null,
+      );
+  @override
+  void setCursorBlinkMode(bool? enabled) => super.noSuchMethod(
+        Invocation.method(
+          #setCursorBlinkMode,
+          [enabled],
+        ),
+        returnValueForMissingStub: null,
+      );
+  @override
+  void setCursorVisibleMode(bool? enabled) => super.noSuchMethod(
+        Invocation.method(
+          #setCursorVisibleMode,
+          [enabled],
+        ),
+        returnValueForMissingStub: null,
+      );
+  @override
+  void useAltBuffer() => super.noSuchMethod(
+        Invocation.method(
+          #useAltBuffer,
+          [],
+        ),
+        returnValueForMissingStub: null,
+      );
+  @override
+  void useMainBuffer() => super.noSuchMethod(
+        Invocation.method(
+          #useMainBuffer,
+          [],
+        ),
+        returnValueForMissingStub: null,
+      );
+  @override
+  void clearAltBuffer() => super.noSuchMethod(
+        Invocation.method(
+          #clearAltBuffer,
+          [],
+        ),
+        returnValueForMissingStub: null,
+      );
+  @override
+  void setAppKeypadMode(bool? enabled) => super.noSuchMethod(
+        Invocation.method(
+          #setAppKeypadMode,
+          [enabled],
+        ),
+        returnValueForMissingStub: null,
+      );
+  @override
+  void setReportFocusMode(bool? enabled) => super.noSuchMethod(
+        Invocation.method(
+          #setReportFocusMode,
+          [enabled],
+        ),
+        returnValueForMissingStub: null,
+      );
+  @override
+  void setMouseReportMode(_i3.MouseReportMode? mode) => super.noSuchMethod(
+        Invocation.method(
+          #setMouseReportMode,
+          [mode],
+        ),
+        returnValueForMissingStub: null,
+      );
+  @override
+  void setAltBufferMouseScrollMode(bool? enabled) => super.noSuchMethod(
+        Invocation.method(
+          #setAltBufferMouseScrollMode,
+          [enabled],
+        ),
+        returnValueForMissingStub: null,
+      );
+  @override
+  void setBracketedPasteMode(bool? enabled) => super.noSuchMethod(
+        Invocation.method(
+          #setBracketedPasteMode,
+          [enabled],
+        ),
+        returnValueForMissingStub: null,
+      );
+  @override
+  void setUnknownDecMode(
+    int? mode,
+    bool? enabled,
+  ) =>
+      super.noSuchMethod(
+        Invocation.method(
+          #setUnknownDecMode,
+          [
+            mode,
+            enabled,
+          ],
+        ),
+        returnValueForMissingStub: null,
+      );
+  @override
+  void resize(
+    int? cols,
+    int? rows,
+  ) =>
+      super.noSuchMethod(
+        Invocation.method(
+          #resize,
+          [
+            cols,
+            rows,
+          ],
+        ),
+        returnValueForMissingStub: null,
+      );
+  @override
+  void sendSize() => super.noSuchMethod(
+        Invocation.method(
+          #sendSize,
+          [],
+        ),
+        returnValueForMissingStub: null,
+      );
+  @override
+  void resetCursorStyle() => super.noSuchMethod(
+        Invocation.method(
+          #resetCursorStyle,
+          [],
+        ),
+        returnValueForMissingStub: null,
+      );
+  @override
+  void setCursorBold() => super.noSuchMethod(
+        Invocation.method(
+          #setCursorBold,
+          [],
+        ),
+        returnValueForMissingStub: null,
+      );
+  @override
+  void setCursorFaint() => super.noSuchMethod(
+        Invocation.method(
+          #setCursorFaint,
+          [],
+        ),
+        returnValueForMissingStub: null,
+      );
+  @override
+  void setCursorItalic() => super.noSuchMethod(
+        Invocation.method(
+          #setCursorItalic,
+          [],
+        ),
+        returnValueForMissingStub: null,
+      );
+  @override
+  void setCursorUnderline() => super.noSuchMethod(
+        Invocation.method(
+          #setCursorUnderline,
+          [],
+        ),
+        returnValueForMissingStub: null,
+      );
+  @override
+  void setCursorBlink() => super.noSuchMethod(
+        Invocation.method(
+          #setCursorBlink,
+          [],
+        ),
+        returnValueForMissingStub: null,
+      );
+  @override
+  void setCursorInverse() => super.noSuchMethod(
+        Invocation.method(
+          #setCursorInverse,
+          [],
+        ),
+        returnValueForMissingStub: null,
+      );
+  @override
+  void setCursorInvisible() => super.noSuchMethod(
+        Invocation.method(
+          #setCursorInvisible,
+          [],
+        ),
+        returnValueForMissingStub: null,
+      );
+  @override
+  void setCursorStrikethrough() => super.noSuchMethod(
+        Invocation.method(
+          #setCursorStrikethrough,
+          [],
+        ),
+        returnValueForMissingStub: null,
+      );
+  @override
+  void unsetCursorBold() => super.noSuchMethod(
+        Invocation.method(
+          #unsetCursorBold,
+          [],
+        ),
+        returnValueForMissingStub: null,
+      );
+  @override
+  void unsetCursorFaint() => super.noSuchMethod(
+        Invocation.method(
+          #unsetCursorFaint,
+          [],
+        ),
+        returnValueForMissingStub: null,
+      );
+  @override
+  void unsetCursorItalic() => super.noSuchMethod(
+        Invocation.method(
+          #unsetCursorItalic,
+          [],
+        ),
+        returnValueForMissingStub: null,
+      );
+  @override
+  void unsetCursorUnderline() => super.noSuchMethod(
+        Invocation.method(
+          #unsetCursorUnderline,
+          [],
+        ),
+        returnValueForMissingStub: null,
+      );
+  @override
+  void unsetCursorBlink() => super.noSuchMethod(
+        Invocation.method(
+          #unsetCursorBlink,
+          [],
+        ),
+        returnValueForMissingStub: null,
+      );
+  @override
+  void unsetCursorInverse() => super.noSuchMethod(
+        Invocation.method(
+          #unsetCursorInverse,
+          [],
+        ),
+        returnValueForMissingStub: null,
+      );
+  @override
+  void unsetCursorInvisible() => super.noSuchMethod(
+        Invocation.method(
+          #unsetCursorInvisible,
+          [],
+        ),
+        returnValueForMissingStub: null,
+      );
+  @override
+  void unsetCursorStrikethrough() => super.noSuchMethod(
+        Invocation.method(
+          #unsetCursorStrikethrough,
+          [],
+        ),
+        returnValueForMissingStub: null,
+      );
+  @override
+  void setForegroundColor16(int? color) => super.noSuchMethod(
+        Invocation.method(
+          #setForegroundColor16,
+          [color],
+        ),
+        returnValueForMissingStub: null,
+      );
+  @override
+  void setForegroundColor256(int? index) => super.noSuchMethod(
+        Invocation.method(
+          #setForegroundColor256,
+          [index],
+        ),
+        returnValueForMissingStub: null,
+      );
+  @override
+  void setForegroundColorRgb(
+    int? r,
+    int? g,
+    int? b,
+  ) =>
+      super.noSuchMethod(
+        Invocation.method(
+          #setForegroundColorRgb,
+          [
+            r,
+            g,
+            b,
+          ],
+        ),
+        returnValueForMissingStub: null,
+      );
+  @override
+  void resetForeground() => super.noSuchMethod(
+        Invocation.method(
+          #resetForeground,
+          [],
+        ),
+        returnValueForMissingStub: null,
+      );
+  @override
+  void setBackgroundColor16(int? color) => super.noSuchMethod(
+        Invocation.method(
+          #setBackgroundColor16,
+          [color],
+        ),
+        returnValueForMissingStub: null,
+      );
+  @override
+  void setBackgroundColor256(int? index) => super.noSuchMethod(
+        Invocation.method(
+          #setBackgroundColor256,
+          [index],
+        ),
+        returnValueForMissingStub: null,
+      );
+  @override
+  void setBackgroundColorRgb(
+    int? r,
+    int? g,
+    int? b,
+  ) =>
+      super.noSuchMethod(
+        Invocation.method(
+          #setBackgroundColorRgb,
+          [
+            r,
+            g,
+            b,
+          ],
+        ),
+        returnValueForMissingStub: null,
+      );
+  @override
+  void resetBackground() => super.noSuchMethod(
+        Invocation.method(
+          #resetBackground,
+          [],
+        ),
+        returnValueForMissingStub: null,
+      );
+  @override
+  void unsupportedStyle(int? param) => super.noSuchMethod(
+        Invocation.method(
+          #unsupportedStyle,
+          [param],
+        ),
+        returnValueForMissingStub: null,
+      );
+  @override
+  void setTitle(String? name) => super.noSuchMethod(
+        Invocation.method(
+          #setTitle,
+          [name],
+        ),
+        returnValueForMissingStub: null,
+      );
+  @override
+  void setIconName(String? name) => super.noSuchMethod(
+        Invocation.method(
+          #setIconName,
+          [name],
+        ),
+        returnValueForMissingStub: null,
+      );
+  @override
+  void unknownOSC(String? ps) => super.noSuchMethod(
+        Invocation.method(
+          #unknownOSC,
+          [ps],
+        ),
+        returnValueForMissingStub: null,
+      );
+}

+ 71 - 0
test/src/ui/controller_test.dart

@@ -0,0 +1,71 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:xterm/xterm.dart';
+
+void main() {
+  group('TerminalController', () {
+    testWidgets('setSelectionRange works', (tester) async {
+      final terminal = Terminal();
+      final terminalView = TerminalController();
+
+      await tester.pumpWidget(MaterialApp(
+        home: Scaffold(
+          body: TerminalView(
+            terminal,
+            controller: terminalView,
+          ),
+        ),
+      ));
+
+      terminalView.setSelectionRange(CellOffset(0, 0), CellOffset(2, 2));
+
+      await tester.pump();
+
+      expect(terminalView.selection, isNotNull);
+    });
+
+    testWidgets('setSelectionMode changes BufferRange type', (tester) async {
+      final terminal = Terminal();
+      final terminalView = TerminalController();
+
+      await tester.pumpWidget(MaterialApp(
+        home: Scaffold(
+          body: TerminalView(
+            terminal,
+            controller: terminalView,
+          ),
+        ),
+      ));
+
+      terminalView.setSelectionRange(CellOffset(0, 0), CellOffset(2, 2));
+
+      expect(terminalView.selection, isA<BufferRangeLine>());
+
+      terminalView.setSelectionMode(SelectionMode.block);
+
+      expect(terminalView.selection, isA<BufferRangeBlock>());
+    });
+
+    testWidgets('clearSelection works', (tester) async {
+      final terminal = Terminal();
+      final terminalView = TerminalController();
+
+      await tester.pumpWidget(MaterialApp(
+        home: Scaffold(
+          body: TerminalView(
+            terminal,
+            controller: terminalView,
+          ),
+        ),
+      ));
+
+      terminalView.setSelectionRange(CellOffset(0, 0), CellOffset(2, 2));
+
+      expect(terminalView.selection, isNotNull);
+
+      terminalView.clearSelection();
+
+      expect(terminalView.selection, isNull);
+    });
+  });
+}