Browse Source

Merge pull request #174 from TerminalStudio/anchor-system

Add anchor system
xuty 2 years ago
parent
commit
6abd99c8ad
62 changed files with 2953 additions and 966 deletions
  1. 1 1
      .github/workflows/ci.yml
  2. 3 1
      .gitignore
  3. 15 15
      example/.metadata
  4. 3 1
      example/README.md
  5. BIN
      example/assets/specs_v1.json.gz
  6. 12 0
      example/ios/RunnerTests/RunnerTests.swift
  7. 95 0
      example/lib/overlay.dart
  8. 268 0
      example/lib/src/suggestion_engine.dart
  9. 584 0
      example/lib/suggestion.dart
  10. 0 4
      example/linux/flutter/generated_plugin_registrant.cc
  11. 0 1
      example/linux/flutter/generated_plugins.cmake
  12. 0 2
      example/macos/Flutter/GeneratedPluginRegistrant.swift
  13. 1 1
      example/macos/Podfile
  14. 2 8
      example/macos/Podfile.lock
  15. 39 38
      example/macos/Runner.xcodeproj/project.pbxproj
  16. 1 1
      example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme
  17. BIN
      example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png
  18. BIN
      example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png
  19. BIN
      example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png
  20. BIN
      example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png
  21. BIN
      example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png
  22. BIN
      example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png
  23. BIN
      example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png
  24. 1 1
      example/macos/Runner/Configs/AppInfo.xcconfig
  25. 6 10
      example/macos/Runner/MainFlutterWindow.swift
  26. 12 0
      example/macos/RunnerTests/RunnerTests.swift
  27. 153 101
      example/pubspec.lock
  28. 4 3
      example/pubspec.yaml
  29. 0 3
      example/windows/flutter/generated_plugin_registrant.cc
  30. 0 1
      example/windows/flutter/generated_plugins.cmake
  31. 1 1
      lib/core.dart
  32. 41 0
      lib/src/base/disposable.dart
  33. 42 0
      lib/src/base/event.dart
  34. 0 0
      lib/src/base/observable.dart
  35. 40 12
      lib/src/core/buffer/buffer.dart
  36. 116 1
      lib/src/core/buffer/line.dart
  37. 1 0
      lib/src/core/buffer/segment.dart
  38. 1 1
      lib/src/core/input/handler.dart
  39. 1 1
      lib/src/core/mouse/handler.dart
  40. 0 0
      lib/src/core/platform.dart
  41. 59 23
      lib/src/core/reflow.dart
  42. 5 5
      lib/src/terminal.dart
  43. 21 3
      lib/src/terminal_view.dart
  44. 90 36
      lib/src/ui/controller.dart
  45. 1 3
      lib/src/ui/keyboard_visibility.dart
  46. 85 24
      lib/src/ui/render.dart
  47. 9 5
      lib/src/ui/shortcut/actions.dart
  48. 315 0
      lib/src/utils/circular_buffer.dart
  49. 0 214
      lib/src/utils/circular_list.dart
  50. 1 1
      lib/src/utils/debugger.dart
  51. 199 0
      lib/suggestion.dart
  52. 259 138
      pubspec.lock
  53. 3 4
      pubspec.yaml
  54. BIN
      test/src/_goldens/colors.png
  55. BIN
      test/src/_goldens/htop_80x25_3s.png
  56. BIN
      test/src/_goldens/text_scale_factor@1x.png
  57. BIN
      test/src/_goldens/text_scale_factor@2x.png
  58. 47 0
      test/src/core/buffer/buffer_test.dart
  59. 16 0
      test/src/core/buffer/line_test.dart
  60. 29 3
      test/src/ui/controller_test.dart
  61. 371 0
      test/src/utils/circular_buffer_test.dart
  62. 0 299
      test/src/utils/circular_list_test.dart

+ 1 - 1
.github/workflows/ci.yml

@@ -31,7 +31,7 @@ jobs:
         run: flutter pub get
 
       - name: Verify formatting
-        run: flutter format --set-exit-if-changed .
+        run: dart format --set-exit-if-changed .
 
       # Consider passing '--fatal-infos' for slightly stricter analysis.
       - name: Analyze project source

+ 3 - 1
.gitignore

@@ -77,4 +77,6 @@ build/
 .vscode/
 example/lib/debug.dart
 
-coverage/
+coverage/
+
+example2/

+ 15 - 15
example/.metadata

@@ -4,7 +4,7 @@
 # This file should be version controlled.
 
 version:
-  revision: f1875d570e39de09040c8f79aa13cc56baab8db1
+  revision: 84a1e904f44f9b0e9c4510138010edcc653163f8
   channel: stable
 
 project_type: app
@@ -13,26 +13,26 @@ project_type: app
 migration:
   platforms:
     - platform: root
-      create_revision: f1875d570e39de09040c8f79aa13cc56baab8db1
-      base_revision: f1875d570e39de09040c8f79aa13cc56baab8db1
+      create_revision: 84a1e904f44f9b0e9c4510138010edcc653163f8
+      base_revision: 84a1e904f44f9b0e9c4510138010edcc653163f8
     - platform: android
-      create_revision: f1875d570e39de09040c8f79aa13cc56baab8db1
-      base_revision: f1875d570e39de09040c8f79aa13cc56baab8db1
+      create_revision: 84a1e904f44f9b0e9c4510138010edcc653163f8
+      base_revision: 84a1e904f44f9b0e9c4510138010edcc653163f8
     - platform: ios
-      create_revision: f1875d570e39de09040c8f79aa13cc56baab8db1
-      base_revision: f1875d570e39de09040c8f79aa13cc56baab8db1
+      create_revision: 84a1e904f44f9b0e9c4510138010edcc653163f8
+      base_revision: 84a1e904f44f9b0e9c4510138010edcc653163f8
     - platform: linux
-      create_revision: f1875d570e39de09040c8f79aa13cc56baab8db1
-      base_revision: f1875d570e39de09040c8f79aa13cc56baab8db1
+      create_revision: 84a1e904f44f9b0e9c4510138010edcc653163f8
+      base_revision: 84a1e904f44f9b0e9c4510138010edcc653163f8
     - platform: macos
-      create_revision: f1875d570e39de09040c8f79aa13cc56baab8db1
-      base_revision: f1875d570e39de09040c8f79aa13cc56baab8db1
+      create_revision: 84a1e904f44f9b0e9c4510138010edcc653163f8
+      base_revision: 84a1e904f44f9b0e9c4510138010edcc653163f8
     - platform: web
-      create_revision: f1875d570e39de09040c8f79aa13cc56baab8db1
-      base_revision: f1875d570e39de09040c8f79aa13cc56baab8db1
+      create_revision: 84a1e904f44f9b0e9c4510138010edcc653163f8
+      base_revision: 84a1e904f44f9b0e9c4510138010edcc653163f8
     - platform: windows
-      create_revision: f1875d570e39de09040c8f79aa13cc56baab8db1
-      base_revision: f1875d570e39de09040c8f79aa13cc56baab8db1
+      create_revision: 84a1e904f44f9b0e9c4510138010edcc653163f8
+      base_revision: 84a1e904f44f9b0e9c4510138010edcc653163f8
 
   # User provided section
 

+ 3 - 1
example/README.md

@@ -1 +1,3 @@
-# xterm.dart demo
+# xterm.dart demo
+
+This package contains minimalistic xterm.dart examples.

BIN
example/assets/specs_v1.json.gz


+ 12 - 0
example/ios/RunnerTests/RunnerTests.swift

@@ -0,0 +1,12 @@
+import Flutter
+import UIKit
+import XCTest
+
+class RunnerTests: XCTestCase {
+
+  func testExample() {
+    // If you add code to the Runner application, consider adding tests here.
+    // See https://developer.apple.com/documentation/xctest for more information about using XCTest.
+  }
+
+}

+ 95 - 0
example/lib/overlay.dart

@@ -0,0 +1,95 @@
+import 'package:flutter/material.dart';
+
+void main() {
+  runApp(App());
+}
+
+class App extends StatelessWidget {
+  const App({super.key});
+
+  @override
+  Widget build(BuildContext context) {
+    return MaterialApp(
+      home: Home(),
+    );
+  }
+}
+
+class Home extends StatelessWidget {
+  const Home({super.key});
+
+  @override
+  Widget build(BuildContext context) {
+    return Scaffold(
+      body: ListView(
+        children: [
+          SizedBox(
+            height: 500,
+          ),
+          OverlayDemo(),
+          SizedBox(
+            height: 2500,
+          ),
+        ],
+      ),
+    );
+  }
+}
+
+class OverlayDemo extends StatefulWidget {
+  const OverlayDemo({super.key});
+
+  @override
+  State<OverlayDemo> createState() => OverlayDemoState();
+}
+
+class OverlayDemoState extends State<OverlayDemo> {
+  final overlay = OverlayPortalController();
+  final link = LayerLink();
+
+  @override
+  Widget build(BuildContext context) {
+    return OverlayPortal(
+      controller: overlay,
+      overlayChildBuilder: (context) {
+        return CompositedTransformFollower(
+          link: link,
+          child: Stack(
+            children: [
+              Container(
+                color: Colors.yellow,
+                width: 100,
+                height: 100,
+              ),
+            ],
+          ),
+        );
+      },
+      child: MouseRegion(
+        onHover: (event) {
+          setState(() {
+            overlay.show();
+          });
+        },
+        onExit: (event) {
+          setState(() {
+            overlay.hide();
+          });
+        },
+        child: CompositedTransformTarget(
+          link: link,
+          child: GestureDetector(
+            onTap: () {
+              debugDumpLayerTree();
+            },
+            child: Container(
+              color: Colors.red,
+              width: 100,
+              height: 100,
+            ),
+          ),
+        ),
+      ),
+    );
+  }
+}

+ 268 - 0
example/lib/src/suggestion_engine.dart

@@ -0,0 +1,268 @@
+class SuggestionEngine {
+  final _specs = <String, FigCommand>{};
+
+  void load(Map<String, dynamic> specs) {
+    for (var spec in specs.entries) {
+      addSpec(spec.key, FigCommand.fromJson(spec.value));
+    }
+  }
+
+  void addSpec(String name, FigCommand spec) {
+    _specs[name] = spec;
+  }
+
+  Iterable<FigToken> getSuggestions(String command) {
+    final args = command.split(' ').where((e) => e.isNotEmpty).toList();
+
+    if (args.isEmpty) {
+      return [];
+    }
+
+    final isComplete = command.endsWith(' ');
+    return _getSuggestions(args, _specs, isComplete);
+  }
+
+  Iterable<FigToken> _getSuggestions(
+    List<String> input,
+    Map<String, FigCommand> searchList,
+    bool isComplete,
+  ) sync* {
+    assert(input.isNotEmpty);
+
+    // The subcommand scope we are currently in.
+    FigCommand? currentCommand;
+
+    // The last suggestion we recongnized. This is used to determine what to
+    // suggest next. Valid values are:
+    // - null: We are at the root of the command.
+    // - currentCommand
+    // - option of currentCommand
+    FigToken? last;
+
+    for (final part in input) {
+      if (currentCommand == null) {
+        currentCommand = searchList[part];
+        if (currentCommand == null) {
+          if (part.length >= 4) {
+            yield* searchList.values.matchPrefix(input.last);
+          }
+          return;
+        }
+        last = currentCommand;
+        continue;
+      }
+
+      final option = currentCommand.options.match(part);
+      if (option != null) {
+        last = option;
+        continue;
+      }
+
+      final subCommand = currentCommand.subCommands.match(part);
+      if (subCommand != null) {
+        currentCommand = subCommand;
+        last = currentCommand;
+        continue;
+      }
+
+      last = null;
+    }
+
+    if (currentCommand == null) {
+      return;
+    }
+
+    if (last is FigCommand) {
+      if (isComplete) {
+        yield* last.subCommands;
+        yield* last.options;
+      }
+    } else if (last is FigOption) {
+      if (isComplete) {
+        yield* last.args;
+        yield* currentCommand.options;
+      } else {
+        yield* last.args;
+      }
+    } else {
+      yield* currentCommand.subCommands.matchPrefix(input.last);
+      yield* currentCommand.options.matchPrefix(input.last);
+      yield* currentCommand.args;
+    }
+  }
+}
+
+extension _FigSuggestionSearch<T extends FigSuggestion> on Iterable<T> {
+  /// Finds the first suggestion that matches [name].
+  T? match(String name) {
+    for (final suggestion in this) {
+      if (suggestion.names.contains(name)) {
+        return suggestion;
+      }
+    }
+    return null;
+  }
+
+  /// Finds all suggestions that start with [name].
+  Iterable<T> matchPrefix(String name) sync* {
+    for (final suggestion in this) {
+      if (suggestion.names.any((e) => e.startsWith(name))) {
+        yield suggestion;
+      }
+    }
+  }
+}
+
+/// A token of a command.
+sealed class FigToken {
+  final String? description;
+
+  const FigToken({this.description});
+}
+
+/// A token of a command that can be suggested.
+sealed class FigSuggestion extends FigToken {
+  final List<String> names;
+
+  const FigSuggestion({
+    required this.names,
+    super.description,
+  });
+}
+
+class FigCommand extends FigSuggestion {
+  final List<FigCommand> subCommands;
+
+  final bool requiresSubCommand;
+
+  final List<FigOption> options;
+
+  final List<FigArgument> args;
+
+  FigCommand({
+    required super.names,
+    super.description,
+    required this.subCommands,
+    required this.requiresSubCommand,
+    required this.options,
+    required this.args,
+  });
+
+  factory FigCommand.fromJson(Map<String, dynamic> json) {
+    return FigCommand(
+      names: singleOrList<String>(json['name']),
+      description: json['description'],
+      subCommands: singleOrList(json['subcommands'])
+          .map<FigCommand>((e) => FigCommand.fromJson(e))
+          .toList(),
+      requiresSubCommand: json['requiresSubCommand'] ?? false,
+      options: singleOrList(json['options'])
+          .map<FigOption>((e) => FigOption.fromJson(e))
+          .toList(),
+      args: singleOrList(json['args'])
+          .map<FigArgument>((e) => FigArgument.fromJson(e))
+          .toList(),
+    );
+  }
+
+  @override
+  String toString() {
+    return 'FigSubCommand($names)';
+  }
+}
+
+class FigOption extends FigSuggestion {
+  final List<FigArgument> args;
+
+  final bool isPersistent;
+
+  final bool isRequired;
+
+  final String? separator;
+
+  final int? repeat;
+
+  final List<String> exclusiveOn;
+
+  final List<String> dependsOn;
+
+  FigOption({
+    required super.names,
+    super.description,
+    required this.args,
+    required this.isPersistent,
+    required this.isRequired,
+    this.separator,
+    this.repeat,
+    required this.exclusiveOn,
+    required this.dependsOn,
+  });
+
+  factory FigOption.fromJson(Map<String, dynamic> json) {
+    return FigOption(
+      names: singleOrList(json['name']).cast<String>(),
+      description: json['description'],
+      args: singleOrList(json['args'])
+          .map<FigArgument>((e) => FigArgument.fromJson(e))
+          .toList(),
+      isPersistent: json['isPersistent'] ?? false,
+      isRequired: json['isRequired'] ?? false,
+      separator: json['separator'],
+      // ignore: prefer-trailing-comma
+      repeat: switch (json['isRepeatable']) {
+        true => 0xFFFF,
+        int count => count,
+        _ => 0,
+      },
+      exclusiveOn: singleOrList<String>(json['exclusiveOn']),
+      dependsOn: singleOrList<String>(json['dependsOn']),
+    );
+  }
+
+  @override
+  String toString() {
+    return 'FigOption($names)';
+  }
+}
+
+class FigArgument extends FigToken {
+  final String? name;
+
+  final bool isDangerous;
+
+  final bool isOptional;
+
+  final bool isCommand;
+
+  final String? defaultValue;
+
+  FigArgument({
+    required this.name,
+    super.description,
+    required this.isDangerous,
+    required this.isOptional,
+    required this.isCommand,
+    this.defaultValue,
+  });
+
+  factory FigArgument.fromJson(Map<String, dynamic> json) {
+    return FigArgument(
+      name: json['name'],
+      description: json['description'],
+      isDangerous: json['isDangerous'] ?? false,
+      isOptional: json['isOptional'] ?? false,
+      isCommand: json['isCommand'] ?? false,
+      defaultValue: json['defaultValue'],
+    );
+  }
+
+  @override
+  String toString() {
+    return 'FigArgument($name)';
+  }
+}
+
+List<T> singleOrList<T>(item) {
+  if (item == null) return <T>[];
+  return item is List ? item.cast<T>() : <T>[item as T];
+}

