xuty 2 anni fa
parent
commit
40101244ed

+ 1 - 1
lib/core.dart

@@ -17,6 +17,6 @@ export 'src/core/mouse/button.dart';
 export 'src/core/mouse/button_state.dart';
 export 'src/core/mouse/handler.dart';
 export 'src/core/mouse/mode.dart';
+export 'src/core/platform.dart';
 export 'src/core/state.dart';
 export 'src/terminal.dart';
-export 'src/utils/platform.dart';

+ 24 - 0
lib/src/base/disposable.dart

@@ -0,0 +1,24 @@
+import 'package:xterm/src/base/event.dart';
+
+mixin Disposable {
+  final _disposables = <Disposable>[];
+
+  bool get disposed => _disposed;
+  bool _disposed = false;
+
+  Event get onDisposed => _onDisposed.event;
+  final _onDisposed = EventEmitter();
+
+  void register(Disposable disposable) {
+    assert(!_disposed);
+    _disposables.add(disposable);
+  }
+
+  void dispose() {
+    _disposed = true;
+    for (final disposable in _disposables) {
+      disposable.dispose();
+    }
+    _onDisposed.emit(null);
+  }
+}

+ 42 - 0
lib/src/base/event.dart

@@ -0,0 +1,42 @@
+import 'package:xterm/src/base/disposable.dart';
+
+typedef EventListener<T> = void Function(T event);
+
+class Event<T> {
+  final EventEmitter<T> emitter;
+
+  Event(this.emitter);
+
+  void call(EventListener<T> listener) {
+    emitter(listener);
+  }
+}
+
+class EventEmitter<T> {
+  final _listeners = <EventListener<T>>[];
+
+  EventSubscription<T> call(EventListener<T> listener) {
+    _listeners.add(listener);
+    return EventSubscription(this, listener);
+  }
+
+  void emit(T event) {
+    for (final listener in _listeners) {
+      listener(event);
+    }
+  }
+
+  Event<T> get event => Event(this);
+}
+
+class EventSubscription<T> with Disposable {
+  final EventEmitter<T> emitter;
+  final EventListener<T> listener;
+
+  EventSubscription(this.emitter, this.listener);
+
+  @override
+  void dispose() {
+    emitter._listeners.remove(listener);
+  }
+}

+ 0 - 0
lib/src/utils/observable.dart → lib/src/base/observable.dart


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

@@ -8,7 +8,7 @@ import 'package:xterm/src/core/charset.dart';
 import 'package:xterm/src/core/cursor.dart';
 import 'package:xterm/src/core/reflow.dart';
 import 'package:xterm/src/core/state.dart';
