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

Issue:
Preserve selection when folding and unfolding
Folding and unfolding code currently resets the cursor position and selection to none.

Pull request description:
- Resolves #81

Patch:
diff --git a/lib/src/code_field/code_controller.dart b/lib/src/code_field/code_controller.dart
index 88fb282e..3c2e677c 100644
--- a/lib/src/code_field/code_controller.dart
+++ b/lib/src/code_field/code_controller.dart
@@ -505,18 +505,26 @@ class CodeController extends TextEditingController {
   }
 
   void foldAt(int line) {
+    final oldCode = _code;
     _code = _code.foldedAt(line);
+
     super.value = TextEditingValue(
       text: _code.visibleText,
-      // TODO(alexeyinkin): Preserve selection, https://github.com/akvelon/flutter-code-editor/issues/81
+      selection: _code.hiddenRanges.cutSelection(
+        oldCode.hiddenRanges.recoverSelection(value.selection),
+      ),
     );
   }
 
   void unfoldAt(int line) {
+    final oldCode = _code;
     _code = _code.unfoldedAt(line);
+
     super.value = TextEditingValue(
       text: _code.visibleText,
-      // TODO(alexeyinkin): Preserve selection, https://github.com/akvelon/flutter-code-editor/issues/81
+      selection: _code.hiddenRanges.cutSelection(
+        oldCode.hiddenRanges.recoverSelection(value.selection),
+      ),
     );
   }
 
diff --git a/lib/src/hidden_ranges/hidden_ranges.dart b/lib/src/hidden_ranges/hidden_ranges.dart
index 066dadcc..0128fed4 100644
--- a/lib/src/hidden_ranges/hidden_ranges.dart
+++ b/lib/src/hidden_ranges/hidden_ranges.dart
@@ -160,6 +160,67 @@ class HiddenRanges {
     );
   }
 
+  /// Translates the [position] in the full text to the position in
+  /// the visible text.
+  ///
+  /// If [position] is hidden, returns the last visible position before it.
+  int cutPosition(int position) {
+    if (ranges.isEmpty || position < ranges.first.start) {
+      return position;
+    }
+
+    if (position >= ranges.last.end) {
+      return position - hiddenCharactersBeforeRanges.last;
+    }
+
+    int lowerRange = 0;
+    int upperRange = ranges.length - 1;
+
+    // Linear interpolation search.
+    while (upperRange > lowerRange) {
+      // Full characters at the lower range start and at the upper range end.
+      final lowerChar = ranges[lowerRange].start;
+      final upperChar = ranges[upperRange].end;
+
+      final rangeIndex = lowerRange +
+          ((position - lowerChar) /
+                  (upperChar - lowerChar) *
+                  (upperRange - lowerRange))
+              .floor();
+
+      if (rangeIndex < lowerRange) {
+        return position - hiddenCharactersBeforeRanges[lowerRange];
+      }
+      if (rangeIndex > upperRange) {
+        return position - hiddenCharactersBeforeRanges[upperRange + 1];
+      }
+
+      final range = ranges[rangeIndex];
+
+      switch (range.compareToPosition(position)) {
+        case 0:
+          return range.start - hiddenCharactersBeforeRanges[rangeIndex];
+        case -1:
+          lowerRange = rangeIndex + 1;
+          break;
+        case 1:
+          upperRange = rangeIndex - 1;
+          break;
+      }
+    }
+
+    // upper == lower - 1, or lower, or lower + 1
+    final range = ranges[upperRange];
+    switch (range.compareToPosition(position)) {
+      case -1:
+        return position - hiddenCharactersBeforeRanges[upperRange + 1];
+      case 1:
+        return position - hiddenCharactersBeforeRanges[upperRange];
+    }
+
+    return range.start - hiddenCharactersBeforeRanges[upperRange];
+  }
+
   /// Translates the [position] in the visible text to the position in
   /// the full text.
   ///
