Repository: akvelon/flutter-code-editor
Pull request #87 (2022-10-18 14:58:31)

issue69 implemented tabs to spaces replacing


Patch:
diff --git a/lib/src/code/string.dart b/lib/src/code/string.dart
index 713f7f2..b79993b 100644
--- a/lib/src/code/string.dart
+++ b/lib/src/code/string.dart
@@ -69,4 +69,8 @@ extension MyString on String {
 
     return TextRange(start: start, end: last + 1);
   }
+
+  String tabsToSpaces(int spaceCount) {
+    return replaceAll('\t', ' ' * spaceCount);
+  }
 }
diff --git a/lib/src/code_field/code_controller.dart b/lib/src/code_field/code_controller.dart
index 96f2bd8..58045d9 100644
--- a/lib/src/code_field/code_controller.dart
+++ b/lib/src/code_field/code_controller.dart
@@ -78,6 +78,8 @@ class CodeController extends TextEditingController {
   /// A list of code modifiers to dynamically update the code upon certain keystrokes
   final List<CodeModifier> modifiers;
 
+  final bool _isTabReplacementEnabled;
+
   /// On web, replace spaces with invisible dots “·” to fix the current issue with spaces
   ///
   /// https://github.com/flutter/flutter/issues/77929
@@ -119,7 +121,8 @@ class CodeController extends TextEditingController {
     this.onChange,
   })  : _theme = theme,
         _readOnlySectionNames = readOnlySectionNames,
-        _lastCode = Code.empty {
+        _lastCode = Code.empty,
+        _isTabReplacementEnabled = modifiers.any((e) => e is TabModifier) {
     this.language = language;
     _updateLastCode(text ?? '');
     fullText = text ?? '';
@@ -310,6 +313,9 @@ class CodeController extends TextEditingController {
         }
       }
 
+      if (_isTabReplacementEnabled) {
+        newValue = newValue.tabsToSpaces(params.tabSpaces);
+      }
       final editResult = _getEditResultNotBreakingReadOnly(newValue);
 
       if (editResult == null) {
diff --git a/lib/src/code_field/text_editing_value.dart b/lib/src/code_field/text_editing_value.dart
index 19c1d1c..8e14565 100644
--- a/lib/src/code_field/text_editing_value.dart
+++ b/lib/src/code_field/text_editing_value.dart
@@ -3,8 +3,9 @@ import 'package:flutter/widgets.dart';
 import '../code/reg_exp.dart';
 import '../code/string.dart';
 import '../code/text_range.dart';
+import 'text_selection.dart';
 
-extension MyTextEditingValue on TextEditingValue {
+extension TextEditingValueExtension on TextEditingValue {
   /// The position where the word at the cursor starts.
   /// `null` for a non-collapsed selection.
   int? get wordAtCursorStart {
@@ -88,4 +89,67 @@ extension MyTextEditingValue on TextEditingValue {
       selection: TextSelection.collapsed(offset: rangeAfter.start),
     );
   }
+
+  TextEditingValue tabsToSpaces(int spaceCount) {
+    final replacedBefore = beforeSelection.tabsToSpaces(spaceCount);
+    final replacedSelected = selected.tabsToSpaces(spaceCount);
+    final replacedAfter = afterSelection.tabsToSpaces(spaceCount);
+
+    final finalText = replacedBefore + replacedSelected + replacedAfter;
+
+    return TextEditingValue(
+      text: finalText,
+      selection: _getSelectionFromSubstrings(
+        replacedBefore,
+        replacedSelected,
+        replacedAfter,
+      ),
+      composing: composing,
+    );
+  }
+
+  TextSelection _getSelectionFromSubstrings(
+    String beforeSelection,
+    String inSelection,
+    String afterSelection,
+  ) {
+    if (selection.baseOffset == -1 || selection.extentOffset == -1) {
+      return const TextSelection.collapsed(offset: -1);
+    }
+
+    final baseOffset = beforeSelection.length;
+    final extentOffset = baseOffset + inSelection.length;
+
+    final result = selection.copyWith(
+      baseOffset: baseOffset,
+      extentOffset: extentOffset,
+    );
+
+    return selection.isSelectionNormalized ? result : result.reversed;
+  }
+
+  String get beforeSelection {
+    if (selection.baseOffset == -1 || selection.extentOffset == -1) {
+      return '';
+    }
+    return text.substring(0, selection.start);
+  }
+
+  String get selected {
+    if (selection.baseOffset == -1 || selection.extentOffset == -1) {
+      return '';
+    }
+    final selectionSubstring = text.substring(
+      selection.start,
+      selection.end,
+    );
+    return selectionSubstring;
+  }
+
+  String get afterSelection {
+    if (selection.baseOffset == -1 || selection.extentOffset == -1) {
+      return text;
+    }
+    return text.substring(selection.end);
+  }
 }
diff --git a/lib/src/code_field/text_selection.dart b/lib/src/code_field/text_selection.dart
new file mode 100644
index 0000000..4a2ebd3
--- /dev/null
+++ b/lib/src/code_field/text_selection.dart
@@ -0,0 +1,16 @@
+import 'package:flutter/widgets.dart';
+
+extension TextSelectionExtension on TextSelection {
+  int get length => end - start;
+
+  TextSelection get reversed {
+    return copyWith(
+      baseOffset: extentOffset,
+      extentOffset: baseOffset,
+    );
+  }
+
+  bool get isSelectionNormalized {
+    return baseOffset <= extentOffset;
+  }
+}
diff --git a/lib/src/code_modifiers/tab_code_modifier.dart b/lib/src/code_modifiers/tab_code_modifier.dart
index 7266931..560f0c2 100644
--- a/lib/src/code_modifiers/tab_code_modifier.dart
+++ b/lib/src/code_modifiers/tab_code_modifier.dart
@@ -12,7 +12,6 @@ class TabModifier extends CodeModifier {
     TextSelection sel,
     EditorParams params,
   ) {
-    final tmp = replace(text, sel.start, sel.end, ' ' * params.tabSpaces);
-    return tmp;
+    return TextEditingValue(text: text, selection: sel);
   }
 }


Test patch:
diff --git a/test/src/code_field/code_controller_tab_test.dart b/test/src/code_field/code_controller_tab_test.dart
index 4459421..cc8a394 100644
--- a/test/src/code_field/code_controller_tab_test.dart
+++ b/test/src/code_field/code_controller_tab_test.dart
@@ -1,6 +1,8 @@
+import 'package:flutter/widgets.dart';
 import 'package:flutter_code_editor/flutter_code_editor.dart';
 import 'package:flutter_test/flutter_test.dart';
 import 'package:highlight/languages/go.dart';
+import 'package:highlight/languages/java.dart';
 
 void main() {
   const snippetWithTabs = '''
@@ -22,10 +24,6 @@ public class MyClass {
 }
 ''';
 
-  bool _areIdenticalTexts(String text1, String text2) {
-    return text1.compareTo(text2) == 0;
-  }
-
   group('Tab replacement', () {
     test(
       'applied if TabModifier is present',
@@ -35,10 +33,7 @@ public class MyClass {
           language: go,
           modifiers: [const TabModifier()],
         );
-        expect(
-          _areIdenticalTexts(controller.text, snippetWithDoubleSpaces),
-          true,
-        );
+        expect(controller.text, snippetWithDoubleSpaces);
       },
     );
 
@@ -50,10 +45,7 @@ public class MyClass {
           language: go,
           modifiers: [],
         );
-        expect(
-          _areIdenticalTexts(controller.text, snippetWithDoubleSpaces),
-          false,
-        );
+        expect(controller.text, snippetWithTabs);
       },
     );
 
@@ -67,11 +59,27 @@ public class MyClass {
           language: go,
           modifiers: [const TabModifier()],
         );
-        expect(
-          _areIdenticalTexts(controller.text, snippetWithTripleSpaces),
-          true,
-        );
+        expect(controller.text, snippetWithTripleSpaces);
       },
     );
