Repository: akvelon/flutter-code-editor
Pull request #140 (2023-01-09 10:39:19)

Shortcuts to comment / uncomment lines


Patch:
diff --git a/lib/src/code/string.dart b/lib/src/code/string.dart
index f06a5bc..4b4edf6 100644
--- a/lib/src/code/string.dart
+++ b/lib/src/code/string.dart
@@ -113,4 +113,6 @@ extension StringExtension on String {
   String tabsToSpaces(int spaceCount) {
     return replaceAll('\t', ' ' * spaceCount);
   }
+
+  bool hasOnlyWhitespaces() => trim() == '';
 }
diff --git a/lib/src/code_field/actions/comment_uncomment.dart b/lib/src/code_field/actions/comment_uncomment.dart
new file mode 100644
index 0000000..9e0a2dc
--- /dev/null
+++ b/lib/src/code_field/actions/comment_uncomment.dart
@@ -0,0 +1,21 @@
+import 'package:flutter/widgets.dart';
+
+import '../code_controller.dart';
+
+class CommentUncommentIntent extends Intent {
+  const CommentUncommentIntent();
+}
+
+class CommentUncommentAction extends Action<CommentUncommentIntent> {
+  final CodeController controller;
+
+  CommentUncommentAction({
+    required this.controller,
+  });
+
+  @override
+  Object? invoke(CommentUncommentIntent intent) {
+    controller.commentOutOrUncommentSelection();
+    return null;
+  }
+}
diff --git a/lib/src/code_field/code_controller.dart b/lib/src/code_field/code_controller.dart
index 2cb0399..b12ffb5 100644
--- a/lib/src/code_field/code_controller.dart
+++ b/lib/src/code_field/code_controller.dart
@@ -8,9 +8,14 @@ import 'package:highlight/highlight_core.dart';
 import '../../flutter_code_editor.dart';
 import '../autocomplete/autocompleter.dart';
 import '../code/code_edit_result.dart';
+import '../code/reg_exp.dart';
 import '../history/code_history_controller.dart';
 import '../history/code_history_record.dart';
+import '../single_line_comments/parser/single_line_comment_parser.dart';
+import '../single_line_comments/parser/single_line_comments.dart';
+import '../single_line_comments/single_line_comment.dart';
 import '../wip/autocomplete/popup_controller.dart';
+import 'actions/comment_uncomment.dart';
 import 'actions/copy.dart';
 import 'actions/indent.dart';
 import 'actions/outdent.dart';
@@ -86,6 +91,7 @@ class CodeController extends TextEditingController {
   TextSpan? lastTextSpan;
 
   late final actions = <Type, Action<Intent>>{
+    CommentUncommentIntent: CommentUncommentAction(controller: this),
     CopySelectionTextIntent: CopyAction(controller: this),
     IndentIntent: IndentIntentAction(controller: this),
     OutdentIntent: OutdentIntentAction(controller: this),
@@ -380,6 +386,102 @@ class CodeController extends TextEditingController {
     });
   }
 