-import 'package:xterm/src/utils/circular_list.dart';
+import 'package:xterm/src/utils/circular_buffer.dart';
 import 'package:xterm/src/utils/unicode_v11.dart';
 
 class Buffer {
@@ -59,7 +59,7 @@ class Buffer {
 
   /// lines of the buffer. the length of [lines] should always be equal or
   /// greater than [viewHeight].
-  late final lines = CircularList<BufferLine>(maxLines);
+  late final lines = IndexAwareCircularBuffer<BufferLine>(maxLines);
 
   /// Total number of lines in the buffer. Always equal or greater than
   /// [viewHeight].
@@ -390,11 +390,23 @@ class Buffer {
 
     setCursorX(0);
 
-    for (var i = 0; i < count; i++) {
-      final shiftStart = absoluteCursorY;
-      final shiftCount = absoluteMarginBottom - absoluteCursorY;
-      lines.shiftElements(shiftStart, shiftCount, 1);
-      lines[absoluteCursorY] = _newEmptyLine();
+    // Number of lines from the cursor to the bottom of the scrollable region
+    // including the cursor itself.
+    final linesBelow = absoluteMarginBottom - absoluteCursorY + 1;
+
+    // Number of empty lines to insert.
+    final linesToInsert = min(count, linesBelow);
+
+    // Number of lines to move up.
+    final linesToMove = linesBelow - linesToInsert;
+
+    for (var i = 0; i < linesToMove; i++) {
+      final index = absoluteMarginBottom - i;
+      lines[index] = lines.swap(index - linesToInsert, _newEmptyLine());
+    }
+
+    for (var i = linesToMove; i < linesToInsert; i++) {
+      lines[absoluteCursorY + i] = _newEmptyLine();
     }
   }
 
@@ -423,8 +435,7 @@ class Buffer {
   }
 
   void resize(int oldWidth, int oldHeight, int newWidth, int newHeight) {
-    lines.forEach((item) => item.resize(newWidth));
-
+    // 1. Adjust the height.
     if (newHeight > oldHeight) {
       // Grow larger
       for (var i = 0; i < newHeight - oldHeight; i++) {
@@ -435,7 +446,7 @@ class Buffer {
         }
       }
     } else {
-      // Shrink smallerclear
+      // Shrink smaller
       for (var i = 0; i < oldHeight - newHeight; i++) {
         if (_cursorY > newHeight - 1) {
           _cursorY--;
@@ -449,8 +460,9 @@ class Buffer {
     _cursorX = _cursorX.clamp(0, newWidth - 1);
     _cursorY = _cursorY.clamp(0, newHeight - 1);
 
-    if (terminal.reflowEnabled) {
-      if (!isAltBuffer && newWidth != oldWidth) {
+    // 2. Adjust the width.
+    if (newWidth != oldWidth) {
+      if (terminal.reflowEnabled && !isAltBuffer) {
         final reflowResult = reflow(lines, oldWidth, newWidth);
 
         while (reflowResult.length < newHeight) {
@@ -458,10 +470,26 @@ class Buffer {
         }
 
         lines.replaceWith(reflowResult);
+      } else {
+        lines.forEach((item) => item.resize(newWidth));
       }
     }
   }
 
+  /// Create a new [CellAnchor] at the specified [x] and [y] coordinates.
+  CellAnchor createAnchor(int x, int y) {
+    return lines[y].createAnchor(x);
+  }
+
+  /// Create a new [CellAnchor] at the specified [x] and [y] coordinates.
+  CellAnchor createAnchorFromOffset(CellOffset offset) {
+    return lines[offset.y].createAnchor(offset.x);
+  }
+
+  CellAnchor createAnchorFromCursor() {
+    return createAnchor(cursorX, absoluteCursorY);
+  }
+
   /// Create a new empty [BufferLine] with the current [viewWidth] if [width]
   /// is not specified.
   BufferLine _newEmptyLine([int? width]) {

+ 116 - 1
lib/src/core/buffer/line.dart

@@ -1,8 +1,10 @@
 import 'dart:math' show min;
 import 'dart:typed_data';
 
+import 'package:xterm/src/core/buffer/cell_offset.dart';
 import 'package:xterm/src/core/cell.dart';
 import 'package:xterm/src/core/cursor.dart';
+import 'package:xterm/src/utils/circular_buffer.dart';
 import 'package:xterm/src/utils/unicode_v11.dart';
 
 const _cellSize = 4;
@@ -15,7 +17,7 @@ const _cellAttributes = 2;
 
 const _cellContent = 3;
 
-class BufferLine {
+class BufferLine with IndexedItem {
   BufferLine(
     this._length, {
     this.isWrapped = false,
@@ -31,6 +33,10 @@ class BufferLine {
 
   int get length => _length;
 
+  final _anchors = <CellAnchor>[];
+
+  List<CellAnchor> get anchors => _anchors;
+
   int getForeground(int index) {
     return _data[index * _cellSize + _cellForeground];
   }
@@ -126,6 +132,8 @@ class BufferLine {
     _data[offset + _cellContent] = 0;
   }
 
+  /// Erase cells whose index satisfies [start] <= index < [end]. Erased cells
+  /// are filled with [style].
   void eraseRange(int start, int end, CursorStyle style) {
     // reset cell one to the left if start is second cell of a wide char
     if (start > 0 && getWidth(start - 1) == 2) {
@@ -143,6 +151,8 @@ class BufferLine {
     }
   }
 
+  /// Remove [count] cells starting at [start]. Cells that are empty after the
+  /// removal are filled with [style].
   void removeCells(int start, int count, [CursorStyle? style]) {
     assert(start >= 0 && start < _length);
     assert(count >= 0 && start + count <= _length);
@@ -165,8 +175,21 @@ class BufferLine {
     if (start > 0 && getWidth(start - 1) == 2) {
       eraseCell(start - 1, style);
     }
+
+    // Update anchors, remove anchors that are inside the removed range.
+    for (var i = 0; i < _anchors.length; i++) {
+      final anchor = _anchors[i];
+      if (anchor.x >= start) {
+        if (anchor.x < start + count) {
+          anchor.dispose();
+        } else {
+          anchor.reposition(anchor.x - count);
+        }
+      }
+    }
   }
 
+  /// Inserts [count] cells at [start]. New cells are initialized with [style].
   void insertCells(int start, int count, [CursorStyle? style]) {
     style ??= CursorStyle.empty;
 
@@ -191,6 +214,19 @@ class BufferLine {
     if (getWidth(_length - 1) == 2) {
       eraseCell(_length - 1, style);
     }
+
+    // Update anchors, move anchors that are after the inserted range.
+    for (var i = 0; i < _anchors.length; i++) {
+      final anchor = _anchors[i];
+      if (anchor.x >= start + count) {
+        anchor.reposition(anchor.x + count);
+
+        // Remove anchors that are now outside the buffer.
+        if (anchor.x >= _length) {
+          anchor.dispose();
+        }
+      }
+    }
   }
 
   void resize(int length) {
@@ -211,8 +247,17 @@ class BufferLine {
     }
 
     _length = length;
+
+    for (var i = 0; i < _anchors.length; i++) {
+      final anchor = _anchors[i];
+      if (anchor.x > _length) {
+        anchor.reposition(_length);
+      }
+    }
   }
 
+  /// Returns the offset of the last cell that has content from the start of
+  /// the line.
   int getTrimmedLength([int? cols]) {
     final maxCols = _data.length ~/ _cellSize;
 
@@ -239,6 +284,8 @@ class BufferLine {
     return 0;
   }
 
+  /// Copies [len] cells from [src] starting at [srcCol] to [dstCol] at this
+  /// line.
   void copyFrom(BufferLine src, int srcCol, int dstCol, int len) {
     resize(dstCol + len);
 
@@ -296,8 +343,76 @@ class BufferLine {
     return builder.toString();
   }
 
+  CellAnchor createAnchor(int offset) {
+    final anchor = CellAnchor(offset, owner: this);
+    _anchors.add(anchor);
+    return anchor;
+  }
+
+  void dispose() {
+    for (final anchor in _anchors) {
+      anchor.dispose();
+    }
+  }
+
   @override
   String toString() {
     return getText();
   }
 }
+
+/// A handle to a cell in a [BufferLine] that can be used to track the location
+/// of the cell. Anchors are guaranteed to be stable, retaining their relative
+/// position to each other after mutations to the buffer.
+class CellAnchor {
+  CellAnchor(int offset, {BufferLine? owner})
+      : _offset = offset,
+        _owner = owner;
+
+  int _offset;
+
+  int get x {
+    return _offset;
+  }
+
+  int get y {
+    assert(attached);
+    return _owner!.index;
+  }
+
+  CellOffset get offset {
+    assert(attached);
+    return CellOffset(_offset, _owner!.index);
+  }
+
+  BufferLine? _owner;
+
+  BufferLine? get line => _owner;
+
+  bool get attached => _owner?.attached ?? false;
+
+  void reparent(BufferLine owner, int offset) {
+    _owner?._anchors.remove(this);
+    _owner = owner;
+    _owner?._anchors.add(this);
+    _offset = offset;
+  }
+
+  void reposition(int offset) {
+    _offset = offset;
+  }
+
+  void dispose() {
+    _owner?._anchors.remove(this);
+    _owner = null;
+  }
+
+  @override
+  String toString() {
+    if (attached) {
+      return 'CellAnchor($x, $y)';
+    } else {
+      return 'CellAnchor($x, detached)';
+    }
+  }
+}

+ 1 - 0
lib/src/core/buffer/segment.dart

@@ -1,6 +1,7 @@
 import 'package:xterm/src/core/buffer/cell_offset.dart';
 import 'package:xterm/src/core/buffer/range.dart';
 
+/// A BufferSegment represents a range within a line.
 class BufferSegment {
   /// The range that this segment belongs to.
   final BufferRange range;

+ 1 - 1
lib/src/core/input/handler.dart

@@ -1,7 +1,7 @@
 import 'package:xterm/src/core/input/keys.dart';
 import 'package:xterm/src/core/input/keytab/keytab.dart';
 import 'package:xterm/src/core/state.dart';
-import 'package:xterm/src/utils/platform.dart';
+import 'package:xterm/src/core/platform.dart';
 
 /// The key event received from the keyboard, along with the state of the
 /// modifier keys and state of the terminal. Typically consumed by the

+ 1 - 1
lib/src/core/mouse/handler.dart

@@ -3,7 +3,7 @@ import 'package:xterm/src/core/mouse/button_state.dart';
 import 'package:xterm/src/core/mouse/mode.dart';
 import 'package:xterm/src/core/mouse/button.dart';
 import 'package:xterm/src/core/mouse/reporter.dart';
-import 'package:xterm/src/utils/platform.dart';
+import 'package:xterm/src/core/platform.dart';
 import 'package:xterm/src/core/state.dart';
 
 class TerminalMouseEvent {

+ 0 - 0
lib/src/utils/platform.dart → lib/src/core/platform.dart


+ 59 - 23
lib/src/core/reflow.dart

@@ -1,5 +1,5 @@
 import 'package:xterm/src/core/buffer/line.dart';
-import 'package:xterm/src/utils/circular_list.dart';
+import 'package:xterm/src/utils/circular_buffer.dart';
 
 class _LineBuilder {
   _LineBuilder([this._capacity = 80]) {
@@ -18,20 +18,27 @@ class _LineBuilder {
 
   bool get isNotEmpty => _length != 0;
 
+  /// Adds a range of cells from [src] to the builder. Anchors within the range
+  /// will be reparented to the new line returned by [take].
   void add(BufferLine src, int start, int length) {
     _result.copyFrom(src, start, _length, length);
     _length += length;
   }
 
+  /// Reuses the given [line] as the initial buffer for this builder.
   void setBuffer(BufferLine line, int length) {
     _result = line;
     _length = length;
   }
 
+  void addAnchor(CellAnchor anchor, int offset) {
+    anchor.reparent(_result, _length + offset);
+  }
+
   BufferLine take({required bool wrapped}) {
     final result = _result;
     result.isWrapped = wrapped;
-    result.resize(_length);
+    // result.resize(_length);
 
     _result = BufferLine(_capacity);
     _length = 0;
@@ -40,6 +47,7 @@ class _LineBuilder {
   }
 }
 
+/// Holds a the state of reflow operation of a single logical line.
 class _LineReflow {
   final int oldWidth;
 
@@ -51,28 +59,36 @@ class _LineReflow {
 
   late final _builder = _LineBuilder(newWidth);
 
+  /// Adds a line to the reflow operation. This method will try to reuse the
+  /// given line if possible.
   void add(BufferLine line) {
-    final length = line.getTrimmedLength(oldWidth);
+    final trimmedLength = line.getTrimmedLength(oldWidth);
 
-    if (length == 0) {
+    // A fast path for empty lines
+    if (trimmedLength == 0) {
       _lines.add(line);
       return;
     }
 
+    // We already have some content in the buffer, so we copy the content into
+    // the builder instead of reusing the line.
     if (_lines.isNotEmpty || _builder.isNotEmpty) {
-      _addRange(line, 0, length);
+      _addPart(line, from: 0, to: trimmedLength);
       return;
     }
 
     if (newWidth >= oldWidth) {
-      _builder.setBuffer(line, length);
+      // Reuse the line to avoid copying the content and object allocation.
+      _builder.setBuffer(line, trimmedLength);
     } else {
       _lines.add(line);
 
-      if (line.getWidth(newWidth - 1) == 2) {
-        _addRange(line, newWidth - 1, length);
-      } else {
-        _addRange(line, newWidth, length);
+      if (trimmedLength > newWidth) {
+        if (line.getWidth(newWidth - 1) == 2) {
+          _addPart(line, from: newWidth - 1, to: trimmedLength);
+        } else {
+          _addPart(line, from: newWidth, to: trimmedLength);
+        }
       }
     }
 
@@ -83,38 +99,58 @@ class _LineReflow {
     }
   }
 
-  void _addRange(BufferLine line, int start, int end) {
-    var cellsLeft = end - start;
+  /// Adds part of [line] from [from] to [to] to the reflow operation.
+  /// Anchors within the range will be removed from [line] and reparented to
+  /// the new line(s) returned by [finish].
+  void _addPart(BufferLine line, {required int from, required int to}) {
+    var cellsLeft = to - from;
 
     while (cellsLeft > 0) {
-      final spaceLeft = newWidth - _builder.length;
-
-      var lineFilled = false;
+      final bufferRemainingCells = newWidth - _builder.length;
 
+      // How many cells we should copy in this iteration.
       var cellsToCopy = cellsLeft;
 
-      if (cellsToCopy >= spaceLeft) {
-        cellsToCopy = spaceLeft;
+      // Whether the buffer is filled up in this iteration.
+      var lineFilled = false;
+
+      if (cellsToCopy >= bufferRemainingCells) {
+        cellsToCopy = bufferRemainingCells;
         lineFilled = true;
       }
 
-      // Avoid breaking wide characters
-      if (cellsToCopy == spaceLeft &&
-          line.getWidth(start + cellsToCopy - 1) == 2) {
+      // Leave the last cell to the next iteration if it's a wide char.
+      if (lineFilled && line.getWidth(from + cellsToCopy - 1) == 2) {
         cellsToCopy--;
       }
 
-      _builder.add(line, start, cellsToCopy);
+      for (var anchor in line.anchors.toList()) {
+        if (anchor.x >= from && anchor.x <= from + cellsToCopy) {
+          _builder.addAnchor(anchor, anchor.x - from);
+        }
+      }
+
+      _builder.add(line, from, cellsToCopy);
 
-      start += cellsToCopy;
+      from += cellsToCopy;
       cellsLeft -= cellsToCopy;
 
+      // Create a new line if the buffer is filled up.
       if (lineFilled) {
         _lines.add(_builder.take(wrapped: _lines.isNotEmpty));
       }
     }
+
+    if (line.anchors.isNotEmpty) {
+      for (var anchor in line.anchors.toList()) {
+        if (anchor.x >= to) {
+          _builder.addAnchor(anchor, anchor.x - to);
+        }
+      }
+    }
   }
 
+  /// Finalizes the reflow operation and returns the result.
   List<BufferLine> finish() {
     if (_builder.isNotEmpty) {
       _lines.add(_builder.take(wrapped: _lines.isNotEmpty));
@@ -125,7 +161,7 @@ class _LineReflow {
 }
 
 List<BufferLine> reflow(
-  CircularList<BufferLine> lines,
+  IndexAwareCircularBuffer<BufferLine> lines,
   int oldWidth,
   int newWidth,
 ) {

+ 5 - 5
lib/src/terminal.dart

@@ -1,5 +1,6 @@
 import 'dart:math' show max;
 
+import 'package:xterm/src/base/observable.dart';
 import 'package:xterm/src/core/buffer/buffer.dart';
 import 'package:xterm/src/core/buffer/cell_offset.dart';
 import 'package:xterm/src/core/buffer/line.dart';
@@ -9,16 +10,15 @@ import 'package:xterm/src/core/escape/handler.dart';
 import 'package:xterm/src/core/escape/parser.dart';
 import 'package:xterm/src/core/input/handler.dart';
 import 'package:xterm/src/core/input/keys.dart';
-import 'package:xterm/src/core/mouse/mode.dart';
 import 'package:xterm/src/core/mouse/button.dart';
 import 'package:xterm/src/core/mouse/button_state.dart';
 import 'package:xterm/src/core/mouse/handler.dart';
+import 'package:xterm/src/core/mouse/mode.dart';
+import 'package:xterm/src/core/platform.dart';
 import 'package:xterm/src/core/state.dart';
 import 'package:xterm/src/core/tabs.dart';
 import 'package:xterm/src/utils/ascii.dart';
-import 'package:xterm/src/utils/circular_list.dart';
-import 'package:xterm/src/utils/observable.dart';
-import 'package:xterm/src/utils/platform.dart';
+import 'package:xterm/src/utils/circular_buffer.dart';
 
 /// [Terminal] is an interface to interact with command line applications. It
 /// translates escape sequences from the application into updates to the
@@ -214,7 +214,7 @@ class Terminal with Observable implements TerminalState, EscapeHandler {
   bool get isUsingAltBuffer => _buffer == _altBuffer;
 
   /// Lines of the active buffer.
-  CircularList<BufferLine> get lines => _buffer.lines;
+  IndexAwareCircularBuffer<BufferLine> get lines => _buffer.lines;
 
   /// Whether the terminal performs reflow when the viewport size changes or
   /// simply truncates lines. true by default.

+ 8 - 0
lib/src/terminal_view.dart

@@ -330,6 +330,14 @@ class TerminalViewState extends State<TerminalView> {
     _customTextEditKey.currentState?.closeKeyboard();
   }
 
+  Rect get cursorRect {
+    final offset = CellOffset(
+      widget.terminal.buffer.cursorX,
+      widget.terminal.buffer.absoluteCursorY,
+    );
+    return renderTerminal.getOffset(offset) & renderTerminal.charSize;
+  }
+
   void _onTapUp(TapUpDetails details) {
     final offset = renderTerminal.getCellOffset(details.localPosition);
     widget.onTapUp?.call(details, offset);

+ 88 - 36
lib/src/ui/controller.dart

@@ -1,6 +1,7 @@
 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/line.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';
@@ -16,44 +17,56 @@ class TerminalController with ChangeNotifier {
         _pointerInputs = pointerInputs,
         _suspendPointerInputs = suspendPointerInput;
 
-  BufferRange? _selection;
-
-  BufferRange? get selection => _selection;
-
-  SelectionMode _selectionMode;
+  CellAnchor? _selectionBase;
+  CellAnchor? _selectionExtent;
 
   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;
+  SelectionMode _selectionMode;
 
   /// The set of pointer events which will be used as mouse input for the terminal.
   PointerInputs get pointerInput => _pointerInputs;
-
-  bool _suspendPointerInputs;
+  PointerInputs _pointerInputs;
 
   /// True if sending pointer events to the terminal is suspended.
   bool get suspendedPointerInputs => _suspendPointerInputs;
+  bool _suspendPointerInputs;
+
+  List<TerminalHighlight> get highlights => _highlights;
+  final _highlights = <TerminalHighlight>[];
 
-  void setSelection(BufferRange? range) {
-    range = range?.normalized;
+  BufferRange? get selection {
+    final base = _selectionBase;
+    final extent = _selectionExtent;
 
-    if (_selection != range) {
-      _selection = range;
-      notifyListeners();
+    if (base == null || extent == null) {
+      return null;
     }
+
+    if (!base.attached || !extent.attached) {
+      return null;
+    }
+
+    return _createRange(base.offset, extent.offset);
   }
 
-  /// 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);
+  /// Set selection on the terminal from [base] to [extent]. This method takes
+  /// the ownership of [base] and [extent] and will dispose them when the
+  /// selection is cleared or changed.
+  void setSelection(CellAnchor base, CellAnchor extent, {SelectionMode? mode}) {
+    _selectionBase?.dispose();
+    _selectionBase = base;
+
+    _selectionExtent?.dispose();
+    _selectionExtent = extent;
+
+    if (mode != null) {
+      _selectionMode = mode;
+    }
+
+    notifyListeners();
   }
 
-  BufferRange _modeRange(CellOffset begin, CellOffset end) {
+  BufferRange _createRange(CellOffset begin, CellOffset end) {
     switch (selectionMode) {
       case SelectionMode.line:
         return BufferRangeLine(begin, end);
@@ -73,19 +86,15 @@ class TerminalController with ChangeNotifier {
     }
     // 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));
+    notifyListeners();
   }
 
   /// Clears the current selection.
   void clearSelection() {
-    _selection = null;
+    _selectionBase?.dispose();
+    _selectionBase = null;
+    _selectionExtent?.dispose();
+    _selectionExtent = null;
     notifyListeners();
   }
 
@@ -110,11 +119,54 @@ class TerminalController with ChangeNotifier {
         : _pointerInputs.inputs.contains(pointerInput);
   }
 
-  void addHighlight(BufferRange? range) {
-    // TODO: implement addHighlight
+  TerminalHighlight addHighlight({
+    required CellAnchor p1,
+    required CellAnchor p2,
+    required Color color,
+  }) {
+    final highlight = TerminalHighlight._(
+      this,
+      p1: p1,
+      p2: p2,
+      color: color,
+    );
+    _highlights.add(highlight);
+    notifyListeners();
+    return highlight;
+  }
+
+  void removeHighlight(TerminalHighlight highlight) {
+    _highlights.remove(highlight);
+    notifyListeners();
+  }
+}
+
+class TerminalHighlight {
+  final TerminalController owner;
+
+  final CellAnchor p1;
+
+  final CellAnchor p2;
+
+  final Color color;
+
+  TerminalHighlight._(
+    this.owner, {
+    required this.p1,
+    required this.p2,
+    required this.color,
+  });
+
+  /// Returns the range of the highlight. May be null if the anchors that
+  /// define the highlight are not attached to the terminal.
+  BufferRange? get range {
+    if (!p1.attached || !p2.attached) {
+      return null;
+    }
+    return BufferRangeLine(p1.offset, p2.offset);
   }
 
-  void clearHighlight() {
-    // TODO: implement clearHighlight
+  void dispose() {
+    owner.removeHighlight(this);
   }
 }

+ 85 - 24
lib/src/ui/render.dart

@@ -1,13 +1,13 @@
 import 'dart:math' show min, max;
 import 'dart:ui';
 
-import 'package:flutter/cupertino.dart';
-import 'package:flutter/material.dart';
 import 'package:flutter/rendering.dart';
 import 'package:flutter/scheduler.dart';
+import 'package:flutter/widgets.dart';
 import 'package:xterm/src/core/buffer/cell_flags.dart';
 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';
 import 'package:xterm/src/core/cell.dart';
 import 'package:xterm/src/core/buffer/line.dart';
 import 'package:xterm/src/core/mouse/button.dart';
@@ -18,6 +18,7 @@ import 'package:xterm/src/ui/controller.dart';
 import 'package:xterm/src/ui/cursor_type.dart';
 import 'package:xterm/src/ui/palette_builder.dart';
 import 'package:xterm/src/ui/paragraph_cache.dart';
+import 'package:xterm/src/ui/selection_mode.dart';
 import 'package:xterm/src/ui/terminal_size.dart';
 import 'package:xterm/src/ui/terminal_text_style.dart';
 import 'package:xterm/src/ui/terminal_theme.dart';
@@ -166,6 +167,7 @@ class RenderTerminal extends RenderBox with RelayoutWhenSystemFontsChangeMixin {
 
   /// The size of a single character in [_textStyle] in pixels. [_textStyle] is
   /// expected to be monospace.
+  Size get charSize => _charSize;
   late Size _charSize;
 
   TerminalSize? _viewportSize;
@@ -280,7 +282,10 @@ class RenderTerminal extends RenderBox with RelayoutWhenSystemFontsChangeMixin {
     final y = offset.dy - _padding.top + _scrollOffset;
     final row = y ~/ _charSize.height;
     final col = x ~/ _charSize.width;
-    return CellOffset(col, row);
+    return CellOffset(
+      col.clamp(0, _terminal.viewWidth - 1),
+      row.clamp(0, _terminal.buffer.lines.length - 1),
+    );
   }
 
   /// Selects entire words in the terminal that contains [from] and [to].
@@ -289,12 +294,21 @@ class RenderTerminal extends RenderBox with RelayoutWhenSystemFontsChangeMixin {
     final fromBoundary = _terminal.buffer.getWordBoundary(fromOffset);
     if (fromBoundary == null) return;
     if (to == null) {
-      _controller.setSelection(fromBoundary);
+      _controller.setSelection(
+        _terminal.buffer.createAnchorFromOffset(fromBoundary.begin),
+        _terminal.buffer.createAnchorFromOffset(fromBoundary.end),
+        mode: SelectionMode.line,
+      );
     } else {
       final toOffset = getCellOffset(to);
       final toBoundary = _terminal.buffer.getWordBoundary(toOffset);
       if (toBoundary == null) return;
-      _controller.setSelection(fromBoundary.merge(toBoundary));
+      final range = fromBoundary.merge(toBoundary);
+      _controller.setSelection(
+        _terminal.buffer.createAnchorFromOffset(range.begin),
+        _terminal.buffer.createAnchorFromOffset(range.end),
+        mode: SelectionMode.line,
+      );
     }
   }
 
@@ -303,13 +317,19 @@ class RenderTerminal extends RenderBox with RelayoutWhenSystemFontsChangeMixin {
   void selectCharacters(Offset from, [Offset? to]) {
     final fromPosition = getCellOffset(from);
     if (to == null) {
-      _controller.setSelectionRange(fromPosition, fromPosition);
+      _controller.setSelection(
+        _terminal.buffer.createAnchorFromOffset(fromPosition),
+        _terminal.buffer.createAnchorFromOffset(fromPosition),
+      );
     } else {
       var toPosition = getCellOffset(to);
       if (toPosition.x >= fromPosition.x) {
         toPosition = CellOffset(toPosition.x + 1, toPosition.y);
       }
-      _controller.setSelectionRange(fromPosition, toPosition);
+      _controller.setSelection(
+        _terminal.buffer.createAnchorFromOffset(fromPosition),
+        _terminal.buffer.createAnchorFromOffset(toPosition),
+      );
     }
   }
 
@@ -449,6 +469,13 @@ class RenderTerminal extends RenderBox with RelayoutWhenSystemFontsChangeMixin {
       }
     }
 
+    _paintHighlights(
+      canvas,
+      _controller.highlights,
+      effectFirstLine,
+      effectLastLine,
+    );
+
     if (_controller.selection != null) {
       _paintSelection(
         canvas,
@@ -564,30 +591,64 @@ class RenderTerminal extends RenderBox with RelayoutWhenSystemFontsChangeMixin {
         break;
       }
 
-      final start = segment.start ?? 0;
-      final end = segment.end ?? _terminal.viewWidth;
+      _paintSegment(canvas, segment, _theme.selection);
+    }
+  }
 
-      final startOffset = Offset(
-        start * _charSize.width,
-        segment.line * _charSize.height + _lineOffset,
-      );
+  void _paintHighlights(
+    Canvas canvas,
+    List<TerminalHighlight> highlights,
+    int firstLine,
+    int lastLine,
+  ) {
+    for (var highlight in _controller.highlights) {
+      final range = highlight.range?.normalized;
 
-      final endOffset = Offset(
-        end * _charSize.width,
-        (segment.line + 1) * _charSize.height + _lineOffset,
-      );
+      if (range == null ||
+          range.begin.y > lastLine ||
+          range.end.y < firstLine) {
+        continue;
+      }
 
-      final paint = Paint()
-        ..color = _theme.selection
-        ..strokeWidth = 1;
+      for (var segment in range.toSegments()) {
+        if (segment.line < firstLine) {
+          continue;
+        }
 
-      canvas.drawRect(
-        Rect.fromPoints(startOffset, endOffset),
-        paint,
-      );
+        if (segment.line > lastLine) {
+          break;
+        }
+
+        _paintSegment(canvas, segment, highlight.color);
+      }
     }
   }
 
+  @pragma('vm:prefer-inline')
+  void _paintSegment(Canvas canvas, BufferSegment segment, Color color) {
+    final start = segment.start ?? 0;
+    final end = segment.end ?? _terminal.viewWidth;
+
+    final startOffset = Offset(
+      start * _charSize.width,
+      segment.line * _charSize.height + _lineOffset,
+    );
+
+    final endOffset = Offset(
+      end * _charSize.width,
+      (segment.line + 1) * _charSize.height + _lineOffset,
+    );
+
+    final paint = Paint()
+      ..color = color
+      ..strokeWidth = 1;
+
+    canvas.drawRect(
+      Rect.fromPoints(startOffset, endOffset),
+      paint,
+    );
+  }
+
   /// Paints the character in the cell represented by [cellData] to [canvas] at
   /// [offset].
   @pragma('vm:prefer-inline')

+ 9 - 3
lib/src/ui/shortcut/actions.dart

@@ -4,6 +4,7 @@ import 'package:xterm/src/core/buffer/cell_offset.dart';
 import 'package:xterm/src/core/buffer/range_line.dart';
 import 'package:xterm/src/terminal.dart';
 import 'package:xterm/src/ui/controller.dart';
+import 'package:xterm/src/ui/selection_mode.dart';
 
 class TerminalActions extends StatelessWidget {
   const TerminalActions({
@@ -52,10 +53,15 @@ class TerminalActions extends StatelessWidget {
         SelectAllTextIntent: CallbackAction<SelectAllTextIntent>(
           onInvoke: (intent) {
             controller.setSelection(
-              BufferRangeLine(
-                CellOffset(0, terminal.buffer.height - terminal.viewHeight),
-                CellOffset(terminal.viewWidth, terminal.buffer.height - 1),
+              terminal.buffer.createAnchor(
+                0,
+                terminal.buffer.height - terminal.viewHeight,
               ),
+              terminal.buffer.createAnchor(
+                terminal.viewWidth,
+                terminal.buffer.height - 1,
+              ),
+              mode: SelectionMode.line,
             );
             return null;
           },

+ 315 - 0
lib/src/utils/circular_buffer.dart

@@ -0,0 +1,315 @@
+/// A circular buffer in which elements know their index in the buffer.
+class IndexAwareCircularBuffer<T extends IndexedItem> {
+  /// Creates a new circular list with the specified [maxLength].
+  IndexAwareCircularBuffer(int maxLength)
+      : _array = List<T?>.filled(maxLength, null);
+
+  /// The backing array for this list. Length is always equal to [maxLength].
+  late List<T?> _array;
+
+  /// The number of elements in the list. This is always less than or equal to
+  /// [maxLength].
+  var _length = 0;
+
+  /// The index of the first element in [_array].
+  var _startIndex = 0;
+
+  /// The start index of this list, including items that has been dropped in
+  /// overflow
+  var _absoluteStartIndex = 0;
+
+  /// Gets the cyclic index for the specified regular index. The cyclic index
+  /// can then be used on the backing array to get the element associated with
+  /// the regular index.
+  @pragma('vm:prefer-inline')
+  int _getCyclicIndex(int index) {
+    return (_startIndex + index) % _array.length;
+  }
+
+  /// Removes the element at [index] from the list.
+  @pragma('vm:prefer-inline')
+  void _dropChild(int index) {
+    final cyclicIndex = _getCyclicIndex(index);
+    _array[cyclicIndex]?._detach();
+    _array[cyclicIndex] = null;
+  }
+
+  /// Adds the specified [child] to the list at the specified [index].
+  @pragma('vm:prefer-inline')
+  void _adoptChild(int index, T child) {
+    final cyclicIndex = _getCyclicIndex(index);
+    _array[cyclicIndex]?._detach();
+    _array[cyclicIndex] = child.._attach(this, index);
+  }
+
+  /// Moves the element at [fromIndex] to [toIndex]. Both indexes should be
+  /// less than [maxLength].
+  @pragma('vm:prefer-inline')
+  void _moveChild(int fromIndex, int toIndex) {
+    final fromCyclicIndex = _getCyclicIndex(fromIndex);
+    final toCyclicIndex = _getCyclicIndex(toIndex);
+    _array[toCyclicIndex]?._detach();
+    _array[toCyclicIndex] = _array[fromCyclicIndex]?.._move(toIndex);
+    _array[fromCyclicIndex] = null;
+  }
+
+  /// Gets the element at the specified [index] in the list.
+  @pragma('vm:prefer-inline')
+  T? _getChild(int index) {
+    return _array[_getCyclicIndex(index)];
+  }
+
+  /// The number of elements that can be stored in the list.
+  int get maxLength {
+    return _array.length;
+  }
+
+  /// Sets the number of elements that can be stored in the list. This operation
+  /// is relatively expensive, as it requires the backing array to be
+  /// reallocated.
+  set maxLength(int value) {
+    if (value <= 0) {
+      throw ArgumentError.value(value, 'value', "maxLength can't be negative!");
+    }
+
+    if (value == _array.length) return;
+
+    // Reconstruct array, starting at index 0. Only transfer values from the
+    // indexes 0 to length.
+    final newArray = List<T?>.generate(
+      value,
+      (index) => index < _length ? _getChild(index) : null,
+    );
+
+    _startIndex = 0;
+    _array = newArray;
+  }
+
+  /// Number of elements in the list.
+  int get length {
+    return _length;
+  }
+
+  /// Iterates over the list and calls [callback] for each element.
+  void forEach(void Function(T item) callback) {
+    final length = _length;
+    for (int i = 0; i < length; i++) {
+      callback(_getChild(i)!);
+    }
+  }
+
+  /// Gets the element at the specified [index] in the list. Throws if the
+  /// index is out of bounds.
+  T operator [](int index) {
+    RangeError.checkValueInInterval(index, 0, length - 1, 'index');
+    return _getChild(index)!;
+  }
+
+  /// Sets the element at the specified [index] in the list. Throws if the
+  /// index is out of bounds.
+  operator []=(int index, T value) {
+    RangeError.checkValueInInterval(index, 0, length - 1, 'index');
+    _adoptChild(index, value);
+  }
+
+  /// Removes all elements from the list.
+  void clear() {
+    for (var i = 0; i < _length; i++) {
+      _dropChild(i);
+    }
+    _startIndex = 0;
+    _length = 0;
+  }
+
+  /// Adds all elements in [items] to the list.
+  void pushAll(Iterable<T> items) {
+    for (var element in items) {
+      push(element);
+    }
+  }
+
+  /// Adds [value] to the end of the list. May cause the first element to be
+  /// trimmed if the list is full.
+  void push(T value) {
+    _adoptChild(_length, value);
+
+    if (_length == _array.length) {
+      // When the list is full, we trim the first element
+      _startIndex++;
+      _absoluteStartIndex++;
+      if (_startIndex == _array.length) {
+        _startIndex = 0;
+      }
+    } else {
+      // When the list is not full, we just increase the length
+      _length++;
+    }
+  }
+
+  /// Removes and returns the last value on the list, throws if the list is
+  /// empty.
+  T pop() {
+    assert(_length > 0, 'Cannot pop from an empty list');
+    final result = _getChild(_length - 1);
+    _dropChild(_length - 1);
+    _length--;
+    return result!;
+  }
+
+  /// Deletes [count] elements starting at [index], shifting all elements after
+  /// [index] to the left.
+  void remove(int index, [int count = 1]) {
+    if (count > 0) {
+      if (index + count >= _length) {
+        count = _length - index;
+      }
+      for (var i = index; i < _length - count; i++) {
+        _moveChild(i + count, i);
+      }
+      for (var i = _length - count; i < _length; i++) {
+        _dropChild(i);
+      }
+      _length -= count;
+    }
+  }
+
+  /// Inserts [item] at [index], shifting all elements after [index] to the
+  /// right. May cause the first element to be trimmed if the list is full.
+  void insert(int index, T item) {
+    RangeError.checkValueInInterval(index, 0, _length, 'index');
+
+    if (index == _length) {
+      return push(item);
+    }
+
+    if (index == 0 && _length >= _array.length) {
+      // when something is inserted at index 0 and the list is full then
+      // the new value immediately gets removed => nothing changes
+      return;
+    }
+
+    for (var i = _length - 1; i >= index; i--) {
+      _moveChild(i, i + 1);
+    }
+
+    _adoptChild(index, item);
+
+    if (_length >= _array.length) {
+      _startIndex += 1;
+      _absoluteStartIndex += 1;
+    } else {
+      _length++;
+    }
+  }
+
+  /// Inserts [items] at [index] in order.
+  void insertAll(int index, List<T> items) {
+    for (var i = items.length - 1; i >= 0; i--) {
+      insert(index, items[i]);
+      // when the list is full then we have to move the index down
+      // as newly inserted values remove values with a lower index
+      if (_length >= _array.length) {
+        index--;
+        if (index < 0) {
+          return;
+        }
+      }
+    }
+  }
+
+  /// Removes [count] elements starting at [index], shifting all elements after
+  /// [index] to the left.
+  ///
+  /// This method is cheap since it does not actually modify the list, but
+  /// instead just adjusts the start index and length.
+  void trimStart(int count) {
+    if (count > _length) count = _length;
+    _startIndex += count;
+    _startIndex %= _array.length;
+    _length -= count;
+  }
+
+  /// Replaces all elements in the list with [replacement].
+  void replaceWith(List<T> replacement) {
+    for (var i = 0; i < _length; i++) {
+      _dropChild(i);
+    }
+
+    var copyStart = 0;
+    if (replacement.length > maxLength) {
+      copyStart = replacement.length - maxLength;
+    }
+
+    for (var i = 0; i < copyStart; i++) {
+      _dropChild(i);
+    }
+
+    final copyLength = replacement.length - copyStart;
+    for (var i = 0; i < copyLength; i++) {
+      _adoptChild(i, replacement[copyStart + i]);
+    }
+
+    _startIndex = 0;
+    _length = copyLength;
+  }
+
+  /// Replaces the element at [index] with [value] and returns the replaced
+  /// item.
+  T swap(int index, T value) {
+    final result = _getChild(index);
+    _adoptChild(index, value);
+    return result!;
+  }
+
+  /// Whether adding another element would cause the first element to be
+  /// trimmed.
+  bool get isFull => length == maxLength;
+
+  /// Returns a list containing all elements in the list.
+  List<T> toList() {
+    return List<T>.generate(length, (index) => this[index]);
+  }
+
+  String debugDump() {
+    final buffer = StringBuffer();
+    buffer.writeln('CircularList:');
+    for (var i = 0; i < _length; i++) {
+      final child = _getChild(i);
+      buffer.writeln('  $i: $child');
+    }
+    return buffer.toString();
+  }
+}
+
+mixin IndexedItem {
+  IndexAwareCircularBuffer? _owner;
+
+  int? _absoluteIndex;
+
+  /// The index of this item in the buffer. Must only be accessed when
+  /// [attached] is true.
+  int get index => _absoluteIndex! - _owner!._absoluteStartIndex;
+
+  /// Whether this item is currently stored in a buffer.
+  bool get attached => _owner != null;
+
+  /// Sets the owner and index of this item. This is called by the buffer when
+  /// the item is adopted.
+  void _attach(IndexAwareCircularBuffer owner, int index) {
+    _owner = owner;
+    _absoluteIndex = owner._absoluteStartIndex + index;
+  }
+
+  /// Marks this item as detached from a buffer. This is called after the item
+  /// has been removed from the buffer.
+  void _detach() {
+    _owner = null;
+    _absoluteIndex = null;
+  }
+
+  /// Moves this item to [newIndex] in the buffer.
+  void _move(int newIndex) {
+    assert(attached);
+    _absoluteIndex = _owner!._absoluteStartIndex + newIndex;
+  }
+}

+ 0 - 214
lib/src/utils/circular_list.dart

@@ -1,214 +0,0 @@
-class CircularList<T> {
-  CircularList(int maxLength) : _array = List<T?>.filled(maxLength, null);
-
-  late List<T?> _array;
-
-  var _length = 0;
-
-  var _startIndex = 0;
-
-  // Gets the cyclic index for the specified regular index. The cyclic index can then be used on the
-  // backing array to get the element associated with the regular index.
-  @pragma('vm:prefer-inline')
-  int _getCyclicIndex(int index) {
-    return (_startIndex + index) % _array.length;
-  }
-
-  int get maxLength {
-    return _array.length;
-  }
-
-  set maxLength(int value) {
-    if (value <= 0) {
-      throw ArgumentError.value(value, 'value', "maxLength can't be negative!");
-    }
-
-    if (value == _array.length) return;
-
-    // Reconstruct array, starting at index 0. Only transfer values from the
-    // indexes 0 to length.
-    final newArray = List<T?>.generate(
-      value,
-      (index) => index < _array.length ? _array[_getCyclicIndex(index)] : null,
-    );
-
-    _startIndex = 0;
-    _array = newArray;
-  }
-
-  int get length {
-    return _length;
-  }
-
-  set length(int value) {
-    if (value > _length) {
-      for (int i = length; i < value; i++) {
-        _array[i] = null;
-      }
-    }
-    _length = value;
-  }
-
-  void forEach(void Function(T item) callback) {
-    final length = _length;
-    for (int i = 0; i < length; i++) {
-      callback(_array[_getCyclicIndex(i)] as T);
-    }
-  }
-
-  T operator [](int index) {
-    if (index >= length || index < 0) {
-      throw RangeError.range(index, 0, length - 1);
-    }
-
-    return _array[_getCyclicIndex(index)]!;
-  }
-
-  operator []=(int index, T value) {
-    if (index >= length || index < 0) {
-      throw RangeError.range(index, 0, length - 1);
-    }
-
-    _array[_getCyclicIndex(index)] = value;
-  }
-
-  void clear() {
-    _startIndex = 0;
-    _length = 0;
-  }
-
-  void pushAll(Iterable<T> items) {
-    for (var element in items) {
-      push(element);
-    }
-  }
-
-  void push(T value) {
-    _array[_getCyclicIndex(_length)] = value;
-    if (_length == _array.length) {
-      _startIndex++;
-      if (_startIndex == _array.length) {
-        _startIndex = 0;
-      }
-    } else {
-      _length++;
-    }
-  }
-
-  /// Removes and returns the last value on the list
-  T pop() {
-    return _array[_getCyclicIndex(_length-- - 1)]!;
-  }
-
-  /// Deletes item at [index].
-  void remove(int index, [int count = 1]) {
-    if (count > 0) {
-      if (index + count >= _length) {
-        count = _length - index;
-      }
-      for (var i = index; i < _length - count; i++) {
-        _array[_getCyclicIndex(i)] = _array[_getCyclicIndex(i + count)];
-      }
-      length -= count;
-    }
-  }
-
-  /// Inserts [item] at [index].
-  void insert(int index, T item) {
-    if (index < 0 || index > _length) {
-      throw RangeError.range(index, 0, _length);
-    }
-
-    if (index == _length) {
-      return push(item);
-    }
-
-    if (index == 0 && _length >= _array.length) {
-      // when something is inserted at index 0 and the list is full then
-      // the new value immediately gets removed => nothing changes
-      return;
-    }
-
-    for (var i = _length - 1; i >= index; i--) {
-      _array[_getCyclicIndex(i + 1)] = _array[_getCyclicIndex(i)];
-    }
-
-    _array[_getCyclicIndex(index)] = item;
-
-    if (_length >= _array.length) {
-      _startIndex += 1;
-    } else {
-      _length++;
-    }
-  }
-
-  /// Inserts [items] at [index] in order.
-  void insertAll(int index, List<T> items) {
-    for (var i = items.length - 1; i >= 0; i--) {
-      insert(index, items[i]);
-      // when the list is full then we have to move the index down
-      // as newly inserted values remove values with a lower index
-      if (_length >= _array.length) {
-        index--;
-        if (index < 0) {
-          return;
-        }
-      }
-    }
-  }
-
-  void trimStart(int count) {
-    if (count > _length) count = _length;
-    _startIndex += count;
-    _startIndex %= _array.length;
-    _length -= count;
-  }
-
-  void shiftElements(int start, int count, int offset) {
-    if (count < 0) return;
-    if (start < 0 || start >= _length) {
-      throw Exception('Start argument is out of range');
-    }
-    if (start + offset < 0) {
-      throw Exception('Can not shift elements in list beyond index 0');
-    }
-    if (offset > 0) {
-      for (var i = count - 1; i >= 0; i--) {
-        this[start + i + offset] = this[start + i];
-      }
-      var expandListBy = (start + count + offset) - _length;
-      if (expandListBy > 0) {
-        _length += expandListBy;
-        while (_length > _array.length) {
-          length--;
-          _startIndex++;
-        }
-      }
-    } else {
-      for (var i = 0; i < count; i++) {
-        this[start + i + offset] = this[start + i];
-      }
-    }
-  }
-
-  void replaceWith(List<T> replacement) {
-    var copyStart = 0;
-    if (replacement.length > maxLength) {
-      copyStart = replacement.length - maxLength;
-    }
-
-    final copyLength = replacement.length - copyStart;
-    for (var i = 0; i < copyLength; i++) {
-      _array[i] = replacement[copyStart + i];
-    }
-
-    _startIndex = 0;
-    _length = copyLength;
-  }
-
-  bool get isFull => length == maxLength;
-
-  List<T> toList() {
-    return List<T>.generate(length, (index) => this[index]);
-  }
-}

+ 1 - 1
lib/src/utils/debugger.dart

@@ -1,7 +1,7 @@
 import 'package:xterm/src/core/escape/handler.dart';
 import 'package:xterm/src/core/escape/parser.dart';
 import 'package:xterm/src/core/mouse/mode.dart';
-import 'package:xterm/src/utils/observable.dart';
+import 'package:xterm/src/base/observable.dart';
 
 class TerminalCommand {
   TerminalCommand(

+ 47 - 0
test/src/core/buffer/buffer_test.dart

@@ -138,6 +138,53 @@ void main() {
     });
   });
 
+  group('Buffer.insertLines()', () {
+    test('works', () {
+      final terminal = Terminal();
+
+      for (var i = 0; i < 10; i++) {
+        terminal.write('line$i\r\n');
+      }
+
+      print(terminal.buffer);
+
+      terminal.setMargins(2, 6);
+      terminal.setCursor(0, 4);
+
+      print(terminal.buffer.absoluteCursorY);
+
+      terminal.buffer.insertLines(1);
+
+      print(terminal.buffer);
+
+      expect(terminal.buffer.lines[3].toString(), 'line3');
+      expect(terminal.buffer.lines[4].toString(), ''); // inserted
+      expect(terminal.buffer.lines[5].toString(), 'line4'); // moved
+      expect(terminal.buffer.lines[6].toString(), 'line5'); // moved
+      expect(terminal.buffer.lines[7].toString(), 'line7');
+    });
+
+    test('has no effect if cursor is out of scroll region', () {
+      final terminal = Terminal();
+
+      for (var i = 0; i < 10; i++) {
+        terminal.write('line$i\r\n');
+      }
+
+      terminal.setMargins(2, 6);
+      terminal.setCursor(0, 1);
+
+      terminal.buffer.insertLines(1);
+
+      expect(terminal.buffer.lines[2].toString(), 'line2');
+      expect(terminal.buffer.lines[3].toString(), 'line3');
+      expect(terminal.buffer.lines[4].toString(), 'line4');
+      expect(terminal.buffer.lines[5].toString(), 'line5');
+      expect(terminal.buffer.lines[6].toString(), 'line6');
+      expect(terminal.buffer.lines[7].toString(), 'line7');
+    });
+  });
+
   group('Buffer.getWordBoundary supports custom word separators', () {
     test('can set word separators', () {
       final terminal = Terminal(wordSeparators: {'o'.codeUnitAt(0)});

+ 16 - 0
test/src/core/buffer/line_test.dart

@@ -103,4 +103,20 @@ void main() {
       expect(line.length, equals(20));
     });
   });
+
+  group('Buffer.createAnchor', () {
+    test('works', () {
+      final terminal = Terminal();
+      final line = terminal.buffer.lines[3];
+      final anchor = line.createAnchor(5);
+
+      terminal.insertLines(5);
+      expect(anchor.x, 5);
+      expect(anchor.y, 8);
+
+      terminal.buffer.clear();
+      expect(line.attached, false);
+      expect(anchor.attached, false);
+    });
+  });
 }

+ 12 - 3
test/src/ui/controller_test.dart

@@ -17,7 +17,10 @@ void main() {
         ),
       ));
 
-      terminalView.setSelectionRange(CellOffset(0, 0), CellOffset(2, 2));
+      terminalView.setSelection(
+        terminal.buffer.createAnchor(0, 0),
+        terminal.buffer.createAnchor(2, 2),
+      );
 
       await tester.pump();
 
@@ -37,7 +40,10 @@ void main() {
         ),
       ));
 
-      terminalView.setSelectionRange(CellOffset(0, 0), CellOffset(2, 2));
+      terminalView.setSelection(
+        terminal.buffer.createAnchor(0, 0),
+        terminal.buffer.createAnchor(2, 2),
+      );
 
       expect(terminalView.selection, isA<BufferRangeLine>());
 
@@ -59,7 +65,10 @@ void main() {
         ),
       ));
 
-      terminalView.setSelectionRange(CellOffset(0, 0), CellOffset(2, 2));
+      terminalView.setSelection(
+        terminal.buffer.createAnchor(0, 0),
+        terminal.buffer.createAnchor(2, 2),
+      );
 
       expect(terminalView.selection, isNotNull);
 

+ 371 - 0
test/src/utils/circular_buffer_test.dart

@@ -0,0 +1,371 @@
+import 'package:flutter_test/flutter_test.dart';
+import 'package:xterm/src/utils/circular_buffer.dart';
+
+class IndexedValue<T> with IndexedItem {
+  T value;
+
+  IndexedValue(this.value);
+
+  @override
+  int get hashCode => value.hashCode;
+
+  @override
+  bool operator ==(Object other) {
+    if (other is IndexedValue) {
+      return other.value == value;
+    }
+    if (other is T) {
+      return other == value;
+    }
+    return false;
+  }
+
+  @override
+  String toString() {
+    return 'IndexedValue($value), index: ${attached ? index : null}}';
+  }
+}
+
+extension ToIndexedValue<T> on T {
+  IndexedValue<T> get indexed => IndexedValue(this);
+}
+
+void main() {
+  group("IndexAwareCircularBuffer", () {
+    test("normal creation test", () {
+      final cl = IndexAwareCircularBuffer<IndexedValue<int>>(1000);
+
+      expect(cl, isNotNull);
+      expect(cl.maxLength, 1000);
+    });
+
+    test("change max value", () {
+      final cl = IndexAwareCircularBuffer<IndexedValue<int>>(2000);
+      expect(cl.maxLength, 2000);
+      cl.maxLength = 3000;
+      expect(cl.maxLength, 3000);
+    });
+
+    test("circle works", () {
+      final cl = IndexAwareCircularBuffer<IndexedValue<int>>(10);
+      expect(cl.maxLength, 10);
+      cl.pushAll(
+        List<int>.generate(10, (index) => index).map(IndexedValue.new),
+      );
+
+      expect(cl.length, 10);
+      expect(cl[0], 0.indexed);
+      expect(cl[9], 9.indexed);
+
+      cl.push(IndexedValue(10));
+
+      expect(cl.length, 10);
+      expect(cl[0], 1.indexed);
+      expect(cl[9], 10.indexed);
+    });
+
+    test("change max value after circle", () {
+      final cl = IndexAwareCircularBuffer<IndexedValue<int>>(10);
+      cl.pushAll(
+        List<int>.generate(15, (index) => index).map(IndexedValue.new),
+      );
+
+      expect(cl.length, 10);
+      expect(cl[0], 5.indexed);
+      expect(cl[9], 14.indexed);
+
+      cl.maxLength = 20;
+
+      expect(cl.length, 10);
+      expect(cl[0], 5.indexed);
+      expect(cl[9], 14.indexed);
+
+      cl.pushAll(
+        List<int>.generate(5, (index) => 15 + index).map(IndexedValue.new),
+      );
+
+      expect(cl[0], 5.indexed);
+      expect(cl[9], 14.indexed);
+      expect(cl[14], 19.indexed);
+    });
+
+    // test("setting the length erases trail", () {
+    //   final cl = CircularList<Box<int>>(10);
+    //   cl.pushAll(List<int>.generate(10, (index) => index).map(Box.new));
+
+    //   expect(cl.length, 10);
+    //   expect(cl[0], 0.box);
+    //   expect(cl[9], 9.box);
+
+    //   cl.length = 5;
+
+    //   expect(cl.length, 5);
+    //   expect(cl[0], 0.box);
+    //   expect(() => cl[5], throwsRangeError);
+    // });
+
+    test("foreach works", () {
+      final cl = IndexAwareCircularBuffer<IndexedValue<int>>(10);
+      cl.pushAll(
+        List<int>.generate(10, (index) => index).map(IndexedValue.new),
+      );
+
+      final collectedItems = List<int>.empty(growable: true);
+
+      cl.forEach((item) {
+        collectedItems.add(item.value);
+      });
+
+      expect(collectedItems.length, 10);
+      expect(collectedItems[0], 0);
+      expect(collectedItems[9], 9);
+    });
+
+    test("index operator set works", () {
+      final cl = IndexAwareCircularBuffer<IndexedValue<int>>(10);
+      cl.pushAll(
+        List<int>.generate(10, (index) => index).map(IndexedValue.new),
+      );
+
+      expect(cl.length, 10);
+      expect(cl[5], 5.indexed);
+
+      cl[5] = IndexedValue(50);
+
+      expect(cl[5], 50.indexed);
+    });
+
+    test("clear works", () {
+      final cl = IndexAwareCircularBuffer<IndexedValue<int>>(10);
+      cl.pushAll(
+        List<int>.generate(10, (index) => index).map(IndexedValue.new),
+      );
+      expect(cl[5], 5.indexed);
+
+      cl.clear();
+
+      expect(cl.length, 0);
+      expect(() => cl[5], throwsRangeError);
+    });
+
+    test("pop works", () {
+      final cl = IndexAwareCircularBuffer<IndexedValue<int>>(10);
+      cl.pushAll(
+        List<int>.generate(10, (index) => index).map(IndexedValue.new),
+      );
+      expect(cl.length, 10);
+      expect(cl[9], 9.indexed);
+
+      final val = cl.pop();
+
+      expect(val, 9.indexed);
+      expect(cl.length, 9);
+      expect(() => cl[9], throwsRangeError);
+      expect(cl[8], 8.indexed);
+    });
+
+    test("pop on empty throws", () {
+      final cl = IndexAwareCircularBuffer<IndexedValue<int>>(10);
+      expect(() => cl.pop(), throwsA(anything));
+    });
+
+    test("remove one works", () {
+      final cl = IndexAwareCircularBuffer<IndexedValue<int>>(10);
+      cl.pushAll(
+        List<int>.generate(10, (index) => index).map(IndexedValue.new),
+      );
+      expect(cl.length, 10);
+      expect(cl[5], 5.indexed);
+
+      cl.remove(5);
+
+      expect(cl.length, 9);
+      expect(cl[5], 6.indexed);
+    });
+
+    test("remove multiple works", () {
+      final cl = IndexAwareCircularBuffer<IndexedValue<int>>(10);
+      cl.pushAll(
+        List<int>.generate(10, (index) => index).map(IndexedValue.new),
+      );
+      expect(cl.length, 10);
+      expect(cl[5], 5.indexed);
+
+      cl.remove(5, 3);
+
+      expect(cl.length, 7);
+      expect(cl[5], 8.indexed);
+    });
+
+    test("remove circle works", () {
+      final cl = IndexAwareCircularBuffer<IndexedValue<int>>(10);
+      cl.pushAll(
+        List<int>.generate(15, (index) => index).map(IndexedValue.new),
+      );
+      expect(cl.length, 10);
+      expect(cl[0], 5.indexed);
+
+      cl.remove(0, 9);
+
+      expect(cl.length, 1);
+      expect(cl[0], 14.indexed);
+    });
+
+    test("remove too much works", () {
+      final cl = IndexAwareCircularBuffer<IndexedValue<int>>(10);
+      cl.pushAll(
+        List<int>.generate(10, (index) => index).map(IndexedValue.new),
+      );
+      expect(cl.length, 10);
+      expect(cl[5], 5.indexed);
+
+      cl.remove(5, 10);
+
+      expect(cl.length, 5);
+      expect(cl[0], 0.indexed);
+    });
+
+    test("insert works", () {
+      final cl = IndexAwareCircularBuffer<IndexedValue<int>>(10);
+      cl.pushAll(
+        List<int>.generate(5, (index) => index).map(IndexedValue.new),
+      );
+      expect(cl.length, 5);
+      expect(cl[0], 0.indexed);
+      cl.insert(0, IndexedValue(100));
+
+      expect(cl.length, 6);
+      expect(cl[0], 100.indexed);
+      expect(cl[1], 0.indexed);
+    });
+
+    test("insert circular works", () {
+      final cl = IndexAwareCircularBuffer<IndexedValue<int>>(10);
+      cl.pushAll(
+        List<int>.generate(10, (index) => index).map(IndexedValue.new),
+      );
+      expect(cl.length, 10);
+      expect(cl[0], 0.indexed);
+      expect(cl[1], 1.indexed);
+      expect(cl[9], 9.indexed);
+
+      cl.insert(1, IndexedValue(100));
+
+      expect(cl.length, 10);
+      expect(cl[0], 100.indexed); //circle leads to 100 moving one index down
+      expect(cl[1], 1.indexed);
+    });
+
+    test("insert circular immediately remove works", () {
+      final cl = IndexAwareCircularBuffer<IndexedValue<int>>(10);
+      cl.pushAll(
+        List<int>.generate(10, (index) => index).map(IndexedValue.new),
+      );
+      expect(cl.length, 10);
+      expect(cl[0], 0.indexed);
+      expect(cl[1], 1.indexed);
+      expect(cl[9], 9.indexed);
+
+      cl.insert(0, IndexedValue(100));
+
+      expect(cl.length, 10);
+      expect(cl[0], 0.indexed); //the inserted 100 fell over immediately
+      expect(cl[1], 1.indexed);
+    });
+
+    test("insert all works", () {
+      final cl = IndexAwareCircularBuffer<IndexedValue<int>>(10);
+      cl.pushAll(
+        List<int>.generate(10, (index) => index).map(IndexedValue.new),
+      );
+      expect(cl.length, 10);
+      expect(cl[0], 0.indexed);
+      expect(cl[1], 1.indexed);
+      expect(cl[9], 9.indexed);
+
+      cl.insertAll(
+        2,
+        List<int>.generate(2, (index) => 20 + index)
+            .map(IndexedValue.new)
+            .toList(),
+      );
+
+      expect(cl.length, 10);
+      expect(cl[0], 20.indexed);
+      expect(cl[1], 21.indexed);
+      expect(cl[3], 3.indexed);
+      expect(cl[9], 9.indexed);
+    });
+
+    test("trim start works", () {
+      final cl = IndexAwareCircularBuffer<IndexedValue<int>>(10);
+      cl.pushAll(
+        List<int>.generate(10, (index) => index).map(IndexedValue.new),
+      );
+      expect(cl.length, 10);
+      expect(cl[0], 0.indexed);
+      expect(cl[1], 1.indexed);
+      expect(cl[9], 9.indexed);
+
+      cl.trimStart(5);
+
+      expect(cl.length, 5);
+      expect(cl[0], 5.indexed);
+      expect(cl[1], 6.indexed);
+      expect(cl[4], 9.indexed);
+    });
+
+    test("trim start with more than length works", () {
+      final cl = IndexAwareCircularBuffer<IndexedValue<int>>(10);
+      cl.pushAll(
+        List<int>.generate(10, (index) => index).map(IndexedValue.new),
+      );
+      expect(cl.length, 10);
+      expect(cl[0], 0.indexed);
+      expect(cl[1], 1.indexed);
+      expect(cl[9], 9.indexed);
+
+      cl.trimStart(15);
+
+      expect(cl.length, 0);
+    });
+
+    test('can track index of items', () {
+      final cl = IndexAwareCircularBuffer<IndexedValue<int>>(3);
+      final item0 = IndexedValue(0);
+      final item1 = IndexedValue(1);
+      final item2 = IndexedValue(2);
+
+      cl.pushAll([item0, item1, item2]);
+
+      expect(item0.index, 0);
+      expect(item1.index, 1);
+      expect(item2.index, 2);
+
+      final item3 = IndexedValue(3);
+      cl.push(item3);
+
+      expect(item0.attached, false);
+      expect(item1.index, 0);
+      expect(item2.index, 1);
+      expect(item3.index, 2);
+
+      final item11 = IndexedValue(4);
+      cl.insert(1, item11);
+
+      expect(item0.attached, false);
+      expect(item1.attached, false);
+      expect(item11.index, 0);
+      expect(item2.index, 1);
+      expect(item3.index, 2);
+
+      cl.remove(0, 2);
+
+      print(cl.debugDump());
+
+      expect(item11.attached, false);
+      expect(item2.attached, false);
+      expect(item3.index, 0);
+    });
+  });
+}

+ 0 - 299
test/src/utils/circular_list_test.dart

@@ -1,299 +0,0 @@
-import 'package:flutter_test/flutter_test.dart';
-import 'package:xterm/src/utils/circular_list.dart';
-
-void main() {
-  group("CircularList Tests", () {
-    test("normal creation test", () {
-      final cl = CircularList<int>(1000);
-
-      expect(cl, isNotNull);
-      expect(cl.maxLength, 1000);
-    });
-
-    test("change max value", () {
-      final cl = CircularList<int>(2000);
-      expect(cl.maxLength, 2000);
-      cl.maxLength = 3000;
-      expect(cl.maxLength, 3000);
-    });
-
-    test("circle works", () {
-      final cl = CircularList<int>(10);
-      expect(cl.maxLength, 10);
-      cl.pushAll(List<int>.generate(10, (index) => index));
-
-      expect(cl.length, 10);
-      expect(cl[0], 0);
-      expect(cl[9], 9);
-
-      cl.push(10);
-
-      expect(cl.length, 10);
-      expect(cl[0], 1);
-      expect(cl[9], 10);
-    });
-
-    test("change max value after circle", () {
-      final cl = CircularList<int>(10);
-      cl.pushAll(List<int>.generate(15, (index) => index));
-
-      expect(cl.length, 10);
-      expect(cl[0], 5);
-      expect(cl[9], 14);
-
-      cl.maxLength = 20;
-
-      expect(cl.length, 10);
-      expect(cl[0], 5);
-      expect(cl[9], 14);
-
-      cl.pushAll(List<int>.generate(5, (index) => 15 + index));
-
-      expect(cl[0], 5);
-      expect(cl[9], 14);
-      expect(cl[14], 19);
-    });
-
-    test("setting the length erases trail", () {
-      final cl = CircularList<int>(10);
-      cl.pushAll(List<int>.generate(10, (index) => index));
-
-      expect(cl.length, 10);
-      expect(cl[0], 0);
-      expect(cl[9], 9);
-
-      cl.length = 5;
-
-      expect(cl.length, 5);
-      expect(cl[0], 0);
-      expect(() => cl[5], throwsRangeError);
-    });
-
-    test("foreach works", () {
-      final cl = CircularList<int>(10);
-      cl.pushAll(List<int>.generate(10, (index) => index));
-
-      final collectedItems = List<int>.empty(growable: true);
-
-      cl.forEach((item) {
-        collectedItems.add(item);
-      });
-
-      expect(collectedItems.length, 10);
-      expect(collectedItems[0], 0);
-      expect(collectedItems[9], 9);
-    });
-
-    test("index operator set works", () {
-      final cl = CircularList<int>(10);
-      cl.pushAll(List<int>.generate(10, (index) => index));
-
-      expect(cl.length, 10);
-      expect(cl[5], 5);
-
-      cl[5] = 50;
-
-      expect(cl[5], 50);
-    });
-
-    test("clear works", () {
-      final cl = CircularList<int>(10);
-      cl.pushAll(List<int>.generate(10, (index) => index));
-      expect(cl[5], 5);
-
-      cl.clear();
-
-      expect(cl.length, 0);
-      expect(() => cl[5], throwsRangeError);
-    });
-
-    test("pop works", () {
-      final cl = CircularList<int>(10);
-      cl.pushAll(List<int>.generate(10, (index) => index));
-      expect(cl.length, 10);
-      expect(cl[9], 9);
-
-      final val = cl.pop();
-
-      expect(val, 9);
-      expect(cl.length, 9);
-      expect(() => cl[9], throwsRangeError);
-      expect(cl[8], 8);
-    });
-
-    test("pop on empty throws", () {
-      final cl = CircularList<int>(10);
-      expect(() => cl.pop(), throwsA(anything));
-    });
-
-    test("remove one works", () {
-      final cl = CircularList<int>(10);
-      cl.pushAll(List<int>.generate(10, (index) => index));
-      expect(cl.length, 10);
-      expect(cl[5], 5);
-
-      cl.remove(5);
-
-      expect(cl.length, 9);
-      expect(cl[5], 6);
-    });
-
-    test("remove multiple works", () {
-      final cl = CircularList<int>(10);
-      cl.pushAll(List<int>.generate(10, (index) => index));
-      expect(cl.length, 10);
-      expect(cl[5], 5);
-
-      cl.remove(5, 3);
-
-      expect(cl.length, 7);
-      expect(cl[5], 8);
-    });
-
-    test("remove circle works", () {
-      final cl = CircularList<int>(10);
-      cl.pushAll(List<int>.generate(15, (index) => index));
-      expect(cl.length, 10);
-      expect(cl[0], 5);
-
-      cl.remove(0, 9);
-
-      expect(cl.length, 1);
-      expect(cl[0], 14);
-    });
-
-    test("remove too much works", () {
-      final cl = CircularList<int>(10);
-      cl.pushAll(List<int>.generate(10, (index) => index));
-      expect(cl.length, 10);
-      expect(cl[5], 5);
-
-      cl.remove(5, 10);
-
-      expect(cl.length, 5);
-      expect(cl[0], 0);
-    });
-
-    test("insert works", () {
-      final cl = CircularList<int>(10);
-      cl.pushAll(List<int>.generate(5, (index) => index));
-      expect(cl.length, 5);
-      expect(cl[0], 0);
-      cl.insert(0, 100);
-
-      expect(cl.length, 6);
-      expect(cl[0], 100);
-      expect(cl[1], 0);
-    });
-
-    test("insert circular works", () {
-      final cl = CircularList<int>(10);
-      cl.pushAll(List<int>.generate(10, (index) => index));
-      expect(cl.length, 10);
-      expect(cl[0], 0);
-      expect(cl[1], 1);
-      expect(cl[9], 9);
-
-      cl.insert(1, 100);
-
-      expect(cl.length, 10);
-      expect(cl[0], 100); //circle leads to 100 moving one index down
-      expect(cl[1], 1);
-    });
-
-    test("insert circular immediately remove works", () {
-      final cl = CircularList<int>(10);
-      cl.pushAll(List<int>.generate(10, (index) => index));
-      expect(cl.length, 10);
-      expect(cl[0], 0);
-      expect(cl[1], 1);
-      expect(cl[9], 9);
-
-      cl.insert(0, 100);
-
-      expect(cl.length, 10);
-      expect(cl[0], 0); //the inserted 100 fell over immediately
-      expect(cl[1], 1);
-    });
-
-    test("insert all works", () {
-      final cl = CircularList<int>(10);
-      cl.pushAll(List<int>.generate(10, (index) => index));
-      expect(cl.length, 10);
-      expect(cl[0], 0);
-      expect(cl[1], 1);
-      expect(cl[9], 9);
-
-      cl.insertAll(2, List<int>.generate(2, (index) => 20 + index));
-
-      expect(cl.length, 10);
-      expect(cl[0], 20);
-      expect(cl[1], 21);
-      expect(cl[3], 3);
-      expect(cl[9], 9);
-    });
-
-    test("trim start works", () {
-      final cl = CircularList<int>(10);
-      cl.pushAll(List<int>.generate(10, (index) => index));
-      expect(cl.length, 10);
-      expect(cl[0], 0);
-      expect(cl[1], 1);
-      expect(cl[9], 9);
-
-      cl.trimStart(5);
-
-      expect(cl.length, 5);
-      expect(cl[0], 5);
-      expect(cl[1], 6);
-      expect(cl[4], 9);
-    });
-
-    test("trim start with more than length works", () {
-      final cl = CircularList<int>(10);
-      cl.pushAll(List<int>.generate(10, (index) => index));
-      expect(cl.length, 10);
-      expect(cl[0], 0);
-      expect(cl[1], 1);
-      expect(cl[9], 9);
-
-      cl.trimStart(15);
-
-      expect(cl.length, 0);
-    });
-
-    test("shift elements works", () {
-      final cl = CircularList<int>(20);
-      cl.pushAll(List<int>.generate(20, (index) => index));
-      expect(cl.length, 20);
-      expect(cl[0], 0);
-      expect(cl[1], 1);
-      expect(cl[9], 9);
-
-      cl.shiftElements(5, 3, 2);
-
-      expect(cl.length, 20);
-      expect(cl[0], 0); // untouched
-      expect(cl[1], 1); // untouched
-      expect(cl[5], 5); // moved
-      expect(cl[6], 6); // moved
-      expect(cl[7], 5); // moved (7) and target (5)
-      expect(cl[8], 6); // target (6)
-      expect(cl[9], 7); // target (7)
-      expect(cl[10], 10); // untouched
-      expect(cl[11], 11); // untouched
-    });
-
-    test("shift elements over bounds throws", () {
-      final cl = CircularList<int>(10);
-      cl.pushAll(List<int>.generate(10, (index) => index));
-      expect(cl.length, 10);
-      expect(cl[0], 0);
-      expect(cl[1], 1);
-      expect(cl[9], 9);
-
-      expect(() => cl.shiftElements(8, 2, 3), throwsA(anything));
-      expect(() => cl.shiftElements(2, 3, -3), throwsA(anything));
-    });
-  });
-}