فهرست منبع

Merge pull request #127 from tauu/v3-block-selection

feat: block selection
xuty 3 سال پیش
والد
کامیت
130d5fa50b

+ 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/cell_offset.dart';
 export 'src/core/buffer/line.dart';
 export 'src/core/buffer/line.dart';
 export 'src/core/buffer/range.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/buffer/segment.dart';
 export 'src/core/cell.dart';
 export 'src/core/cell.dart';
 export 'src/core/color.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/cell_offset.dart';
 import 'package:xterm/src/core/buffer/line.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/buffer/range.dart';
 import 'package:xterm/src/core/charset.dart';
 import 'package:xterm/src/core/charset.dart';
 import 'package:xterm/src/core/cursor.dart';
 import 'package:xterm/src/core/cursor.dart';
@@ -464,7 +465,7 @@ class Buffer {
     r'\'.codeUnitAt(0),
     r'\'.codeUnitAt(0),
   };
   };
 
 
-  BufferRange? getWordBoundary(CellOffset position) {
+  BufferRangeLine? getWordBoundary(CellOffset position) {
     if (position.y >= lines.length) {
     if (position.y >= lines.length) {
       return null;
       return null;
     }
     }
@@ -499,7 +500,7 @@ class Buffer {
       return null;
       return null;
     }
     }
 
 
-    return BufferRange(
+    return BufferRangeLine(
       CellOffset(start, position.y),
       CellOffset(start, position.y),
       CellOffset(end, position.y),
       CellOffset(end, position.y),
     );
     );
@@ -508,7 +509,7 @@ class Buffer {
   /// Get the plain text content of the buffer including the scrollback.
   /// Get the plain text content of the buffer including the scrollback.
   /// Accepts an optional [range] to get a specific part of the buffer.
   /// Accepts an optional [range] to get a specific part of the buffer.
   String getText([BufferRange? range]) {
   String getText([BufferRange? range]) {
-    range ??= BufferRange(
+    range ??= BufferRangeLine(
       CellOffset(0, 0),
       CellOffset(0, 0),
       CellOffset(viewWidth - 1, height - 1),
       CellOffset(viewWidth - 1, height - 1),
     );
     );
@@ -517,23 +518,17 @@ class Buffer {
 
 
     final builder = StringBuffer();
     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;
         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();
     return builder.toString();

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

@@ -36,7 +36,7 @@ class CellOffset {
   }
   }
 
 
   bool isWithin(BufferRange range) {
   bool isWithin(BufferRange range) {
-    return range.begin.isBeforeOrSame(this) && range.end.isAfterOrSame(this);
+    return range.contains(this);
   }
   }
 
 
   @override
   @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/cell_offset.dart';
 import 'package:xterm/src/core/buffer/segment.dart';
 import 'package:xterm/src/core/buffer/segment.dart';
 
 
-class BufferRange {
+abstract class BufferRange {
   final CellOffset begin;
   final CellOffset begin;
 
 
   final CellOffset end;
   final CellOffset end;
 
 
-  BufferRange(this.begin, this.end);
+  const BufferRange(this.begin, this.end);
 
 
   BufferRange.collapsed(this.begin) : end = begin;
   BufferRange.collapsed(this.begin) : end = begin;
 
 
@@ -18,45 +18,21 @@ class BufferRange {
     return begin.isEqual(end);
     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
   @override
   operator ==(Object other) {
   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.
   /// The line that this segment resides on.
   final int line;
   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;
   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;
   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) {
   bool isWithin(CellOffset position) {
     if (position.y != line) {
     if (position.y != line) {
@@ -33,7 +35,11 @@ class BufferSegment {
   }
   }
 
 
   @override
   @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
   @override
   int get hashCode =>
   int get hashCode =>

+ 52 - 0
lib/src/ui/controller.dart

@@ -1,11 +1,25 @@
 import 'package:flutter/material.dart';
 import 'package:flutter/material.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.dart';
+import 'package:xterm/src/core/buffer/range_block.dart';
+import 'package:xterm/src/core/buffer/range_line.dart';
+import 'package:xterm/src/ui/selection_mode.dart';
 
 
 class TerminalController with ChangeNotifier {
 class TerminalController with ChangeNotifier {
   BufferRange? _selection;
   BufferRange? _selection;
 
 
   BufferRange? get selection => _selection;
   BufferRange? get selection => _selection;
 
 
+  SelectionMode _selectionMode;
+
+  SelectionMode get selectionMode => _selectionMode;
+
+  TerminalController({SelectionMode selectionMode = SelectionMode.line})
+      : _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].
   void setSelection(BufferRange? range) {
   void setSelection(BufferRange? range) {
     range = range?.normalized;
     range = range?.normalized;
 
 
@@ -15,6 +29,44 @@ 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() {
   void clearSelection() {
     _selection = null;
     _selection = null;
     notifyListeners();
     notifyListeners();

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

@@ -280,14 +280,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]) {
   void selectCharacters(Offset from, [Offset? to]) {
     final fromPosition = getCellOffset(from);
     final fromPosition = getCellOffset(from);
     if (to == null) {
     if (to == null) {
-      _controller.setSelection(BufferRange.collapsed(fromPosition));
+      _controller.setSelectionRange(fromPosition, fromPosition);
     } else {
     } 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/services.dart';
 import 'package:flutter/widgets.dart';
 import 'package:flutter/widgets.dart';
 import 'package:xterm/src/core/buffer/cell_offset.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/terminal.dart';
 import 'package:xterm/src/ui/controller.dart';
 import 'package:xterm/src/ui/controller.dart';
 
 
@@ -52,7 +52,7 @@ class TerminalActions extends StatelessWidget {
         SelectAllTextIntent: CallbackAction<SelectAllTextIntent>(
         SelectAllTextIntent: CallbackAction<SelectAllTextIntent>(
           onInvoke: (intent) {
           onInvoke: (intent) {
             controller.setSelection(
             controller.setSelection(
-              BufferRange(
+              BufferRangeLine(
                 CellOffset(0, terminal.buffer.height - terminal.viewHeight),
                 CellOffset(0, terminal.buffer.height - terminal.viewHeight),
                 CellOffset(terminal.viewWidth, terminal.buffer.height - 1),
                 CellOffset(terminal.viewWidth, terminal.buffer.height - 1),
               ),
               ),

+ 1 - 0
lib/ui.dart

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

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

@@ -37,7 +37,7 @@ void main() {
 
 
       expect(
       expect(
         terminal.buffer.getText(
         terminal.buffer.getText(
-          BufferRange(CellOffset(-100, -100), CellOffset(100, 100)),
+          BufferRangeLine(CellOffset(-100, -100), CellOffset(100, 100)),
         ),
         ),
         startsWith('Hello World'),
         startsWith('Hello World'),
       );
       );
@@ -50,7 +50,7 @@ void main() {
 
 
       expect(
       expect(
         terminal.buffer.getText(
         terminal.buffer.getText(
-          BufferRange(CellOffset(0, 0), CellOffset(100, 100)),
+          BufferRangeLine(CellOffset(0, 0), CellOffset(100, 100)),
         ),
         ),
         startsWith('Hello World'),
         startsWith('Hello World'),
       );
       );
@@ -63,11 +63,25 @@ void main() {
 
 
       expect(
       expect(
         terminal.buffer.getText(
         terminal.buffer.getText(
-          BufferRange(CellOffset(5, 5), CellOffset(0, 0)),
+          BufferRangeLine(CellOffset(5, 5), CellOffset(0, 0)),
         ),
         ),
         startsWith('Hello World'),
         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()', () {
   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);
+    });
+  });
+}

+ 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);
+    });
+  });
+}