+  /// Comments out or uncomments the currently selected lines.
+  ///
+  /// Doesn't affect empty lines.
+  ///
+  /// If any of the selected lines is not a single line comment:
+  /// adds one level of single line comment to every selected line.
+  ///
+  /// If all of the selected lines are single line comments:
+  /// removes one level of single line comment from every selected line.
+  ///
+  /// When commenting out, adds `// ` or `# ` (or another symbol depending on a language) with a space after.
+  /// Removes these spaces on uncommenting.
+  /// (if there are no spaces just removes the comments)
+  ///
+  /// The method doesn't account for multiline comments
+  /// and treats them as a normal text (not a comment).
+  void commentOutOrUncommentSelection() {
+    if (_anySelectedLineUncommented()) {
+      _commentOutSelectedLines();
+    } else {
+      _uncommentSelectedLines();
+    }
+  }
+
+  bool _anySelectedLineUncommented() {
+    return _anySelectedLine((line) {
+      for (final commentType in SingleLineComments.byMode[language] ?? []) {
+        if (line.trimLeft().startsWith(commentType) ||
+            line.hasOnlyWhitespaces()) {
+          return false;
+        }
+      }
+      return true;
+    });
+  }
+
+  /// Whether any of the selected lines meets the condition in the callback.
+  bool _anySelectedLine(bool Function(String line) callback) {
+    if (selection.start == -1 || selection.end == -1) {
+      return false;
+    }
+
+    final selectedLinesRange = getSelectedLineRange();
+
+    for (int i = selectedLinesRange.start; i < selectedLinesRange.end; i++) {
+      final currentLineMatchesCondition = callback(_code.lines.lines[i].text);
+      if (currentLineMatchesCondition) {
+        return true;
+      }
+    }
+
+    return false;
+  }
+
+  void _commentOutSelectedLines() {
+    final sequence = SingleLineComments.byMode[language]?.first;
+    if (sequence == null) {
+      return;
+    }
+
+    modifySelectedLines((line) {
+      if (line.hasOnlyWhitespaces()) {
+        return line;
+      }
+
+      return line.replaceRange(
+        0,
+        0,
+        '$sequence ',
+      );
+    });
+  }
+
+  void _uncommentSelectedLines() {
+    modifySelectedLines((line) {
+      if (line.hasOnlyWhitespaces()) {
+        return line;
+      }
+
+      for (final sequence
+          in SingleLineComments.byMode[language] ?? <String>[]) {
+        // if there is a space after a sequence we should remove it with the sequence
+        if (line.trim().startsWith('$sequence ')) {
+          return line.replaceFirst('$sequence ', '');
+        }
+        // if there is no space after a sequence we should remove the sequence
+        if (line.trim().startsWith(sequence)) {
+          return line.replaceFirst(sequence, '');
+        }
+      }
+
+      // if line is not commented just return it
+      return line;
+    });
+  }
+
   /// Filters the lines that have at least one character selected.
   ///
   /// IMPORTANT: this method also changes the selection to be:
@@ -399,28 +501,11 @@ class CodeController extends TextEditingController {
       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;
+    final lineRange = getSelectedLineRange();
 
     // apply modification to the selected lines
     final modifiedLinesBuffer = StringBuffer();
-    for (int i = firstLineIndex; i <= lastLineIndex; i++) {
+    for (int i = lineRange.start; i < lineRange.end; i++) {
       // cancel modification entirely if any of the lines is readOnly
       if (_code.lines.lines[i].isReadOnly) {
         return;
@@ -431,6 +516,9 @@ class CodeController extends TextEditingController {
 
     final modifiedLinesString = modifiedLinesBuffer.toString();
 
+    final firstLineStart = _code.lines.lines[lineRange.start].textRange.start;
+    final lastLineEnd = _code.lines.lines[lineRange.end - 1].textRange.end;
+
     // replace selected lines with modified ones
     final finalFullText = _code.text.replaceRange(
       firstLineStart,
@@ -461,6 +549,26 @@ class CodeController extends TextEditingController {
     );
   }
 
+  TextRange getSelectedLineRange() {
+    final firstChar = _code.hiddenRanges.recoverPosition(
+      selection.start,
+      placeHiddenRanges: TextAffinity.downstream,
+    );
+    final lastChar = _code.hiddenRanges.recoverPosition(
+      // to avoid including the next line if `\n` is selected
+      selection.isCollapsed ? selection.end : selection.end - 1,
+      placeHiddenRanges: TextAffinity.downstream,
+    );
+
+    final firstLineIndex = _code.lines.characterIndexToLineIndex(firstChar);
+    final lastLineIndex = _code.lines.characterIndexToLineIndex(lastChar);
+
+    return TextRange(
+      start: firstLineIndex,
+      end: lastLineIndex + 1,
+    );
+  }
+
   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 1575bd2..e834874 100644
--- a/lib/src/code_field/code_field.dart
+++ b/lib/src/code_field/code_field.dart
@@ -10,19 +10,20 @@ import '../gutter/gutter.dart';
 import '../line_numbers/line_number_style.dart';
 import '../sizes.dart';
 import '../wip/autocomplete/popup.dart';
+import 'actions/comment_uncomment.dart';
 import 'actions/indent.dart';
 import 'actions/outdent.dart';
 import 'code_controller.dart';
 
-final _shortcuts = {
+final _shortcuts = <ShortcutActivator, Intent>{
   // Copy
   LogicalKeySet(
     LogicalKeyboardKey.control,
     LogicalKeyboardKey.keyC,
   ): CopySelectionTextIntent.copy,
-  LogicalKeySet(
-    LogicalKeyboardKey.meta,
+  const SingleActivator(
     LogicalKeyboardKey.keyC,
+    meta: true,
   ): CopySelectionTextIntent.copy,
   LogicalKeySet(
     LogicalKeyboardKey.control,
@@ -34,9 +35,9 @@ final _shortcuts = {
     LogicalKeyboardKey.control,
     LogicalKeyboardKey.keyX,
   ): const CopySelectionTextIntent.cut(SelectionChangedCause.keyboard),
-  LogicalKeySet(
-    LogicalKeyboardKey.meta,
+  const SingleActivator(
     LogicalKeyboardKey.keyX,
+    meta: true,
   ): const CopySelectionTextIntent.cut(SelectionChangedCause.keyboard),
   LogicalKeySet(
     LogicalKeyboardKey.shift,
@@ -48,9 +49,9 @@ final _shortcuts = {
     LogicalKeyboardKey.control,
     LogicalKeyboardKey.keyZ,
   ): const UndoTextIntent(SelectionChangedCause.keyboard),
-  LogicalKeySet(
-    LogicalKeyboardKey.meta,
+  const SingleActivator(
     LogicalKeyboardKey.keyZ,
+    meta: true,
   ): const UndoTextIntent(SelectionChangedCause.keyboard),
 
   // Redo
@@ -75,6 +76,16 @@ final _shortcuts = {
     LogicalKeyboardKey.shift,
     LogicalKeyboardKey.tab,
   ): const OutdentIntent(),
+
+  // Comment Uncomment
+  LogicalKeySet(
+    LogicalKeyboardKey.control,
+    LogicalKeyboardKey.slash,
+  ): const CommentUncommentIntent(),
+  const SingleActivator(
+    LogicalKeyboardKey.slash,
+    meta: true,
+  ): const CommentUncommentIntent(),
 };
 
 class CodeField extends StatefulWidget {
diff --git a/lib/src/single_line_comments/parser/single_line_comments.dart b/lib/src/single_line_comments/parser/single_line_comments.dart
index 32e0a81..e29066c 100644
--- a/lib/src/single_line_comments/parser/single_line_comments.dart
+++ b/lib/src/single_line_comments/parser/single_line_comments.dart
@@ -2,6 +2,7 @@ import 'package:highlight/highlight_core.dart';
 import 'package:highlight/languages/dart.dart';
 import 'package:highlight/languages/go.dart';
 import 'package:highlight/languages/java.dart';
+import 'package:highlight/languages/php.dart';
 import 'package:highlight/languages/python.dart';
 import 'package:highlight/languages/scala.dart';
 
@@ -9,11 +10,12 @@ class SingleLineComments {
   const SingleLineComments._();
 
   static final byMode = <Mode, List<String>>{
-    java: [_slashes],
     dart: [_slashes],
     go: [_slashes],
-    scala: [_slashes],
+    java: [_slashes],
+    php: [_slashes, _hash],
     python: [_hash],
+    scala: [_slashes],
   };
 
   static const _slashes = '//';


Test patch:
diff --git a/test/src/code_field/code_controller_comment_uncomment_test.dart b/test/src/code_field/code_controller_comment_uncomment_test.dart
new file mode 100644
index 0000000..f2bb66a
--- /dev/null
+++ b/test/src/code_field/code_controller_comment_uncomment_test.dart
@@ -0,0 +1,482 @@
+// 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';
+import 'package:highlight/languages/php.dart';
+
+void main() {
+  group('Comment Out / Uncomment', () {
+    group('Language: java', () {
+      test(
+          'WHEN selection is collapsed '
+          'SHOULD comment out the selected line if it is not a comment '
+          'and uncomment otherwise '
+          'AND select whole lines', () {
+        final examples = [
+          //
+          _Example(
+            'Document has only 1 not-commented line and it is selected',
+            initialFullText: 'aa',
+            initialSelection: TextSelection.collapsed(offset: 0),
+            expectedFullText: '// aa',
+            expectedSelection: TextSelection(baseOffset: 0, extentOffset: 5),
+          ),
+
+          _Example(
+            'Document has only 1 commented out line with a space after comment',
+            initialFullText: '// aa',
+            initialSelection: TextSelection.collapsed(offset: 0),
+            expectedFullText: 'aa',
+            expectedSelection: TextSelection(baseOffset: 0, extentOffset: 2),
+          ),
+
+          _Example(
+            'Document has only 1 commented out line without spaces between comment and text',
+            initialFullText: '//aa',
+            initialSelection: TextSelection.collapsed(offset: 0),
+            expectedFullText: 'aa',
+            expectedSelection: TextSelection(baseOffset: 0, extentOffset: 2),
+          ),
+        ];
+
+        for (final example in examples) {
+          final controller = CodeController(language: java);
+          controller.text = example.initialFullText;
+          controller.selection = example.initialSelection;
+
+          controller.commentOutOrUncommentSelection();
+
+          expect(
+            controller.value.text,
+            example.expectedFullText,
+            reason: example.name,
+          );
+          expect(
+            controller.code.text,
+            example.expectedFullText,
+            reason: example.name,
+          );
+          expect(
+            controller.value.selection,
+            example.expectedSelection,
+            reason: example.name,
+          );
+        }
+      });
+
+      test('Selection is a range that occupies several lines', () {
+        final examples = [
+          //
+          _Example(
+            'WHEN all selected lines are uncommented '
+            'SHOULD comment out all selected lines',
+            initialFullText: '''
+aA
+AA
+Aa
+''',
+            initialSelection: TextSelection(baseOffset: 1, extentOffset: 7),
+            expectedFullText: '''
+// aA
+// AA
+// Aa
+''',
+            expectedSelection: TextSelection(baseOffset: 0, extentOffset: 18),
+          ),
+
+          _Example(
+            'WHEN all selected lines are commented out '
+            'SHOULD uncomment all selected lines',
+            initialFullText: '''
+// aA
+// AA
+// Aa
+''',
+            initialSelection: TextSelection(baseOffset: 4, extentOffset: 16),
+            expectedFullText: '''
+aA
+AA
+Aa
+''',
+            expectedSelection: TextSelection(baseOffset: 0, extentOffset: 9),
+          ),
+
+          _Example(
+            'WHEN first selected line is uncommented, the rest are commented out '
+            'SHOULD comment all lines out',
+            initialFullText: '''
+aA
+// AA
+// Aa
+''',
+            initialSelection: TextSelection(baseOffset: 1, extentOffset: 13),
+            expectedFullText: '''
+// aA
+// // AA
+// // Aa
+''',
+            expectedSelection: TextSelection(baseOffset: 0, extentOffset: 24),
+          ),
+
+          _Example(
+            'WHEN non-first selected line is uncommented, the rest are commented out '
+            'SHOULD comment out all lines',
+            initialFullText: '''
+// aA
+AA
+// Aa
+''',
+            initialSelection: TextSelection(baseOffset: 4, extentOffset: 13),
+            expectedFullText: '''
+// // aA
+// AA
+// // Aa
+''',
+            expectedSelection: TextSelection(baseOffset: 0, extentOffset: 24),
+          ),
+
+          _Example(
+            'WHEN last selected line is uncommented, the rest are commented out '
+            'SHOULD comment out all lines',
+            initialFullText: '''
+// aA
+// AA
+Aa
+''',
+            initialSelection: TextSelection(baseOffset: 4, extentOffset: 13),
+            expectedFullText: '''
+// // aA
+// // AA
+// Aa
+''',
+            expectedSelection: TextSelection(baseOffset: 0, extentOffset: 24),
+          ),
+        ];
+
+        for (final example in examples) {
+          final controller = CodeController(language: java);
+          controller.text = example.initialFullText;
+          controller.selection = example.initialSelection;
+
+          controller.commentOutOrUncommentSelection();
+
+          expect(
+            controller.value.text,
+            example.expectedFullText,
+            reason: example.name,
+          );
+          expect(
+            controller.code.text,
+            example.expectedFullText,
+            reason: example.name,
+          );
+          expect(
+            controller.value.selection,
+            example.expectedSelection,
+            reason: example.name,
+          );
+        }
+      });
+    });
+
+    group('Language: PHP', () {
+      test('Selection is collapsed', () {
+        final examples = [
+          //
+          _Example(
+            'WHEN selection is invalid '
+            'SHOULD do nothing',
+            initialFullText: '''
+a
+''',
+            initialSelection: TextSelection.collapsed(offset: -1),
+            expectedFullText: '''
+a
+''',
+            expectedSelection: TextSelection.collapsed(offset: -1),
+          ),
+
+          _Example(
+            'WHEN line is not commented out '
+            'SHOULD comment it out with the first sequence in the list',
+            initialFullText: '''
+a
+''',
+            initialSelection: TextSelection.collapsed(offset: 0),
+            expectedFullText: '''
+// a
+''',
+            expectedSelection: TextSelection(baseOffset: 0, extentOffset: 5),
+          ),
+
+          _Example(
+            'WHEN line is commented out with the first type of comment '
+            'SHOULD uncomment it',
+            initialFullText: '''
+// a
+''',
+            initialSelection: TextSelection.collapsed(offset: 0),
+            expectedFullText: '''
+a
+''',
+            expectedSelection: TextSelection(baseOffset: 0, extentOffset: 2),
+          ),
+
+          _Example(
+            'WHEN line is commented out with the second type of comment '
+            'SHOULD uncomment it',
+            initialFullText: '''
+# a
+''',
+            initialSelection: TextSelection.collapsed(offset: 0),
+            expectedFullText: '''
+a
+''',
+            expectedSelection: TextSelection(baseOffset: 0, extentOffset: 2),
+          ),
+
+          _Example(
+            'WHEN line is empty '
+            'SHOULD only change selection',
+            initialFullText: '''
+
+''',
+            initialSelection: TextSelection.collapsed(offset: 0),
+            expectedFullText: '''
+
+''',
+            expectedSelection: TextSelection(baseOffset: 0, extentOffset: 1),
+          ),
+        ];
+
+        for (final example in examples) {
+          final controller = CodeController(language: php);
+          controller.text = example.initialFullText;
+          controller.selection = example.initialSelection;
+
+          controller.commentOutOrUncommentSelection();
+
+          expect(
+            controller.value.text,
+            example.expectedFullText,
+            reason: example.name,
+          );
+          expect(
+            controller.code.text,
+            example.expectedFullText,
+            reason: example.name,
+          );
+          expect(
+            controller.value.selection,
+            example.expectedSelection,
+            reason: example.name,
+          );
+        }
+      });
+
+      test('Selection is a range', () {
+        final examples = [
+          //
+          _Example(
+            'WHEN every selected line has different type of single line comments '
+            'SHOULD uncomment all of them',
+            initialFullText: '''
+// aA
+# AA
+// AA
+# Aa
+''',
+            initialSelection: TextSelection(baseOffset: 4, extentOffset: 19),
+            expectedFullText: '''
+aA
+AA
+AA
+Aa
+''',
+            expectedSelection: TextSelection(baseOffset: 0, extentOffset: 12),
+          ),
+
+          _Example(
+            'WHEN every selected line has different type of single line comments '
+            'and one line inbetween is empty '
+            'SHOULD uncomment all commented lines and do not affect empty line',
+            initialFullText: '''
+// aA
+# AA
+
+# Aa
+''',
+            initialSelection: TextSelection(baseOffset: 4, extentOffset: 15),
+            expectedFullText: '''
+aA
+AA
+
+Aa
+''',
+            expectedSelection: TextSelection(baseOffset: 0, extentOffset: 10),
+          ),
+
+          _Example(
+            'WHEN every selected line has different type of single line comments '
+            'but one of them is not commented '
+            'SHOULD comment out all lines',
+            initialFullText: '''
+// aA
+AA
+
+# Aa
+''',
+            initialSelection: TextSelection(baseOffset: 4, extentOffset: 13),
+            expectedFullText: '''
+// // aA
+// AA
+
+// # Aa
+''',
+            expectedSelection: TextSelection(baseOffset: 0, extentOffset: 24),
+          ),
+
+          _Example(
+            'WHEN every selected line has different type of single line comments '
+            'and they have different indentation '
+            'SHOULD uncomment all commented out lines and do not affect empty line',
+            initialFullText: '''
+    // aA
+  # AA
+
+      # Aa
+''',
+            initialSelection: TextSelection(baseOffset: 8, extentOffset: 27),
+            expectedFullText: '''
+    aA
+  AA
+
+      Aa
+''',
+            expectedSelection: TextSelection(baseOffset: 0, extentOffset: 22),
+          ),
+
+          _Example(
+            'WHEN every selected line has different type of single line comments '
+            'and they have different indentation '
+            'SHOULD uncomment all commented lines and do not affect empty line',
+            initialFullText: '''
+  // aA
+    # AA
+
+      # Aa
+''',
+            initialSelection: TextSelection(baseOffset: 6, extentOffset: 27),
+            expectedFullText: '''
+  aA
+    AA
+
+      Aa
+''',
+            expectedSelection: TextSelection(baseOffset: 0, extentOffset: 22),
+          ),
+
+          _Example(
+            'WHEN every selected line has different type of single line comments '
+            'and they have different indentation '
+            'SHOULD uncomment all commented lines and do not affect empty line',
+            initialFullText: '''
+    // aA
+  # AA
+
+# Aa
+''',
+            initialSelection: TextSelection(baseOffset: 8, extentOffset: 21),
+            expectedFullText: '''
+    aA
+  AA
+
+Aa
+''',
+            expectedSelection: TextSelection(baseOffset: 0, extentOffset: 16),
+          ),
+
+          _Example(
+            'WHEN non-commented lines have different indentation '
+            'SHOULD comment out all lines and do not affect empty line',
+            initialFullText: '''
+    aA
+  AA
+
+      Aa
+''',
+            initialSelection: TextSelection(baseOffset: 5, extentOffset: 20),
+            expectedFullText: '''
+//     aA
+//   AA
+
+//       Aa
+''',
+            expectedSelection: TextSelection(baseOffset: 0, extentOffset: 31),
+          ),
+
+          _Example(
+            'WHEN there are deselected lines '
+            'SHOULD only comment selected lines.',
+            initialFullText: '''
+a
+A
+A
+a
+''',
+            initialSelection: TextSelection(baseOffset: 2, extentOffset: 5),
+            expectedFullText: '''
+a
+// A
+// A
+a
+''',
+            expectedSelection: TextSelection(baseOffset: 2, extentOffset: 12),
+          ),
+        ];
+
+        for (final example in examples) {
+          final controller = CodeController(language: php);
+          controller.text = example.initialFullText;
+          controller.selection = example.initialSelection;
+
+          controller.commentOutOrUncommentSelection();
+
+          expect(
+            controller.value.text,
+            example.expectedFullText,
+            reason: example.name,
+          );
+          expect(
+            controller.code.text,
+            example.expectedFullText,
+            reason: example.name,
+          );
+          expect(
+            controller.value.selection,
+            example.expectedSelection,
+            reason: example.name,
+          );
+        }
+      });
+    });
+  });
+}
+
+class _Example {
+  final String name;
+  final String initialFullText;
+  final TextSelection initialSelection;
+  final String expectedFullText;
+  final TextSelection expectedSelection;
+
+  const _Example(
+    this.name, {
+    required this.initialFullText,
+    required this.initialSelection,
+    required this.expectedFullText,
+    required this.expectedSelection,
+  });
+}