+ 584 - 0
example/lib/suggestion.dart

@@ -0,0 +1,584 @@
+import 'dart:convert';
+import 'dart:io';
+import 'dart:math';
+
+import 'package:example/src/platform_menu.dart';
+import 'package:example/src/suggestion_engine.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter/services.dart';
+import 'package:flutter_pty/flutter_pty.dart';
+import 'package:xterm/xterm.dart';
+import 'package:xterm/suggestion.dart';
+
+final engine = SuggestionEngine();
+
+Future<Map<String, dynamic>> loadSuggestion() async {
+  final data = await rootBundle.load('assets/specs_v1.json.gz');
+  return await Stream.value(data.buffer.asUint8List())
+      .cast<List<int>>()
+      .transform(gzip.decoder)
+      .transform(utf8.decoder)
+      .transform(json.decoder)
+      .first as Map<String, dynamic>;
+}
+
+void main() async {
+  WidgetsFlutterBinding.ensureInitialized();
+  engine.load(await loadSuggestion());
+  runApp(MyApp());
+}
+
+class MyApp extends StatelessWidget {
+  @override
+  Widget build(BuildContext context) {
+    return MaterialApp(
+      title: 'xterm.dart demo',
+      debugShowCheckedModeBanner: false,
+      home: AppPlatformMenu(child: Home()),
+    );
+  }
+}
+
+class Home extends StatefulWidget {
+  Home({Key? key}) : super(key: key);
+
+  @override
+  // ignore: library_private_types_in_public_api
+  _HomeState createState() => _HomeState();
+}
+
+class _HomeState extends State<Home> {
+  late final terminal = Terminal(
+    maxLines: 10000,
+    onPrivateOSC: _handlePrivateOSC,
+  );
+
+  final terminalController = TerminalController();
+
+  final terminalKey = GlobalKey<TerminalViewState>();
+
+  final suggestionOverlay = SuggestionPortalController();
+
+  late final Pty pty;
+
+  @override
+  void initState() {
+    super.initState();
+    terminal.addListener(_handleTerminalChanged);
+
+    WidgetsBinding.instance.endOfFrame.then(
+      (_) {
+        if (mounted) _startPty();
+      },
+    );
+  }
+
+  @override
+  void dispose() {
+    super.dispose();
+    terminal.removeListener(_handleTerminalChanged);
+  }
+
+  void _startPty() {
+    pty = Pty.start(
+      shell,
+      columns: terminal.viewWidth,
+      rows: terminal.viewHeight,
+    );
+
+    pty.output
+        .cast<List<int>>()
+        .transform(Utf8Decoder())
+        .listen(terminal.write);
+
+    pty.exitCode.then((code) {
+      terminal.write('the process exited with exit code $code');
+    });
+
+    terminal.onOutput = (data) {
+      pty.write(const Utf8Encoder().convert(data));
+    };
+
+    terminal.onResize = (w, h, pw, ph) {
+      pty.resize(h, w);
+    };
+  }
+
+  /// Where the current shell prompt starts
+  CellAnchor? _promptStart;
+
+  /// Where the current user input starts
+  CellAnchor? _commandStart;
+
+  /// Where the current user input ends and the command starts to execute
+  CellAnchor? _commandEnd;
+
+  /// Where the command finishes
+  CellAnchor? _commandFinished;
+
+  void _handlePrivateOSC(String code, List<String> args) {
+    switch (code) {
+      case '133':
+        _handleFinalTermOSC(args);
+    }
+  }
+
+  void _handleFinalTermOSC(List<String> args) {
+    switch (args) {
+      case ['A']:
+        _promptStart?.dispose();
+        _promptStart = terminal.buffer.createAnchorFromCursor();
+        _commandStart?.dispose();
+        _commandStart = null;
+        _commandEnd?.dispose();
+        _commandEnd = null;
+        _commandFinished?.dispose();
+        _commandFinished = null;
+      case ['B']:
+        _commandStart?.dispose();
+        _commandStart = terminal.buffer.createAnchorFromCursor();
+        break;
+      case ['C', ..._]:
+        _commandEnd?.dispose();
+        _commandEnd = terminal.buffer.createAnchorFromCursor();
+        _handleCommandEnd();
+        break;
+      case ['D', String exitCode]:
+        _commandFinished?.dispose();
+        _commandFinished = terminal.buffer.createAnchorFromCursor();
+        _handleCommandFinished(int.tryParse(exitCode));
+        break;
+    }
+  }
+
+  void _handleCommandEnd() {
+    if (_commandStart == null || _commandEnd == null) return;
+    final command = terminal.buffer
+        .getText(BufferRangeLine(_commandStart!.offset, _commandEnd!.offset))
+        .trim();
+    print('command: $command');
+  }
+
+  void _handleCommandFinished(int? exitCode) {
+    if (_commandEnd == null || _commandFinished == null) return;
+    final result = terminal.buffer
+        .getText(BufferRangeLine(_commandEnd!.offset, _commandFinished!.offset))
+        .trim();
+    print('result: $result');
+    print('exitCode: $exitCode');
+  }
+
+  final suggestionView = SuggestionViewController();
+
+  String? get commandBuffer {
+    final commandStart = _commandStart;
+    if (commandStart == null || _commandEnd != null) {
+      return null;
+    }
+
+    var commandRange = BufferRangeLine(
+      commandStart.offset,
+      CellOffset(
+        terminal.buffer.cursorX,
+        terminal.buffer.absoluteCursorY,
+      ),
+    );
+    return terminal.buffer.getText(commandRange).trimRightNewline();
+  }
+
+  void _handleTerminalChanged() {
+    final command = commandBuffer;
+
+    if (command == null || command.isEmpty) {
+      suggestionOverlay.hide();
+      return;
+    }
+
+    final suggestions = engine.getSuggestions(command).toList();
+    suggestionView.update(suggestions);
+
+    print('suggestions: $suggestions');
+
+    if (suggestions.isNotEmpty) {
+      suggestionOverlay.update(terminalKey.currentState!.cursorRect);
+    } else {
+      suggestionOverlay.hide();
+    }
+  }
+
+  void _handleSuggestionSelected(FigToken suggestion) {
+    final command = commandBuffer;
+    if (command == null) {
+      return;
+    }
+
+    final incompleteCommand =
+        command.endsWith(' ') ? null : command.split(' ').last;
+
+    switch (suggestion) {
+      case FigCommand(:var names):
+        if (incompleteCommand == null) {
+          _emitSuggestion(names.first);
+        } else {
+          for (final name in names) {
+            if (name.startsWith(incompleteCommand)) {
+              _emitSuggestion(name.substring(incompleteCommand.length));
+              break;
+            }
+          }
+        }
+      case FigOption(:var names):
+        if (incompleteCommand == null) {
+          _emitSuggestion(names.first);
+        } else {
+          for (final name in names) {
+            if (name.startsWith(incompleteCommand)) {
+              _emitSuggestion(name.substring(incompleteCommand.length));
+              break;
+            }
+          }
+        }
+        break;
+      default:
+    }
+  }
+
+  void _emitSuggestion(String text) {
+    pty.write(const Utf8Encoder().convert(text));
+  }
+
+  @override
+  Widget build(BuildContext context) {
+    return Scaffold(
+      backgroundColor: Colors.transparent,
+      body: SuggestionPortal(
+        controller: suggestionOverlay,
+        overlayBuilder: (context) {
+          return SuggestionView(
+            suggestionView,
+            onSuggestionSelected: _handleSuggestionSelected,
+          );
+        },
+        child: TerminalView(
+          terminal,
+          key: terminalKey,
+          controller: terminalController,
+          autofocus: true,
+          backgroundOpacity: 0.7,
+          onKey: (node, event) {
+            if (event is! RawKeyDownEvent) {
+              return KeyEventResult.ignored;
+            }
+
+            if (suggestionOverlay.isShowing) {
+              switch (event.logicalKey) {
+                case LogicalKeyboardKey.escape:
+                  suggestionOverlay.hide();
+                  return KeyEventResult.handled;
+                case LogicalKeyboardKey.tab:
+                  final suggestion = suggestionView.currentSuggestion;
+                  if (suggestion != null) {
+                    _handleSuggestionSelected(suggestion);
+                    return KeyEventResult.handled;
+                  }
+                case LogicalKeyboardKey.arrowUp:
+                  suggestionView.selectPrevious();
+                  return KeyEventResult.handled;
+                case LogicalKeyboardKey.arrowDown:
+                  suggestionView.selectNext();
+                  return KeyEventResult.handled;
+                default:
+              }
+            }
+
+            return KeyEventResult.ignored;
+          },
+        ),
+      ),
+    );
+  }
+}
+
+class SuggestionViewController extends ChangeNotifier {
+  final scrollController = ScrollController();
+
+  List<FigToken> get suggestions => _suggestions;
+  List<FigToken> _suggestions = [];
+
+  var _selected = 0;
+  set selected(int index) {
+    _selected = max(0, min(index, suggestions.length - 1));
+    notifyListeners();
+  }
+
+  double get itemExtent => _itemExtent;
+  double _itemExtent = 20;
+  set itemExtent(double value) {
+    if (value == _itemExtent) return;
+    _itemExtent = value;
+    notifyListeners();
+  }
+
+  FigToken? get currentSuggestion {
+    if (_suggestions.isEmpty) return null;
+    return _suggestions[_selected];
+  }
+
+  void update(List<FigToken> suggestions) {
+    _suggestions = suggestions;
+    _selected = 0;
+    notifyListeners();
+  }
+
+  void selectNext() {
+    _selected = (_selected + 1) % _suggestions.length;
+    ensureVisible(_selected);
+    notifyListeners();
+  }
+
+  void selectPrevious() {
+    _selected = (_selected - 1) % _suggestions.length;
+    ensureVisible(_selected);
+    notifyListeners();
+  }
+
+  void ensureVisible(int index) {
+    if (!scrollController.hasClients) {
+      return;
+    }
+    final position = scrollController.position;
+
+    final targetOffset = itemExtent * index;
+    final viewportBottomOffset = position.pixels + position.viewportDimension;
+
+    if (targetOffset < position.pixels) {
+      position.jumpTo(targetOffset);
+    } else if (targetOffset + itemExtent > viewportBottomOffset) {
+      position.jumpTo(
+        max(0, targetOffset + itemExtent - position.viewportDimension),
+      );
+    }
+  }
+}
+
+class SuggestionView extends StatelessWidget {
+  const SuggestionView(
+    this.controller, {
+    super.key,
+    this.onSuggestionSelected,
+  });
+
+  final SuggestionViewController controller;
+
+  final void Function(FigToken)? onSuggestionSelected;
+
+  @override
+  Widget build(BuildContext context) {
+    return ListenableBuilder(
+      listenable: controller,
+      builder: (context, child) {
+        return _build(context);
+      },
+    );
+  }
+
+  Widget _build(BuildContext context) {
+    return ClipRRect(
+      borderRadius: BorderRadius.circular(4),
+      child: Container(
+        constraints: BoxConstraints(
+          maxWidth: 300,
+          maxHeight: 200,
+        ),
+        decoration: BoxDecoration(
+          color: Colors.grey[800],
+        ),
+        child: Column(
+          crossAxisAlignment: CrossAxisAlignment.start,
+          children: [
+            Expanded(
+              child: ListView.builder(
+                controller: controller.scrollController,
+                itemExtent: controller.itemExtent,
+                itemCount: controller._suggestions.length,
+                itemBuilder: (context, index) {
+                  final suggestion = controller._suggestions[index];
+                  return GestureDetector(
+                    onTapDown: (_) => controller.selected = index,
+                    onDoubleTapDown: (_) =>
+                        onSuggestionSelected?.call(suggestion),
+                    child: ClipRect(
+                      child: SuggestionTile(
+                        selected: index == controller._selected,
+                        suggestion: suggestion,
+                      ),
+                    ),
+                  );
+                },
+              ),
+            ),
+            if (controller.currentSuggestion != null) ...[
+              Divider(
+                height: 1,
+                thickness: 1,
+                color: Colors.grey[700],
+              ),
+              SuggestionDescriptionView(controller.currentSuggestion!),
+            ],
+          ],
+        ),
+      ),
+    );
+  }
+}
+
+class SuggestionDescriptionView extends StatefulWidget {
+  const SuggestionDescriptionView(
+    this.suggestion, {
+    super.key,
+  });
+
+  final FigToken suggestion;
+
+  @override
+  State<SuggestionDescriptionView> createState() =>
+      _SuggestionDescriptionViewState();
+}
+
+class _SuggestionDescriptionViewState extends State<SuggestionDescriptionView> {
+  var isHovering = false;
+
+  @override
+  Widget build(BuildContext context) {
+    return MouseRegion(
+      onEnter: (event) {
+        setState(() {
+          isHovering = true;
+        });
+      },
+      onExit: (event) {
+        setState(() {
+          isHovering = false;
+        });
+      },
+      child: Container(
+        padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
+        child: Text(
+          widget.suggestion.description ?? '',
+          maxLines: isHovering ? null : 1,
+          overflow: isHovering ? null : TextOverflow.ellipsis,
+          style: TextStyle(color: Colors.grey[400], fontSize: 12),
+        ),
+      ),
+    );
+  }
+}
+
+/// An item in the suggestion list.
+class SuggestionTile extends StatelessWidget {
+  const SuggestionTile({
+    super.key,
+    required this.selected,
+    required this.suggestion,
+  });
+
+  final bool selected;
+
+  final FigToken suggestion;
+
+  static final primaryStyle = TerminalStyle().toTextStyle().copyWith(
+        leadingDistribution: TextLeadingDistribution.even,
+      );
+
+  static final argumentStyle = TerminalStyle().toTextStyle().copyWith(
+        leadingDistribution: TextLeadingDistribution.even,
+        color: Colors.grey[500],
+        fontSize: 12,
+      );
+
+  @override
+  Widget build(BuildContext context) {
+    final (icon, iconColor) = _getIcon(suggestion);
+    final canSelect = suggestion is! FigArgument;
+
+    return Container(
+      color: selected ? Colors.blue[800] : Colors.transparent,
+      child: Row(
+        crossAxisAlignment: CrossAxisAlignment.center,
+        children: [
+          SizedBox(width: 4),
+          Icon(icon, size: 14, color: iconColor),
+          SizedBox(width: 4),
+          Expanded(
+            child: RichText(
+              overflow: TextOverflow.ellipsis,
+              maxLines: 1,
+              text: TextSpan(
+                text: _getContent(suggestion) ?? '',
+                children: [...buildArgs()],
+                style: canSelect
+                    ? primaryStyle
+                    : primaryStyle.copyWith(fontStyle: FontStyle.italic),
+              ),
+            ),
+          ),
+          if (selected && canSelect) ...[
+            Icon(Icons.keyboard_tab_rounded, size: 16, color: Colors.grey[400]),
+            SizedBox(width: 4),
+          ],
+        ],
+      ),
+    );
+  }
+
+  Iterable<InlineSpan> buildArgs() sync* {
+    final args = switch (suggestion) {
+      FigCommand(:var args) => args,
+      FigOption(:var args) => args,
+      _ => <FigArgument>[],
+    };
+
+    const indent = ' ';
+
+    for (final arg in args) {
+      yield TextSpan(
+        text: arg.isOptional ? '$indent[${arg.name}]' : '$indent<${arg.name}>',
+        style: argumentStyle,
+      );
+    }
+  }
+
+  static (IconData, Color) _getIcon(FigToken suggestion) {
+    return switch (suggestion) {
+      FigCommand() => (Icons.subdirectory_arrow_right, Colors.blue),
+      FigOption() => (Icons.settings, Colors.green),
+      FigArgument() => (Icons.text_fields, Colors.yellow),
+    };
+  }
+
+  static String? _getContent(FigToken suggestion) {
+    return switch (suggestion) {
+      FigCommand(:final names) => names.join(', '),
+      FigOption(names: final name) => name.join(', '),
+      FigArgument(:final name) => name,
+    };
+  }
+}
+
+String get shell {
+  if (Platform.isMacOS || Platform.isLinux) {
+    return Platform.environment['SHELL'] ?? 'bash';
+  }
+
+  if (Platform.isWindows) {
+    return 'cmd.exe';
+  }
+
+  return 'sh';
+}
+
+extension on String {
+  String trimRightNewline() {
+    return endsWith('\n') ? substring(0, length - 1) : this;
+  }
+}