@@ -178,8 +239,9 @@ class HiddenRanges {
     int lowerRange = 0;
     int upperRange = ranges.length - 1;
 
+    // Linear interpolation search.
     while (upperRange > lowerRange) {
-      // Visible characters if the range collapse positions.
+      // Visible characters at the ranges' collapse positions.
       final lowerChar =
           ranges[lowerRange].end - hiddenCharactersBeforeRanges[lowerRange + 1];
       final upperChar =
@@ -245,6 +307,46 @@ class HiddenRanges {
     }
   }
 
+  TextSelection cutSelection(TextSelection selection) {
+    if (selection.isCollapsed) {
+      final position = cutPosition(selection.start);
+      return selection.copyWith(
+        baseOffset: position,
+        extentOffset: position,
+      );
+    }
+
+    return selection.copyWith(
+      baseOffset: cutPosition(selection.baseOffset),
+      extentOffset: cutPosition(selection.extentOffset),
+    );
+  }
+
+  TextSelection recoverSelection(TextSelection selection) {
+    if (selection.isCollapsed) {
+      final position = recoverPosition(
+        selection.start,
+        placeHiddenRanges: TextAffinity.downstream,
+      );
+
+      return selection.copyWith(
+        baseOffset: position,
+        extentOffset: position,
+      );
+    }
+
+    return selection.copyWith(
+      baseOffset: recoverPosition(
+        selection.baseOffset,
+        placeHiddenRanges: TextAffinity.downstream,
+      ),
+      extentOffset: recoverPosition(
+        selection.extentOffset,
+        placeHiddenRanges: TextAffinity.downstream,
+      ),
+    );
+  }
+
   @override
   int get hashCode => Object.hash(
         Object.hashAll(ranges),


Test patch:
diff --git a/test/src/code_field/code_controller_folding_test.dart b/test/src/code_field/code_controller_folding_test.dart
index 8ad31c50..87dfbc31 100644
--- a/test/src/code_field/code_controller_folding_test.dart
+++ b/test/src/code_field/code_controller_folding_test.dart
@@ -152,8 +152,7 @@ int c;
           controller.value,
           const TextEditingValue(
             text: _codeFolded1,
-            // TODO(alexeyinkin): Selection.
-            //selection: TextSelection(baseOffset: 1, extentOffset: 2),
+            selection: TextSelection(baseOffset: 1, extentOffset: 2),
           ),
         );
 
@@ -163,8 +162,7 @@ int c;
           controller.value,
           TextEditingValue(
             text: textBefore,
-            // TODO(alexeyinkin): Selection.
-            //selection: TextSelection(baseOffset: 1, extentOffset: 2),
+            selection: const TextSelection(baseOffset: 1, extentOffset: 2),
           ),
         );
       });
@@ -212,8 +210,7 @@ public class MyClass {
   }
 }
 ''',
-            // TODO(alexeyinkin): Selection.
-            //selection: TextSelection(baseOffset: 1, extentOffset: 2),
+            selection: TextSelection(baseOffset: 0, extentOffset: 6),
           ),
         );
       });
@@ -253,8 +250,7 @@ public class MyClass {
   }
 }
 ''',
-            // TODO(alexeyinkin): Selection.
-            //selection: TextSelection(baseOffset: 1, extentOffset: 2),
+            selection: TextSelection(baseOffset: 0, extentOffset: 6),
           ),
         );
       });
