Repository: akvelon/flutter-code-editor (dart)

Issue:
Pasting text with newlines after a folded block inserts it as that block's 2nd line

Pull request:
- Resolves #90

Patch:
diff --git a/lib/src/code/code.dart b/lib/src/code/code.dart
index 81b63fd5..2df163a2 100644
--- a/lib/src/code/code.dart
+++ b/lib/src/code/code.dart
@@ -220,11 +220,33 @@ class Code {
     return false;
   }
 
-  CodeEditResult getEditResult(TextEditingValue visibleAfter) {
-    final visibleRangeAfter = visibleAfter.text.getChangedRange(
-      visibleText,
-      attributeChangeTo: TextAffinity.upstream,
-    );
+  /// Returns the resulting full text if a given visible text edit is applied.
+  ///
+  /// The text in [visibleAfter] should be different from the [visibleText]
+  /// otherwise the selected text is still considered to change.
+  /// This is for the purpose of speed because this method is only supposed
+  /// to be called during a change.
+  ///
+  /// [oldSelection] is the [TextSelection] before the edit.
+  /// It is considered in a few special cases if the edit is around
+  /// the selection. This is required to disambiguate
+  /// edits that can otherwise lead to erroneous actions on hidden ranges.
+  ///
+  /// Outside of those special cases the [visibleAfter] is just diffed
+  /// against [visibleText]. This changed region may be ambiguous.
+  /// In this case the change is attributed upstream meaning the first
+  /// possible region is considered to hold the change.
+  CodeEditResult getEditResult(
+    TextSelection oldSelection,
+    TextEditingValue visibleAfter,
+  ) {
+    final visibleRangeAfter = visibleAfter.text.getChangedRangeAroundSelection(
+          TextEditingValue(text: visibleText, selection: oldSelection),
+        ) ??
+        visibleAfter.text.getChangedRange(
+          visibleText,
+          attributeChangeTo: TextAffinity.upstream,
+        );
 
     if (visibleRangeAfter.start == -1 && visibleRangeAfter.end == -1) {
       return CodeEditResult(
diff --git a/lib/src/code/string.dart b/lib/src/code/string.dart
index b79993bc..f06a5bc3 100644
--- a/lib/src/code/string.dart
+++ b/lib/src/code/string.dart
@@ -1,7 +1,45 @@
 import 'dart:math';
-import 'dart:ui';
 
-extension MyString on String {
+import 'package:flutter/widgets.dart';
+
+import '../code_field/text_editing_value.dart';
+
+extension StringExtension on String {
+  /// Returns a [TextRange] round selection in [oldValue]
+  /// that is different from this.
+  ///
+  /// This is useful if the ordinary diff with [getChangedRange]
+  /// may be ambiguous. This method tests for the most common changes
+  /// and snaps the changed range to the old selection.
+  ///
+  /// Handles these special cases:
+  /// - Selected text is replaced / deleted / inserted, outside is unchanged.
+  /// - Insertion at a collapsed selection.
+  ///
+  /// Returns null if neither of the above cases apply.
+  TextRange? getChangedRangeAroundSelection(TextEditingValue oldValue) {
+    final oldBefore = oldValue.beforeSelection;
+    final oldAfter = oldValue.afterSelection;
+
+    if (length < oldBefore.length + oldAfter.length) {
+      // The outside of the selection has shortened.
+      // definitely not an above special case.
+      return null;
+    }
+
+    final newBefore = substring(0, oldBefore.length);
+    final newAfter = substring(length - oldAfter.length);
+
+    if (oldBefore == newBefore && oldAfter == newAfter) {
+      return TextRange(
+        start: newBefore.length,
+        end: length - newAfter.length,
+      );
+    }
+
+    return null;
+  }
+
   /// Returns the widest [TextRange] of this that is different from [old].
   ///
   /// `start` refers to the common prefix. It is the first character of this
@@ -16,6 +54,8 @@ extension MyString on String {
   /// cases:
   ///  - Inserting a duplicate: aBc -> aBBc
   ///  - Deletion of a duplicate: aBBc -> aBc
+  ///
+  /// This method should be used if [getChangedRangeAroundSelection] failed.
   TextRange getChangedRange(
     String old, {
     required TextAffinity attributeChangeTo,
diff --git a/lib/src/code_field/code_controller.dart b/lib/src/code_field/code_controller.dart
index 901b80ad..88fb282e 100644
--- a/lib/src/code_field/code_controller.dart
+++ b/lib/src/code_field/code_controller.dart
@@ -360,7 +360,7 @@ class CodeController extends TextEditingController {
   Code get code => _code;
 
   CodeEditResult? _getEditResultNotBreakingReadOnly(TextEditingValue newValue) {
-    final editResult = _code.getEditResult(newValue);
+    final editResult = _code.getEditResult(value.selection, newValue);
     if (!_code.isReadOnlyInLineRange(editResult.linesChanged)) {
       return editResult;
     }


Test patch:
diff --git a/test/src/code/code_get_edit_result_test.dart b/test/src/code/code_get_edit_result_test.dart
index 39fe3ceb..d4b3365a 100644
--- a/test/src/code/code_get_edit_result_test.dart
+++ b/test/src/code/code_get_edit_result_test.dart
@@ -1,5 +1,4 @@
-import 'dart:ui';
-
+import 'package:flutter/widgets.dart';
 import 'package:flutter_code_editor/src/code/code.dart';
 import 'package:flutter_code_editor/src/code/code_edit_result.dart';
 import 'package:flutter_code_editor/src/named_sections/parsers/brackets_start_end.dart';
@@ -39,16 +38,6 @@ void main() {
   test('Code. getEditResult', () {
     const examples = [
       //
-      _Example(
-        'Empty -> Empty',
-        fullTextBefore: '',
-        visibleValueAfter: TextEditingValue.empty,
-        expected: CodeEditResult(
-          fullTextAfter: '',
-          linesChanged: TextRange.empty,
-        ),
-      ),
-
       _Example(
         'Empty -> Something',
         fullTextBefore: '',
@@ -59,16 +48,6 @@ void main() {
         ),
       ),
 
-      _Example(
-        'Same',
-        fullTextBefore: _fullText1,
-        visibleValueAfter: TextEditingValue(text: _visibleText1),
-        expected: CodeEditResult(
-          fullTextAfter: _fullText1,
-          linesChanged: TextRange.empty,
-        ),
-      ),
-
       _Example(
         'Something -> Empty',
         fullTextBefore: _fullText1,
@@ -191,16 +170,6 @@ public class MyClass {
         ),
       ),
 
-      _Example(
-        'All text is a single hidden range -> Empty -> No change',
-        fullTextBefore: '//[START section1]',
-        visibleValueAfter: TextEditingValue.empty,
-        expected: CodeEditResult(
-          fullTextAfter: '//[START section1]',
-          linesChanged: TextRange.empty,
-        ),
-      ),
-
       _Example(
         'If all text is a single hidden range, insert before it',
         fullTextBefore: '//[START section1]',
@@ -227,18 +196,25 @@ public class MyClass {
         namedSectionParser: const BracketsStartEndNamedSectionParser(),
       );
 
-      expect(
-        () => code.getEditResult(example.visibleValueAfter),
-        returnsNormally,
-        reason: example.name,
-      );
-
-      final result = code.getEditResult(example.visibleValueAfter);
-      expect(
-        result,
-        example.expected,
-        reason: example.name,
-      );
+      final selections = [
+        for (int n = code.visibleText.length; --n >= -1;)
+          TextSelection.collapsed(offset: n),
+      ];
+
+      for (final selection in selections) {
+        expect(
+          () => code.getEditResult(selection, example.visibleValueAfter),
+          returnsNormally,
+          reason: example.name,
+        );
+
+        final result = code.getEditResult(selection, example.visibleValueAfter);
+        expect(
+          result,
+          example.expected,
+          reason: example.name,
+        );
+      }
     }
   });
 }
diff --git a/test/src/code/string_get_changed_range_around_selection_test.dart b/test/src/code/string_get_changed_range_around_selection_test.dart
new file mode 100644
index 00000000..146b183c
--- /dev/null
+++ b/test/src/code/string_get_changed_range_around_selection_test.dart
@@ -0,0 +1,144 @@
+import 'package:flutter/widgets.dart';
+import 'package:flutter_code_editor/src/code/string.dart';
+import 'package:flutter_code_editor/src/code_field/text_selection.dart';
+import 'package:flutter_test/flutter_test.dart';
+
+void main() {
+  test('String.getChangedRangeAroundSelection', () {
+    const examples = [
+      //
+      _Example(
+        'Selection deleted',
+        oldValue: TextEditingValue(
+          text: 'abcDEFghi',
+          //        <->
+          selection: TextSelection(baseOffset: 3, extentOffset: 6),
+        ),
+        newString: 'abcghi',
+        expected: TextRange(start: 3, end: 3),
+      ),
+
+      _Example(
+        'Selection replaced',
+        oldValue: TextEditingValue(
+          text: 'abcDEFghi',
+          //        <->
+          selection: TextSelection(baseOffset: 3, extentOffset: 6),
+        ),
+        newString: 'abcdeghi',
+        expected: TextRange(start: 3, end: 5),
+      ),
+
+      _Example(
+        'Disambiguated insertion before',
+        oldValue: TextEditingValue(
+          text: 'abcDghi',
+          //        \ cursor
+          selection: TextSelection.collapsed(offset: 3),
+        ),
+        newString: 'abcDDghi',
+        //             +
+        expected: TextRange(start: 3, end: 4),
+      ),
+
+      _Example(
+        'Disambiguated insertion after',
+        oldValue: TextEditingValue(
+          text: 'abcDghi',
+          //         \ cursor
+          selection: TextSelection.collapsed(offset: 4),
+        ),
+        newString: 'abcDDghi',
+        //              +
+        expected: TextRange(start: 4, end: 5),
+      ),
+
+      _Example(
+        'Delete',
+        oldValue: TextEditingValue(
+          text: 'abc',
+          //      \ cursor
+          selection: TextSelection.collapsed(offset: 1),
+        ),
+        newString: 'ac',
+        expected: null,
+      ),
+
+      _Example(
+        'Backspace',
+        oldValue: TextEditingValue(
+          text: 'abc',
+          //       \ cursor
+          selection: TextSelection.collapsed(offset: 2),
+        ),
+        newString: 'ac',
+        expected: null,
+      ),
+
+      _Example(
+        'Change before selection',
+        oldValue: TextEditingValue(
+          text: 'abc',
+          //      \ cursor
+          selection: TextSelection.collapsed(offset: 1),
+        ),
+        newString: 'Abc',
+        expected: null,
+      ),
+
+      _Example(
+        'Change after selection',
+        oldValue: TextEditingValue(
+          text: 'abc',
+          //      \ cursor
+          selection: TextSelection.collapsed(offset: 1),
+        ),
+        newString: 'abC',
+        expected: null,
+      ),
+    ];
+
+    for (final example in examples) {
+      expect(
+        () =>
+            example.newString.getChangedRangeAroundSelection(example.oldValue),
+        returnsNormally,
+        reason: example.name,
+      );
+
+      final range = example.newString.getChangedRangeAroundSelection(
+        example.oldValue,
+      );
+      final reversedRange = example.newString.getChangedRangeAroundSelection(
+        example.oldValue.copyWith(
+          selection: example.oldValue.selection.reversed,
+        ),
+      );
+
+      expect(
+        range,
+        example.expected,
+        reason: example.name,
+      );
+      expect(
+        reversedRange,
+        example.expected,
+        reason: example.name,
+      );
+    }
+  });
+}
+
+class _Example {
+  final String name;
+  final TextEditingValue oldValue;
+  final String newString;
+  final TextRange? expected;
+
+  const _Example(
+    this.name, {
+    required this.oldValue,
+    required this.newString,
+    required this.expected,
+  });
+}
diff --git a/test/src/code_field/code_controller_folding_test.dart b/test/src/code_field/code_controller_folding_test.dart
index b4136f04..8ad31c50 100644
--- a/test/src/code_field/code_controller_folding_test.dart
+++ b/test/src/code_field/code_controller_folding_test.dart
@@ -32,18 +32,35 @@ private class MyClass {
 }
 ''';
 
+const _commentsCode = '''
+private class MyClass {
+  //comment1
+  //comment2
+  void method() {}
+}
+''';
+
 void main() {
-  late CodeController controller;
   late FocusNode focusNode;
 
   setUp(() {
-    controller = CodeController(
-      text: _code,
+    focusNode = FocusNode();
+  });
+
+  CodeController createController(String text) {
+    return CodeController(
+      text: text,
       language: java,
       namedSectionParser: const BracketsStartEndNamedSectionParser(),
     );
-    focusNode = FocusNode();
-  });
+  }
+
+  Future<CodeController> pumpController(WidgetTester wt, String text) async {
+    final controller = createController(text);
+    await wt.pumpWidget(createApp(controller, focusNode));
+    focusNode.requestFocus();
+    return controller;
+  }
 
   group('CodeController. Folding.', () {
     group('Trivial.', () {
@@ -53,20 +70,16 @@ int a;
 int b;
 int c;
 ''';
-
-        controller = CodeController(
-          text: text,
-          language: java,
-          namedSectionParser: const BracketsStartEndNamedSectionParser(),
-        );
+        final controller = createController(text);
 
         controller.foldAt(1);
-        controller.unfoldAt(1);
+        controller.unfoldAt(2);
 
         expect(controller.code.visibleText, text);
       });
 
       test('Folding does nothing if no foldable block on the line', () {
+        final controller = createController(_code);
         final oldCode = controller.code;
 
         controller.foldAt(3);
@@ -75,6 +88,7 @@ int c;
       });
 
       test('Double folding changes nothing', () {
+        final controller = createController(_code);
         controller.foldAt(1);
         final oldCode = controller.code;
 
@@ -84,6 +98,7 @@ int c;
       });
 
       test('Unfolding does nothing if no foldable block on the line', () {
+        final controller = createController(_code);
         final oldCode = controller.code;
 
         controller.unfoldAt(3);
@@ -92,6 +107,7 @@ int c;
       });
 
       test('Unfolding non-folded changes nothing', () {
+        final controller = createController(_code);
         final oldCode = controller.code;
 
         controller.unfoldAt(1);
@@ -102,6 +118,7 @@ int c;
 
     group('Hides text.', () {
       test('Hides highlighted', () {
+        final controller = createController(_code);
         final originalHtml = controller.code.visibleHighlighted?.toHtml();
 
         controller.foldAt(1);
@@ -125,12 +142,10 @@ int c;
       });
 
       testWidgets('Cursor before', (WidgetTester wt) async {
+        final controller = await pumpController(wt, _code);
         final textBefore = controller.rawText;
-
-        await wt.pumpWidget(createApp(controller, focusNode));
-        focusNode.requestFocus();
-
         await wt.selectFromHome(1, offset: 1);
+
         controller.foldAt(1);
 
         expect(
@@ -157,11 +172,10 @@ int c;
 
     group('Editing', () {
       testWidgets('above a folded block', (WidgetTester wt) async {
-        await wt.pumpWidget(createApp(controller, focusNode));
-        focusNode.requestFocus();
-
+        final controller = await pumpController(wt, _code);
         controller.foldAt(1);
         await wt.selectFromHome(0, offset: 7);
+
         controller.value = controller.value.replacedSelection('public');
 
         expect(
@@ -205,11 +219,10 @@ public class MyClass {
       });
 
       testWidgets('the first line of a folded block', (WidgetTester wt) async {
-        await wt.pumpWidget(createApp(controller, focusNode));
-        focusNode.requestFocus();
-
+        final controller = await pumpController(wt, _code);
         controller.foldAt(0);
         await wt.selectFromHome(0, offset: 7);
+
         controller.value = controller.value.replacedSelection('public');
 
         expect(
@@ -247,17 +260,12 @@ public class MyClass {
       });
 
       testWidgets('between folded blocks', (WidgetTester wt) async {
-        await wt.pumpWidget(createApp(controller, focusNode));
-        focusNode.requestFocus();
-
+        final controller = await pumpController(wt, _code);
         controller.foldAt(1);
         controller.foldAt(7);
 
-        // TODO(alexeyinkin): Allow this edit as one pasting, https://github.com/akvelon/flutter-code-editor/issues/82
         await wt.selectFromHome(43);
-        controller.value = controller.value.replacedSelection('int n;');
-        await wt.selectFromHome(49);
-        controller.value = controller.value.replacedSelection('\n');
+        controller.value = controller.value.replacedSelection('int n;\n');
 
         expect(
           controller.value,
@@ -303,9 +311,7 @@ int n;
 
     group('Deleting folded blocks.', () {
       testWidgets('First block of the same length', (WidgetTester wt) async {
-        await wt.pumpWidget(createApp(controller, focusNode));
-        focusNode.requestFocus();
-
+        final controller = await pumpController(wt, _code);
         controller.foldAt(1);
 
         await wt.selectFromHome(41, offset: 2);
@@ -331,14 +337,12 @@ private class MyClass {
       });
 
       testWidgets('Second block of the same length', (WidgetTester wt) async {
-        await wt.pumpWidget(createApp(controller, focusNode));
-        focusNode.requestFocus();
-
+        final controller = await pumpController(wt, _code);
         controller.foldAt(7);
-
         await wt.selectFromHome(102, offset: 2);
         // ...void method2() {
         //                   \ cursor
+
         controller.value = controller.value.replacedSelection(';');
 
         expect(
@@ -364,27 +368,19 @@ private class MyClass {
       testWidgets(
         'When deleting 2nd identical folded block, 1st one incorrectly folds',
         (WidgetTester wt) async {
-          controller = CodeController(
-            text: '''
+          final controller = await pumpController(wt, '''
 {
 if (true) {
 }
 if (true) {
 }
 }
-''',
-            language: java,
-            namedSectionParser: const BracketsStartEndNamedSectionParser(),
-          );
-
-          await wt.pumpWidget(createApp(controller, focusNode));
-          focusNode.requestFocus();
-
+''');
           controller.foldAt(3);
-
           await wt.selectFromHome(26, offset: 2);
           // {\nif (true) {\n}\nif (true) {}\n\n
           //                              \ cursor
+
           controller.value = controller.value.replacedSelection(';');
 
           expect(
@@ -401,6 +397,52 @@ if (true) ;}
           );
         },
       );
+
+      testWidgets('Deleting folded comments', (WidgetTester wt) async {
+        final controller = await pumpController(wt, _commentsCode);
+        controller.foldAt(1);
+        await wt.selectFromHome(26, offset: 13);
+        // private class MyClass {\n  //comment1\n  void method...
+        //                            \--selected-->
+
+        controller.value = controller.value.replacedSelection('');
+
+        expect(
+          controller.value,
+          const TextEditingValue(
+            text: '''
+private class MyClass {
+  void method() {}
+}
+''',
+            selection: TextSelection.collapsed(offset: 26),
+          ),
+        );
+      });
+
+      testWidgets('Inserting after folded comments', (WidgetTester wt) async {
+        final controller = await pumpController(wt, _commentsCode);
+        controller.foldAt(1);
+        await wt.selectFromHome(37);
+        // private class MyClass {\n  //comment1\n  void method...
+        //                                        \ cursor
+
+        controller.value = controller.value.replacedSelection('  int n;\n');
+
+        expect(
+          controller.value,
+          const TextEditingValue(
+            text: '''
+private class MyClass {
+  //comment1
+  int n;
+  void method() {}
+}
+''',
+            selection: TextSelection.collapsed(offset: 46),
+          ),
+        );
+      });
     });
   });
 }
diff --git a/test/src/code_field/code_controller_readonly_test.dart b/test/src/code_field/code_controller_readonly_test.dart
index 8acb4c6c..6f289b6e 100644
--- a/test/src/code_field/code_controller_readonly_test.dart
+++ b/test/src/code_field/code_controller_readonly_test.dart
@@ -270,16 +270,16 @@ void main() {
         expect(
           controller.value,
           const TextEditingValue(
-            text: 'abc\n\nro\nro\nabc\n',
-            //            \ cursor
-            selection: TextSelection.collapsed(offset: 5),
+            text: 'abc\nro\nro\nabc\n',
+            //          \ cursor
+            selection: TextSelection.collapsed(offset: 4),
           ),
-          reason: 'Newline at start of readonly - OK',
+          reason: 'Newline at start of readonly - No effect',
         );
         expect(
           controller.fullText,
-          'abc\n\nro//readonly\nro//readonly\nabc\n',
-          reason: 'Newline at start of readonly - OK',
+          'abc\nro//readonly\nro//readonly\nabc\n',
+          reason: 'Newline at start of readonly - No effect',
         );
       },
     );
@@ -349,20 +349,21 @@ void main() {
         expect(
           controller.value,
           const TextEditingValue(
-            text: 'abc\nro\nro\nabc\n',
-            //          cursor /
-            selection: TextSelection.collapsed(offset: 10),
+            text: 'abc\nro\nro\n\nabc\n',
+            //            cursor /
+            selection: TextSelection.collapsed(offset: 11),
           ),
-          reason: 'Type at start of first editable line - OK, Newline - No eff',
+          reason: 'Type at start of first editable line - OK',
         );
         expect(
           controller.fullText,
-          'abc\nro//readonly\nro//readonly\nabc\n',
-          reason: 'Type at start of first editable line - OK, Newline - No eff',
+          'abc\nro//readonly\nro//readonly\n\nabc\n',
+          reason: 'Type at start of first editable line - OK',
         );
 
         // To the end of the last readonly line.
         await wt.sendKeyEvent(LogicalKeyboardKey.arrowLeft);
+        await wt.sendKeyEvent(LogicalKeyboardKey.arrowLeft);
 
         await wt.sendKeyEvent(LogicalKeyboardKey.delete);
         await wt.sendKeyEvent(LogicalKeyboardKey.backspace);
@@ -371,7 +372,7 @@ void main() {
         expect(
           controller.value,
           const TextEditingValue(
-            text: 'abc\nro\nro\nabc\n',
+            text: 'abc\nro\nro\n\nabc\n',
             //        cursor /
             selection: TextSelection.collapsed(offset: 9),
           ),
@@ -379,7 +380,7 @@ void main() {
         );
         expect(
           controller.fullText,
-          'abc\nro//readonly\nro//readonly\nabc\n',
+          'abc\nro//readonly\nro//readonly\n\nabc\n',
           reason: 'Delete, Backspace, Type at last RO EOL - No effect',
         );
       },