+ 0 - 4
example/linux/flutter/generated_plugin_registrant.cc

@@ -6,10 +6,6 @@
 
 #include "generated_plugin_registrant.h"
 
-#include <flutter_acrylic/flutter_acrylic_plugin.h>
 
 void fl_register_plugins(FlPluginRegistry* registry) {
-  g_autoptr(FlPluginRegistrar) flutter_acrylic_registrar =
-      fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterAcrylicPlugin");
-  flutter_acrylic_plugin_register_with_registrar(flutter_acrylic_registrar);
 }

+ 0 - 1
example/linux/flutter/generated_plugins.cmake

@@ -3,7 +3,6 @@
 #
 
 list(APPEND FLUTTER_PLUGIN_LIST
-  flutter_acrylic
 )
 
 list(APPEND FLUTTER_FFI_PLUGIN_LIST

+ 0 - 2
example/macos/Flutter/GeneratedPluginRegistrant.swift

@@ -5,8 +5,6 @@
 import FlutterMacOS
 import Foundation
 
-import flutter_acrylic
 
 func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
-  FlutterAcrylicPlugin.register(with: registry.registrar(forPlugin: "FlutterAcrylicPlugin"))
 }

+ 1 - 1
example/macos/Podfile

@@ -1,4 +1,4 @@
-platform :osx, '10.11'
+platform :osx, '10.14'
 
 # CocoaPods analytics sends network stats synchronously affecting flutter build latency.
 ENV['COCOAPODS_DISABLE_STATS'] = 'true'

+ 2 - 8
example/macos/Podfile.lock

@@ -1,28 +1,22 @@
 PODS:
-  - flutter_acrylic (0.1.0):
-    - FlutterMacOS
   - flutter_pty (0.0.1):
     - FlutterMacOS
   - FlutterMacOS (1.0.0)
 
 DEPENDENCIES:
-  - flutter_acrylic (from `Flutter/ephemeral/.symlinks/plugins/flutter_acrylic/macos`)
   - flutter_pty (from `Flutter/ephemeral/.symlinks/plugins/flutter_pty/macos`)
   - FlutterMacOS (from `Flutter/ephemeral`)
 
 EXTERNAL SOURCES:
-  flutter_acrylic:
-    :path: Flutter/ephemeral/.symlinks/plugins/flutter_acrylic/macos
   flutter_pty:
     :path: Flutter/ephemeral/.symlinks/plugins/flutter_pty/macos
   FlutterMacOS:
     :path: Flutter/ephemeral
 
 SPEC CHECKSUMS:
-  flutter_acrylic: c3df24ae52ab6597197837ce59ef2a8542640c17
   flutter_pty: 41b6f848ade294be726a6b94cdd4a67c3bc52f59
-  FlutterMacOS: ae6af50a8ea7d6103d888583d46bd8328a7e9811
+  FlutterMacOS: 8f6f14fa908a6fb3fba0cd85dbd81ec4b251fb24
 
-PODFILE CHECKSUM: 6eac6b3292e5142cfc23bdeb71848a40ec51c14c
+PODFILE CHECKSUM: 353c8bcc5d5b0994e508d035b5431cfe18c1dea7
 
 COCOAPODS: 1.11.3

+ 39 - 38
example/macos/Runner.xcodeproj/project.pbxproj

@@ -3,7 +3,7 @@
 	archiveVersion = 1;
 	classes = {
 	};
-	objectVersion = 51;
+	objectVersion = 54;
 	objects = {
 
 /* Begin PBXAggregateTarget section */
@@ -26,7 +26,7 @@
 		33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F22044A3C60003C045 /* Assets.xcassets */; };
 		33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F42044A3C60003C045 /* MainMenu.xib */; };
 		33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */; };
-		A6151BD419F68182C8FF85D2 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 413CC3D3B2FBCF7B69907E26 /* Pods_Runner.framework */; };
+		A63EDDC4424F7733E4F1089E /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3BFF3568FCDF6A623BAE2DF2 /* Pods_Runner.framework */; };
 /* End PBXBuildFile section */
 
 /* Begin PBXContainerItemProxy section */
@@ -53,6 +53,7 @@
 /* End PBXCopyFilesBuildPhase section */
 
 /* Begin PBXFileReference section */
+		327BAFDBBDAC4BC4B3F62492 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = "<group>"; };
 		333000ED22D3DE5D00554162 /* Warnings.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Warnings.xcconfig; sourceTree = "<group>"; };
 		335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeneratedPluginRegistrant.swift; sourceTree = "<group>"; };
 		33CC10ED2044A3C60003C045 /* example.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = example.app; sourceTree = BUILT_PRODUCTS_DIR; };
@@ -67,12 +68,11 @@
 		33E51913231747F40026EE4D /* DebugProfile.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = DebugProfile.entitlements; sourceTree = "<group>"; };
 		33E51914231749380026EE4D /* Release.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = Release.entitlements; sourceTree = "<group>"; };
 		33E5194F232828860026EE4D /* AppInfo.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AppInfo.xcconfig; sourceTree = "<group>"; };
-		413CC3D3B2FBCF7B69907E26 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; };
-		49AA7380F80473893FD60C2E /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = "<group>"; };
+		3BFF3568FCDF6A623BAE2DF2 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; };
 		7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = "<group>"; };
+		831993642494A8EE586D7606 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = "<group>"; };
 		9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = "<group>"; };
-		E0725F2979814304119369B0 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = "<group>"; };
-		F2CA369AF483BC5EA72B3581 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = "<group>"; };
+		98BB7BB02D086BDD59672A1C /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = "<group>"; };
 /* End PBXFileReference section */
 
 /* Begin PBXFrameworksBuildPhase section */
@@ -80,7 +80,7 @@
 			isa = PBXFrameworksBuildPhase;
 			buildActionMask = 2147483647;
 			files = (
-				A6151BD419F68182C8FF85D2 /* Pods_Runner.framework in Frameworks */,
+				A63EDDC4424F7733E4F1089E /* Pods_Runner.framework in Frameworks */,
 			);
 			runOnlyForDeploymentPostprocessing = 0;
 		};
@@ -105,7 +105,7 @@
 				33CEB47122A05771004F2AC0 /* Flutter */,
 				33CC10EE2044A3C60003C045 /* Products */,
 				D73912EC22F37F3D000D13A0 /* Frameworks */,
-				F3BFAC646F7524479B4C81FB /* Pods */,
+				5D0EEDE84B575FFD99D06C18 /* Pods */,
 			);
 			sourceTree = "<group>";
 		};
@@ -152,23 +152,23 @@
 			path = Runner;
 			sourceTree = "<group>";
 		};