@@ -302,8 +298,7 @@ int n;
   }
 }
 ''',
-            // TODO(alexeyinkin): Selection.
-            //selection: TextSelection(baseOffset: 1, extentOffset: 2),
+            selection: TextSelection.collapsed(offset: 91),
           ),
         );
       });
diff --git a/test/src/hidden_ranges/common.dart b/test/src/hidden_ranges/common.dart
new file mode 100644
index 00000000..02e99e0d
--- /dev/null
+++ b/test/src/hidden_ranges/common.dart
@@ -0,0 +1,20 @@
+import 'package:flutter_code_editor/src/hidden_ranges/hidden_range.dart';
+import 'package:flutter_code_editor/src/hidden_ranges/hidden_ranges.dart';
+
+final hiddenRanges = HiddenRanges(
+  ranges: const [
+    //            How many characters are hidden by the beginning of this range:
+    HiddenRange(20, 23, firstLine: 0, lastLine: 0, wholeFirstLine: true), //   0
+    HiddenRange(31, 42, firstLine: 0, lastLine: 0, wholeFirstLine: true), //   3
+    HiddenRange(67, 91, firstLine: 0, lastLine: 0, wholeFirstLine: true), //  14
+    HiddenRange(100, 101, firstLine: 0, lastLine: 0, wholeFirstLine: true), //38
+    HiddenRange(102, 103, firstLine: 0, lastLine: 0, wholeFirstLine: true), //39
+    HiddenRange(104, 105, firstLine: 0, lastLine: 0, wholeFirstLine: true), //40
+    HiddenRange(106, 107, firstLine: 0, lastLine: 0, wholeFirstLine: true), //41
+    HiddenRange(108, 109, firstLine: 0, lastLine: 0, wholeFirstLine: true), //42
+    HiddenRange(110, 111, firstLine: 0, lastLine: 0, wholeFirstLine: true), //43
+    HiddenRange(113, 123, firstLine: 0, lastLine: 0, wholeFirstLine: true), //44
+    //                                                                        54
+  ],
+  textLength: 140,
+);
diff --git a/test/src/hidden_ranges/hidden_ranges_position_test.dart b/test/src/hidden_ranges/hidden_ranges_position_test.dart
index 83cc9a07..010c8d7e 100644
--- a/test/src/hidden_ranges/hidden_ranges_position_test.dart
+++ b/test/src/hidden_ranges/hidden_ranges_position_test.dart
@@ -4,28 +4,110 @@ import 'package:flutter_code_editor/src/hidden_ranges/hidden_range.dart';
 import 'package:flutter_code_editor/src/hidden_ranges/hidden_ranges.dart';
 import 'package:flutter_test/flutter_test.dart';
 
-final _hiddenRanges = HiddenRanges(
-  ranges: const [
-    //                     How many chars hidden by the beginning of this range:
-    HiddenRange(20, 23, firstLine: 0, lastLine: 0, wholeFirstLine: true), //   0
-    HiddenRange(31, 38, firstLine: 0, lastLine: 0, wholeFirstLine: true), //   3
-    HiddenRange(38, 42, firstLine: 0, lastLine: 0, wholeFirstLine: true), //  10
-    HiddenRange(67, 91, firstLine: 0, lastLine: 0, wholeFirstLine: true), //  14
-    HiddenRange(100, 101, firstLine: 0, lastLine: 0, wholeFirstLine: true), //38
-    HiddenRange(102, 103, firstLine: 0, lastLine: 0, wholeFirstLine: true), //39
-    HiddenRange(104, 105, firstLine: 0, lastLine: 0, wholeFirstLine: true), //40
-    HiddenRange(106, 107, firstLine: 0, lastLine: 0, wholeFirstLine: true), //41
-    HiddenRange(108, 109, firstLine: 0, lastLine: 0, wholeFirstLine: true), //42
-    HiddenRange(110, 111, firstLine: 0, lastLine: 0, wholeFirstLine: true), //43
-    HiddenRange(113, 123, firstLine: 0, lastLine: 0, wholeFirstLine: true), //44
-    //                                                                        54
-  ],
-  textLength: 140,
-);
+import 'common.dart';
 
 const affinities = [TextAffinity.upstream, TextAffinity.downstream];
 
 void main() {
+  group('HiddenRanges. cutPosition', () {
+    test('No ranges - No changes', () {
+      expect(
+        HiddenRanges.empty.cutPosition(7),
+        7,
+      );
+    });
+
+    void testCut(HiddenRanges hiddenRanges, Map<int, int> examples) {
+      int i = 0;
+
+      for (final example in examples.entries) {
+        final input = example.key;
+        final expected = example.value;
+
+        int cutPosition() {
+          return hiddenRanges.cutPosition(input);
+        }
+
+        final reason = '#$i. $input -> $expected';
+        expect(cutPosition, returnsNormally, reason: reason);
+        expect(cutPosition(), expected, reason: reason);
+
+        i++;
+      }
+    }
+
+    test('Cut with visible beginning and end', () {
+      final examples = <int, int>{
+        -1: -1, //    No selection does not change
+        0: 0, //      Zero before the first range does not change
+
+        19: 19, //    Range 0 just before
+        20: 20, //    Range 0 collapse
+        21: 20, //    Range 0 hidden
+        22: 20, //    Range 0 hidden
+        23: 20, //    Range 0 last hidden
+        24: 21, //    Range 0 just after
+
+        30: 27, //    Range 1 just before
+        31: 28, //    Range 1 collapse
+        32: 28, //    Range 1 hidden
+        42: 28, //    Range 1 last hidden
+        43: 29, //    Range 1 just after
+
+        66: 52, //    Range 2 just before
+        67: 53, //    Range 2 collapse
+        68: 53, //    Range 2 hidden
+        91: 53, //    Range 2 last hidden
+        92: 54, //    Range 2 just after
+
+        99: 61, //    Range 3 just before
+        100: 62, //   Range 3 collapse
+        101: 62, //   Range 3 last hidden
+        102: 63, //   Range 3 just after / Range 4 collapse
+        103: 63, //   Range 4 hidden
+        104: 64, //   Range 4 just after / Range 5 collapse
+        105: 64, //   Range 5 hidden
+        106: 65, //   Range 5 just after / Range 6 collapse
+        107: 65, //   Range 6 hidden
+        108: 66, //   Range 6 just after / Range 7 collapse
+        109: 66, //   Range 7 hidden
+        110: 67, //   Range 7 just after / Range 8 collapse
+        111: 67, //   Range 8 hidden
+        112: 68, //   Range 8 just after / Range 9 just before
+        113: 69, //   Range 9 collapse
+        114: 69, //   Range 9 hidden
+        123: 69, //   Range 9 last hidden
+        124: 70, //   Range 9 just after
+
+        224: 170, //  Way after all ranges
+      };
+
+      testCut(hiddenRanges, examples);
+    });
+
+    test('Cut with a single range', () {
+      final hiddenRanges = HiddenRanges(
+        ranges: const [
+          HiddenRange(5, 10, firstLine: 0, lastLine: 0, wholeFirstLine: true),
+        ],
+        textLength: 140,
+      );
+
+      final examples = <int, int>{
+        4: 4,
+        5: 5,
+        6: 5,
+        7: 5,
+        8: 5,
+        9: 5,
+        10: 5,
+        11: 6,
+      };
+
+      testCut(hiddenRanges, examples);
+    });
+  });
+
   group('HiddenRanges. recoverPosition.', () {
     test('No ranges - No changes', () {
       expect(
@@ -53,28 +135,28 @@ void main() {
         20: [23, 20], //   Range 0 collapse
         21: [24, 24], //   Range 0 just after
 
-        27: [30, 30], //   Ranges 1+2 just before
-        28: [42, 31], //   Ranges 1+2 collapse
-        29: [43, 43], //   Ranges 1+2 just after
+        27: [30, 30], //   Range 1 just before
+        28: [42, 31], //   Range 1 collapse
+        29: [43, 43], //   Range 1 just after
 
-        52: [66, 66], //   Range 3 just before
-        53: [91, 67], //   Range 3 collapse
-        54: [92, 92], //   Range 3 just after
+        52: [66, 66], //   Range 2 just before
+        53: [91, 67], //   Range 2 collapse
+        54: [92, 92], //   Range 2 just after
 
-        61: [99, 99], //   Range 4 just before
-        62: [101, 100], // Range 4 collapse / Range 5 just before
-        63: [103, 102], // Range 4 just after / Range 5 collapse
-        64: [105, 104], // Range 5 just after / Range 6 collapse
-        65: [107, 106], // Range 6 just after / Range 7 collapse
-        66: [109, 108], // Range 7 just after / Range 8 collapse
-        67: [111, 110], // Range 8 just after / Range 9 collapse
-        68: [112, 112], // Range 9 just after
+        61: [99, 99], //   Range 3 just before
+        62: [101, 100], // Range 3 collapse / Range 4 just before
+        63: [103, 102], // Range 3 just after / Range 4 collapse
+        64: [105, 104], // Range 4 just after / Range 5 collapse
+        65: [107, 106], // Range 5 just after / Range 6 collapse
+        66: [109, 108], // Range 6 just after / Range 7 collapse
+        67: [111, 110], // Range 7 just after / Range 8 collapse
+        68: [112, 112], // Range 8 just after
 
-        69: [123, 113], // Range 10 collapse
+        69: [123, 113], // Range 9 collapse
 
         70: [124, 124], // After all ranges
       };
-      int i = 1;
+      int i = 0;
 
       for (final example in examples.entries) {
         for (int affinityIndex = 0; affinityIndex < 2; affinityIndex++) {
@@ -82,7 +164,7 @@ void main() {
           final expected = example.value[affinityIndex];
 
           int recoverPosition() {
-            return _hiddenRanges.recoverPosition(
+            return hiddenRanges.recoverPosition(
               example.key,
               placeHiddenRanges: placeHiddenRanges,
             );
@@ -110,21 +192,22 @@ void main() {
         5: [10, 5],
         6: [11, 11],
       };
-      int i = 1;
+      int i = 0;
 
       for (final example in examples.entries) {
         for (int affinityIndex = 0; affinityIndex < 2; affinityIndex++) {
+          final input = example.key;
           final placeHiddenRanges = affinities[affinityIndex];
           final expected = example.value[affinityIndex];
 
           int recoverPosition() {
             return hiddenRanges.recoverPosition(
-              example.key,
+              input,
               placeHiddenRanges: placeHiddenRanges,
             );
           }
 
-          final reason = '#$i. $placeHiddenRanges: ${example.key} -> $expected';
+          final reason = '#$i. $placeHiddenRanges: $input -> $expected';
           expect(recoverPosition, returnsNormally, reason: reason);
           expect(recoverPosition(), expected, reason: reason);
         }
diff --git a/test/src/hidden_ranges/hidden_ranges_selection_test.dart b/test/src/hidden_ranges/hidden_ranges_selection_test.dart
new file mode 100644
index 00000000..d699e73e
--- /dev/null
+++ b/test/src/hidden_ranges/hidden_ranges_selection_test.dart
@@ -0,0 +1,196 @@
+// ignore_for_file: avoid_redundant_argument_values
+// ignore_for_file: prefer_const_constructors
+
+import 'package:flutter/widgets.dart';
+import 'package:flutter_code_editor/src/code_field/text_selection.dart';
+import 'package:flutter_code_editor/src/hidden_ranges/hidden_ranges.dart';
+import 'package:flutter_test/flutter_test.dart';
+
+import 'common.dart';
+
+const affinities = [TextAffinity.upstream, TextAffinity.downstream];
+
+void main() {
+  group('HiddenRanges. cutSelection, recoverSelection', () {
+    test('No ranges - No changes', () {
+      const selections = [
+        TextSelection.collapsed(offset: -1),
+        TextSelection.collapsed(offset: 0),
+        TextSelection(
+          baseOffset: 5,
+          extentOffset: 10,
+          affinity: TextAffinity.downstream,
+          isDirectional: true,
+        ),
+        TextSelection(
+          baseOffset: 5,
+          extentOffset: 10,
+          affinity: TextAffinity.upstream,
+          isDirectional: true,
+        ),
+        TextSelection(
+          baseOffset: 5,
+          extentOffset: 10,
+          affinity: TextAffinity.downstream,
+          isDirectional: false,
+        ),
+        TextSelection(
+          baseOffset: 5,
+          extentOffset: 10,
+          affinity: TextAffinity.upstream,
+          isDirectional: false,
+        ),
+      ];
+
+      for (final selection in selections) {
+        expect(
+          HiddenRanges.empty.cutSelection(selection),
+          selection,
+        );
+        expect(
+          HiddenRanges.empty.cutSelection(selection.reversed),
+          selection.reversed,
+        );
+
+        expect(
+          HiddenRanges.empty.recoverSelection(selection),
+          selection,
+        );
+        expect(
+          HiddenRanges.empty.recoverSelection(selection.reversed),
+          selection.reversed,
+        );
+      }
+    });
+
+    test('With visible beginning and end', () {
+      const examples = [
+        //
+        _Example.nonDestructive(
+          'Collapsed before ranges',
+          full: TextRange.empty,
+          cut: TextRange.empty,
+        ),
+
+        _Example.nonDestructive(
+          'Collapsed before ranges',
+          full: TextRange.collapsed(7),
+          cut: TextRange.collapsed(7),
+        ),
+
+        _Example.nonDestructive(
+          'Collapsed at a hidden range collapse point',
+          full: TextRange.collapsed(7),
+          cut: TextRange.collapsed(7),
+        ),
+
+        _Example.transit(
+          'Collapsed at a hidden range collapse point',
+          full: TextRange.collapsed(32),
+          cut: TextRange.collapsed(28),
+          recovered: TextRange.collapsed(31),
+        ),
+
+        _Example.nonDestructive(
+          'Collapsed after ranges',
+          full: TextRange.collapsed(124),
+          cut: TextRange.collapsed(70),
+        ),
+
+        _Example.nonDestructive(
+          'Spanning. Before ranges',
+          full: TextRange(start: 5, end: 10),
+          cut: TextRange(start: 5, end: 10),
+        ),
+
+        _Example.nonDestructive(
+          'Spanning. After ranges',
+          full: TextRange(start: 124, end: 134),
+          cut: TextRange(start: 70, end: 80),
+        ),
+
+        _Example.nonDestructive(
+          'Spanning across 2 ranges',
+          full: TextRange(start: 15, end: 60),
+          cut: TextRange(start: 15, end: 46),
+        ),
+
+        _Example.transit(
+          'Spanning between visible and hidden',
+          full: TextRange(start: 50, end: 70),
+          cut: TextRange(start: 36, end: 53),
+          recovered: TextRange(start: 50, end: 67),
+        ),
+      ];
+
+      for (final e in examples) {
+        for (final affinity in affinities) {
+          for (final isDirectional in [true, false]) {
+            if (e.cutInput != null || e.cutExpected != null) {
+              final full = TextSelection(
+                baseOffset: e.cutInput!.start,
+                extentOffset: e.cutInput!.end,
+                affinity: affinity,
+                isDirectional: isDirectional,
+              );
+              final out = TextSelection(
+                baseOffset: e.cutExpected!.start,
+                extentOffset: e.cutExpected!.end,
+                affinity: affinity,
+                isDirectional: isDirectional,
+              );
+
+              expect(hiddenRanges.cutSelection(full), out);
+              expect(hiddenRanges.cutSelection(full.reversed), out.reversed);
+            }
+
+            if (e.recoverInput != null || e.recoverExpected != null) {
+              final cut = TextSelection(
+                baseOffset: e.recoverInput!.start,
+                extentOffset: e.recoverInput!.end,
+                affinity: affinity,
+                isDirectional: isDirectional,
+              );
+              final out = TextSelection(
+                baseOffset: e.recoverExpected!.start,
+                extentOffset: e.recoverExpected!.end,
+                affinity: affinity,
+                isDirectional: isDirectional,
+              );
+
+              expect(hiddenRanges.recoverSelection(cut), out);
+              expect(hiddenRanges.recoverSelection(cut.reversed), out.reversed);
+            }
+          }
+        }
+      }
+    });
+  });
+}
+
+class _Example {
+  final String name;
+  final TextRange? cutInput;
+  final TextRange? cutExpected;
+  final TextRange? recoverInput;
+  final TextRange? recoverExpected;
+
+  const _Example.nonDestructive(
+    this.name, {
+    required TextRange full,
+    required TextRange cut,
+  })  : cutInput = full,
+        cutExpected = cut,
+        recoverInput = cut,
+        recoverExpected = full;
+
+  const _Example.transit(
+    this.name, {
+    required TextRange full,
+    required TextRange cut,
+    required TextRange recovered,
+  })  : cutInput = full,
+        cutExpected = cut,
+        recoverInput = cut,
+        recoverExpected = recovered;
+}