Bladeren bron

Prepare for v3 release

xuty 3 jaren geleden
bovenliggende
commit
e83866f639

+ 27 - 7
example/lib/main.dart

@@ -3,6 +3,7 @@ import 'dart:io';
 
 import 'package:flutter/foundation.dart';
 import 'package:flutter/material.dart';
+import 'package:flutter/services.dart';
 import 'package:flutter_acrylic/flutter_acrylic.dart';
 import 'package:flutter_pty/flutter_pty.dart';
 import 'package:xterm/xterm.dart';
@@ -29,7 +30,7 @@ bool get isDesktop {
 Future<void> setupAcrylic() async {
   await Window.initialize();
   await Window.makeTitlebarTransparent();
-  await Window.setEffect(effect: WindowEffect.aero);
+  await Window.setEffect(effect: WindowEffect.aero, color: Color(0xFFFFFFFF));
   await Window.setBlurViewState(MacOSBlurViewState.active);
 }
 
@@ -39,24 +40,27 @@ class MyApp extends StatelessWidget {
     return MaterialApp(
       title: 'xterm.dart demo',
       debugShowCheckedModeBanner: false,
-      home: MyHomePage(),
+      home: Home(),
+      // shortcuts: ,
     );
   }
 }
 
-class MyHomePage extends StatefulWidget {
-  MyHomePage({Key? key}) : super(key: key);
+class Home extends StatefulWidget {
+  Home({Key? key}) : super(key: key);
 
   @override
   // ignore: library_private_types_in_public_api
-  _MyHomePageState createState() => _MyHomePageState();
+  _HomeState createState() => _HomeState();
 }
 
-class _MyHomePageState extends State<MyHomePage> {
+class _HomeState extends State<Home> {
   final terminal = Terminal(
     maxLines: 10000,
   );
 
+  final terminalController = TerminalController();
+
   late final Pty pty;
 
   @override
@@ -102,7 +106,23 @@ class _MyHomePageState extends State<MyHomePage> {
       body: SafeArea(
         child: TerminalView(
           terminal,
-          backgroundOpacity: 0.8,
+          controller: terminalController,
+          autofocus: true,
+          backgroundOpacity: 0.7,
+          onSecondaryTapDown: (details, offset) async {
+            final selection = terminalController.selection;
+            if (selection != null) {
+              final text = terminal.buffer.getText(selection);
+              terminalController.clearSelection();
+              await Clipboard.setData(ClipboardData(text: text));
+            } else {
+              final data = await Clipboard.getData('text/plain');
+              final text = data?.text;
+              if (text != null) {
+                terminal.paste(text);
+              }
+            }
+          },
         ),
       ),
     );

+ 1 - 1
example/macos/Podfile.lock

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

+ 13 - 20
example/pubspec.lock

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

+ 15 - 1
lib/core.dart

@@ -1,2 +1,16 @@
+export 'src/core/buffer/buffer.dart';
+export 'src/core/buffer/cell_flags.dart';
+export 'src/core/buffer/cell_offset.dart';
+export 'src/core/buffer/line.dart';
+export 'src/core/buffer/range.dart';
+export 'src/core/buffer/segment.dart';
+export 'src/core/cell.dart';
+export 'src/core/color.dart';
+export 'src/core/cursor.dart';
+export 'src/core/escape/handler.dart';
+export 'src/core/escape/parser.dart';
+export 'src/core/input/handler.dart';
+export 'src/core/input/keys.dart';
+export 'src/core/mouse.dart';
+export 'src/core/state.dart';
 export 'src/terminal.dart';
-export 'src/terminal_view.dart';

+ 44 - 13
lib/src/core/buffer/buffer.dart

@@ -1,12 +1,13 @@
 import 'dart:math' show max, min;
 
-import 'package:xterm/src/core/buffer/position.dart';
+import 'package:flutter/material.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/charset.dart';
 import 'package:xterm/src/core/cursor.dart';
-import 'package:xterm/src/core/buffer/line.dart';
 import 'package:xterm/src/core/reflow.dart';
 import 'package:xterm/src/core/state.dart';
-import 'package:xterm/src/core/charset.dart';
 import 'package:xterm/src/utils/circular_list.dart';
 import 'package:xterm/src/utils/unicode_v11.dart';
 
@@ -464,7 +465,7 @@ class Buffer {
     r'\'.codeUnitAt(0),
   };
 
-  BufferRange? getWordBoundary(BufferPosition position) {
+  BufferRange? getWordBoundary(CellOffset position) {
     if (position.y >= lines.length) {
       return null;
     }
@@ -495,24 +496,41 @@ class Buffer {
       end++;
     } while (true);
 
+    if (start == end) {
+      return null;
+    }
+
     return BufferRange(
-      BufferPosition(start, position.y),
-      BufferPosition(end, position.y),
+      CellOffset(start, position.y),
+      CellOffset(end, position.y),
     );
   }
 
-  String getText(BufferRange range) {
+  /// Get the plain text content of the buffer including the scrollback.
+  /// Accepts an optional [range] to get a specific part of the buffer.
+  String getText([BufferRange? range]) {
+    range ??= BufferRange(
+      CellOffset(0, 0),
+      CellOffset(viewWidth - 1, height - 1),
+    );
+
+    range = range.normalized;
+
     final builder = StringBuffer();
-    for (var i = range.begin.y; i <= range.end.y; i++) {
+
+    final firstLine = range.begin.y.clamp(0, height - 1);
+    final lastLine = range.end.y.clamp(firstLine, height - 1);
+
+    for (var i = firstLine; i <= lastLine; i++) {
       if (i < 0 || i >= lines.length) {
-        break;
+        continue;
       }
 
       final line = lines[i];
-      final start = i == range.begin.y ? range.begin.x : 0;
-      final end = i == range.end.y ? range.end.x : line.length;
+      final start = i == firstLine ? range.begin.x : 0;
+      final end = i == lastLine ? range.end.x : line.length;
 
-      if (i != range.begin.y && line.isWrapped) {
+      if (!(i == firstLine || line.isWrapped)) {
         builder.write('\n');
       }
 
@@ -522,13 +540,26 @@ class Buffer {
     return builder.toString();
   }
 
+  /// Returns a debug representation of the buffer.
   @override
   String toString() {
     final builder = StringBuffer();
     final lineNumberLength = lines.length.toString().length;
+
     for (var i = 0; i < lines.length; i++) {
-      builder.writeln('${i.toString().padLeft(lineNumberLength)}: ${lines[i]}');
+      final line = lines[i];
+
+      builder.write('${i.toString().padLeft(lineNumberLength)}: |${lines[i]}|');
+
+      TextEditingValue;
+
+      if (line.isWrapped) {
+        builder.write(' (⏎)');
+      }
+
+      builder.write('\n');
     }
+
     return builder.toString();
   }
 }

+ 11 - 11
lib/src/core/buffer/position.dart → lib/src/core/buffer/cell_offset.dart

@@ -1,37 +1,37 @@
 import 'package:xterm/src/core/buffer/range.dart';
 
-class BufferPosition {
+class CellOffset {
   final int x;
 
   final int y;
 
-  const BufferPosition(this.x, this.y);
+  const CellOffset(this.x, this.y);
 
-  bool isEqual(BufferPosition other) {
+  bool isEqual(CellOffset other) {
     return other.x == x && other.y == y;
   }
 
-  bool isBefore(BufferPosition other) {
+  bool isBefore(CellOffset other) {
     return y < other.y || (y == other.y && x < other.x);
   }
 
-  bool isAfter(BufferPosition other) {
+  bool isAfter(CellOffset other) {
     return y > other.y || (y == other.y && x > other.x);
   }
 
-  bool isBeforeOrSame(BufferPosition other) {
+  bool isBeforeOrSame(CellOffset other) {
     return y < other.y || (y == other.y && x <= other.x);
   }
 
-  bool isAfterOrSame(BufferPosition other) {
+  bool isAfterOrSame(CellOffset other) {
     return y > other.y || (y == other.y && x >= other.x);
   }
 
-  bool isAtSameRow(BufferPosition other) {
+  bool isAtSameRow(CellOffset other) {
     return y == other.y;
   }
 
-  bool isAtSameColumn(BufferPosition other) {
+  bool isAtSameColumn(CellOffset other) {
     return x == other.x;
   }
 
@@ -40,7 +40,7 @@ class BufferPosition {
   }
 
   @override
-  String toString() => 'Position($x, $y)';
+  String toString() => 'CellOffset($x, $y)';
 
   @override
   int get hashCode => x.hashCode ^ y.hashCode;
@@ -48,7 +48,7 @@ class BufferPosition {
   @override
   bool operator ==(Object other) =>
       identical(this, other) ||
-      other is BufferPosition &&
+      other is CellOffset &&
           runtimeType == other.runtimeType &&
           x == other.x &&
           y == other.y;

+ 2 - 2
lib/src/core/buffer/line.dart

@@ -268,11 +268,11 @@ class BufferLine {
   }
 
   String getText([int? from, int? to]) {
-    if (from == null) {
+    if (from == null || from < 0) {
       from = 0;
     }
 
-    if (to == null) {
+    if (to == null || to > _length) {
       to = _length;
     }
 

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

@@ -1,10 +1,10 @@
-import 'package:xterm/src/core/buffer/position.dart';
+import 'package:xterm/src/core/buffer/cell_offset.dart';
 import 'package:xterm/src/core/buffer/segment.dart';
 
 class BufferRange {
-  final BufferPosition begin;
+  final CellOffset begin;
 
-  final BufferPosition end;
+  final CellOffset end;
 
   BufferRange(this.begin, this.end);
 
@@ -18,6 +18,14 @@ class BufferRange {
     return begin.isEqual(end);
   }
 
+  BufferRange get normalized {
+    if (isNormalized) {
+      return this;
+    } else {
+      return BufferRange(end, begin);
+    }
+  }
+
   Iterable<BufferSegment> toSegments() sync* {
     var start = this.begin;
     var end = this.end;
@@ -34,7 +42,7 @@ class BufferRange {
     }
   }
 
-  bool isWithin(BufferPosition position) {
+  bool contains(CellOffset position) {
     return begin.isBeforeOrSame(position) && end.isAfterOrSame(position);
   }
 
@@ -44,7 +52,7 @@ class BufferRange {
     return BufferRange(begin, end);
   }
 
-  BufferRange extend(BufferPosition position) {
+  BufferRange extend(CellOffset position) {
     final begin = this.begin.isBefore(position) ? position : this.begin;
     final end = this.end.isAfter(position) ? position : this.end;
     return BufferRange(begin, end);

+ 2 - 2
lib/src/core/buffer/segment.dart

@@ -1,4 +1,4 @@
-import 'package:xterm/src/core/buffer/position.dart';
+import 'package:xterm/src/core/buffer/cell_offset.dart';
 import 'package:xterm/src/core/buffer/range.dart';
 
 class BufferSegment {
@@ -16,7 +16,7 @@ class BufferSegment {
 
   const BufferSegment(this.range, this.line, this.start, this.end);
 
-  bool isWithin(BufferPosition position) {
+  bool isWithin(CellOffset position) {
     if (position.y != line) {
       return false;
     }

+ 5 - 0
lib/src/terminal.dart

@@ -263,6 +263,11 @@ class Terminal with Observable implements TerminalState, EscapeHandler {
     _mainBuffer.resetVerticalMargins();
   }
 
+  @override
+  String toString() {
+    return 'Terminal(#$hashCode, $_viewWidth x $_viewHeight, ${_buffer.height} lines)';
+  }
+
   /* Handlers */
 
   @override

+ 96 - 84
lib/src/terminal_view.dart

@@ -2,10 +2,10 @@ import 'package:flutter/cupertino.dart';
 import 'package:flutter/material.dart';
 import 'package:flutter/rendering.dart';
 import 'package:flutter/services.dart';
+import 'package:xterm/src/core/buffer/cell_offset.dart';
 
 import 'package:xterm/src/core/input/keys.dart';
 import 'package:xterm/src/terminal.dart';
-import 'package:xterm/src/ui/char_metrics.dart';
 import 'package:xterm/src/ui/controller.dart';
 import 'package:xterm/src/ui/cursor_type.dart';
 import 'package:xterm/src/ui/custom_text_edit.dart';
@@ -13,6 +13,7 @@ import 'package:xterm/src/ui/gesture/gesture_handler.dart';
 import 'package:xterm/src/ui/input_map.dart';
 import 'package:xterm/src/ui/keyboard_visibility.dart';
 import 'package:xterm/src/ui/render.dart';
+import 'package:xterm/src/ui/shortcut/shortcuts.dart';
 import 'package:xterm/src/ui/terminal_text_style.dart';
 import 'package:xterm/src/ui/terminal_theme.dart';
 import 'package:xterm/src/ui/themes.dart';
@@ -30,13 +31,16 @@ class TerminalView extends StatefulWidget {
     this.backgroundOpacity = 1,
     this.focusNode,
     this.autofocus = false,
-    this.onTap,
+    this.onTapUp,
+    this.onSecondaryTapDown,
+    this.onSecondaryTapUp,
     this.mouseCursor = SystemMouseCursors.text,
     this.keyboardType = TextInputType.emailAddress,
     this.keyboardAppearance = Brightness.dark,
     this.cursorType = TerminalCursorType.block,
     this.alwaysShowCursor = false,
     this.deleteDetection = false,
+    this.shortcuts,
   }) : super(key: key);
 
   /// The underlying terminal that this widget renders.
@@ -72,12 +76,14 @@ class TerminalView extends StatefulWidget {
   final bool autofocus;
 
   /// Callback for when the user taps on the terminal.
-  ///
-  /// This exists because [TerminalView] builds a [GestureDetector] internally
-  /// to to trigger focus requests, adjust the selection, etc. Handling some of
-  /// those events by wrapping [TerminalView] with a competingGestureDetector is
-  /// problematic.
-  final VoidCallback? onTap;
+  final void Function(TapUpDetails, CellOffset)? onTapUp;
+
+  /// Function called when the user taps on the terminal with a secondary
+  /// button.
+  final void Function(TapDownDetails, CellOffset)? onSecondaryTapDown;
+
+  /// Function called when the user stops holding down a secondary button.
+  final void Function(TapUpDetails, CellOffset)? onSecondaryTapUp;
 
   /// The mouse cursor for mouse pointers that are hovering over the terminal.
   /// [SystemMouseCursors.text] by default.
@@ -104,6 +110,10 @@ class TerminalView extends StatefulWidget {
   /// default.
   final bool deleteDetection;
 
+  /// Shortcuts for this terminal. This has higher priority than the input
+  /// handler. If not provided, [defaultTerminalShortcuts] will be used.
+  final Map<ShortcutActivator, Intent>? shortcuts;
+
   @override
   State<TerminalView> createState() => TerminalViewState();
 }
@@ -139,6 +149,12 @@ class TerminalViewState extends State<TerminalView> {
     if (oldWidget.focusNode != widget.focusNode) {
       _focusNode = widget.focusNode ?? FocusNode();
     }
+    if (oldWidget.controller != widget.controller) {
+      _controller = widget.controller ?? TerminalController();
+    }
+    if (oldWidget.scrollController != widget.scrollController) {
+      _scrollController = widget.scrollController ?? ScrollController();
+    }
     super.didUpdateWidget(oldWidget);
   }
 
@@ -148,69 +164,8 @@ class TerminalViewState extends State<TerminalView> {
     super.dispose();
   }
 
-  void requestKeyboard() {
-    _customTextEditKey.currentState?.requestKeyboard();
-  }
-
-  void closeKeyboard() {
-    _customTextEditKey.currentState?.closeKeyboard();
-  }
-
-  bool get hasInputConnection {
-    return _customTextEditKey.currentState?.hasInputConnection == true;
-  }
-
-  KeyEventResult _onKeyEvent(RawKeyEvent event) {
-    if (event is! RawKeyDownEvent) {
-      return KeyEventResult.ignored;
-    }
-
-    final key = inputMap(event.logicalKey);
-
-    if (key == null) {
-      return KeyEventResult.ignored;
-    }
-
-    final handled = widget.terminal.keyInput(
-      key,
-      ctrl: event.isControlPressed,
-      alt: event.isAltPressed,
-      shift: event.isShiftPressed,
-    );
-
-    if (handled) {
-      _scrollToBottom();
-    }
-
-    return handled ? KeyEventResult.handled : KeyEventResult.ignored;
-  }
-
-  void _onKeyboardShow() {
-    if (_focusNode.hasFocus) {
-      WidgetsBinding.instance.addPostFrameCallback((_) {
-        _scrollToBottom();
-      });
-    }
-  }
-
-  void _onEditableRect(Rect rect, Rect caretRect) {
-    _customTextEditKey.currentState?.setEditableRect(rect, caretRect);
-  }
-
-  void _scrollToBottom() {
-    final position = _scrollableKey.currentState?.position;
-    if (position != null) {
-      position.jumpTo(position.maxScrollExtent);
-    }
-  }
-
   @override
   Widget build(BuildContext context) {
-    // Calculate everytime build happens, because some fonts library
-    // lazily load fonts (such as google_fonts) and this can change the
-    // font metrics while textStyle is still the same.
-    final charMetrics = calcCharMetrics(widget.textStyle);
-
     Widget child = Scrollable(
       key: _scrollableKey,
       controller: _scrollController,
@@ -222,7 +177,6 @@ class TerminalViewState extends State<TerminalView> {
           offset: offset,
           padding: MediaQuery.of(context).padding,
           autoResize: widget.autoResize,
-          charMetrics: charMetrics,
           textStyle: widget.textStyle,
           theme: widget.theme,
           focusNode: _focusNode,
@@ -234,6 +188,11 @@ class TerminalViewState extends State<TerminalView> {
       },
     );
 
+    child = Shortcuts(
+      shortcuts: widget.shortcuts ?? defaultTerminalShortcuts,
+      child: child,
+    );
+
     child = Container(
       color: widget.theme.background.withOpacity(widget.backgroundOpacity),
       padding: widget.padding,
@@ -276,7 +235,10 @@ class TerminalViewState extends State<TerminalView> {
       terminalView: this,
       onTapUp: _onTapUp,
       onTapDown: _onTapDown,
-      onSecondaryTapDown: _onSecondaryTapDown,
+      onSecondaryTapDown:
+          widget.onSecondaryTapDown != null ? _onSecondaryTapDown : null,
+      onSecondaryTapUp:
+          widget.onSecondaryTapUp != null ? _onSecondaryTapUp : null,
       child: child,
     );
 
@@ -288,12 +250,21 @@ class TerminalViewState extends State<TerminalView> {
     return child;
   }
 
-  void _onTapUp(_) {
-    widget.onTap?.call();
+  void requestKeyboard() {
+    _customTextEditKey.currentState?.requestKeyboard();
+  }
+
+  void closeKeyboard() {
+    _customTextEditKey.currentState?.closeKeyboard();
+  }
+
+  void _onTapUp(TapUpDetails details) {
+    final offset = renderTerminal.getCellOffset(details.localPosition);
+    widget.onTapUp?.call(details, offset);
   }
 
   void _onTapDown(_) {
-    if (_controller.hasSelection) {
+    if (_controller.selection != null) {
       _controller.clearSelection();
     } else {
       _customTextEditKey.currentState?.requestKeyboard();
@@ -301,14 +272,60 @@ class TerminalViewState extends State<TerminalView> {
   }
 
   void _onSecondaryTapDown(TapDownDetails details) {
-    final position = renderTerminal.positionFromOffset(
-      renderTerminal.globalToLocal(details.globalPosition),
+    final offset = renderTerminal.getCellOffset(details.localPosition);
+    widget.onSecondaryTapDown?.call(details, offset);
+  }
+
+  void _onSecondaryTapUp(TapUpDetails details) {
+    final offset = renderTerminal.getCellOffset(details.localPosition);
+    widget.onSecondaryTapUp?.call(details, offset);
+  }
+
+  bool get hasInputConnection {
+    return _customTextEditKey.currentState?.hasInputConnection == true;
+  }
+
+  KeyEventResult _onKeyEvent(RawKeyEvent event) {
+    if (event is! RawKeyDownEvent) {
+      return KeyEventResult.ignored;
+    }
+
+    final key = inputMap(event.logicalKey);
+
+    if (key == null) {
+      return KeyEventResult.ignored;
+    }
+
+    final handled = widget.terminal.keyInput(
+      key,
+      ctrl: event.isControlPressed,
+      alt: event.isAltPressed,
+      shift: event.isShiftPressed,
     );
 
-    final selection = _controller.selection;
+    if (handled) {
+      _scrollToBottom();
+    }
+
+    return handled ? KeyEventResult.handled : KeyEventResult.ignored;
+  }
+
+  void _onKeyboardShow() {
+    if (_focusNode.hasFocus) {
+      WidgetsBinding.instance.addPostFrameCallback((_) {
+        _scrollToBottom();
+      });
+    }
+  }
+
+  void _onEditableRect(Rect rect, Rect caretRect) {
+    _customTextEditKey.currentState?.setEditableRect(rect, caretRect);
+  }
 
-    if (selection == null || !selection.isWithin(position)) {
-      renderTerminal.selectWord(details.globalPosition);
+  void _scrollToBottom() {
+    final position = _scrollableKey.currentState?.position;
+    if (position != null) {
+      position.jumpTo(position.maxScrollExtent);
     }
   }
 }
@@ -321,7 +338,6 @@ class _TerminalView extends LeafRenderObjectWidget {
     required this.offset,
     required this.padding,
     required this.autoResize,
-    required this.charMetrics,
     required this.textStyle,
     required this.theme,
     required this.focusNode,
@@ -341,8 +357,6 @@ class _TerminalView extends LeafRenderObjectWidget {
 
   final bool autoResize;
 
-  final Size charMetrics;
-
   final TerminalStyle textStyle;
 
   final TerminalTheme theme;
@@ -365,7 +379,6 @@ class _TerminalView extends LeafRenderObjectWidget {
       offset: offset,
       padding: padding,
       autoResize: autoResize,
-      charMetrics: charMetrics,
       textStyle: textStyle,
       theme: theme,
       focusNode: focusNode,
@@ -384,7 +397,6 @@ class _TerminalView extends LeafRenderObjectWidget {
       ..offset = offset
       ..padding = padding
       ..autoResize = autoResize
-      ..charMetrics = charMetrics
       ..textStyle = textStyle
       ..theme = theme
       ..focusNode = focusNode

+ 1 - 1
lib/src/ui/char_metrics.dart

@@ -2,7 +2,7 @@ import 'dart:ui';
 
 import 'package:xterm/src/ui/terminal_text_style.dart';
 
-Size calcCharMetrics(TerminalStyle style) {
+Size calcCharSize(TerminalStyle style) {
   const test = 'mmmmmmmmmm';
 
   final textStyle = style.toTextStyle();

+ 2 - 2
lib/src/ui/controller.dart

@@ -6,9 +6,9 @@ class TerminalController with ChangeNotifier {
 
   BufferRange? get selection => _selection;
 
-  bool get hasSelection => _selection != null;
-
   void setSelection(BufferRange? range) {
+    range = range?.normalized;
+
     if (_selection != range) {
       _selection = range;
       notifyListeners();

+ 5 - 1
lib/src/ui/gesture/gesture_detector.dart

@@ -11,6 +11,7 @@ class TerminalGestureDetector extends StatefulWidget {
     this.onTapUp,
     this.onTapDown,
     this.onSecondaryTapDown,
+    this.onSecondaryTapUp,
     this.onLongPressStart,
     this.onLongPressMoveUpdate,
     this.onLongPressUp,
@@ -29,6 +30,8 @@ class TerminalGestureDetector extends StatefulWidget {
 
   final GestureTapDownCallback? onSecondaryTapDown;
 
+  final GestureTapUpCallback? onSecondaryTapUp;
+
   final GestureTapDownCallback? onDoubleTapDown;
 
   final GestureLongPressStartCallback? onLongPressStart;
@@ -106,7 +109,8 @@ class _TerminalGestureDetectorState extends State<TerminalGestureDetector> {
         instance
           ..onTapDown = _handleTapDown
           ..onTapUp = _handleTapUp
-          ..onSecondaryTapDown = widget.onSecondaryTapDown;
+          ..onSecondaryTapDown = widget.onSecondaryTapDown
+          ..onSecondaryTapUp = widget.onSecondaryTapUp;
       },
     );
 

+ 13 - 11
lib/src/ui/gesture/gesture_handler.dart

@@ -13,6 +13,7 @@ class TerminalGestureHandler extends StatefulWidget {
     this.onSingleTapUp,
     this.onTapDown,
     this.onSecondaryTapDown,
+    this.onSecondaryTapUp,
   });
 
   final TerminalViewState terminalView;
@@ -27,6 +28,8 @@ class TerminalGestureHandler extends StatefulWidget {
 
   final GestureTapDownCallback? onSecondaryTapDown;
 
+  final GestureTapUpCallback? onSecondaryTapUp;
+
   @override
   State<TerminalGestureHandler> createState() => _TerminalGestureHandlerState();
 }
@@ -48,6 +51,7 @@ class _TerminalGestureHandlerState extends State<TerminalGestureHandler> {
       onSingleTapUp: widget.onSingleTapUp,
       onTapDown: widget.onTapDown,
       onSecondaryTapDown: widget.onSecondaryTapDown,
+      onSecondaryTapUp: widget.onSecondaryTapUp,
       onLongPressStart: onLongPressStart,
       onLongPressMoveUpdate: onLongPressMoveUpdate,
       // onLongPressUp: onLongPressUp,
@@ -58,20 +62,18 @@ class _TerminalGestureHandlerState extends State<TerminalGestureHandler> {
   }
 
   void onDoubleTapDown(TapDownDetails details) {
-    renderTerminal.selectWord(
-      details.globalPosition,
-    );
+    renderTerminal.selectWord(details.localPosition);
   }
 
   void onLongPressStart(LongPressStartDetails details) {
     _lastLongPressStartDetails = details;
-    renderTerminal.selectWord(details.globalPosition);
+    renderTerminal.selectWord(details.localPosition);
   }
 
   void onLongPressMoveUpdate(LongPressMoveUpdateDetails details) {
     renderTerminal.selectWord(
-      _lastLongPressStartDetails!.globalPosition,
-      details.globalPosition,
+      _lastLongPressStartDetails!.localPosition,
+      details.localPosition,
     );
   }
 
@@ -81,14 +83,14 @@ class _TerminalGestureHandlerState extends State<TerminalGestureHandler> {
     _lastDragStartDetails = details;
 
     details.kind == PointerDeviceKind.mouse
-        ? renderTerminal.selectPosition(details.globalPosition)
-        : renderTerminal.selectWord(details.globalPosition);
+        ? renderTerminal.selectCharacters(details.localPosition)
+        : renderTerminal.selectWord(details.localPosition);
   }
 
   void onDragUpdate(DragUpdateDetails details) {
-    renderTerminal.selectPosition(
-      _lastDragStartDetails!.globalPosition,
-      details.globalPosition,
+    renderTerminal.selectCharacters(
+      _lastDragStartDetails!.localPosition,
+      details.localPosition,
     );
   }
 }

+ 110 - 75
lib/src/ui/render.dart

@@ -6,11 +6,12 @@ import 'package:flutter/material.dart';
 import 'package:flutter/rendering.dart';
 import 'package:flutter/scheduler.dart';
 import 'package:xterm/src/core/buffer/cell_flags.dart';
-import 'package:xterm/src/core/buffer/position.dart';
+import 'package:xterm/src/core/buffer/cell_offset.dart';
 import 'package:xterm/src/core/buffer/range.dart';
 import 'package:xterm/src/core/cell.dart';
 import 'package:xterm/src/core/buffer/line.dart';
 import 'package:xterm/src/terminal.dart';
+import 'package:xterm/src/ui/char_metrics.dart';
 import 'package:xterm/src/ui/controller.dart';
 import 'package:xterm/src/ui/cursor_type.dart';
 import 'package:xterm/src/ui/palette_builder.dart';
@@ -21,14 +22,13 @@ import 'package:xterm/src/ui/terminal_theme.dart';
 
 typedef EditableRectCallback = void Function(Rect rect, Rect caretRect);
 
-class RenderTerminal extends RenderBox {
+class RenderTerminal extends RenderBox with RelayoutWhenSystemFontsChangeMixin {
   RenderTerminal({
     required Terminal terminal,
     required TerminalController controller,
     required ViewportOffset offset,
     required EdgeInsets padding,
     required bool autoResize,
-    required Size charMetrics,
     required TerminalStyle textStyle,
     required TerminalTheme theme,
     required FocusNode focusNode,
@@ -41,7 +41,6 @@ class RenderTerminal extends RenderBox {
         _offset = offset,
         _padding = padding,
         _autoResize = autoResize,
-        _charMetrics = charMetrics,
         _textStyle = textStyle,
         _theme = theme,
         _focusNode = focusNode,
@@ -50,6 +49,7 @@ class RenderTerminal extends RenderBox {
         _onEditableRect = onEditableRect,
         _composingText = composingText {
     _updateColorPalette();
+    _updateCharSize();
   }
 
   Terminal _terminal;
@@ -94,17 +94,11 @@ class RenderTerminal extends RenderBox {
     markNeedsLayout();
   }
 
-  Size _charMetrics;
-  set charMetrics(Size value) {
-    if (value == _charMetrics) return;
-    _charMetrics = value;
-    markNeedsLayout();
-  }
-
   TerminalStyle _textStyle;
   set textStyle(TerminalStyle value) {
     if (value == _textStyle) return;
     _textStyle = value;
+    _updateCharSize();
     markNeedsLayout();
   }
 
@@ -153,20 +147,32 @@ class RenderTerminal extends RenderBox {
     markNeedsPaint();
   }
 
-  final _paragraphCache = ParagraphCache(10240);
-
+  /// The lookup table for converting terminal colors to Flutter colors. This is
+  /// generated from the [_theme].
   late List<Color> _colorPalette;
 
+  /// The size of a single character in [_textStyle] in pixels. [_textStyle] is
+  /// expected to be monospace.
+  late Size _charSize;
+
   TerminalSize? _viewportSize;
 
+  /// Updates [_colorPalette] based on the current [_theme]. This should be
+  /// called whenever the [_theme] changes.
   void _updateColorPalette() {
     _colorPalette = PaletteBuilder(_theme).build();
   }
 
+  /// Updates [_charSize] based on the current [_textStyle]. This should be
+  /// called whenever the [_textStyle] changes or the system font changes.
+  void _updateCharSize() {
+    _charSize = calcCharSize(_textStyle);
+  }
+
   var _stickToBottom = true;
 
   void _onScroll() {
-    _stickToBottom = _offset.pixels >= _maxScrollExtent;
+    _stickToBottom = _scrollOffset >= _maxScrollExtent;
     markNeedsLayout();
   }
 
@@ -208,6 +214,12 @@ class RenderTerminal extends RenderBox {
     return true;
   }
 
+  @override
+  void systemFontsDidChange() {
+    _updateCharSize();
+    super.systemFontsDidChange();
+  }
+
   @override
   void performLayout() {
     size = constraints.biggest;
@@ -217,56 +229,63 @@ class RenderTerminal extends RenderBox {
     _updateScrollOffset();
 
     if (_stickToBottom) {
-      _offset.correctBy(_maxScrollExtent - _offset.pixels);
+      _offset.correctBy(_maxScrollExtent - _scrollOffset);
     }
 
     SchedulerBinding.instance
         .addPostFrameCallback((_) => _notifyEditableRect());
   }
 
-  double get lineHeight => _charMetrics.height;
-
-  double get terminalHeight =>
-      _terminal.buffer.lines.length * _charMetrics.height;
+  /// Total height of the terminal in pixels. Includes scrollback buffer.
+  double get _terminalHeight =>
+      _terminal.buffer.lines.length * _charSize.height;
 
-  double get scrollOffset => _offset.pixels;
+  /// The distance from the top of the terminal to the top of the viewport.
+  // double get _scrollOffset => _offset.pixels;
+  double get _scrollOffset {
+    return _offset.pixels ~/ _charSize.height * _charSize.height;
+  }
 
-  BufferPosition positionFromOffset(Offset offset) {
-    final x = offset.dx - _padding.left;
-    final y = offset.dy - _padding.top + _offset.pixels;
-    final row = y ~/ _charMetrics.height;
-    final col = x ~/ _charMetrics.width;
-    return BufferPosition(col, row);
+  /// Get the top-left corner of the cell at [cellOffset] in pixels.
+  Offset getOffset(CellOffset cellOffset) {
+    final row = cellOffset.y;
+    final col = cellOffset.x;
+    final x = col * _charSize.width;
+    final y = row * _charSize.height;
+    return Offset(x + _padding.left, y + _padding.top - _scrollOffset);
   }
 
-  Offset offsetFromPosition(BufferPosition position) {
-    final row = position.y;
-    final col = position.x;
-    final x = col * _charMetrics.width;
-    final y = row * _charMetrics.height;
-    return Offset(x + _padding.left, y + _padding.top - _offset.pixels);
+  /// Get the [CellOffset] of the cell that [offset] is in.
+  CellOffset getCellOffset(Offset offset) {
+    final x = offset.dx - _padding.left;
+    final y = offset.dy - _padding.top + _scrollOffset;
+    final row = y ~/ _charSize.height;
+    final col = x ~/ _charSize.width;
+    return CellOffset(col, row);
   }
 
+  /// Selects entire words in the terminal that contains [from] and [to].
   void selectWord(Offset from, [Offset? to]) {
-    final fromOffset = positionFromOffset(globalToLocal(from));
+    final fromOffset = getCellOffset(from);
     final fromBoundary = _terminal.buffer.getWordBoundary(fromOffset);
     if (fromBoundary == null) return;
     if (to == null) {
       _controller.setSelection(fromBoundary);
     } else {
-      final toOffset = positionFromOffset(globalToLocal(to));
+      final toOffset = getCellOffset(to);
       final toBoundary = _terminal.buffer.getWordBoundary(toOffset);
       if (toBoundary == null) return;
       _controller.setSelection(fromBoundary.merge(toBoundary));
     }
   }
 
-  void selectPosition(Offset from, [Offset? to]) {
-    final fromPosition = positionFromOffset(globalToLocal(from));
+  /// Selects characters in the terminal that starts from [from] to [to].
+  void selectCharacters(Offset from, [Offset? to]) {
+    final fromPosition = getCellOffset(from);
     if (to == null) {
       _controller.setSelection(BufferRange.collapsed(fromPosition));
     } else {
-      final toPosition = positionFromOffset(globalToLocal(to));
+      final toPosition = getCellOffset(to);
       _controller.setSelection(BufferRange(fromPosition, toPosition));
     }
   }
@@ -278,22 +297,24 @@ class RenderTerminal extends RenderBox {
       cursor.dx,
       cursor.dy,
       size.width,
-      cursor.dy + _charMetrics.height,
+      cursor.dy + _charSize.height,
     );
 
-    final caretRect = cursor & _charMetrics;
+    final caretRect = cursor & _charSize;
 
     _onEditableRect?.call(rect, caretRect);
   }
 
+  /// Update the viewport size in cells based on the current widget size in
+  /// pixels.
   void _updateViewportSize() {
-    if (size <= _charMetrics) {
+    if (size <= _charSize) {
       return;
     }
 
     final viewportSize = TerminalSize(
-      size.width ~/ _charMetrics.width,
-      _viewportHeight ~/ _charMetrics.height,
+      size.width ~/ _charSize.width,
+      _viewportHeight ~/ _charSize.height,
     );
 
     if (_viewportSize != viewportSize) {
@@ -302,17 +323,25 @@ class RenderTerminal extends RenderBox {
     }
   }
 
+  /// Notify the underlying terminal that the viewport size has changed.
   void _resizeTerminalIfNeeded() {
     if (_autoResize && _viewportSize != null) {
       _terminal.resize(
         _viewportSize!.width,
         _viewportSize!.height,
-        _charMetrics.width.round(),
-        _charMetrics.height.round(),
+        _charSize.width.round(),
+        _charSize.height.round(),
       );
     }
   }
 
+  /// Update the scroll offset based on the current terminal state. This should
+  /// be called in [performLayout] after the viewport size has been updated.
+  void _updateScrollOffset() {
+    _offset.applyViewportDimension(_viewportHeight);
+    _offset.applyContentDimensions(0, _maxScrollExtent);
+  }
+
   bool get _isComposingText {
     return _composingText != null && _composingText!.isNotEmpty;
   }
@@ -326,24 +355,21 @@ class RenderTerminal extends RenderBox {
   }
 
   double get _maxScrollExtent {
-    return max(terminalHeight - _viewportHeight, 0.0);
+    return max(_terminalHeight - _viewportHeight, 0.0);
   }
 
   double get _lineOffset {
-    return -_offset.pixels + _padding.top;
+    return -_scrollOffset + _padding.top;
   }
 
   Offset get _cursorOffset {
     return Offset(
-      _terminal.buffer.cursorX * _charMetrics.width,
-      _terminal.buffer.absoluteCursorY * _charMetrics.height + _lineOffset,
+      _terminal.buffer.cursorX * _charSize.width,
+      _terminal.buffer.absoluteCursorY * _charSize.height + _lineOffset,
     );
   }
 
-  void _updateScrollOffset() {
-    _offset.applyViewportDimension(_viewportHeight);
-    _offset.applyContentDimensions(0, _maxScrollExtent);
-  }
+  final _paragraphCache = ParagraphCache(10240);
 
   @override
   void paint(PaintingContext context, Offset offset) {
@@ -355,10 +381,10 @@ class RenderTerminal extends RenderBox {
     final canvas = context.canvas;
 
     final lines = _terminal.buffer.lines;
-    final charHeight = _charMetrics.height;
+    final charHeight = _charSize.height;
 
-    final firstLineOffset = _offset.pixels - _padding.top;
-    final lastLineOffset = _offset.pixels + size.height + _padding.bottom;
+    final firstLineOffset = _scrollOffset - _padding.top;
+    final lastLineOffset = _scrollOffset + size.height + _padding.bottom;
 
     final firstLine = firstLineOffset ~/ charHeight;
     final lastLine = lastLineOffset ~/ charHeight;
@@ -397,6 +423,7 @@ class RenderTerminal extends RenderBox {
     }
   }
 
+  /// Paints the cursor based on the current cursor type.
   void _paintCursor(Canvas canvas, Offset offset) {
     final paint = Paint()
       ..color = _theme.cursor
@@ -404,30 +431,32 @@ class RenderTerminal extends RenderBox {
 
     if (!_focusNode.hasFocus) {
       paint.style = PaintingStyle.stroke;
-      canvas.drawRect(offset & _charMetrics, paint);
+      canvas.drawRect(offset & _charSize, paint);
       return;
     }
 
     switch (_cursorType) {
       case TerminalCursorType.block:
         paint.style = PaintingStyle.fill;
-        canvas.drawRect(offset & _charMetrics, paint);
+        canvas.drawRect(offset & _charSize, paint);
         return;
       case TerminalCursorType.underline:
         return canvas.drawLine(
-          Offset(offset.dx, _charMetrics.height - 1),
-          Offset(offset.dx + _charMetrics.width, _charMetrics.height - 1),
+          Offset(offset.dx, _charSize.height - 1),
+          Offset(offset.dx + _charSize.width, _charSize.height - 1),
           paint,
         );
       case TerminalCursorType.verticalBar:
         return canvas.drawLine(
           Offset(offset.dx, 0),
-          Offset(offset.dx, _charMetrics.height),
+          Offset(offset.dx, _charSize.height),
           paint,
         );
     }
   }
 
+  /// Paints the text that is currently being composed in IME to [canvas] at
+  /// [offset]. [offset] is usually the cursor position.
   void _paintComposingText(Canvas canvas, Offset offset) {
     final composingText = _composingText;
 
@@ -444,7 +473,7 @@ class RenderTerminal extends RenderBox {
     final builder = ParagraphBuilder(style.getParagraphStyle());
     builder.addPlaceholder(
       offset.dx,
-      _charMetrics.height,
+      _charSize.height,
       PlaceholderAlignment.middle,
     );
     builder.pushStyle(style.getTextStyle());
@@ -456,19 +485,23 @@ class RenderTerminal extends RenderBox {
     canvas.drawParagraph(paragraph, Offset(0, offset.dy));
   }
 
+  /// Paints [line] to [canvas] at [offset]. The x offset of [offset] is usually
+  /// 0, and the y offset is the top of the line.
   void _paintLine(Canvas canvas, BufferLine line, Offset offset) {
     final cellData = CellData.empty();
-    final cellWidth = _charMetrics.width;
+    final cellWidth = _charSize.width;
 
     final visibleCells = size.width ~/ cellWidth + 1;
     final effectCells = min(visibleCells, line.length);
 
     for (var i = 0; i < effectCells; i++) {
       line.getCellData(i, cellData);
+
       final charWidth = cellData.content >> CellContent.widthShift;
       final cellOffset = offset.translate(i * cellWidth, 0);
+
       _paintCellBackground(canvas, cellOffset, cellData);
-      _paintCellForeground(canvas, cellOffset, line, cellData);
+      _paintCellForeground(canvas, cellOffset, cellData);
 
       if (charWidth == 2) {
         i++;
@@ -499,13 +532,13 @@ class RenderTerminal extends RenderBox {
       final end = segment.end ?? _terminal.viewWidth;
 
       final startOffset = Offset(
-        start * _charMetrics.width,
-        segment.line * _charMetrics.height + _lineOffset,
+        start * _charSize.width,
+        segment.line * _charSize.height + _lineOffset,
       );
 
       final endOffset = Offset(
-        end * _charMetrics.width,
-        (segment.line + 1) * _charMetrics.height + _lineOffset,
+        end * _charSize.width,
+        (segment.line + 1) * _charSize.height + _lineOffset,
       );
 
       final paint = Paint()
@@ -519,18 +552,14 @@ class RenderTerminal extends RenderBox {
     }
   }
 
+  /// Paints the character in the cell represented by [cellData] to [canvas] at
+  /// [offset].
   @pragma('vm:prefer-inline')
-  void _paintCellForeground(
-    Canvas canvas,
-    Offset offset,
-    BufferLine line,
-    CellData cellData,
-  ) {
+  void _paintCellForeground(Canvas canvas, Offset offset, CellData cellData) {
     final charCode = cellData.content & CellContent.codepointMask;
     if (charCode == 0) return;
 
     final hash = cellData.getHash();
-    // final hash = cellData.getHash() + line.hashCode;
     var paragraph = _paragraphCache.getLayoutFromCache(hash);
 
     if (paragraph == null) {
@@ -561,6 +590,8 @@ class RenderTerminal extends RenderBox {
     canvas.drawParagraph(paragraph, offset);
   }
 
+  /// Paints the background of a cell represented by [cellData] to [canvas] at
+  /// [offset].
   @pragma('vm:prefer-inline')
   void _paintCellBackground(Canvas canvas, Offset offset, CellData cellData) {
     late Color color;
@@ -577,10 +608,12 @@ class RenderTerminal extends RenderBox {
     final paint = Paint()..color = color;
     final doubleWidth = cellData.content >> CellContent.widthShift == 2;
     final widthScale = doubleWidth ? 2 : 1;
-    final size = Size(_charMetrics.width * widthScale + 1, _charMetrics.height);
+    final size = Size(_charSize.width * widthScale + 1, _charSize.height);
     canvas.drawRect(offset & size, paint);
   }
 
+  /// Get the effective foreground color for a cell from information encoded in
+  /// [cellColor].
   @pragma('vm:prefer-inline')
   Color _resolveForegroundColor(int cellColor) {
     final colorType = cellColor & CellColor.typeMask;
@@ -598,6 +631,8 @@ class RenderTerminal extends RenderBox {
     }
   }
 
+  /// Get the effective background color for a cell from information encoded in
+  /// [cellColor].
   @pragma('vm:prefer-inline')
   Color _resolveBackgroundColor(int cellColor) {
     final colorType = cellColor & CellColor.typeMask;

+ 79 - 0
lib/src/ui/shortcut/actions.dart

@@ -0,0 +1,79 @@
+import 'package:flutter/services.dart';
+import 'package:flutter/widgets.dart';
+import 'package:xterm/core.dart';
+import 'package:xterm/src/ui/shortcut/intents.dart';
+
+class TerminalActions extends StatelessWidget {
+  const TerminalActions({
+    super.key,
+    required this.terminal,
+    required this.child,
+  });
+
+  final Terminal terminal;
+
+  final Widget child;
+
+  @override
+  Widget build(BuildContext context) {
+    TextEditingController;
+
+    return Actions(
+      actions: {
+        TerminalPasteIntent: CallbackAction(
+          onInvoke: (Intent intent) async {
+            final data = await Clipboard.getData(Clipboard.kTextPlain);
+            final text = data?.text;
+            if (text != null) {
+              terminal.paste(text);
+            }
+          },
+        ),
+        // TerminalCopyIntent: CallbackAction(
+        //   onInvoke: (Intent intent) => terminal.copy(),
+        // ),
+        // TerminalSelectAllIntent: CallbackAction(
+        //   onInvoke: (Intent intent) => terminal.selectAll(),
+        // ),
+      },
+      child: child,
+    );
+  }
+}
+
+class TerminalPasteAction extends Action<TerminalPasteIntent> {
+  TerminalPasteAction(this.terminal);
+
+  final Terminal terminal;
+
+  @override
+  void invoke(TerminalPasteIntent intent) async {
+    final data = await Clipboard.getData(Clipboard.kTextPlain);
+    final text = data?.text;
+    if (text != null) {
+      terminal.paste(text);
+    }
+  }
+}
+
+class TerminalCopyAction extends Action<TerminalCopyIntent> {
+  TerminalCopyAction(this.terminal);
+
+  final Terminal terminal;
+
+  @override
+  void invoke(TerminalCopyIntent intent) {
+    // terminal
+  }
+}
+
+class TerminalSelectAllAction extends Action<TerminalSelectAllIntent> {
+  TerminalSelectAllAction(this.terminal);
+
+  final Terminal terminal;
+
+  @override
+  void invoke(TerminalSelectAllIntent intent) {
+    // terminal.selectAll();
+  }
+}

+ 13 - 0
lib/src/ui/shortcut/intents.dart

@@ -0,0 +1,13 @@
+import 'package:flutter/widgets.dart';
+
+class TerminalPasteIntent extends Intent {
+  const TerminalPasteIntent();
+}
+
+class TerminalCopyIntent extends Intent {
+  const TerminalCopyIntent();
+}
+
+class TerminalSelectAllIntent extends Intent {
+  const TerminalSelectAllIntent();
+}

+ 35 - 0
lib/src/ui/shortcut/shortcuts.dart

@@ -0,0 +1,35 @@
+import 'package:flutter/foundation.dart';
+import 'package:flutter/services.dart';
+import 'package:flutter/widgets.dart';
+import 'package:xterm/src/ui/shortcut/intents.dart';
+
+Map<ShortcutActivator, Intent> get defaultTerminalShortcuts {
+  switch (defaultTargetPlatform) {
+    case TargetPlatform.android:
+    case TargetPlatform.fuchsia:
+    case TargetPlatform.linux:
+    case TargetPlatform.windows:
+      return _defaultShortcuts;
+    case TargetPlatform.iOS:
+    case TargetPlatform.macOS:
+      return _defaultAppleShortcuts;
+  }
+}
+
+final _defaultShortcuts = {
+  SingleActivator(LogicalKeyboardKey.keyC, control: true):
+      const TerminalCopyIntent(),
+  SingleActivator(LogicalKeyboardKey.keyV, control: true):
+      const TerminalPasteIntent(),
+  SingleActivator(LogicalKeyboardKey.keyA, control: true):
+      const TerminalSelectAllIntent(),
+};
+
+final _defaultAppleShortcuts = {
+  SingleActivator(LogicalKeyboardKey.keyC, meta: true):
+      const TerminalCopyIntent(),
+  SingleActivator(LogicalKeyboardKey.keyV, meta: true):
+      const TerminalPasteIntent(),
+  SingleActivator(LogicalKeyboardKey.keyA, meta: true):
+      const TerminalSelectAllIntent(),
+};

+ 5 - 1
lib/ui.dart

@@ -1,3 +1,7 @@
+export 'src/terminal_view.dart';
+export 'src/ui/controller.dart';
 export 'src/ui/cursor_type.dart';
-export 'src/ui/terminal_text_style.dart';
 export 'src/ui/keyboard_visibility.dart';
+export 'src/ui/terminal_text_style.dart';
+export 'src/ui/terminal_theme.dart';
+export 'src/ui/themes.dart';

+ 7 - 0
test/_fixture/_fixture.dart

@@ -0,0 +1,7 @@
+import 'dart:io';
+
+abstract class TestFixtures {
+  static String htop_80x25_3s() {
+    return File('test/_fixture/htop_80x25_3s.txt').readAsStringSync();
+  }
+}

+ 0 - 0
fixture/htop_80x25_3s.txt → test/_fixture/htop_80x25_3s.txt


+ 0 - 42
test/next/core/buffer/line_test.dart

@@ -1,42 +0,0 @@
-import 'package:test/test.dart';
-import 'package:xterm/src/core/buffer/line.dart';
-
-void main() {
-  group('BufferLine', () {
-    test('getText() can get text', () {
-      final line = BufferLine(10);
-
-      final text = 'ABCDEFGHIJ';
-
-      for (var i = 0; i < text.length; i++) {
-        line.setCodePoint(i, text.codeUnitAt(i));
-      }
-
-      expect(line.getText(), equals(text));
-    });
-
-    test('getText() should support wide characters', () {
-      final line = BufferLine(10);
-
-      final text = '😀😁😂🤣😃';
-
-      for (var i = 0; i < text.runes.length; i++) {
-        line.setCodePoint(i * 2, text.runes.elementAt(i));
-      }
-
-      expect(line.getText(), equals(text));
-    });
-
-    test('getTrimmedLength() can get trimmed length', () {
-      final line = BufferLine(10);
-
-      final text = 'ABCDEF';
-
-      for (var i = 0; i < text.length; i++) {
-        line.setCodePoint(i, text.codeUnitAt(i));
-      }
-
-      expect(line.getTrimmedLength(), equals(text.length));
-    });
-  });
-}

BIN
test/src/_goldens/htop_80x25_3s.png


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

@@ -0,0 +1,72 @@
+import 'package:test/test.dart';
+import 'package:xterm/xterm.dart';
+
+void main() {
+  group('Buffer.getText()', () {
+    test('should return the text', () {
+      final terminal = Terminal();
+      terminal.write('Hello World');
+      expect(terminal.buffer.getText(), startsWith('Hello World'));
+    });
+
+    test('can handle line wrap', () {
+      final terminal = Terminal();
+      terminal.resize(10, 10);
+
+      final line1 = 'This is a long line that should wrap';
+      final line2 = 'This is a short line';
+      final line3 = 'This is a long long long long line that should wrap';
+      final line4 = 'Short';
+
+      terminal.write('$line1\r\n');
+      terminal.write('$line2\r\n');
+      terminal.write('$line3\r\n');
+      terminal.write('$line4\r\n');
+
+      final lines = terminal.buffer.getText().split('\n');
+      expect(lines[0], line1);
+      expect(lines[1], line2);
+      expect(lines[2], line3);
+      expect(lines[3], line4);
+    });
+
+    test('can handle negative start', () {
+      final terminal = Terminal();
+
+      terminal.write('Hello World');
+
+      expect(
+        terminal.buffer.getText(
+          BufferRange(CellOffset(-100, -100), CellOffset(100, 100)),
+        ),
+        startsWith('Hello World'),
+      );
+    });
+
+    test('can handle invalid end', () {
+      final terminal = Terminal();
+
+      terminal.write('Hello World');
+
+      expect(
+        terminal.buffer.getText(
+          BufferRange(CellOffset(0, 0), CellOffset(100, 100)),
+        ),
+        startsWith('Hello World'),
+      );
+    });
+
+    test('can handle reversed range', () {
+      final terminal = Terminal();
+
+      terminal.write('Hello World');
+
+      expect(
+        terminal.buffer.getText(
+          BufferRange(CellOffset(5, 5), CellOffset(0, 0)),
+        ),
+        startsWith('Hello World'),
+      );
+    });
+  });
+}

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

@@ -0,0 +1,57 @@
+import 'package:test/test.dart';
+import 'package:xterm/xterm.dart';
+
+void main() {
+  group('BufferLine.getText()', () {
+    test('should return the text', () {
+      final terminal = Terminal();
+      terminal.write('Hello World');
+      expect(terminal.buffer.lines[0].getText(), 'Hello World');
+    });
+
+    test('getText() should support wide characters', () {
+      final text = '😀😁😂🤣😃';
+      final terminal = Terminal();
+      terminal.write(text);
+      expect(terminal.buffer.lines[0].getText(), equals(text));
+    });
+
+    test('can specify a range', () {
+      final terminal = Terminal();
+      terminal.write('Hello World');
+      expect(terminal.buffer.lines[0].getText(0, 5), 'Hello');
+    });
+
+    test('can handle invalid ranges', () {
+      final terminal = Terminal();
+      terminal.write('Hello World');
+      expect(terminal.buffer.lines[0].getText(0, 100), 'Hello World');
+    });
+
+    test('can handle negative ranges', () {
+      final terminal = Terminal();
+      terminal.write('Hello World');
+      expect(terminal.buffer.lines[0].getText(-100, 100), 'Hello World');
+    });
+
+    test('can handle reversed ranges', () {
+      final terminal = Terminal();
+      terminal.write('Hello World');
+      expect(terminal.buffer.lines[0].getText(5, 0), '');
+    });
+  });
+
+  group('BufferLine.getTrimmedLength()', () {
+    test('can get trimmed length', () {
+      final line = BufferLine(10);
+
+      final text = 'ABCDEF';
+
+      for (var i = 0; i < text.length; i++) {
+        line.setCodePoint(i, text.codeUnitAt(i));
+      }
+
+      expect(line.getTrimmedLength(), equals(text.length));
+    });
+  });
+}

+ 0 - 2
test/next/core/reflow_test.dart → test/src/core/reflow_test.dart

@@ -68,8 +68,6 @@ void main() {
     expect(terminal.buffer.lines[1].toString(), '光疑是地');
     expect(terminal.buffer.lines[2].toString(), '上霜');
 
-    print('-----------');
-
     terminal.resize(11, 10);
 
     expect(terminal.buffer.lines[0].toString(), '床前明月光');

+ 24 - 0
test/src/terminal_view_test.dart

@@ -0,0 +1,24 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:xterm/xterm.dart';
+
+import '../_fixture/_fixture.dart';
+
+void main() {
+  testWidgets('Golden test', (WidgetTester tester) async {
+    final terminal = Terminal();
+    await tester.pumpWidget(MaterialApp(
+      home: Scaffold(
+        body: TerminalView(terminal),
+      ),
+    ));
+
+    terminal.write(TestFixtures.htop_80x25_3s());
+    await tester.pump();
+
+    await expectLater(
+      find.byType(TerminalView),
+      matchesGoldenFile('_goldens/htop_80x25_3s.png'),
+    );
+  });
+}

+ 0 - 0
test/util/circular_list_test.dart → test/src/utils/circular_list_test.dart