-		D73912EC22F37F3D000D13A0 /* Frameworks */ = {
+		5D0EEDE84B575FFD99D06C18 /* Pods */ = {
 			isa = PBXGroup;
 			children = (
-				413CC3D3B2FBCF7B69907E26 /* Pods_Runner.framework */,
+				327BAFDBBDAC4BC4B3F62492 /* Pods-Runner.debug.xcconfig */,
+				98BB7BB02D086BDD59672A1C /* Pods-Runner.release.xcconfig */,
+				831993642494A8EE586D7606 /* Pods-Runner.profile.xcconfig */,
 			);
-			name = Frameworks;
+			name = Pods;
+			path = Pods;
 			sourceTree = "<group>";
 		};
-		F3BFAC646F7524479B4C81FB /* Pods */ = {
+		D73912EC22F37F3D000D13A0 /* Frameworks */ = {
 			isa = PBXGroup;
 			children = (
-				49AA7380F80473893FD60C2E /* Pods-Runner.debug.xcconfig */,
-				F2CA369AF483BC5EA72B3581 /* Pods-Runner.release.xcconfig */,
-				E0725F2979814304119369B0 /* Pods-Runner.profile.xcconfig */,
+				3BFF3568FCDF6A623BAE2DF2 /* Pods_Runner.framework */,
 			);
-			name = Pods;
-			path = Pods;
+			name = Frameworks;
 			sourceTree = "<group>";
 		};
 /* End PBXGroup section */
@@ -178,13 +178,13 @@
 			isa = PBXNativeTarget;
 			buildConfigurationList = 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */;
 			buildPhases = (
-				3B927F223EF0FB46C39660FE /* [CP] Check Pods Manifest.lock */,
+				8E54A65EC2374C96D72E72EB /* [CP] Check Pods Manifest.lock */,
 				33CC10E92044A3C60003C045 /* Sources */,
 				33CC10EA2044A3C60003C045 /* Frameworks */,
 				33CC10EB2044A3C60003C045 /* Resources */,
 				33CC110E2044A8840003C045 /* Bundle Framework */,
 				3399D490228B24CF009A79C7 /* ShellScript */,
-				9092D76C686A497A89A31B0A /* [CP] Embed Pods Frameworks */,
+				8AEB9E182B43A490F7FF61EE /* [CP] Embed Pods Frameworks */,
 			);
 			buildRules = (
 			);
@@ -203,7 +203,7 @@
 			isa = PBXProject;
 			attributes = {
 				LastSwiftUpdateCheck = 0920;
-				LastUpgradeCheck = 1300;
+				LastUpgradeCheck = 1430;
 				ORGANIZATIONNAME = "";
 				TargetAttributes = {
 					33CC10EC2044A3C60003C045 = {
@@ -256,6 +256,7 @@
 /* Begin PBXShellScriptBuildPhase section */
 		3399D490228B24CF009A79C7 /* ShellScript */ = {
 			isa = PBXShellScriptBuildPhase;
+			alwaysOutOfDate = 1;
 			buildActionMask = 2147483647;
 			files = (
 			);
@@ -291,43 +292,43 @@
 			shellPath = /bin/sh;
 			shellScript = "\"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh && touch Flutter/ephemeral/tripwire";
 		};
-		3B927F223EF0FB46C39660FE /* [CP] Check Pods Manifest.lock */ = {
+		8AEB9E182B43A490F7FF61EE /* [CP] Embed Pods Frameworks */ = {
 			isa = PBXShellScriptBuildPhase;
 			buildActionMask = 2147483647;
 			files = (
 			);
 			inputFileListPaths = (
+				"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist",
 			);
-			inputPaths = (
-				"${PODS_PODFILE_DIR_PATH}/Podfile.lock",
-				"${PODS_ROOT}/Manifest.lock",
-			);
-			name = "[CP] Check Pods Manifest.lock";
+			name = "[CP] Embed Pods Frameworks";
 			outputFileListPaths = (
-			);
-			outputPaths = (
-				"$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt",
+				"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist",
 			);
 			runOnlyForDeploymentPostprocessing = 0;
 			shellPath = /bin/sh;
-			shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n    # print error to STDERR\n    echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n    exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
+			shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n";
 			showEnvVarsInLog = 0;
 		};
-		9092D76C686A497A89A31B0A /* [CP] Embed Pods Frameworks */ = {
+		8E54A65EC2374C96D72E72EB /* [CP] Check Pods Manifest.lock */ = {
 			isa = PBXShellScriptBuildPhase;
 			buildActionMask = 2147483647;
 			files = (
 			);
 			inputFileListPaths = (
-				"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist",
 			);
-			name = "[CP] Embed Pods Frameworks";
+			inputPaths = (
+				"${PODS_PODFILE_DIR_PATH}/Podfile.lock",
+				"${PODS_ROOT}/Manifest.lock",
+			);
+			name = "[CP] Check Pods Manifest.lock";
 			outputFileListPaths = (
-				"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist",
+			);
+			outputPaths = (
+				"$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt",
 			);
 			runOnlyForDeploymentPostprocessing = 0;
 			shellPath = /bin/sh;
-			shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n";
+			shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n    # print error to STDERR\n    echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n    exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
 			showEnvVarsInLog = 0;
 		};
 /* End PBXShellScriptBuildPhase section */
@@ -404,7 +405,7 @@
 				GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
 				GCC_WARN_UNUSED_FUNCTION = YES;
 				GCC_WARN_UNUSED_VARIABLE = YES;
-				MACOSX_DEPLOYMENT_TARGET = 10.11;
+				MACOSX_DEPLOYMENT_TARGET = 10.14;
 				MTL_ENABLE_DEBUG_INFO = NO;
 				SDKROOT = macosx;
 				SWIFT_COMPILATION_MODE = wholemodule;
@@ -483,7 +484,7 @@
 				GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
 				GCC_WARN_UNUSED_FUNCTION = YES;
 				GCC_WARN_UNUSED_VARIABLE = YES;
-				MACOSX_DEPLOYMENT_TARGET = 10.11;
+				MACOSX_DEPLOYMENT_TARGET = 10.14;
 				MTL_ENABLE_DEBUG_INFO = YES;
 				ONLY_ACTIVE_ARCH = YES;
 				SDKROOT = macosx;
@@ -530,7 +531,7 @@
 				GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
 				GCC_WARN_UNUSED_FUNCTION = YES;
 				GCC_WARN_UNUSED_VARIABLE = YES;
-				MACOSX_DEPLOYMENT_TARGET = 10.11;
+				MACOSX_DEPLOYMENT_TARGET = 10.14;
 				MTL_ENABLE_DEBUG_INFO = NO;
 				SDKROOT = macosx;
 				SWIFT_COMPILATION_MODE = wholemodule;

+ 1 - 1
example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme

@@ -1,6 +1,6 @@
 <?xml version="1.0" encoding="UTF-8"?>
 <Scheme
-   LastUpgradeVersion = "1300"
+   LastUpgradeVersion = "1430"
    version = "1.3">
    <BuildAction
       parallelizeBuildables = "YES"

BIN
example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png


BIN
example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png


BIN
example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png


BIN
example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png


BIN
example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png


BIN
example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png


BIN
example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png


+ 1 - 1
example/macos/Runner/Configs/AppInfo.xcconfig

@@ -11,4 +11,4 @@ PRODUCT_NAME = example
 PRODUCT_BUNDLE_IDENTIFIER = com.example.example
 
 // The copyright displayed in application information
-PRODUCT_COPYRIGHT = Copyright © 2022 com.example. All rights reserved.
+PRODUCT_COPYRIGHT = Copyright © 2023 com.example. All rights reserved.

+ 6 - 10
example/macos/Runner/MainFlutterWindow.swift

@@ -1,19 +1,15 @@
 import Cocoa
 import FlutterMacOS
-import flutter_acrylic
 
 class MainFlutterWindow: NSWindow {
   override func awakeFromNib() {
-   let windowFrame = self.frame
-   let blurryContainerViewController = BlurryContainerViewController()
-   self.contentViewController = blurryContainerViewController
-   self.setFrame(windowFrame, display: true)
+    let flutterViewController = FlutterViewController.init()
+    let windowFrame = self.frame
+    self.contentViewController = flutterViewController
+    self.setFrame(windowFrame, display: true)
 
-   /* Initialize the flutter_acrylic plugin */
-   MainFlutterWindowManipulator.start(mainFlutterWindow: self)
-
-   RegisterGeneratedPlugins(registry: blurryContainerViewController.flutterViewController)
+    RegisterGeneratedPlugins(registry: flutterViewController)
 
     super.awakeFromNib()
   }
-}
+}

+ 12 - 0
example/macos/RunnerTests/RunnerTests.swift

@@ -0,0 +1,12 @@
+import FlutterMacOS
+import Cocoa
+import XCTest
+
+class RunnerTests: XCTestCase {
+
+  func testExample() {
+    // If you add code to the Runner application, consider adding tests here.
+    // See https://developer.apple.com/documentation/xctest for more information about using XCTest.
+  }
+
+}

+ 153 - 101
example/pubspec.lock

@@ -5,163 +5,186 @@ packages:
     dependency: transitive
     description:
       name: _fe_analyzer_shared
-      url: "https://pub.dartlang.org"
+      sha256: d976d24314f193899a3079b14fe336215a63a3b1e1c3743eabba8f83e049e9a9
+      url: "https://pub.dev"
     source: hosted
     version: "49.0.0"
   after_layout:
     dependency: "direct main"
     description:
       name: after_layout
-      url: "https://pub.dartlang.org"
+      sha256: "95a1cb2ca1464f44f14769329fbf15987d20ab6c88f8fc5d359bd362be625f29"
+      url: "https://pub.dev"
     source: hosted
     version: "1.2.0"
   analyzer:
     dependency: transitive
     description:
       name: analyzer
-      url: "https://pub.dartlang.org"
+      sha256: "40ba2c6d2ab41a66476f8f1f099da6be0795c1b47221f5e2c5f8ad6048cdffae"
+      url: "https://pub.dev"
     source: hosted
     version: "5.1.0"
   analyzer_plugin:
     dependency: transitive
     description:
       name: analyzer_plugin
-      url: "https://pub.dartlang.org"
+      sha256: c1d5f167683de03d5ab6c3b53fc9aeefc5d59476e7810ba7bbddff50c6f4392d
+      url: "https://pub.dev"
     source: hosted
     version: "0.11.2"
   ansicolor:
     dependency: transitive
     description:
       name: ansicolor
-      url: "https://pub.dartlang.org"
+      sha256: "607f8fa9786f392043f169898923e6c59b4518242b68b8862eb8a8b7d9c30b4a"
+      url: "https://pub.dev"
     source: hosted
     version: "2.0.1"
   args:
     dependency: transitive
     description:
       name: args
-      url: "https://pub.dartlang.org"
+      sha256: c372bb384f273f0c2a8aaaa226dad84dc27c8519a691b888725dec59518ad53a
+      url: "https://pub.dev"
     source: hosted
-    version: "2.4.0"
+    version: "2.4.1"
   asn1lib:
     dependency: transitive
     description:
       name: asn1lib
-      url: "https://pub.dartlang.org"
+      sha256: ab96a1cb3beeccf8145c52e449233fe68364c9641623acd3adad66f8184f1039
+      url: "https://pub.dev"
     source: hosted
     version: "1.4.0"
   async:
     dependency: transitive
     description:
       name: async
-      url: "https://pub.dartlang.org"
+      sha256: "947bfcf187f74dbc5e146c9eb9c0f10c9f8b30743e341481c1e2ed3ecc18c20c"
+      url: "https://pub.dev"
     source: hosted
-    version: "2.9.0"
+    version: "2.11.0"
   boolean_selector:
     dependency: transitive
     description:
       name: boolean_selector
-      url: "https://pub.dartlang.org"
+      sha256: "6cfb5af12253eaf2b368f07bacc5a80d1301a071c73360d746b7f2e32d762c66"
+      url: "https://pub.dev"
     source: hosted
-    version: "2.1.0"
+    version: "2.1.1"
   characters:
     dependency: transitive
     description:
       name: characters
-      url: "https://pub.dartlang.org"
+      sha256: "04a925763edad70e8443c99234dc3328f442e811f1d8fd1a72f1c8ad0f69a605"
+      url: "https://pub.dev"
     source: hosted
-    version: "1.2.1"
+    version: "1.3.0"
   clock:
     dependency: transitive
     description:
       name: clock
-      url: "https://pub.dartlang.org"
+      sha256: cb6d7f03e1de671e34607e909a7213e31d7752be4fb66a86d29fe1eb14bfb5cf
+      url: "https://pub.dev"
     source: hosted
     version: "1.1.1"
   collection:
     dependency: transitive
     description:
       name: collection
-      url: "https://pub.dartlang.org"
+      sha256: "4a07be6cb69c84d677a6c3096fcf960cc3285a8330b4603e0d463d15d9bd934c"
+      url: "https://pub.dev"
     source: hosted
-    version: "1.16.0"
+    version: "1.17.1"
   convert:
     dependency: transitive
     description:
       name: convert
-      url: "https://pub.dartlang.org"
+      sha256: "0f08b14755d163f6e2134cb58222dd25ea2a2ee8a195e53983d57c075324d592"
+      url: "https://pub.dev"
     source: hosted
     version: "3.1.1"
   crypto:
     dependency: transitive
     description:
       name: crypto
-      url: "https://pub.dartlang.org"
+      sha256: ff625774173754681d66daaf4a448684fb04b78f902da9cb3d308c19cc5e8bab
+      url: "https://pub.dev"
     source: hosted
-    version: "3.0.2"
+    version: "3.0.3"
   csslib:
     dependency: transitive
     description:
       name: csslib
-      url: "https://pub.dartlang.org"
+      sha256: b36c7f7e24c0bdf1bf9a3da461c837d1de64b9f8beb190c9011d8c72a3dfd745
+      url: "https://pub.dev"
     source: hosted
     version: "0.17.2"
   dart_code_metrics:
     dependency: "direct dev"
     description:
       name: dart_code_metrics
-      url: "https://pub.dartlang.org"
+      sha256: "219607f5abbf4c0d254ca39ee009f9ff28df91c40aef26718fde15af6b7a6c24"
+      url: "https://pub.dev"
     source: hosted
     version: "4.21.3"
   dart_style:
     dependency: transitive
     description:
       name: dart_style
-      url: "https://pub.dartlang.org"
+      sha256: "5be16bf1707658e4c03078d4a9b90208ded217fb02c163e207d334082412f2fb"
+      url: "https://pub.dev"
     source: hosted
     version: "2.2.5"
   dartssh2:
     dependency: "direct main"
     description:
       name: dartssh2
-      url: "https://pub.dartlang.org"
+      sha256: "53a230c7dd6f487b704ceef1b29323ad64d19be89e786ccbc81e157a70417a56"
+      url: "https://pub.dev"
     source: hosted
     version: "2.8.2"
   equatable:
     dependency: transitive
     description:
       name: equatable
-      url: "https://pub.dartlang.org"
+      sha256: c2b87cb7756efdf69892005af546c56c0b5037f54d2a88269b4f347a505e3ca2
+      url: "https://pub.dev"
     source: hosted
     version: "2.0.5"
   fake_async:
     dependency: transitive
     description:
       name: fake_async
-      url: "https://pub.dartlang.org"
+      sha256: "511392330127add0b769b75a987850d136345d9227c6b94c96a04cf4a391bf78"
+      url: "https://pub.dev"
     source: hosted
     version: "1.3.1"
   ffi:
     dependency: transitive
     description:
       name: ffi
-      url: "https://pub.dartlang.org"
+      sha256: ed5337a5660c506388a9f012be0288fb38b49020ce2b45fe1f8b8323fe429f99
+      url: "https://pub.dev"
     source: hosted
-    version: "2.0.1"
+    version: "2.0.2"
   file:
     dependency: transitive
     description:
       name: file
-      url: "https://pub.dartlang.org"
+      sha256: "1b92bec4fc2a72f59a8e15af5f52cd441e4a7860b49499d69dfa817af20e925d"
+      url: "https://pub.dev"
     source: hosted
     version: "6.1.4"
   file_picker:
     dependency: "direct main"
     description:
       name: file_picker
-      url: "https://pub.dartlang.org"
+      sha256: c7a8e25ca60e7f331b153b0cb3d405828f18d3e72a6fa1d9440c86556fffc877
+      url: "https://pub.dev"
     source: hosted
-    version: "5.0.1"
+    version: "5.3.0"
   flutter:
     dependency: "direct main"
     description: flutter
@@ -171,16 +194,18 @@ packages:
     dependency: transitive
     description:
       name: flutter_plugin_android_lifecycle
-      url: "https://pub.dartlang.org"
+      sha256: "96af49aa6b57c10a312106ad6f71deed5a754029c24789bbf620ba784f0bd0b0"
+      url: "https://pub.dev"
     source: hosted
-    version: "2.0.12"
+    version: "2.0.14"
   flutter_pty:
     dependency: "direct main"
     description:
       name: flutter_pty
-      url: "https://pub.dartlang.org"
+      sha256: "08b6f37a4f394159e3a6adb6f07295e6fb6acb71270c87a4d0be187db34535cd"
+      url: "https://pub.dev"
     source: hosted
-    version: "0.3.1"
+    version: "0.4.0"
   flutter_test:
     dependency: "direct dev"
     description: flutter
@@ -195,154 +220,168 @@ packages:
     dependency: transitive
     description:
       name: glob
-      url: "https://pub.dartlang.org"
+      sha256: "4515b5b6ddb505ebdd242a5f2cc5d22d3d6a80013789debfbda7777f47ea308c"
+      url: "https://pub.dev"
     source: hosted
     version: "2.1.1"
   html:
     dependency: transitive
     description:
       name: html
-      url: "https://pub.dartlang.org"
+      sha256: "58e3491f7bf0b6a4ea5110c0c688877460d1a6366731155c4a4580e7ded773e8"
+      url: "https://pub.dev"
     source: hosted
-    version: "0.15.2"
+    version: "0.15.3"
   http:
     dependency: transitive
     description:
       name: http
-      url: "https://pub.dartlang.org"
+      sha256: "5895291c13fa8a3bd82e76d5627f69e0d85ca6a30dcac95c4ea19a5d555879c2"
+      url: "https://pub.dev"
     source: hosted
-    version: "0.13.5"
+    version: "0.13.6"
   http_parser:
     dependency: transitive
     description:
       name: http_parser
-      url: "https://pub.dartlang.org"
+      sha256: "2aa08ce0341cc9b354a498388e30986515406668dbcc4f7c950c3e715496693b"
+      url: "https://pub.dev"
     source: hosted
     version: "4.0.2"
   js:
     dependency: transitive
     description:
       name: js
-      url: "https://pub.dartlang.org"
+      sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3
+      url: "https://pub.dev"
     source: hosted
-    version: "0.6.4"
+    version: "0.6.7"
   json_annotation:
     dependency: transitive
     description:
       name: json_annotation
-      url: "https://pub.dartlang.org"
+      sha256: b10a7b2ff83d83c777edba3c6a0f97045ddadd56c944e1a23a3fdf43a1bf4467
+      url: "https://pub.dev"
     source: hosted
-    version: "4.8.0"
+    version: "4.8.1"
   lints:
     dependency: "direct dev"
     description:
       name: lints
-      url: "https://pub.dartlang.org"
+      sha256: "6b0206b0bf4f04961fc5438198ccb3a885685cd67d4d4a32cc20ad7f8adbe015"
+      url: "https://pub.dev"
     source: hosted
-    version: "2.0.1"
+    version: "2.1.0"
   matcher:
     dependency: transitive
     description:
       name: matcher
-      url: "https://pub.dartlang.org"
+      sha256: "6501fbd55da300384b768785b83e5ce66991266cec21af89ab9ae7f5ce1c4cbb"
+      url: "https://pub.dev"
     source: hosted
-    version: "0.12.12"
+    version: "0.12.15"
   material_color_utilities:
     dependency: transitive
     description:
       name: material_color_utilities
-      url: "https://pub.dartlang.org"
+      sha256: d92141dc6fe1dad30722f9aa826c7fbc896d021d792f80678280601aff8cf724
+      url: "https://pub.dev"
     source: hosted
-    version: "0.1.5"
+    version: "0.2.0"
   meta:
     dependency: transitive
     description:
       name: meta
-      url: "https://pub.dartlang.org"
+      sha256: "3c74dbf8763d36539f114c799d8a2d87343b5067e9d796ca22b5eb8437090ee3"
+      url: "https://pub.dev"
     source: hosted
-    version: "1.8.0"
+    version: "1.9.1"
   package_config:
     dependency: transitive
     description:
       name: package_config
-      url: "https://pub.dartlang.org"
+      sha256: "1c5b77ccc91e4823a5af61ee74e6b972db1ef98c2ff5a18d3161c982a55448bd"
+      url: "https://pub.dev"
     source: hosted
     version: "2.1.0"
   path:
     dependency: "direct main"
     description:
       name: path
-      url: "https://pub.dartlang.org"
+      sha256: "8829d8a55c13fc0e37127c29fedf290c102f4e40ae94ada574091fe0ff96c917"
+      url: "https://pub.dev"
     source: hosted
-    version: "1.8.2"
+    version: "1.8.3"
   petitparser:
     dependency: transitive
     description:
       name: petitparser
-      url: "https://pub.dartlang.org"
+      sha256: cb3798bef7fc021ac45b308f4b51208a152792445cce0448c9a4ba5879dd8750
+      url: "https://pub.dev"
     source: hosted
-    version: "5.1.0"
+    version: "5.4.0"
   pinenacl:
     dependency: transitive
     description:
       name: pinenacl
-      url: "https://pub.dartlang.org"
+      sha256: "3a5503637587d635647c93ea9a8fecf48a420cc7deebe6f1fc85c2a5637ab327"
+      url: "https://pub.dev"
     source: hosted
     version: "0.5.1"
   platform:
     dependency: transitive
     description:
       name: platform
-      url: "https://pub.dartlang.org"
+      sha256: "4a451831508d7d6ca779f7ac6e212b4023dd5a7d08a27a63da33756410e32b76"
+      url: "https://pub.dev"
     source: hosted
     version: "3.1.0"
-  platform_info:
-    dependency: transitive
-    description:
-      name: platform_info
-      url: "https://pub.dartlang.org"
-    source: hosted
-    version: "3.2.0"
   plugin_platform_interface:
     dependency: transitive
     description:
       name: plugin_platform_interface
-      url: "https://pub.dartlang.org"
+      sha256: "6a2128648c854906c53fa8e33986fc0247a1116122f9534dd20e3ab9e16a32bc"
+      url: "https://pub.dev"
     source: hosted
     version: "2.1.4"
   pointycastle:
     dependency: transitive
     description:
       name: pointycastle
-      url: "https://pub.dartlang.org"
+      sha256: "7c1e5f0d23c9016c5bbd8b1473d0d3fb3fc851b876046039509e18e0c7485f2c"
+      url: "https://pub.dev"
     source: hosted
     version: "3.7.3"
   process:
     dependency: transitive
     description:
       name: process
-      url: "https://pub.dartlang.org"
+      sha256: "53fd8db9cec1d37b0574e12f07520d582019cb6c44abf5479a01505099a34a09"
+      url: "https://pub.dev"
     source: hosted
     version: "4.2.4"
   pub_semver:
     dependency: transitive
     description:
       name: pub_semver
-      url: "https://pub.dartlang.org"
+      sha256: "40d3ab1bbd474c4c2328c91e3a7df8c6dd629b79ece4c4bd04bee496a224fb0c"
+      url: "https://pub.dev"
     source: hosted
-    version: "2.1.3"
+    version: "2.1.4"
   pub_updater:
     dependency: transitive
     description:
       name: pub_updater
-      url: "https://pub.dartlang.org"
+      sha256: "42890302ab2672adf567dc2b20e55b4ecc29d7e19c63b6b98143ab68dd717d3a"
+      url: "https://pub.dev"
     source: hosted
     version: "0.2.4"
   quiver:
     dependency: transitive
     description:
       name: quiver
-      url: "https://pub.dartlang.org"
+      sha256: b1c1ac5ce6688d77f65f3375a9abb9319b3cb32486bdc7a1e0fdf004d7ba4e47
+      url: "https://pub.dev"
     source: hosted
     version: "3.2.1"
   sky_engine:
@@ -354,100 +393,113 @@ packages:
     dependency: transitive
     description:
       name: source_span
-      url: "https://pub.dartlang.org"
+      sha256: dd904f795d4b4f3b870833847c461801f6750a9fa8e61ea5ac53f9422b31f250
+      url: "https://pub.dev"
     source: hosted
-    version: "1.9.0"
+    version: "1.9.1"
   stack_trace:
     dependency: transitive
     description:
       name: stack_trace
-      url: "https://pub.dartlang.org"
+      sha256: c3c7d8edb15bee7f0f74debd4b9c5f3c2ea86766fe4178eb2a18eb30a0bdaed5
+      url: "https://pub.dev"
     source: hosted
-    version: "1.10.0"
+    version: "1.11.0"
   stream_channel:
     dependency: transitive
     description:
       name: stream_channel
-      url: "https://pub.dartlang.org"
+      sha256: "83615bee9045c1d322bbbd1ba209b7a749c2cbcdcb3fdd1df8eb488b3279c1c8"
+      url: "https://pub.dev"
     source: hosted
-    version: "2.1.0"
+    version: "2.1.1"
   string_scanner:
     dependency: transitive
     description:
       name: string_scanner
-      url: "https://pub.dartlang.org"
+      sha256: "556692adab6cfa87322a115640c11f13cb77b3f076ddcc5d6ae3c20242bedcde"
+      url: "https://pub.dev"
     source: hosted
-    version: "1.1.1"
+    version: "1.2.0"
   term_glyph:
     dependency: transitive
     description:
       name: term_glyph
-      url: "https://pub.dartlang.org"
+      sha256: a29248a84fbb7c79282b40b8c72a1209db169a2e0542bce341da992fe1bc7e84
+      url: "https://pub.dev"
     source: hosted
     version: "1.2.1"
   test_api:
     dependency: transitive
     description:
       name: test_api
-      url: "https://pub.dartlang.org"
+      sha256: eb6ac1540b26de412b3403a163d919ba86f6a973fe6cc50ae3541b80092fdcfb
+      url: "https://pub.dev"
     source: hosted
-    version: "0.4.12"
+    version: "0.5.1"
   typed_data:
     dependency: transitive
     description:
       name: typed_data
-      url: "https://pub.dartlang.org"
+      sha256: facc8d6582f16042dd49f2463ff1bd6e2c9ef9f3d5da3d9b087e244a7b564b3c
+      url: "https://pub.dev"
     source: hosted
-    version: "1.3.1"
+    version: "1.3.2"
   vector_math:
     dependency: transitive
     description:
       name: vector_math
-      url: "https://pub.dartlang.org"
+      sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803"
+      url: "https://pub.dev"
     source: hosted
-    version: "2.1.2"
+    version: "2.1.4"
   watcher:
     dependency: transitive
     description:
       name: watcher
-      url: "https://pub.dartlang.org"
+      sha256: "3d2ad6751b3c16cf07c7fca317a1413b3f26530319181b37e3b9039b84fc01d8"
+      url: "https://pub.dev"
     source: hosted
-    version: "1.0.2"
+    version: "1.1.0"
   win32:
     dependency: transitive
     description:
       name: win32
-      url: "https://pub.dartlang.org"
+      sha256: "5a751eddf9db89b3e5f9d50c20ab8612296e4e8db69009788d6c8b060a84191c"
+      url: "https://pub.dev"
     source: hosted
-    version: "2.7.0"
+    version: "4.1.4"
   xml:
     dependency: transitive
     description:
       name: xml
-      url: "https://pub.dartlang.org"
+      sha256: "5bc72e1e45e941d825fd7468b9b4cc3b9327942649aeb6fc5cdbf135f0a86e84"
+      url: "https://pub.dev"
     source: hosted
-    version: "6.1.0"
+    version: "6.3.0"
   xterm:
     dependency: "direct main"
     description:
       path: ".."
       relative: true
     source: path
-    version: "3.5.0"
+    version: "3.6.1-pre"
   yaml:
     dependency: transitive
     description:
       name: yaml
-      url: "https://pub.dartlang.org"
+      sha256: "75769501ea3489fca56601ff33454fe45507ea3bfb014161abc3b43ae25989d5"
+      url: "https://pub.dev"
     source: hosted
-    version: "3.1.1"
+    version: "3.1.2"
   zmodem:
     dependency: transitive
     description:
       name: zmodem
-      url: "https://pub.dartlang.org"
+      sha256: "3b7e5b29f3a7d8aee472029b05165a68438eff2f3f7766edf13daba1e297adbf"
+      url: "https://pub.dev"
     source: hosted
     version: "0.0.6"
 sdks:
-  dart: ">=2.18.0 <3.0.0"
-  flutter: ">=3.0.0"
+  dart: ">=3.0.0 <4.0.0"
+  flutter: ">=3.10.0"

+ 4 - 3
example/pubspec.yaml

@@ -18,7 +18,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev
 version: 1.0.0+1
 
 environment:
-  sdk: ">=2.17.0 <3.0.0"
+  sdk: ">=3.0.0 <4.0.0"
 
 dependencies:
   xterm:
@@ -26,7 +26,7 @@ dependencies:
 
   dartssh2: ^2.5.0
 
-  flutter_pty: ^0.3.0
+  flutter_pty: ^0.4.0
 
   flutter:
     sdk: flutter
@@ -62,7 +62,8 @@ flutter:
   uses-material-design: true
 
   # To add assets to your application, add an assets section, like this:
-  # assets:
+  assets:
+    - assets/
   #   - images/a_dot_burr.jpeg
   #   - images/a_dot_ham.jpeg
 

+ 0 - 3
example/windows/flutter/generated_plugin_registrant.cc

@@ -6,9 +6,6 @@
 
 #include "generated_plugin_registrant.h"
 
-#include <flutter_acrylic/flutter_acrylic_plugin.h>
 
 void RegisterPlugins(flutter::PluginRegistry* registry) {
-  FlutterAcrylicPluginRegisterWithRegistrar(
-      registry->GetRegistrarForPlugin("FlutterAcrylicPlugin"));
 }

+ 0 - 1
example/windows/flutter/generated_plugins.cmake

@@ -3,7 +3,6 @@
 #
 
 list(APPEND FLUTTER_PLUGIN_LIST
-  flutter_acrylic
 )
 
 list(APPEND FLUTTER_FFI_PLUGIN_LIST

+ 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';

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

@@ -0,0 +1,41 @@
+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 registerCallback(void Function() callback) {
+    assert(!_disposed);
+    _disposables.add(_DisposeCallback(callback));
+  }
+
+  void dispose() {
+    _disposed = true;
+    for (final disposable in _disposables) {
+      disposable.dispose();
+    }
+    _onDisposed.emit(null);
+  }
+}
+
+class _DisposeCallback with Disposable {
+  final void Function() callback;
+
+  _DisposeCallback(this.callback);
+
+  @override
+  void dispose() {
+    super.dispose();
+    callback();
+  }
+}

+ 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.

+ 21 - 3
lib/src/terminal_view.dart

@@ -45,6 +45,7 @@ class TerminalView extends StatefulWidget {
     this.alwaysShowCursor = false,
     this.deleteDetection = false,
     this.shortcuts,
+    this.onKey,
     this.readOnly = false,
     this.hardwareKeyboardOnly = false,
     this.simulateScroll = true,
@@ -126,6 +127,10 @@ class TerminalView extends StatefulWidget {
   /// of the terminal If not provided, [defaultTerminalShortcuts] will be used.
   final Map<ShortcutActivator, Intent>? shortcuts;
 
+  /// Keyboard event handler of the terminal. This has higher priority than
+  /// [shortcuts] and input handler of the terminal.
+  final FocusOnKeyCallback? onKey;
+
   /// True if no input should send to the terminal.
   final bool readOnly;
 
@@ -268,7 +273,7 @@ class TerminalViewState extends State<TerminalView> {
             widget.terminal.keyInput(TerminalKey.enter);
           }
         },
-        onKey: _onKeyEvent,
+        onKey: _handleKeyEvent,
         readOnly: widget.readOnly,
         child: child,
       );
@@ -280,7 +285,7 @@ class TerminalViewState extends State<TerminalView> {
         autofocus: widget.autofocus,
         onInsert: _onInsert,
         onComposing: _onComposing,
-        onKey: _onKeyEvent,
+        onKey: _handleKeyEvent,
       );
     }
 
@@ -330,6 +335,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);
@@ -380,7 +393,12 @@ class TerminalViewState extends State<TerminalView> {
     setState(() => _composingText = text);
   }
 
-  KeyEventResult _onKeyEvent(FocusNode focusNode, RawKeyEvent event) {
+  KeyEventResult _handleKeyEvent(FocusNode focusNode, RawKeyEvent event) {
+    final resultOverride = widget.onKey?.call(focusNode, event);
+    if (resultOverride != null && resultOverride != KeyEventResult.ignored) {
+      return resultOverride;
+    }
+
     // ignore: invalid_use_of_protected_member
     final shortcutResult = _shortcutManager.handleKeypress(
       focusNode.context!,

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

@@ -1,6 +1,8 @@
 import 'package:flutter/material.dart';
 import 'package:meta/meta.dart';
+import 'package:xterm/src/base/disposable.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 +18,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;
 
-  void setSelection(BufferRange? range) {
-    range = range?.normalized;
+  List<TerminalHighlight> get highlights => _highlights;
+  final _highlights = <TerminalHighlight>[];
 
-    if (_selection != range) {
-      _selection = range;
-      notifyListeners();
+  BufferRange? get selection {
+    final base = _selectionBase;
+    final extent = _selectionExtent;
+
+    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 +87,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 +120,55 @@ class TerminalController with ChangeNotifier {
         : _pointerInputs.inputs.contains(pointerInput);
   }
 
-  void addHighlight(BufferRange? range) {
-    // TODO: implement addHighlight
+  /// Creates a new highlight on the terminal from [p1] to [p2] with the given
+  /// [color]. The highlight will be removed when the returned object is
+  /// disposed.
+  TerminalHighlight highlight({
+    required CellAnchor p1,
+    required CellAnchor p2,
+    required Color color,
+  }) {
+    final highlight = TerminalHighlight(
+      this,
+      p1: p1,
+      p2: p2,
+      color: color,
+    );
+
+    _highlights.add(highlight);
+    notifyListeners();
+
+    highlight.registerCallback(() {
+      _highlights.remove(highlight);
+      notifyListeners();
+    });
+
+    return highlight;
   }
+}
+
+class TerminalHighlight with Disposable {
+  final TerminalController owner;
+
+  final CellAnchor p1;
 
-  void clearHighlight() {
-    // TODO: implement clearHighlight
+  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);
   }
 }

+ 1 - 3
lib/src/ui/keyboard_visibility.dart

@@ -1,5 +1,3 @@
-import 'dart:ui';
-
 import 'package:flutter/widgets.dart';
 
 class KeyboardVisibilty extends StatefulWidget {
@@ -36,7 +34,7 @@ class KeyboardVisibiltyState extends State<KeyboardVisibilty>
 
   @override
   void didChangeMetrics() {
-    final bottomInset = window.viewInsets.bottom;
+    final bottomInset = View.of(context).viewInsets.bottom;
 
     if (bottomInset != _lastBottomInset) {
       if (bottomInset > 0) {

+ 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 - 5
lib/src/ui/shortcut/actions.dart

@@ -1,9 +1,8 @@
 import 'package:flutter/services.dart';
 import 'package:flutter/widgets.dart';
-import 'package:xterm/src/core/buffer/cell_offset.dart';
-import 'package:xterm/src/core/buffer/range_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 +51,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(

+ 199 - 0
lib/suggestion.dart

@@ -0,0 +1,199 @@
+import 'dart:math';
+
+import 'package:flutter/foundation.dart';
+import 'package:flutter/rendering.dart';
+import 'package:flutter/widgets.dart';
+
+/// Controls the location of the suggestion popup of [SuggestionPortal].
+class SuggestionPortalController extends OverlayPortalController {
+  final _cursorRect = ValueNotifier<Rect>(Rect.zero);
+
+  /// Updates the location of the suggestion popup to [rect]. If the popup is
+  /// not showing, it will be shown after this call.
+  void update(Rect rect) {
+    _cursorRect.value = rect;
+    if (!isShowing) show();
+  }
+}
+
+/// A convenience widget to place a suggestion popup around the cursor specified
+/// by [SuggestionPortalController].
+class SuggestionPortal extends StatefulWidget {
+  const SuggestionPortal({
+    super.key,
+    required this.controller,
+    required this.overlayBuilder,
+    required this.child,
+    this.padding = const EdgeInsets.all(8),
+    this.cursorMargin = const EdgeInsets.all(4),
+  });
+
+  final SuggestionPortalController controller;
+
+  final WidgetBuilder overlayBuilder;
+
+  /// The minimum space between [child] and the screen edge.
+  final EdgeInsets padding;
+
+  /// The minimum space between [child] and the cursor. Currently, only top and
+  /// bottom are used.
+  final EdgeInsets cursorMargin;
+
+  final Widget child;
+
+  @override
+  State<SuggestionPortal> createState() => _SuggestionPortalState();
+}
+
+class _SuggestionPortalState extends State<SuggestionPortal> {
+  @override
+  Widget build(BuildContext context) {
+    return OverlayPortal.targetsRootOverlay(
+      controller: widget.controller,
+      overlayChildBuilder: (context) {
+        return SuggestionLayout(
+          cursorRect: widget.controller._cursorRect,
+          padding: widget.padding,
+          cursorMargin: widget.cursorMargin,
+          child: widget.overlayBuilder(context),
+        );
+      },
+      child: widget.child,
+    );
+  }
+}
+
+/// A widget that places [child] around [cursorRect].
+class SuggestionLayout extends SingleChildRenderObjectWidget {
+  SuggestionLayout({
+    super.child,
+    required this.cursorRect,
+    required this.padding,
+    required this.cursorMargin,
+  });
+
+  /// The location of the cursor relative to the top left corner of this widget.
+  final ValueListenable<Rect> cursorRect;
+
+  /// The minimum space between [child] and the edge of this widget.
+  final EdgeInsets padding;
+
+  /// The minimum space between [child] and the cursor. Currently, only top and
+  /// bottom are used.
+  final EdgeInsets cursorMargin;
+
+  @override
+  RenderObject createRenderObject(BuildContext context) {
+    return RenderCompletionLayout(
+      null,
+      cursorRect: cursorRect,
+      padding: padding,
+      cursorMargin: cursorMargin,
+    );
+  }
+
+  @override
+  void updateRenderObject(
+    BuildContext context,
+    covariant RenderCompletionLayout renderObject,
+  ) {
+    renderObject.cursorRect = cursorRect;
+    renderObject.padding = padding;
+    renderObject.cursorMargin = cursorMargin;
+  }
+}
+
+class RenderCompletionLayout extends RenderShiftedBox {
+  RenderCompletionLayout(
+    super.child, {
+    required ValueListenable<Rect> cursorRect,
+    required EdgeInsets padding,
+    required EdgeInsets cursorMargin,
+  })  : _cursorRect = cursorRect,
+        _padding = padding,
+        _cursorPadding = cursorMargin;
+
+  ValueListenable<Rect> _cursorRect;
+  ValueListenable<Rect> get cursorRect => _cursorRect;
+  set cursorRect(ValueListenable<Rect> value) {
+    if (_cursorRect == value) return;
+    _cursorRect.removeListener(markNeedsLayout);
+    _cursorRect = value;
+    _cursorRect.addListener(markNeedsLayout);
+    markNeedsLayout();
+  }
+
+  EdgeInsets _padding;
+  EdgeInsets get padding => _padding;
+  set padding(EdgeInsets value) {
+    if (_padding == value) return;
+    _padding = value;
+    markNeedsLayout();
+  }
+
+  EdgeInsets _cursorPadding;
+  EdgeInsets get cursorMargin => _cursorPadding;
+  set cursorMargin(EdgeInsets value) {
+    if (_cursorPadding == value) return;
+    _cursorPadding = value;
+    markNeedsLayout();
+  }
+
+  @override
+  void attach(covariant PipelineOwner owner) {
+    cursorRect.addListener(markNeedsLayout);
+    super.attach(owner);
+  }
+
+  @override
+  void detach() {
+    cursorRect.removeListener(markNeedsLayout);
+    super.detach();
+  }
+
+  @override
+  void performLayout() {
+    final child = this.child;
+
+    if (child == null) {
+      size = constraints.smallest;
+      return;
+    }
+
+    size = constraints.biggest;
+
+    // space available for the completion overlay above the cursor
+    final spaceAbove = cursorRect.value.top - padding.top - cursorMargin.top;
+
+    // space available for the completion overlay below the cursor
+    final spaceBelow = size.height -
+        cursorRect.value.bottom -
+        padding.bottom -
+        cursorMargin.bottom;
+
+    final childConstraints = BoxConstraints(
+      minWidth: 0,
+      maxWidth: size.width - padding.horizontal,
+      minHeight: 0,
+      maxHeight: max(spaceAbove, spaceBelow),
+    );
+
+    child.layout(childConstraints, parentUsesSize: true);
+
+    // Whether the completion overlay can be placed above the cursor.
+    final fitsBelow = spaceBelow >= child.size.height;
+
+    final childParentData = child.parentData as BoxParentData;
+    childParentData.offset = Offset(
+      min(
+        size.width - padding.right - child.size.width,
+        cursorRect.value.left,
+      ),
+      // Showing the completion overlay below the cursor is preferred, unless
+      // there's insufficient space for it.
+      fitsBelow
+          ? cursorRect.value.bottom + cursorMargin.bottom
+          : cursorRect.value.top - cursorMargin.top - child.size.height,
+    );
+  }
+}

+ 259 - 138
pubspec.lock

@@ -5,212 +5,250 @@ packages:
     dependency: transitive
     description:
       name: _fe_analyzer_shared
-      url: "https://pub.dartlang.org"
+      sha256: "8880b4cfe7b5b17d57c052a5a3a8cc1d4f546261c7cc8fbd717bd53f48db0568"
+      url: "https://pub.dev"
     source: hosted
-    version: "47.0.0"
+    version: "59.0.0"
   analyzer:
     dependency: transitive
     description:
       name: analyzer
-      url: "https://pub.dartlang.org"
+      sha256: a89627f49b0e70e068130a36571409726b04dab12da7e5625941d2c8ec278b96
+      url: "https://pub.dev"
     source: hosted
-    version: "4.7.0"
+    version: "5.11.1"
   analyzer_plugin:
     dependency: transitive
     description:
       name: analyzer_plugin
-      url: "https://pub.dartlang.org"
+      sha256: c1d5f167683de03d5ab6c3b53fc9aeefc5d59476e7810ba7bbddff50c6f4392d
+      url: "https://pub.dev"
     source: hosted
-    version: "0.10.0"
+    version: "0.11.2"
   ansicolor:
     dependency: transitive
     description:
       name: ansicolor
-      url: "https://pub.dartlang.org"
+      sha256: "607f8fa9786f392043f169898923e6c59b4518242b68b8862eb8a8b7d9c30b4a"
+      url: "https://pub.dev"
     source: hosted
     version: "2.0.1"
   args:
     dependency: transitive
     description:
       name: args
-      url: "https://pub.dartlang.org"
+      sha256: c372bb384f273f0c2a8aaaa226dad84dc27c8519a691b888725dec59518ad53a
+      url: "https://pub.dev"
     source: hosted
-    version: "2.4.0"
+    version: "2.4.1"
   async:
     dependency: transitive
     description:
       name: async
-      url: "https://pub.dartlang.org"
+      sha256: "947bfcf187f74dbc5e146c9eb9c0f10c9f8b30743e341481c1e2ed3ecc18c20c"
+      url: "https://pub.dev"
     source: hosted
-    version: "2.9.0"
+    version: "2.11.0"
   boolean_selector:
     dependency: transitive
     description:
       name: boolean_selector
-      url: "https://pub.dartlang.org"
+      sha256: "6cfb5af12253eaf2b368f07bacc5a80d1301a071c73360d746b7f2e32d762c66"
+      url: "https://pub.dev"
     source: hosted
-    version: "2.1.0"
+    version: "2.1.1"
   build:
     dependency: transitive
     description:
       name: build
-      url: "https://pub.dartlang.org"
+      sha256: "43865b79fbb78532e4bff7c33087aa43b1d488c4fdef014eaef568af6d8016dc"
+      url: "https://pub.dev"
     source: hosted
-    version: "2.3.1"
+    version: "2.4.0"
   build_config:
     dependency: transitive
     description:
       name: build_config
-      url: "https://pub.dartlang.org"
+      sha256: bf80fcfb46a29945b423bd9aad884590fb1dc69b330a4d4700cac476af1708d1
+      url: "https://pub.dev"
     source: hosted
     version: "1.1.1"
   build_daemon:
     dependency: transitive
     description:
       name: build_daemon
-      url: "https://pub.dartlang.org"
+      sha256: "5f02d73eb2ba16483e693f80bee4f088563a820e47d1027d4cdfe62b5bb43e65"
+      url: "https://pub.dev"
     source: hosted
-    version: "3.1.1"
+    version: "4.0.0"
   build_resolvers:
     dependency: transitive
     description:
       name: build_resolvers
-      url: "https://pub.dartlang.org"
+      sha256: db49b8609ef8c81cca2b310618c3017c00f03a92af44c04d310b907b2d692d95
+      url: "https://pub.dev"
     source: hosted
-    version: "2.0.10"
+    version: "2.2.0"
   build_runner:
     dependency: "direct dev"
     description:
       name: build_runner
-      url: "https://pub.dartlang.org"
+      sha256: "220ae4553e50d7c21a17c051afc7b183d28a24a420502e842f303f8e4e6edced"
+      url: "https://pub.dev"
     source: hosted
-    version: "2.3.0"
+    version: "2.4.4"
   build_runner_core:
     dependency: transitive
     description:
       name: build_runner_core
-      url: "https://pub.dartlang.org"
+      sha256: "30859c90e9ddaccc484f56303931f477b1f1ba2bab74aa32ed5d6ce15870f8cf"
+      url: "https://pub.dev"
     source: hosted
-    version: "7.2.7"
+    version: "7.2.8"
   built_collection:
     dependency: transitive
     description:
       name: built_collection
-      url: "https://pub.dartlang.org"
+      sha256: "376e3dd27b51ea877c28d525560790aee2e6fbb5f20e2f85d5081027d94e2100"
+      url: "https://pub.dev"
     source: hosted
     version: "5.1.1"
   built_value:
     dependency: transitive
     description:
       name: built_value
-      url: "https://pub.dartlang.org"
+      sha256: "2f17434bd5d52a26762043d6b43bb53b3acd029b4d9071a329f46d67ef297e6d"
+      url: "https://pub.dev"
     source: hosted
-    version: "8.4.4"
+    version: "8.5.0"
   characters:
     dependency: transitive
     description:
       name: characters
-      url: "https://pub.dartlang.org"
+      sha256: "04a925763edad70e8443c99234dc3328f442e811f1d8fd1a72f1c8ad0f69a605"
+      url: "https://pub.dev"
     source: hosted
-    version: "1.2.1"
+    version: "1.3.0"
   checked_yaml:
     dependency: transitive
     description:
       name: checked_yaml
-      url: "https://pub.dartlang.org"
+      sha256: feb6bed21949061731a7a75fc5d2aa727cf160b91af9a3e464c5e3a32e28b5ff
+      url: "https://pub.dev"
     source: hosted
-    version: "2.0.2"
+    version: "2.0.3"
   clock:
     dependency: transitive
     description:
       name: clock
-      url: "https://pub.dartlang.org"
+      sha256: cb6d7f03e1de671e34607e909a7213e31d7752be4fb66a86d29fe1eb14bfb5cf
+      url: "https://pub.dev"
     source: hosted
     version: "1.1.1"
   code_builder:
     dependency: transitive
     description:
       name: code_builder
-      url: "https://pub.dartlang.org"
+      sha256: "0d43dd1288fd145de1ecc9a3948ad4a6d5a82f0a14c4fdd0892260787d975cbe"
+      url: "https://pub.dev"
     source: hosted
     version: "4.4.0"
   collection:
     dependency: transitive
     description:
       name: collection
-      url: "https://pub.dartlang.org"
+      sha256: "4a07be6cb69c84d677a6c3096fcf960cc3285a8330b4603e0d463d15d9bd934c"
+      url: "https://pub.dev"
     source: hosted
-    version: "1.16.0"
+    version: "1.17.1"
   convert:
     dependency: "direct main"
     description:
       name: convert
-      url: "https://pub.dartlang.org"
+      sha256: "0f08b14755d163f6e2134cb58222dd25ea2a2ee8a195e53983d57c075324d592"
+      url: "https://pub.dev"
     source: hosted
     version: "3.1.1"
   coverage:
     dependency: transitive
     description:
       name: coverage
-      url: "https://pub.dartlang.org"
+      sha256: "2fb815080e44a09b85e0f2ca8a820b15053982b2e714b59267719e8a9ff17097"
+      url: "https://pub.dev"
     source: hosted
     version: "1.6.3"
   crypto:
     dependency: transitive
     description:
       name: crypto
-      url: "https://pub.dartlang.org"
+      sha256: ff625774173754681d66daaf4a448684fb04b78f902da9cb3d308c19cc5e8bab
+      url: "https://pub.dev"
     source: hosted
-    version: "3.0.2"
+    version: "3.0.3"
   csslib:
     dependency: transitive
     description:
       name: csslib
-      url: "https://pub.dartlang.org"
+      sha256: b36c7f7e24c0bdf1bf9a3da461c837d1de64b9f8beb190c9011d8c72a3dfd745
+      url: "https://pub.dev"
     source: hosted
     version: "0.17.2"
   dart_code_metrics:
     dependency: "direct dev"
     description:
       name: dart_code_metrics
-      url: "https://pub.dartlang.org"
+      sha256: "162c81dbd0a2ba182f38ca615335f3e8878f212ec7beea83d6bfad4e99eb541a"
+      url: "https://pub.dev"
     source: hosted
-    version: "4.19.2"
+    version: "5.7.3"
+  dart_code_metrics_presets:
+    dependency: transitive
+    description:
+      name: dart_code_metrics_presets
+      sha256: "22e27f98e8c7d8b11cca43d2656a822935280747050ae65e8cd03c52d09c0d1c"
+      url: "https://pub.dev"
+    source: hosted
+    version: "1.7.0"
   dart_style:
     dependency: transitive
     description:
       name: dart_style
-      url: "https://pub.dartlang.org"
+      sha256: f4f1f73ab3fd2afcbcca165ee601fe980d966af6a21b5970c6c9376955c528ad
+      url: "https://pub.dev"
     source: hosted
-    version: "2.2.4"
+    version: "2.3.1"
   equatable:
     dependency: "direct main"
     description:
       name: equatable
-      url: "https://pub.dartlang.org"
+      sha256: c2b87cb7756efdf69892005af546c56c0b5037f54d2a88269b4f347a505e3ca2
+      url: "https://pub.dev"
     source: hosted
     version: "2.0.5"
   fake_async:
     dependency: transitive
     description:
       name: fake_async
-      url: "https://pub.dartlang.org"
+      sha256: "511392330127add0b769b75a987850d136345d9227c6b94c96a04cf4a391bf78"
+      url: "https://pub.dev"
     source: hosted
     version: "1.3.1"
   file:
     dependency: transitive
     description:
       name: file
-      url: "https://pub.dartlang.org"
+      sha256: "1b92bec4fc2a72f59a8e15af5f52cd441e4a7860b49499d69dfa817af20e925d"
+      url: "https://pub.dev"
     source: hosted
     version: "6.1.4"
   fixnum:
     dependency: transitive
     description:
       name: fixnum
-      url: "https://pub.dartlang.org"
+      sha256: "25517a4deb0c03aa0f32fd12db525856438902d9c16536311e76cdc57b31d7d1"
+      url: "https://pub.dev"
     source: hosted
-    version: "1.0.1"
+    version: "1.1.0"
   flutter:
     dependency: "direct main"
     description: flutter
@@ -225,205 +263,258 @@ packages:
     dependency: transitive
     description:
       name: frontend_server_client
-      url: "https://pub.dartlang.org"
+      sha256: "408e3ca148b31c20282ad6f37ebfa6f4bdc8fede5b74bc2f08d9d92b55db3612"
+      url: "https://pub.dev"
     source: hosted
-    version: "2.1.3"
+    version: "3.2.0"
   glob:
     dependency: transitive
     description:
       name: glob
-      url: "https://pub.dartlang.org"
+      sha256: "4515b5b6ddb505ebdd242a5f2cc5d22d3d6a80013789debfbda7777f47ea308c"
+      url: "https://pub.dev"
     source: hosted
     version: "2.1.1"
   graphs:
     dependency: transitive
     description:
       name: graphs
-      url: "https://pub.dartlang.org"
+      sha256: "772db3d53d23361d4ffcf5a9bb091cf3ee9b22f2be52cd107cd7a2683a89ba0e"
+      url: "https://pub.dev"
     source: hosted
-    version: "2.2.0"
+    version: "2.3.0"
   html:
     dependency: transitive
     description:
       name: html
-      url: "https://pub.dartlang.org"
+      sha256: "58e3491f7bf0b6a4ea5110c0c688877460d1a6366731155c4a4580e7ded773e8"
+      url: "https://pub.dev"
     source: hosted
-    version: "0.15.2"
+    version: "0.15.3"
+  http:
+    dependency: transitive
+    description:
+      name: http
+      sha256: "5895291c13fa8a3bd82e76d5627f69e0d85ca6a30dcac95c4ea19a5d555879c2"
+      url: "https://pub.dev"
+    source: hosted
+    version: "0.13.6"
   http_multi_server:
     dependency: transitive
     description:
       name: http_multi_server
-      url: "https://pub.dartlang.org"
+      sha256: "97486f20f9c2f7be8f514851703d0119c3596d14ea63227af6f7a481ef2b2f8b"
+      url: "https://pub.dev"
     source: hosted
     version: "3.2.1"
   http_parser:
     dependency: transitive
     description:
       name: http_parser
-      url: "https://pub.dartlang.org"
+      sha256: "2aa08ce0341cc9b354a498388e30986515406668dbcc4f7c950c3e715496693b"
+      url: "https://pub.dev"
     source: hosted
     version: "4.0.2"
   io:
     dependency: transitive
     description:
       name: io
-      url: "https://pub.dartlang.org"
+      sha256: "2ec25704aba361659e10e3e5f5d672068d332fc8ac516421d483a11e5cbd061e"
+      url: "https://pub.dev"
     source: hosted
     version: "1.0.4"
   js:
     dependency: transitive
     description:
       name: js
-      url: "https://pub.dartlang.org"
+      sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3
+      url: "https://pub.dev"
     source: hosted
-    version: "0.6.5"
+    version: "0.6.7"
   json_annotation:
     dependency: transitive
     description:
       name: json_annotation
-      url: "https://pub.dartlang.org"
+      sha256: b10a7b2ff83d83c777edba3c6a0f97045ddadd56c944e1a23a3fdf43a1bf4467
+      url: "https://pub.dev"
     source: hosted
-    version: "4.8.0"
+    version: "4.8.1"
   lints:
     dependency: "direct dev"
     description:
       name: lints
-      url: "https://pub.dartlang.org"
+      sha256: "6b0206b0bf4f04961fc5438198ccb3a885685cd67d4d4a32cc20ad7f8adbe015"
+      url: "https://pub.dev"
     source: hosted
-    version: "2.0.1"
+    version: "2.1.0"
   logging:
     dependency: transitive
     description:
       name: logging
-      url: "https://pub.dartlang.org"
+      sha256: "04094f2eb032cbb06c6f6e8d3607edcfcb0455e2bb6cbc010cb01171dcb64e6d"
+      url: "https://pub.dev"
     source: hosted
     version: "1.1.1"
   matcher:
     dependency: transitive
     description:
       name: matcher
-      url: "https://pub.dartlang.org"
+      sha256: "6501fbd55da300384b768785b83e5ce66991266cec21af89ab9ae7f5ce1c4cbb"
+      url: "https://pub.dev"
     source: hosted
-    version: "0.12.12"
+    version: "0.12.15"
   material_color_utilities:
     dependency: transitive
     description:
       name: material_color_utilities
-      url: "https://pub.dartlang.org"
+      sha256: d92141dc6fe1dad30722f9aa826c7fbc896d021d792f80678280601aff8cf724
+      url: "https://pub.dev"
     source: hosted
-    version: "0.1.5"
+    version: "0.2.0"
   meta:
     dependency: "direct main"
     description:
       name: meta
-      url: "https://pub.dartlang.org"
+      sha256: "3c74dbf8763d36539f114c799d8a2d87343b5067e9d796ca22b5eb8437090ee3"
+      url: "https://pub.dev"
     source: hosted
-    version: "1.8.0"
+    version: "1.9.1"
   mime:
     dependency: transitive
     description:
       name: mime
-      url: "https://pub.dartlang.org"
+      sha256: e4ff8e8564c03f255408decd16e7899da1733852a9110a58fe6d1b817684a63e
+      url: "https://pub.dev"
     source: hosted
     version: "1.0.4"
   mockito:
     dependency: "direct dev"
     description:
       name: mockito
-      url: "https://pub.dartlang.org"
+      sha256: dd61809f04da1838a680926de50a9e87385c1de91c6579629c3d1723946e8059
+      url: "https://pub.dev"
     source: hosted
-    version: "5.3.2"
+    version: "5.4.0"
   node_preamble:
     dependency: transitive
     description:
       name: node_preamble
-      url: "https://pub.dartlang.org"
+      sha256: "6e7eac89047ab8a8d26cf16127b5ed26de65209847630400f9aefd7cd5c730db"
+      url: "https://pub.dev"
     source: hosted
     version: "2.0.2"
   package_config:
     dependency: transitive
     description:
       name: package_config
-      url: "https://pub.dartlang.org"
+      sha256: "1c5b77ccc91e4823a5af61ee74e6b972db1ef98c2ff5a18d3161c982a55448bd"
+      url: "https://pub.dev"
     source: hosted
     version: "2.1.0"
   path:
     dependency: transitive
     description:
       name: path
-      url: "https://pub.dartlang.org"
+      sha256: "8829d8a55c13fc0e37127c29fedf290c102f4e40ae94ada574091fe0ff96c917"
+      url: "https://pub.dev"
     source: hosted
-    version: "1.8.2"
+    version: "1.8.3"
   petitparser:
     dependency: transitive
     description:
       name: petitparser
-      url: "https://pub.dartlang.org"
+      sha256: cb3798bef7fc021ac45b308f4b51208a152792445cce0448c9a4ba5879dd8750
+      url: "https://pub.dev"
     source: hosted
-    version: "5.1.0"
-  platform_info:
-    dependency: "direct main"
+    version: "5.4.0"
+  platform:
+    dependency: transitive
     description:
-      name: platform_info
-      url: "https://pub.dartlang.org"
+      name: platform
+      sha256: "4a451831508d7d6ca779f7ac6e212b4023dd5a7d08a27a63da33756410e32b76"
+      url: "https://pub.dev"
     source: hosted
-    version: "3.2.0"
+    version: "3.1.0"
   pool:
     dependency: transitive
     description:
       name: pool
-      url: "https://pub.dartlang.org"
+      sha256: "20fe868b6314b322ea036ba325e6fc0711a22948856475e2c2b6306e8ab39c2a"
+      url: "https://pub.dev"
     source: hosted
     version: "1.5.1"
+  process:
+    dependency: transitive
+    description:
+      name: process
+      sha256: "53fd8db9cec1d37b0574e12f07520d582019cb6c44abf5479a01505099a34a09"
+      url: "https://pub.dev"
+    source: hosted
+    version: "4.2.4"
   pub_semver:
     dependency: transitive
     description:
       name: pub_semver
-      url: "https://pub.dartlang.org"
+      sha256: "40d3ab1bbd474c4c2328c91e3a7df8c6dd629b79ece4c4bd04bee496a224fb0c"
+      url: "https://pub.dev"
     source: hosted
-    version: "2.1.3"
+    version: "2.1.4"
+  pub_updater:
+    dependency: transitive
+    description:
+      name: pub_updater
+      sha256: "05ae70703e06f7fdeb05f7f02dd680b8aad810e87c756a618f33e1794635115c"
+      url: "https://pub.dev"
+    source: hosted
+    version: "0.3.0"
   pubspec_parse:
     dependency: transitive
     description:
       name: pubspec_parse
-      url: "https://pub.dartlang.org"
+      sha256: c63b2876e58e194e4b0828fcb080ad0e06d051cb607a6be51a9e084f47cb9367
+      url: "https://pub.dev"
     source: hosted
-    version: "1.2.2"
+    version: "1.2.3"
   quiver:
     dependency: "direct main"
     description:
       name: quiver
-      url: "https://pub.dartlang.org"
+      sha256: b1c1ac5ce6688d77f65f3375a9abb9319b3cb32486bdc7a1e0fdf004d7ba4e47
+      url: "https://pub.dev"
     source: hosted
     version: "3.2.1"
   shelf:
     dependency: transitive
     description:
       name: shelf
-      url: "https://pub.dartlang.org"
+      sha256: ad29c505aee705f41a4d8963641f91ac4cee3c8fad5947e033390a7bd8180fa4
+      url: "https://pub.dev"
     source: hosted
-    version: "1.4.0"
+    version: "1.4.1"
   shelf_packages_handler:
     dependency: transitive
     description:
       name: shelf_packages_handler
-      url: "https://pub.dartlang.org"
+      sha256: "89f967eca29607c933ba9571d838be31d67f53f6e4ee15147d5dc2934fee1b1e"
+      url: "https://pub.dev"
     source: hosted
-    version: "3.0.1"
+    version: "3.0.2"
   shelf_static:
     dependency: transitive
     description:
       name: shelf_static
-      url: "https://pub.dartlang.org"
+      sha256: a41d3f53c4adf0f57480578c1d61d90342cd617de7fc8077b1304643c2d85c1e
+      url: "https://pub.dev"
     source: hosted
-    version: "1.1.1"
+    version: "1.1.2"
   shelf_web_socket:
     dependency: transitive
     description:
       name: shelf_web_socket
-      url: "https://pub.dartlang.org"
+      sha256: "9ca081be41c60190ebcb4766b2486a7d50261db7bd0f5d9615f2d653637a84c1"
+      url: "https://pub.dev"
     source: hosted
-    version: "1.0.3"
+    version: "1.0.4"
   sky_engine:
     dependency: transitive
     description: flutter
@@ -433,156 +524,186 @@ packages:
     dependency: transitive
     description:
       name: source_gen
-      url: "https://pub.dartlang.org"
+      sha256: "373f96cf5a8744bc9816c1ff41cf5391bbdbe3d7a96fe98c622b6738a8a7bd33"
+      url: "https://pub.dev"
     source: hosted
-    version: "1.2.6"
+    version: "1.3.2"
   source_map_stack_trace:
     dependency: transitive
     description:
       name: source_map_stack_trace
-      url: "https://pub.dartlang.org"
+      sha256: "84cf769ad83aa6bb61e0aa5a18e53aea683395f196a6f39c4c881fb90ed4f7ae"
+      url: "https://pub.dev"
     source: hosted
     version: "2.1.1"
   source_maps:
     dependency: transitive
     description:
       name: source_maps
-      url: "https://pub.dartlang.org"
+      sha256: "708b3f6b97248e5781f493b765c3337db11c5d2c81c3094f10904bfa8004c703"
+      url: "https://pub.dev"
     source: hosted
     version: "0.10.12"
   source_span:
     dependency: transitive
     description:
       name: source_span
-      url: "https://pub.dartlang.org"
+      sha256: dd904f795d4b4f3b870833847c461801f6750a9fa8e61ea5ac53f9422b31f250
+      url: "https://pub.dev"
     source: hosted
-    version: "1.9.0"
+    version: "1.9.1"
   stack_trace:
     dependency: transitive
     description:
       name: stack_trace
-      url: "https://pub.dartlang.org"
+      sha256: c3c7d8edb15bee7f0f74debd4b9c5f3c2ea86766fe4178eb2a18eb30a0bdaed5
+      url: "https://pub.dev"
     source: hosted
-    version: "1.10.0"
+    version: "1.11.0"
   stream_channel:
     dependency: transitive
     description:
       name: stream_channel
-      url: "https://pub.dartlang.org"
+      sha256: "83615bee9045c1d322bbbd1ba209b7a749c2cbcdcb3fdd1df8eb488b3279c1c8"
+      url: "https://pub.dev"
     source: hosted
-    version: "2.1.0"
+    version: "2.1.1"
   stream_transform:
     dependency: transitive
     description:
       name: stream_transform
-      url: "https://pub.dartlang.org"
+      sha256: "14a00e794c7c11aa145a170587321aedce29769c08d7f58b1d141da75e3b1c6f"
+      url: "https://pub.dev"
     source: hosted
     version: "2.1.0"
   string_scanner:
     dependency: transitive
     description:
       name: string_scanner
-      url: "https://pub.dartlang.org"
+      sha256: "556692adab6cfa87322a115640c11f13cb77b3f076ddcc5d6ae3c20242bedcde"
+      url: "https://pub.dev"
     source: hosted
-    version: "1.1.1"
+    version: "1.2.0"
   term_glyph:
     dependency: transitive
     description:
       name: term_glyph
-      url: "https://pub.dartlang.org"
+      sha256: a29248a84fbb7c79282b40b8c72a1209db169a2e0542bce341da992fe1bc7e84
+      url: "https://pub.dev"
     source: hosted
     version: "1.2.1"
   test:
     dependency: "direct dev"
     description:
       name: test
-      url: "https://pub.dartlang.org"
+      sha256: "3dac9aecf2c3991d09b9cdde4f98ded7b30804a88a0d7e4e7e1678e78d6b97f4"
+      url: "https://pub.dev"
     source: hosted
-    version: "1.21.4"
+    version: "1.24.1"
   test_api:
     dependency: transitive
     description:
       name: test_api
-      url: "https://pub.dartlang.org"
+      sha256: eb6ac1540b26de412b3403a163d919ba86f6a973fe6cc50ae3541b80092fdcfb
+      url: "https://pub.dev"
     source: hosted
-    version: "0.4.12"
+    version: "0.5.1"
   test_core:
     dependency: transitive
     description:
       name: test_core
-      url: "https://pub.dartlang.org"
+      sha256: "5138dbffb77b2289ecb12b81c11ba46036590b72a64a7a90d6ffb880f1a29e93"
+      url: "https://pub.dev"
     source: hosted
-    version: "0.4.16"
+    version: "0.5.1"
   timing:
     dependency: transitive
     description:
       name: timing
-      url: "https://pub.dartlang.org"
+      sha256: "70a3b636575d4163c477e6de42f247a23b315ae20e86442bebe32d3cabf61c32"
+      url: "https://pub.dev"
     source: hosted
     version: "1.0.1"
   typed_data:
     dependency: transitive
     description:
       name: typed_data
-      url: "https://pub.dartlang.org"
+      sha256: facc8d6582f16042dd49f2463ff1bd6e2c9ef9f3d5da3d9b087e244a7b564b3c
+      url: "https://pub.dev"
     source: hosted
-    version: "1.3.1"
+    version: "1.3.2"
+  uuid:
+    dependency: transitive
+    description:
+      name: uuid
+      sha256: "648e103079f7c64a36dc7d39369cabb358d377078a051d6ae2ad3aa539519313"
+      url: "https://pub.dev"
+    source: hosted
+    version: "3.0.7"
   vector_math:
     dependency: transitive
     description:
       name: vector_math
-      url: "https://pub.dartlang.org"
+      sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803"
+      url: "https://pub.dev"
     source: hosted
-    version: "2.1.2"
+    version: "2.1.4"
   vm_service:
     dependency: transitive
     description:
       name: vm_service
-      url: "https://pub.dartlang.org"
+      sha256: f3743ca475e0c9ef71df4ba15eb2d7684eecd5c8ba20a462462e4e8b561b2e11
+      url: "https://pub.dev"
     source: hosted
-    version: "9.4.0"
+    version: "11.6.0"
   watcher:
     dependency: transitive
     description:
       name: watcher
-      url: "https://pub.dartlang.org"
+      sha256: "3d2ad6751b3c16cf07c7fca317a1413b3f26530319181b37e3b9039b84fc01d8"
+      url: "https://pub.dev"
     source: hosted
-    version: "1.0.2"
+    version: "1.1.0"
   web_socket_channel:
     dependency: transitive
     description:
       name: web_socket_channel
-      url: "https://pub.dartlang.org"
+      sha256: d88238e5eac9a42bb43ca4e721edba3c08c6354d4a53063afaa568516217621b
+      url: "https://pub.dev"
     source: hosted
     version: "2.4.0"
   webkit_inspection_protocol:
     dependency: transitive
     description:
       name: webkit_inspection_protocol
-      url: "https://pub.dartlang.org"
+      sha256: "67d3a8b6c79e1987d19d848b0892e582dbb0c66c57cc1fef58a177dd2aa2823d"
+      url: "https://pub.dev"
     source: hosted
     version: "1.2.0"
   xml:
     dependency: transitive
     description:
       name: xml
-      url: "https://pub.dartlang.org"
+      sha256: "5bc72e1e45e941d825fd7468b9b4cc3b9327942649aeb6fc5cdbf135f0a86e84"
+      url: "https://pub.dev"
     source: hosted
-    version: "6.1.0"
+    version: "6.3.0"
   yaml:
     dependency: transitive
     description:
       name: yaml
-      url: "https://pub.dartlang.org"
+      sha256: "75769501ea3489fca56601ff33454fe45507ea3bfb014161abc3b43ae25989d5"
+      url: "https://pub.dev"
     source: hosted
-    version: "3.1.1"
+    version: "3.1.2"
   zmodem:
     dependency: "direct main"
     description:
       name: zmodem
-      url: "https://pub.dartlang.org"
+      sha256: "3b7e5b29f3a7d8aee472029b05165a68438eff2f3f7766edf13daba1e297adbf"
+      url: "https://pub.dev"
     source: hosted
     version: "0.0.6"
 sdks:
-  dart: ">=2.18.0 <3.0.0"
-  flutter: ">=3.0.0"
+  dart: ">=3.0.0 <4.0.0"
+  flutter: ">=3.10.0"

+ 3 - 4
pubspec.yaml

@@ -4,14 +4,13 @@ version: 3.6.1-pre
 homepage: https://github.com/TerminalStudio/xterm.dart
 
 environment:
-  sdk: ">=2.17.0 <3.0.0"
-  flutter: ">=3.0.0"
+  sdk: ">=3.0.0 <4.0.0"
+  flutter: ">=3.10.0"
 
 dependencies:
   convert: ^3.0.0
   meta: ^1.3.0
   quiver: ^3.0.0
-  platform_info: ^3.0.0
   equatable: ^2.0.3
   flutter:
     sdk: flutter
@@ -22,7 +21,7 @@ dev_dependencies:
     sdk: flutter
   test: ^1.6.5
   lints: ^2.0.0
-  dart_code_metrics: ^4.16.0
+  dart_code_metrics: ^5.0.0
   mockito: ^5.3.1
   build_runner: ^2.1.1
 

BIN
test/src/_goldens/colors.png


BIN
test/src/_goldens/htop_80x25_3s.png


BIN
test/src/_goldens/text_scale_factor@1x.png


BIN
test/src/_goldens/text_scale_factor@2x.png


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

+ 29 - 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);
 
@@ -68,4 +77,21 @@ void main() {
       expect(terminalView.selection, isNull);
     });
   });
+
+  group('TerminalController.highlight', () {
+    test('works', () {
+      final terminal = Terminal();
+      final controller = TerminalController();
+
+      final highlight = controller.highlight(
+        p1: terminal.buffer.createAnchor(5, 5),
+        p2: terminal.buffer.createAnchor(5, 10),
+        color: Colors.yellow,
+      );
+      assert(controller.highlights.length == 1);
+
+      highlight.dispose();
+      assert(controller.highlights.isEmpty);
+    });
+  });
 }

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