+
+    test('works with several tab insertion', () {
+      final controller = CodeController(
+        text: snippetWithTabs,
+        language: java,
+        modifiers: [const TabModifier()],
+      );
+      const tabCount = 2;
+
+      controller.value = TextEditingValue(
+        text: snippetWithTabs + '\t' * tabCount,
+        selection: const TextSelection.collapsed(offset: 0),
+      );
+
+      expect(
+        controller.text,
+        snippetWithDoubleSpaces + ' ' * controller.params.tabSpaces * tabCount,
+      );
+    });
   });
 }
diff --git a/test/src/code_field/tabs_to_spaces_test.dart b/test/src/code_field/tabs_to_spaces_test.dart
new file mode 100644
index 0000000..382aa33
--- /dev/null
+++ b/test/src/code_field/tabs_to_spaces_test.dart
@@ -0,0 +1,211 @@
+import 'package:flutter/widgets.dart';
+import 'package:flutter_code_editor/src/code_field/text_editing_value.dart';
+import 'package:flutter_test/flutter_test.dart';
+
+const _codeWithTabs = '''
+class Foo {
+\tint field;
+\tvoid method() {
+\t\tprint(field);
+\t}
+}
+''';
+const _codeWithDoubleSpaces = '''
+class Foo {
+  int field;
+  void method() {
+    print(field);
+  }
+}
+''';
+const _spaceCount = 2;
+
+void main() {
+  group('TextEditingValue.tabsToSpaces.', () {
+    void expectSame(TextEditingValue value) {
+      var actual = value;
+      final expected = value.copyWith();
+
+      actual = actual.tabsToSpaces(_spaceCount);
+
+      expect(actual, expected);
+    }
+
+    group('Empty', () {
+      test('without selection do not changes', () {
+        expectSame(TextEditingValue.empty);
+      });
+
+      test('with cursor at start do not changes', () {
+        expectSame(
+          TextEditingValue.empty.copyWith(
+            selection: const TextSelection.collapsed(offset: 0),
+          ),
+        );
+      });
+    });
+
+    group('Code without tabs', () {
+      test('without selection', () {
+        expectSame(
+          const TextEditingValue(text: _codeWithDoubleSpaces),
+        );
+      });
+
+      test('with cursor at the start', () {
+        expectSame(
+          const TextEditingValue(
+            text: _codeWithDoubleSpaces,
+            selection: TextSelection.collapsed(offset: 0),
+          ),
+        );
+      });
+
+      test('with cursor at the middle', () {
+        expectSame(
+          const TextEditingValue(
+            text: _codeWithDoubleSpaces,
+            selection: TextSelection.collapsed(
+              offset: 5,
+              affinity: TextAffinity.upstream,
+            ),
+          ),
+        );
+      });
+
+      test('with cursor at the end', () {
+        expectSame(
+          const TextEditingValue(
+            text: _codeWithDoubleSpaces,
+            selection:
+                TextSelection.collapsed(offset: _codeWithDoubleSpaces.length),
+          ),
+        );
+      });
+
+      test('with non-empty normalized selection', () {
+        expectSame(
+          const TextEditingValue(
+            text: _codeWithDoubleSpaces,
+            selection: TextSelection(
+              baseOffset: 5,
+              extentOffset: 10,
+              isDirectional: true,
+            ),
+          ),
+        );
+      });
+
+      test('with non-enpty reversed selection', () {
+        expectSame(
+          const TextEditingValue(
+            text: _codeWithDoubleSpaces,
+            selection: TextSelection(
+              baseOffset: 10,
+              extentOffset: 5,
+            ),
+          ),
+        );
+      });
+    });
+
+    group('Code with tabs', () {
+      test('without selection', () {
+        TextEditingValue value = const TextEditingValue(text: _codeWithTabs);
+        const expected = TextEditingValue(text: _codeWithDoubleSpaces);
+
+        value = value.tabsToSpaces(_spaceCount);
+
+        expect(value, expected);
+      });
+
+      test('with cursor at the start', () {
+        TextEditingValue value = const TextEditingValue(
+          text: _codeWithTabs,
+          selection: TextSelection.collapsed(offset: 0),
+        );
+        final expected = value.copyWith(text: _codeWithDoubleSpaces);
+
+        value = value.tabsToSpaces(_spaceCount);
+
+        expect(value, expected);
+      });
+
+      test('with cursor at the middle', () {
+        const cursorPosition = 31;
+        TextEditingValue value = const TextEditingValue(
+          text: _codeWithTabs,
+          selection: TextSelection.collapsed(offset: cursorPosition),
+        );
+        final expected = value.copyWith(
+          text: _codeWithDoubleSpaces,
+          selection: const TextSelection.collapsed(offset: 33),
+        );
+
+        value = value.tabsToSpaces(_spaceCount);
+
+        expect(value, expected);
+      });
+
+      test('with cursor at the end', () {
+        const cursorPosition = _codeWithTabs.length;
+        TextEditingValue value = const TextEditingValue(
+          text: _codeWithTabs,
+          selection: TextSelection.collapsed(
+            offset: cursorPosition,
+            affinity: TextAffinity.upstream,
+          ),
+        );
+        final tabsCount = RegExp('\t').allMatches(_codeWithTabs).length;
+        final expected = value.copyWith(
+          text: _codeWithDoubleSpaces,
+          selection: value.selection.copyWith(
+            baseOffset: cursorPosition + tabsCount,
+            extentOffset: cursorPosition + tabsCount,
+          ),
+        );
+
+        value = value.tabsToSpaces(_spaceCount);
+
+        expect(value, expected);
+      });
+
+      test('with non-empty normalized selection', () {
+        TextEditingValue value = const TextEditingValue(
+          text: _codeWithTabs,
+          selection: TextSelection(
+            baseOffset: 24,
+            extentOffset: 59,
+            isDirectional: true,
+          ),
+        );
+        final expected = value.copyWith(
+          text: _codeWithDoubleSpaces,
+          selection: value.selection.copyWith(baseOffset: 25, extentOffset: 64),
+        );
+
+        value = value.tabsToSpaces(_spaceCount);
+
+        expect(value, expected);
+      });
+
+      test('with non-empty reversed selection', () {
+        TextEditingValue value = const TextEditingValue(
+          text: _codeWithTabs,
+          selection: TextSelection(
+            baseOffset: 59,
+            extentOffset: 24,
+          ),
+        );
+        final expected = value.copyWith(
+          text: _codeWithDoubleSpaces,
+          selection: const TextSelection(baseOffset: 64, extentOffset: 25),
+        );
+
+        value = value.tabsToSpaces(_spaceCount);
+
+        expect(value, expected);
+      });
+    });
+  });
+}