Repository: TerminalStudio/xterm.dart

Issue:
Fix for occasional unintended multi character input
Under load and fast key typing the Terminal can receive a character multiple times.

When this happens the onTextEdit method gets the first character and immediately afterwards the first combined with the second character.

onTextEdit('l')
onTextEdit('ls')

--> Terminal receives 'lls' but the user typed 'ls'

The "solution" here is to only take the last rune in the given String into account but I'm not sure if this creates any regression. @xtyxtyx maybe you have more insight here?



Fix patch:
diff --git a/lib/frontend/input_behavior_default.dart b/lib/frontend/input_behavior_default.dart
index f7306d5f..e08cf5a8 100644
--- a/lib/frontend/input_behavior_default.dart
+++ b/lib/frontend/input_behavior_default.dart
@@ -34,6 +34,8 @@ class InputBehaviorDefault extends InputBehavior {
 
   String? _composingString = null;
 
+  TextEditingValue? _lastEditingState;
+
   @override
   TextEditingValue? onTextEdit(
       TextEditingValue value, TerminalUiInteraction terminal) {
@@ -43,6 +45,7 @@ class InputBehaviorDefault extends InputBehavior {
     if (value.composing.start != value.composing.end) {
       _composingString = inputText;
       terminal.updateComposingString(_composingString!);
+      _lastEditingState = value;
       return null;
     }
     //when we reach this point the composing state is over
@@ -51,7 +54,21 @@ class InputBehaviorDefault extends InputBehavior {
       terminal.updateComposingString('');
     }
 
-    terminal.raiseOnInput(inputText);
+    //this is a hack to bypass some race condition in the input system
+    //we just take the last rune if there are more than one as it sometimes
+    //happens that the last value is still part of the new value
+
+    if (_lastEditingState?.text.isNotEmpty == true) {
+      if (inputText.length > _lastEditingState!.text.length) {
+        inputText = inputText.substring(_lastEditingState!.text.length);
+      }
+    }
+
+    if (inputText.isNotEmpty) {
+      terminal.raiseOnInput(inputText);
+    }
+
+    _lastEditingState = value;
 
     if (value == TextEditingValue.empty || inputText == '') {
       return null;


Test patch:
diff --git a/test/frontend/input_test.dart b/test/frontend/input_test.dart
new file mode 100644
index 00000000..48da3429
--- /dev/null
+++ b/test/frontend/input_test.dart
@@ -0,0 +1,140 @@
+import 'package:flutter/material.dart';
+import 'package:flutter/services.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:mockito/annotations.dart';
+import 'package:mockito/mockito.dart';
+import 'package:xterm/frontend/input_behavior_desktop.dart';
+import 'package:xterm/xterm.dart';
+
+import 'input_test.mocks.dart';
+
+@GenerateMocks([
+  TerminalUiInteraction,
+])
+void main() {
+  group('InputBehaviorDesktop', () {
+    test('can handle fast typing', () {
+      final mockTerminal = MockTerminalUiInteraction();
+      final inputBehavior = InputBehaviorDesktop();
+
+      inputBehavior.onTextEdit(composing('l', -1, -1), mockTerminal);
+      verify(mockTerminal.raiseOnInput('l'));
+      verifyNever(mockTerminal.updateComposingString(any));
+
+      inputBehavior.onTextEdit(composing('ls', -1, -1), mockTerminal);
+      verify(mockTerminal.raiseOnInput('s'));
+      verifyNever(mockTerminal.updateComposingString(any));
+
+      inputBehavior.onTextEdit(TextEditingValue.empty, mockTerminal);
+      verifyNever(mockTerminal.raiseOnInput(any));
+      verifyNever(mockTerminal.updateComposingString(any));
+    });
+
+    test('can handle English', () {
+      final mockTerminal = MockTerminalUiInteraction();
+      final inputBehavior = InputBehaviorDesktop();
+
+      // typing 'hello'
+
+      inputBehavior.onTextEdit(composing('h', 1, 1), mockTerminal);
+      verify(mockTerminal.raiseOnInput('h'));
+      verifyNever(mockTerminal.updateComposingString(any));
+
+      inputBehavior.onTextEdit(TextEditingValue.empty, mockTerminal);
+      inputBehavior.onTextEdit(composing('e', 1, 1), mockTerminal);
+      verify(mockTerminal.raiseOnInput('e'));
+      verifyNever(mockTerminal.updateComposingString(any));
+
+      inputBehavior.onTextEdit(TextEditingValue.empty, mockTerminal);
+      inputBehavior.onTextEdit(composing('l', 1, 1), mockTerminal);
+      verify(mockTerminal.raiseOnInput('l'));
+      verifyNever(mockTerminal.updateComposingString(any));
+
+      inputBehavior.onTextEdit(TextEditingValue.empty, mockTerminal);
+      inputBehavior.onTextEdit(composing('l', 1, 1), mockTerminal);
+      verify(mockTerminal.raiseOnInput('l'));
+      verifyNever(mockTerminal.updateComposingString(any));
+
+      inputBehavior.onTextEdit(TextEditingValue.empty, mockTerminal);
+      inputBehavior.onTextEdit(composing('o', 1, 1), mockTerminal);
+      verify(mockTerminal.raiseOnInput('o'));
+      verifyNever(mockTerminal.updateComposingString(any));
+    });
+
+    test('can handle Chinese', () {
+      final mockTerminal = MockTerminalUiInteraction();
+      final inputBehavior = InputBehaviorDesktop();
+
+      // typing '你好'
+
+      inputBehavior.onTextEdit(composing('n', 0, 1), mockTerminal);
+      inputBehavior.onTextEdit(composing('ni', 0, 2), mockTerminal);
+      inputBehavior.onTextEdit(composing('ni h', 0, 4), mockTerminal);
+      inputBehavior.onTextEdit(composing('ni ha', 0, 5), mockTerminal);
+      inputBehavior.onTextEdit(composing('ni hao', 0, 6), mockTerminal);
+      inputBehavior.onTextEdit(composing('你好', 0, 2), mockTerminal);
+      verify(mockTerminal.updateComposingString(any)).called(6);
+      verifyNever(mockTerminal.raiseOnInput(any));
+
+      inputBehavior.onTextEdit(composing('你好', -1, -1), mockTerminal);
+      verify(mockTerminal.raiseOnInput('你好'));
+      verify(mockTerminal.updateComposingString(''));
+    });
+
+    test('can handle Japanese', () {
+      final mockTerminal = MockTerminalUiInteraction();
+      final inputBehavior = InputBehaviorDesktop();
+
+      // typing 'どうも'
+
+      inputBehavior.onTextEdit(composing('d', 0, 1), mockTerminal);
+      inputBehavior.onTextEdit(composing('ど', 0, 1), mockTerminal);
+      inputBehavior.onTextEdit(composing('どう', 0, 2), mockTerminal);
+      inputBehavior.onTextEdit(composing('どうm', 0, 3), mockTerminal);
+      inputBehavior.onTextEdit(composing('どうも', 0, 3), mockTerminal);
+      verify(mockTerminal.updateComposingString(any)).called(5);
+      verifyNever(mockTerminal.raiseOnInput(any));
+
+      inputBehavior.onTextEdit(composing('どうも', -1, -1), mockTerminal);
+      verify(mockTerminal.raiseOnInput('どうも'));
+      verify(mockTerminal.updateComposingString(''));
+    });
+
+    test('can handle Korean', () {
+      final mockTerminal = MockTerminalUiInteraction();
+      final inputBehavior = InputBehaviorDesktop();
+
+      // typing '안녕'
+
+      inputBehavior.onTextEdit(composing('ㅇ', 0, 1), mockTerminal);
+      inputBehavior.onTextEdit(composing('아', 0, 1), mockTerminal);
+      inputBehavior.onTextEdit(composing('안', 0, 1), mockTerminal);
+      inputBehavior.onTextEdit(composing('안', 0, 1), mockTerminal);
+      verify(mockTerminal.updateComposingString(any)).called(4);
+      verifyNever(mockTerminal.raiseOnInput(any));
+
+      inputBehavior.onTextEdit(composing('안', 1, 1), mockTerminal);
+      verify(mockTerminal.raiseOnInput('안'));
+      verify(mockTerminal.updateComposingString(''));
+
+      inputBehavior.onTextEdit(TextEditingValue.empty, mockTerminal);
+      inputBehavior.onTextEdit(composing('ㄴ', 0, 1), mockTerminal);
+      inputBehavior.onTextEdit(composing('녀', 0, 1), mockTerminal);
+      inputBehavior.onTextEdit(composing('녕', 0, 1), mockTerminal);
+      verify(mockTerminal.updateComposingString(any)).called(3);
+      verifyNever(mockTerminal.raiseOnInput(any));
+
+      inputBehavior.onTextEdit(composing('녕', 1, 1), mockTerminal);
+      verify(mockTerminal.raiseOnInput('녕'));
+      verify(mockTerminal.updateComposingString(''));
+    });
+  });
+}
+
+TextEditingValue composing(String text, int start, int end) {
+  return TextEditingValue(
+    text: text,
+    selection: TextSelection.collapsed(offset: text.length),
+    composing: TextRange(start: start, end: end),
+  );
+}
diff --git a/test/frontend/input_test.mocks.dart b/test/frontend/input_test.mocks.dart
new file mode 100644
index 00000000..3ea8cb43
--- /dev/null
+++ b/test/frontend/input_test.mocks.dart
@@ -0,0 +1,239 @@
+// Mocks generated by Mockito 5.0.15 from annotations
+// in xterm/test/frontend/input_test.dart.
+// Do not manually edit this file.
+
+import 'dart:async' as _i6;
+
+import 'package:mockito/mockito.dart' as _i1;
+import 'package:xterm/buffer/line/line.dart' as _i7;
+import 'package:xterm/input/keys.dart' as _i9;
+import 'package:xterm/mouse/position.dart' as _i8;
+import 'package:xterm/terminal/platform.dart' as _i3;
+import 'package:xterm/terminal/terminal_search.dart' as _i4;
+import 'package:xterm/terminal/terminal_ui_interaction.dart' as _i5;
+import 'package:xterm/theme/terminal_theme.dart' as _i2;
+
+// ignore_for_file: avoid_redundant_argument_values
+// ignore_for_file: avoid_setters_without_getters
+// ignore_for_file: comment_references
+// ignore_for_file: implementation_imports
+// ignore_for_file: invalid_use_of_visible_for_testing_member
+// ignore_for_file: prefer_const_constructors
+// ignore_for_file: unnecessary_parenthesis
+
+class _FakeTerminalTheme_0 extends _i1.Fake implements _i2.TerminalTheme {}
+
+class _FakePlatformBehavior_1 extends _i1.Fake implements _i3.PlatformBehavior {
+}
+
+class _FakeTerminalSearchResult_2 extends _i1.Fake
+    implements _i4.TerminalSearchResult {}
+
+class _FakeTerminalSearchOptions_3 extends _i1.Fake
+    implements _i4.TerminalSearchOptions {}
+
+/// A class which mocks [TerminalUiInteraction].
+///
+/// See the documentation for Mockito's code generation for more information.
+class MockTerminalUiInteraction extends _i1.Mock
+    implements _i5.TerminalUiInteraction {
+  MockTerminalUiInteraction() {
+    _i1.throwOnMissingStub(this);
+  }
+
+  @override
+  _i2.TerminalTheme get theme => (super.noSuchMethod(Invocation.getter(#theme),
+      returnValue: _FakeTerminalTheme_0()) as _i2.TerminalTheme);
+  @override
+  int get scrollOffsetFromBottom =>
+      (super.noSuchMethod(Invocation.getter(#scrollOffsetFromBottom),
+          returnValue: 0) as int);
+  @override
+  int get scrollOffsetFromTop => (super
+          .noSuchMethod(Invocation.getter(#scrollOffsetFromTop), returnValue: 0)
+      as int);
+  @override
+  int get bufferHeight =>
+      (super.noSuchMethod(Invocation.getter(#bufferHeight), returnValue: 0)
+          as int);
+  @override
+  int get terminalHeight =>
+      (super.noSuchMethod(Invocation.getter(#terminalHeight), returnValue: 0)
+          as int);
+  @override
+  int get terminalWidth =>
+      (super.noSuchMethod(Invocation.getter(#terminalWidth), returnValue: 0)
+          as int);
+  @override
+  int get invisibleHeight =>
+      (super.noSuchMethod(Invocation.getter(#invisibleHeight), returnValue: 0)
+          as int);
+  @override
+  bool get showCursor =>
+      (super.noSuchMethod(Invocation.getter(#showCursor), returnValue: false)
+          as bool);
+  @override
+  int get cursorY =>
+      (super.noSuchMethod(Invocation.getter(#cursorY), returnValue: 0) as int);
+  @override
+  int get cursorX =>
+      (super.noSuchMethod(Invocation.getter(#cursorX), returnValue: 0) as int);
+  @override
+  int get cursorColor =>
+      (super.noSuchMethod(Invocation.getter(#cursorColor), returnValue: 0)
+          as int);
+  @override
+  int get backgroundColor =>
+      (super.noSuchMethod(Invocation.getter(#backgroundColor), returnValue: 0)
+          as int);
+  @override
+  bool get dirty =>
+      (super.noSuchMethod(Invocation.getter(#dirty), returnValue: false)
+          as bool);
+  @override
+  _i3.PlatformBehavior get platform =>
+      (super.noSuchMethod(Invocation.getter(#platform),
+          returnValue: _FakePlatformBehavior_1()) as _i3.PlatformBehavior);
+  @override
+  bool get isReady =>
+      (super.noSuchMethod(Invocation.getter(#isReady), returnValue: false)
+          as bool);
+  @override
+  _i6.Future<int> get backendExited =>
+      (super.noSuchMethod(Invocation.getter(#backendExited),
+          returnValue: Future<int>.value(0)) as _i6.Future<int>);
+  @override
+  bool get isTerminated =>
+      (super.noSuchMethod(Invocation.getter(#isTerminated), returnValue: false)
+          as bool);
+  @override
+  String get composingString =>
+      (super.noSuchMethod(Invocation.getter(#composingString), returnValue: '')
+          as String);
+  @override
+  _i4.TerminalSearchResult get userSearchResult => (super.noSuchMethod(
+      Invocation.getter(#userSearchResult),
+      returnValue: _FakeTerminalSearchResult_2()) as _i4.TerminalSearchResult);
+  @override
+  int get numberOfSearchHits => (super
+          .noSuchMethod(Invocation.getter(#numberOfSearchHits), returnValue: 0)
+      as int);
+  @override
+  set currentSearchHit(int? currentSearchHit) =>
+      super.noSuchMethod(Invocation.setter(#currentSearchHit, currentSearchHit),
+          returnValueForMissingStub: null);
+  @override
+  _i4.TerminalSearchOptions get userSearchOptions =>
+      (super.noSuchMethod(Invocation.getter(#userSearchOptions),
+              returnValue: _FakeTerminalSearchOptions_3())
+          as _i4.TerminalSearchOptions);
+  @override
+  set userSearchOptions(_i4.TerminalSearchOptions? options) =>
+      super.noSuchMethod(Invocation.setter(#userSearchOptions, options),
+          returnValueForMissingStub: null);
+  @override
+  set userSearchPattern(String? pattern) =>
+      super.noSuchMethod(Invocation.setter(#userSearchPattern, pattern),
+          returnValueForMissingStub: null);
+  @override
+  bool get isUserSearchActive =>
+      (super.noSuchMethod(Invocation.getter(#isUserSearchActive),
+          returnValue: false) as bool);
+  @override
+  set isUserSearchActive(bool? isUserSearchActive) => super.noSuchMethod(
+      Invocation.setter(#isUserSearchActive, isUserSearchActive),
+      returnValueForMissingStub: null);
+  @override
+  Set<void Function()> get listeners =>
+      (super.noSuchMethod(Invocation.getter(#listeners),
+          returnValue: <void Function()>{}) as Set<void Function()>);
+  @override
+  List<_i7.BufferLine> getVisibleLines() =>
+      (super.noSuchMethod(Invocation.method(#getVisibleLines, []),
+          returnValue: <_i7.BufferLine>[]) as List<_i7.BufferLine>);
+  @override
+  void refresh() => super.noSuchMethod(Invocation.method(#refresh, []),
+      returnValueForMissingStub: null);
+  @override
+  void clearSelection() =>
+      super.noSuchMethod(Invocation.method(#clearSelection, []),
+          returnValueForMissingStub: null);
+  @override
+  void selectAll() => super.noSuchMethod(Invocation.method(#selectAll, []),
+      returnValueForMissingStub: null);
+  @override
+  void onMouseTap(_i8.Position? position) =>
+      super.noSuchMethod(Invocation.method(#onMouseTap, [position]),
+          returnValueForMissingStub: null);
+  @override
+  void onMouseDoubleTap(_i8.Position? position) =>
+      super.noSuchMethod(Invocation.method(#onMouseDoubleTap, [position]),
+          returnValueForMissingStub: null);
+  @override
+  void onPanStart(_i8.Position? position) =>
+      super.noSuchMethod(Invocation.method(#onPanStart, [position]),
+          returnValueForMissingStub: null);
+  @override
+  void onPanUpdate(_i8.Position? position) =>
+      super.noSuchMethod(Invocation.method(#onPanUpdate, [position]),
+          returnValueForMissingStub: null);
+  @override
+  void setScrollOffsetFromBottom(int? offset) => super.noSuchMethod(
+      Invocation.method(#setScrollOffsetFromBottom, [offset]),
+      returnValueForMissingStub: null);
+  @override
+  int convertViewLineToRawLine(int? viewLine) => (super.noSuchMethod(
+      Invocation.method(#convertViewLineToRawLine, [viewLine]),
+      returnValue: 0) as int);
+  @override
+  void raiseOnInput(String? input) =>
+      super.noSuchMethod(Invocation.method(#raiseOnInput, [input]),
+          returnValueForMissingStub: null);
+  @override
+  void write(String? text) =>
+      super.noSuchMethod(Invocation.method(#write, [text]),
+          returnValueForMissingStub: null);
+  @override
+  void paste(String? data) =>
+      super.noSuchMethod(Invocation.method(#paste, [data]),
+          returnValueForMissingStub: null);
+  @override
+  void resize(int? newWidth, int? newHeight, int? newPixelWidth,
+          int? newPixelHeight) =>
+      super.noSuchMethod(
+          Invocation.method(
+              #resize, [newWidth, newHeight, newPixelWidth, newPixelHeight]),
+          returnValueForMissingStub: null);
+  @override
+  void keyInput(_i9.TerminalKey? key,
+          {bool? ctrl = false,
+          bool? alt = false,
+          bool? shift = false,
+          bool? mac = false}) =>
+      super.noSuchMethod(
+          Invocation.method(#keyInput, [key],
+              {#ctrl: ctrl, #alt: alt, #shift: shift, #mac: mac}),
+          returnValueForMissingStub: null);
+  @override
+  void terminateBackend() =>
+      super.noSuchMethod(Invocation.method(#terminateBackend, []),
+          returnValueForMissingStub: null);
+  @override
+  void updateComposingString(String? value) =>
+      super.noSuchMethod(Invocation.method(#updateComposingString, [value]),
+          returnValueForMissingStub: null);
+  @override
+  String toString() => super.toString();
+  @override
+  void addListener(void Function()? listener) =>
+      super.noSuchMethod(Invocation.method(#addListener, [listener]),
+          returnValueForMissingStub: null);
+  @override
+  void removeListener(void Function()? listener) =>
+      super.noSuchMethod(Invocation.method(#removeListener, [listener]),
+          returnValueForMissingStub: null);
+  @override
+  void notifyListeners() =>
+      super.noSuchMethod(Invocation.method(#notifyListeners, []),
+          returnValueForMissingStub: null);
+}