Repository: akvelon/flutter-code-editor
Pull request #130 (2022-12-26 18:21:53)

Tab and Untab added
Refactored old approach to Tab -> replaced with shortcuts and actions.
Untab functionality added.


Patch:
diff --git a/lib/src/code/code.dart b/lib/src/code/code.dart
index b8efed3..58cefe9 100644
--- a/lib/src/code/code.dart
+++ b/lib/src/code/code.dart
@@ -437,9 +437,15 @@ class Code {
     final firstLine = lines.lines[block.firstLine + 1]; //Keep 1st line visible.
     final lastLine = lines.lines[block.lastLine];
 
+    // Exclude \n from the last line
+    var endOfRange = lastLine.textRange.end - 1;
+    if (lastLine.text[lastLine.text.length - 1] != '\n') {
+      endOfRange++;
+    }
+
     return HiddenRange(
       firstLine.textRange.start - 1, // Includes '\n' before.
-      lastLine.textRange.end - 1, // Excludes '\n' after.
+      endOfRange,
       firstLine: block.firstLine,
       lastLine: block.lastLine,
       wholeFirstLine: false, // Some characters of the first line are visible.
diff --git a/lib/src/code_field/actions/indent.dart b/lib/src/code_field/actions/indent.dart
new file mode 100644
index 0000000..78bf898
--- /dev/null
+++ b/lib/src/code_field/actions/indent.dart
@@ -0,0 +1,21 @@
+import 'package:flutter/cupertino.dart';
+
+import '../../../flutter_code_editor.dart';
+
+class IndentIntent extends Intent {
+  const IndentIntent();
+}
+
+class IndentIntentAction extends Action<IndentIntent> {
+  final CodeController controller;
+
+  IndentIntentAction({
+    required this.controller,
+  });
+
+  @override
+  Object? invoke(IndentIntent intent) {
+    controller.indentSelection();
+    return null;
+  }
+}
diff --git a/lib/src/code_field/actions/outdent.dart b/lib/src/code_field/actions/outdent.dart
new file mode 100644
index 0000000..89b7b91
--- /dev/null
+++ b/lib/src/code_field/actions/outdent.dart
@@ -0,0 +1,22 @@
+import 'package:flutter/cupertino.dart';
+
+import '../../../flutter_code_editor.dart';
+
+class OutdentIntent extends Intent {
+  const OutdentIntent();
+}
+
+class OutdentIntentAction extends Action<OutdentIntent> {
+  final CodeController controller;
+
+  OutdentIntentAction({
+    required this.controller,
+  });
+
+  @override
+  Object? invoke(OutdentIntent intent) {
+    controller.outdentSelection();
+
+    return null;
+  }
+}
diff --git a/lib/src/code_field/code_controller.dart b/lib/src/code_field/code_controller.dart
index 9ee2d35..2d38fc0 100644
--- a/lib/src/code_field/code_controller.dart
+++ b/lib/src/code_field/code_controller.dart
@@ -5,24 +5,17 @@ import 'package:flutter/material.dart';
 import 'package:flutter/services.dart';
 import 'package:highlight/highlight_core.dart';
 
+import '../../flutter_code_editor.dart';
 import '../autocomplete/autocompleter.dart';
-import '../code/code.dart';
 import '../code/code_edit_result.dart';
-import '../code_field/text_editing_value.dart';
-import '../code_modifiers/close_block_code_modifier.dart';
-import '../code_modifiers/code_modifier.dart';
-import '../code_modifiers/indent_code_modifier.dart';
-import '../code_modifiers/tab_code_modifier.dart';
-import '../code_theme/code_theme.dart';
-import '../code_theme/code_theme_data.dart';
 import '../history/code_history_controller.dart';
 import '../history/code_history_record.dart';
-import '../named_sections/parsers/abstract.dart';
 import '../wip/autocomplete/popup_controller.dart';
 import 'actions/copy.dart';
+import 'actions/indent.dart';
+import 'actions/outdent.dart';
 import 'actions/redo.dart';
 import 'actions/undo.dart';
-import 'editor_params.dart';
 import 'span_builder.dart';
 
 class CodeController extends TextEditingController {
@@ -94,6 +87,8 @@ class CodeController extends TextEditingController {
 
   late final actions = <Type, Action<Intent>>{
     CopySelectionTextIntent: CopyAction(controller: this),
+    IndentIntent: IndentIntentAction(controller: this),
+    OutdentIntent: OutdentIntentAction(controller: this),
     RedoTextIntent: RedoAction(controller: this),
     UndoTextIntent: UndoAction(controller: this),
   };
@@ -112,7 +107,6 @@ class CodeController extends TextEditingController {
     this.modifiers = const [
       IndentModifier(),
       CloseBlockModifier(),
-      TabModifier(),
     ],
   })  : _readOnlySectionNames = readOnlySectionNames,
         _code = Code.empty,
@@ -203,12 +197,6 @@ class CodeController extends TextEditingController {
   }
 
   KeyEventResult _onKeyDownRepeat(KeyEvent event) {
-    // TODO(alexeyinkin): Use a shortcut, https://github.com/akvelon/flutter-code-editor/issues/21
-    if (event.logicalKey == LogicalKeyboardKey.tab) {
-      text = text.replaceRange(selection.start, selection.end, '\t');
-      return KeyEventResult.handled;
-    }
-
     if (popupController.isPopupShown) {
       if (event.logicalKey == LogicalKeyboardKey.arrowUp) {
         popupController.scrollByArrow(ScrollDirection.up);
@@ -295,6 +283,7 @@ class CodeController extends TextEditingController {
       if (_isTabReplacementEnabled) {
         newValue = newValue.tabsToSpaces(params.tabSpaces);
       }
+
       final editResult = _getEditResultNotBreakingReadOnly(newValue);
 
       if (editResult == null) {
@@ -334,6 +323,134 @@ class CodeController extends TextEditingController {
     );
   }
 
+  void outdentSelection() {
+    final tabSpaces = params.tabSpaces;
+    if (selection.start == -1 || selection.end == -1) {
+      return;
+    }
+
+    modifySelectedLines((line) {
+      if (line == '\n') {
+        return line;
+      }
+
+      if (line.length < tabSpaces) {
+        return line.trimLeft();
+      }
+
+      final subStr = line.substring(0, tabSpaces);
+      if (subStr == ' ' * tabSpaces) {
+        return line.substring(tabSpaces, line.length);
+      }
+      return line.trimLeft();
+    });
+  }
+
+  void indentSelection() {
+    final tabSpaces = params.tabSpaces;
+    final tab = ' ' * tabSpaces;
+    final lines = _code.lines.lines;
+    if (selection.start == -1 || selection.end == -1) {
+      return;
+    }
+
+    if (selection.isCollapsed) {
+      final fullPosition = _code.hiddenRanges.recoverPosition(
+        selection.start,
+        placeHiddenRanges: TextAffinity.downstream,
+      );
+      final lineIndex = _code.lines.characterIndexToLineIndex(fullPosition);
+      final columnIndex = fullPosition - lines[lineIndex].textRange.start;
+      final insert = ' ' * (tabSpaces - (columnIndex % tabSpaces));
+      value = value.replaced(selection, insert);
+      return;
+    }
+
+    modifySelectedLines((line) {
+      if (line == '\n') {
+        return line;
+      }
+      return tab + line;
+    });
+  }
+
+  /// Filters the lines that have at least one character selected.
+  ///
+  /// IMPORTANT: this method also changes the selection to be:
+  /// start: start of the first selected line
+  /// end: end of the last line
+  ///
+  /// Folded blocks are considered to be selected
+  /// if they are located between start and end of a selection.
+  ///
+  /// [modifierCallback] - transformation function that modifies the line.
+  /// `line` in the callback contains '\n' symbol at the end, except for the last line of the document.
+  // TODO(yescorp): need to preserve folding..
+  void modifySelectedLines(
+    String Function(String line) modifierCallback,
+  ) {
+    if (selection.start == -1 || selection.end == -1) {
+      return;
+    }
+
+    final selectionStart = _code.hiddenRanges.recoverPosition(
+      selection.start,
+      placeHiddenRanges: TextAffinity.downstream,
+    );
+    final selectionEnd = _code.hiddenRanges.recoverPosition(
+      // to avoid including the next line if `\n` is selected
+      selection.isCollapsed ? selection.end : selection.end - 1,
+      placeHiddenRanges: TextAffinity.downstream,
+    );
+
+    // index of the first line to modify
+    final firstLineIndex =
+        _code.lines.characterIndexToLineIndex(selectionStart);
+    // index of the last line to modify
+    final lastLineIndex = _code.lines.characterIndexToLineIndex(selectionEnd);
+
+    final firstLineStart = _code.lines.lines[firstLineIndex].textRange.start;
+    final lastLineEnd = _code.lines.lines[lastLineIndex].textRange.end;
+
+    // apply modification to the selected lines
+    final modifiedLinesBuffer = StringBuffer();
+    for (int i = firstLineIndex; i <= lastLineIndex; i++) {
+      // cancel modification entirely if any of the lines is readOnly
+      if (_code.lines.lines[i].isReadOnly) {
+        return;
+      }
+      final modifiedString = modifierCallback(_code.lines.lines[i].text);
+      modifiedLinesBuffer.write(modifiedString);
+    }
+
+    final modifiedLinesString = modifiedLinesBuffer.toString();
+
+    // replace selected lines with modified ones
+    final finalFullText = _code.text.replaceRange(
+      firstLineStart,
+      lastLineEnd,
+      modifiedLinesString,
+    );
+
+    _updateCodeIfChanged(finalFullText);
+
+    final finalFullSelection = TextSelection(
+      baseOffset: firstLineStart,
+      extentOffset: firstLineStart + modifiedLinesString.length,
+    );
+    final finalVisibleSelection =
+        _code.hiddenRanges.cutSelection(finalFullSelection);
+
+    // TODO(yescorp): move to the listener both here and in `set value`
+    /// or come up with a different approach
+    historyController.beforeChanged(_code, finalVisibleSelection);
+
+    super.value = TextEditingValue(
+      text: _code.visibleText,
+      selection: finalVisibleSelection,
+    );
+  }
+
   Code get code => _code;
 
   CodeEditResult? _getEditResultNotBreakingReadOnly(TextEditingValue newValue) {
diff --git a/lib/src/code_field/code_field.dart b/lib/src/code_field/code_field.dart
index 86686cb..1575bd2 100644
--- a/lib/src/code_field/code_field.dart
+++ b/lib/src/code_field/code_field.dart
@@ -10,6 +10,8 @@ import '../gutter/gutter.dart';
 import '../line_numbers/line_number_style.dart';
 import '../sizes.dart';
 import '../wip/autocomplete/popup.dart';
+import 'actions/indent.dart';
+import 'actions/outdent.dart';
 import 'code_controller.dart';
 
 final _shortcuts = {
@@ -62,6 +64,17 @@ final _shortcuts = {
     LogicalKeyboardKey.meta,
     LogicalKeyboardKey.keyZ,
   ): const RedoTextIntent(SelectionChangedCause.keyboard),
+
+  // Indent
+  LogicalKeySet(
+    LogicalKeyboardKey.tab,
+  ): const IndentIntent(),
+
+  // Outdent
+  LogicalKeySet(
+    LogicalKeyboardKey.shift,
+    LogicalKeyboardKey.tab,
+  ): const OutdentIntent(),
 };
 
 class CodeField extends StatefulWidget {


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 e1cd9e6..66e4a68 100644
--- a/test/src/code_field/code_controller_folding_test.dart
+++ b/test/src/code_field/code_controller_folding_test.dart
@@ -2,6 +2,7 @@
 
 import 'package:flutter/widgets.dart';
 import 'package:flutter_test/flutter_test.dart';
+import 'package:highlight/languages/python.dart';
 
 import '../common/create_app.dart';
 import '../common/snippets.dart';
@@ -367,6 +368,21 @@ package mypackage;
           foldedVisible,
         );
       });
+
+      test(
+          'When the last foldable block is folded '
+          'SHOULD NOT show characters other than `\n`', () {
+        const initialText = '''
+aaaa:
+  aaaa
+  aaaa''';
+        const finalVisibleText = '''
+aaaa:''';
+        final controller = createController(initialText, language: python);
+        controller.foldAt(0);
+
+        expect(controller.value.text, finalVisibleText);
+      });
     });
   });
 }
diff --git a/test/src/code_field/code_controller_indent_selection_test.dart b/test/src/code_field/code_controller_indent_selection_test.dart
new file mode 100644
index 0000000..d62e6af
--- /dev/null
+++ b/test/src/code_field/code_controller_indent_selection_test.dart
@@ -0,0 +1,546 @@
+// ignore_for_file: avoid_redundant_argument_values
+// ignore_for_file: prefer_const_constructors
+
+import 'package:flutter/material.dart';
+import 'package:flutter_code_editor/flutter_code_editor.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:highlight/languages/java.dart';
+
+void main() {
+  group('CodeController.indentSelection() => Unfolded text', () {
+    const indentLength = 2;
+    final indent = ' ' * indentLength;
+
+    test('Selection is collapsed', () {
+      final examples = [
+        const _Example(
+          'WHEN start == -1 && end == -1 SHOULD NOT modify anything',
+          initialFullText: '''
+aaaa
+aaaa
+aaaa
+''',
+          expectedFullText: '''
+aaaa
+aaaa
+aaaa
+''',
+          expectedVisibleText: '''
+aaaa
+aaaa
+aaaa
+''',
+          initialSelection: TextSelection(
+            baseOffset: -1,
+            extentOffset: -1,
+          ),
+          expectedSelection: TextSelection(
+            baseOffset: -1,
+            extentOffset: -1,
+          ),
+        ),
+        _Example(
+          'WHEN start == 0 && end == 0 '
+          'SHOULD add indentation to the cursor location',
+          initialFullText: '''
+aaaa
+aaaa
+aaaa
+''',
+          expectedFullText: '''
+${indent}aaaa
+aaaa
+aaaa
+''',
+          expectedVisibleText: '''
+${indent}aaaa
+aaaa
+aaaa
+''',
+          initialSelection: const TextSelection(
+            baseOffset: 0,
+            extentOffset: 0,
+          ),
+          expectedSelection: TextSelection(
+            baseOffset: 0 + indentLength,
+            extentOffset: 0 + indentLength,
+          ),
+        ),
+        _Example(
+          'WHEN collapsed at start of a non-first line '
+          'SHOULD add indentation to the cursor location',
+          initialFullText: '''
+aaaa
+aaaa
+aaaa
+''',
+          expectedFullText: '''
+aaaa
+${indent}aaaa
+aaaa
+''',
+          expectedVisibleText: '''
+aaaa
+${indent}aaaa
+aaaa
+''',
+          initialSelection: const TextSelection(
+            baseOffset: 5,
+            extentOffset: 5,
+          ),
+          expectedSelection: TextSelection(
+            baseOffset: 5 + indentLength,
+            extentOffset: 5 + indentLength,
+          ),
+        ),
+        const _Example(
+          'WHEN at a column that is not a multiple of indent_length '
+          'SHOULD add spaces to adjust indentation to its multiple',
+          initialFullText: '''
+aaaa
+   aaaa
+aaaa
+''',
+          expectedFullText: '''
+aaaa
+    aaaa
+aaaa
+''',
+          expectedVisibleText: '''
+aaaa
+    aaaa
+aaaa
+''',
+          initialSelection: TextSelection(
+            baseOffset: 8,
+            extentOffset: 8,
+          ),
+          expectedSelection: TextSelection(
+            baseOffset: 8 + 1,
+            extentOffset: 8 + 1,
+          ),
+        ),
+        _Example(
+          'WHEN at a column that is a multiple of indent_length '
+          'SHOULD add full indentation to the cursor location',
+          initialFullText: '''
+aaaa
+  aaaa
+aaaa
+''',
+          expectedFullText: '''
+aaaa
+  ${indent}aaaa
+aaaa
+''',
+          expectedVisibleText: '''
+aaaa
+  ${indent}aaaa
+aaaa
+''',
+          initialSelection: const TextSelection(
+            baseOffset: 7,
+            extentOffset: 7,
+          ),
+          expectedSelection: TextSelection(
+            baseOffset: 7 + indentLength,
+            extentOffset: 7 + indentLength,
+          ),
+        ),
+        _Example(
+          'WHEN at the end of a document '
+          'SHOULD add indentation to the cursor location',
+          initialFullText: '''
+aaaa
+aaaa
+aaaa
+''',
+          expectedFullText: '''
+aaaa
+aaaa
+aaaa$indent
+''',
+          expectedVisibleText: '''
+aaaa
+aaaa
+aaaa$indent
+''',
+          initialSelection: const TextSelection(
+            baseOffset: 14,
+            extentOffset: 14,
+          ),
+          expectedSelection: TextSelection(
+            baseOffset: 14 + indentLength,
+            extentOffset: 14 + indentLength,
+          ),
+        ),
+      ];
+
+      for (final example in examples) {
+        final controller = CodeController();
+        controller.text = example.initialFullText;
+        controller.selection = example.initialSelection;
+        controller.indentSelection();
+
+        expect(
+          controller.value.text,
+          example.expectedVisibleText,
+          reason: example.name,
+        );
+        expect(
+          controller.code.text,
+          example.expectedFullText,
+          reason: example.name,
+        );
+        expect(
+          controller.code.visibleText,
+          controller.text,
+          reason: example.name,
+        );
+        expect(
+          controller.value.selection,
+          example.expectedSelection,
+          reason: example.name,
+        );
+        expect(
+          controller.code,
+          controller.historyController.lastCode,
+        );
+        expect(
+          controller.value.selection,
+          controller.historyController.lastSelection,
+        );
+      }
+    });
+
+    test('Selection is a range', () {
+      final examples = [
+        _Example(
+          'WHEN non-collapsed selection, two lines, not first, not last. '
+          'SHOULD add indentation to the selected lines and select the whole lines',
+          initialFullText: '''
+aaaa
+aaAA
+AAAa
+aaaa
+''',
+          expectedFullText: '''
+aaaa
+${indent}aaAA
+${indent}AAAa
+aaaa
+''',
+          expectedVisibleText: '''
+aaaa
+${indent}aaAA
+${indent}AAAa
+aaaa
+''',
+          initialSelection: const TextSelection(
+            baseOffset: 7,
+            extentOffset: 13,
+          ),
+          expectedSelection: TextSelection(
+            baseOffset: 5,
+            extentOffset: 19,
+          ),
+        ),
+        _Example(
+          'WHEN entire document is selected without new line at the end '
+          'SHOULD add indentation to all lines',
+          initialFullText: '''
+AAA
+AAA
+AAA''',
+          expectedFullText: '''
+${indent}AAA
+${indent}AAA
+${indent}AAA''',
+          expectedVisibleText: '''
+${indent}AAA
+${indent}AAA
+${indent}AAA''',
+          initialSelection: const TextSelection(
+            baseOffset: 0,
+            extentOffset: 11,
+          ),
+          expectedSelection: TextSelection(
+            baseOffset: 0,
+            extentOffset: 17,
+          ),
+        ),
+        _Example(
+          'WHEN entire document is selected with new line at the end '
+          'SHOULD add indentation to all lines',
+          initialFullText: '''
+AAA
+AAA
+AAA
+''',
+          expectedFullText: '''
+${indent}AAA
+${indent}AAA
+${indent}AAA
+''',
+          expectedVisibleText: '''
+${indent}AAA
+${indent}AAA
+${indent}AAA
+''',
+          initialSelection: const TextSelection(
+            baseOffset: 0,
+            extentOffset: 12,
+          ),
+          expectedSelection: TextSelection(
+            baseOffset: 0,
+            extentOffset: 18,
+          ),
+        ),
+        _Example(
+          'Indent SHOULD NOT unfold folded comment at line 0 '
+          'and folded imports',
+          initialFullText: '''
+// comment1
+// comment 2
+package org.apache.beam.examples;
+
+import java.util.Arrays;
+
+a;
+''',
+          expectedFullText: '''
+// comment1
+// comment 2
+package org.apache.beam.examples;
+
+import java.util.Arrays;
+
+  a;
+''',
+          expectedVisibleText: '''
+// comment1
+package org.apache.beam.examples;
+
+  a;
+''',
+          initialSelection: TextSelection(baseOffset: 47, extentOffset: 49),
+          expectedSelection: TextSelection(baseOffset: 47, extentOffset: 52),
+        ),
+      ];
+
+      for (final example in examples) {
+        final controller = CodeController(
+          language: java,
+        );
+        controller.text = example.initialFullText;
+        controller.foldCommentAtLineZero();
+        controller.foldImports();
+        controller.selection = example.initialSelection;
+
+        controller.indentSelection();
+
+        expect(
+          controller.value.text,
+          example.expectedVisibleText,
+          reason: example.name,
+        );
+        expect(
+          controller.code.text,
+          example.expectedFullText,
+          reason: example.name,
+        );
+        expect(
+          controller.code.visibleText,
+          controller.text,
+          reason: example.name,
+        );
+        expect(
+          controller.value.selection,
+          example.expectedSelection,
+          reason: example.name,
+        );
+        expect(
+          controller.code,
+          controller.historyController.lastCode,
+        );
+        expect(
+          controller.value.selection,
+          controller.historyController.lastSelection,
+        );
+      }
+    });
+  });
+
+  group('Readonly blocks', () {
+    final language = java;
+    const readonlySectionName = 'readonlySection'; // length = 15
+    const indentLength = 2;
+    final indent = ' ' * indentLength;
+
+    test(
+        'If the there is at least 1 readonly line selected, '
+        'entire modification should be cancelled', () {
+      final examples = [
+        _Example(
+          'Selection is within readonly section',
+          initialFullText: '''
+// [START $readonlySectionName]
+aAA{
+  AAAA();
+  AAaa();
+}
+// [END $readonlySectionName]
+''',
+          initialVisibleText: '''
+
+aAA{
+  AAAA();
+  AAaa();
+}
+
+''',
+          expectedFullText: '''
+// [START $readonlySectionName]
+aAA{
+  AAAA();
+  AAaa();
+}
+// [END $readonlySectionName]
+''',
+          expectedVisibleText: '''
+
+aAA{
+  AAAA();
+  AAaa();
+}
+
+''',
+          initialSelection: TextSelection(baseOffset: 2, extentOffset: 20),
+          expectedSelection: TextSelection(baseOffset: 2, extentOffset: 20),
+        ),
+        _Example(
+          'Selection goes through readonly section',
+          initialFullText: '''
+aAA{
+
+}
+// [START $readonlySectionName]
+AAA{
+  AAAA();
+  AAAA();
+}
+// [END $readonlySectionName]
+Aaa{
+
+}
+''',
+          initialVisibleText: '''
+aAA{
+
+}
+
+AAA{
+  AAAA();
+  AAAA();
+}
+
+Aaa{
+
+}
+''',
+          expectedFullText: '''
+aAA{
+
+}
+// [START $readonlySectionName]
+AAA{
+  AAAA();
+  AAAA();
+}
+// [END $readonlySectionName]
+Aaa{
+
+}
+''',
+          expectedVisibleText: '''
+aAA{
+
+}
+
+AAA{
+  AAAA();
+  AAAA();
+}
+
+Aaa{
+
+}
+''',
+          initialSelection: TextSelection(baseOffset: 1, extentOffset: 38),
+          expectedSelection: TextSelection(baseOffset: 1, extentOffset: 38),
+        ),
+      ];
+
+      for (final example in examples) {
+        final controller = CodeController(
+          language: language,
+          namedSectionParser: BracketsStartEndNamedSectionParser(),
+          readOnlySectionNames: {readonlySectionName},
+        );
+        controller.text = example.initialFullText;
+        controller.selection = example.initialSelection;
+
+        expect(
+          controller.value.text,
+          example.initialVisibleText,
+        );
+
+        controller.indentSelection();
+
+        expect(
+          controller.value.text,
+          example.expectedVisibleText,
+          reason: example.name,
+        );
+        expect(
+          controller.code.text,
+          example.expectedFullText,
+          reason: example.name,
+        );
+        expect(
+          controller.value.selection,
+          example.expectedSelection,
+          reason: example.name,
+        );
+        expect(
+          controller.code,
+          controller.historyController.lastCode,
+        );
+        expect(
+          controller.value.selection,
+          controller.historyController.lastSelection,
+        );
+      }
+    });
+  });
+}
+
+class _Example {
+  final String name;
+  final String initialFullText;
+  final String? initialVisibleText;
+  final String expectedFullText;
+  final String expectedVisibleText;
+  final TextSelection initialSelection;
+  final TextSelection expectedSelection;
+
+  const _Example(
+    this.name, {
+    required this.initialFullText,
+    this.initialVisibleText,
+    required this.expectedFullText,
+    required this.expectedVisibleText,
+    required this.initialSelection,
+    required this.expectedSelection,
+  });
+}
diff --git a/test/src/code_field/code_controller_outdent_selection_test.dart b/test/src/code_field/code_controller_outdent_selection_test.dart
new file mode 100644
index 0000000..d157cad
--- /dev/null
+++ b/test/src/code_field/code_controller_outdent_selection_test.dart
@@ -0,0 +1,360 @@
+// ignore_for_file: avoid_redundant_argument_values
+// ignore_for_file: prefer_const_constructors
+// ignore_for_file: prefer_final_locals
+
+import 'package:flutter/cupertino.dart';
+import 'package:flutter_code_editor/flutter_code_editor.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:highlight/languages/java.dart';
+
+void main() {
+  group('Unfolded', () {
+    const indentLength = 2;
+    final indent = ' ' * indentLength;
+
+    test('Selection is collapsed', () {
+      final examples = [
+        const _Example(
+          'WHEN start == -1 && end == -1 SHOULD NOT modify anything',
+          initialFullText: '''
+  aaaa
+aaaa
+aaaa
+''',
+          expectedFullText: '''
+  aaaa
+aaaa
+aaaa
+''',
+          expectedVisibleText: '''
+  aaaa
+aaaa
+aaaa
+''',
+          initialSelection: TextSelection(
+            baseOffset: -1,
+            extentOffset: -1,
+          ),
+          expectedSelection: TextSelection(
+            baseOffset: -1,
+            extentOffset: -1,
+          ),
+        ),
+        const _Example(
+          'WHEN at the start of the first line '
+          'SHOULD modify first line',
+          initialFullText: '''
+  aaaa
+aaaa
+aaaa
+''',
+          expectedFullText: '''
+aaaa
+aaaa
+aaaa
+''',
+          expectedVisibleText: '''
+aaaa
+aaaa
+aaaa
+''',
+          initialSelection: TextSelection(
+            baseOffset: 0,
+            extentOffset: 0,
+          ),
+          expectedSelection: TextSelection(
+            baseOffset: 0,
+            extentOffset: 5,
+          ),
+        ),
+        const _Example(
+          'WHEN at the start of a non-first line '
+          'SHOULD modify that line',
+          initialFullText: '''
+  aaaa
+    aaaa
+aaaa
+''',
+          expectedFullText: '''
+  aaaa
+  aaaa
+aaaa
+''',
+          expectedVisibleText: '''
+  aaaa
+  aaaa
+aaaa
+''',
+          initialSelection: TextSelection(
+            baseOffset: 7,
+            extentOffset: 7,
+          ),
+          expectedSelection: TextSelection(
+            baseOffset: 7,
+            extentOffset: 14,
+          ),
+        ),
+        _Example(
+          'WHEN at the middle of a line SHOULD modify that line',
+          initialFullText: '''
+  aaaa
+aaaa
+aaaa
+''',
+          expectedFullText: '''
+aaaa
+aaaa
+aaaa
+''',
+          expectedVisibleText: '''
+aaaa
+aaaa
+aaaa
+''',
+          initialSelection: TextSelection(
+            baseOffset: 4,
+            extentOffset: 4,
+          ),
+          expectedSelection: TextSelection(
+            baseOffset: 0,
+            extentOffset: 5,
+          ),
+        ),
+        const _Example(
+          'WHEN indented less than a full indent '
+          'SHOULD remove all beginning whitespaces of the line ',
+          initialFullText: '''
+  aaaa
+ aaaa
+aaaa
+''',
+          expectedFullText: '''
+  aaaa
+aaaa
+aaaa
+''',
+          expectedVisibleText: '''
+  aaaa
+aaaa
+aaaa
+''',
+          initialSelection: TextSelection(
+            baseOffset: 8,
+            extentOffset: 8,
+          ),
+          expectedSelection: TextSelection(
+            baseOffset: 7,
+            extentOffset: 12,
+          ),
+        ),
+        _Example(
+          'WHEN at the end of a line '
+          'SHOULD modify that line',
+          initialFullText: '''
+  aaaa
+aaaa
+aaaa
+''',
+          expectedFullText: '''
+aaaa
+aaaa
+aaaa
+''',
+          expectedVisibleText: '''
+aaaa
+aaaa
+aaaa
+''',
+          initialSelection: TextSelection(
+            baseOffset: 6,
+            extentOffset: 6,
+          ),
+          expectedSelection: TextSelection(
+            baseOffset: 0,
+            extentOffset: 5,
+          ),
+        ),
+      ];
+
+      for (final example in examples) {
+        final controller = CodeController();
+        controller.text = example.initialFullText;
+        controller.selection = example.initialSelection;
+        controller.outdentSelection();
+
+        expect(
+          controller.value.text,
+          example.expectedVisibleText,
+          reason: example.name,
+        );
+        expect(
+          controller.code.text,
+          example.expectedFullText,
+          reason: example.name,
+        );
+        expect(
+          controller.code.visibleText,
+          controller.text,
+          reason: example.name,
+        );
+        expect(
+          controller.value.selection,
+          example.expectedSelection,
+          reason: example.name,
+        );
+        expect(
+          controller.code,
+          controller.historyController.lastCode,
+        );
+        expect(
+          controller.value.selection,
+          controller.historyController.lastSelection,
+        );
+      }
+    });
+
+    test('Selection is a range', () {
+      final examples = [
+        _Example(
+          'WHEN the entire document is selectd '
+          'SHOULD outdent all lines',
+          initialFullText: '''
+  AAAA
+      AAAA
+  AAAA''',
+          expectedFullText: '''
+AAAA
+    AAAA
+AAAA''',
+          expectedVisibleText: '''
+AAAA
+    AAAA
+AAAA''',
+          initialSelection: TextSelection(
+            baseOffset: 0,
+            extentOffset: 24,
+          ),
+          expectedSelection: TextSelection(
+            baseOffset: 0,
+            extentOffset: 18,
+          ),
+        ),
+        _Example(
+          'WHEN unindented lines are selected '
+          'SHOULD NOT outdent that lines',
+          initialFullText: '''
+AAAA
+      AAAA
+  AAAA
+''',
+          expectedFullText: '''
+AAAA
+    AAAA
+AAAA
+''',
+          expectedVisibleText: '''
+AAAA
+    AAAA
+AAAA
+''',
+          initialSelection: TextSelection(
+            baseOffset: 0,
+            extentOffset: 22,
+          ),
+          expectedSelection: TextSelection(
+            baseOffset: 0,
+            extentOffset: 19,
+          ),
+        ),
+        _Example(
+          'Outdent SHOULD NOT unfold folded comment at line 0 '
+          'and folded imports',
+          initialFullText: '''
+// comment1
+// comment 2
+package org.apache.beam.examples;
+
+import java.util.Arrays;
+
+  a;
+''',
+          expectedFullText: '''
+// comment1
+// comment 2
+package org.apache.beam.examples;
+
+import java.util.Arrays;
+
+a;
+''',
+          expectedVisibleText: '''
+// comment1
+package org.apache.beam.examples;
+
+a;
+''',
+          initialSelection: TextSelection(baseOffset: 47, extentOffset: 52),
+          expectedSelection: TextSelection(baseOffset: 47, extentOffset: 50),
+        ),
+      ];
+
+      for (final example in examples) {
+        final controller = CodeController(
+          language: java,
+        );
+        controller.text = example.initialFullText;
+        controller.foldCommentAtLineZero();
+        controller.foldImports();
+        controller.selection = example.initialSelection;
+        controller.outdentSelection();
+
+        expect(
+          controller.value.text,
+          example.expectedVisibleText,
+          reason: example.name,
+        );
+        expect(
+          controller.code.text,
+          example.expectedFullText,
+          reason: example.name,
+        );
+        expect(
+          controller.code.visibleText,
+          controller.text,
+          reason: example.name,
+        );
+        expect(
+          controller.value.selection,
+          example.expectedSelection,
+          reason: example.name,
+        );
+        expect(
+          controller.code,
+          controller.historyController.lastCode,
+        );
+        expect(
+          controller.value.selection,
+          controller.historyController.lastSelection,
+        );
+      }
+    });
+  });
+}
+
+class _Example {
+  final String name;
+  final String initialFullText;
+  final String expectedFullText;
+  final String expectedVisibleText;
+  final TextSelection initialSelection;
+  final TextSelection expectedSelection;
+
+  const _Example(
+    this.name, {
+    required this.initialFullText,
+    required this.expectedFullText,
+    required this.expectedVisibleText,
+    required this.initialSelection,
+    required this.expectedSelection,
+  });
+}