Repository: akvelon/flutter-code-editor
Pull request #96 (2022-10-27 05:19:25)

Copy folded text to clipboard with keyboard shortcuts (#24)
- Resolves: #24 

This PR:
- Overrides copying and cutting with keyboard shortcuts.
- Adds a test to Ctrl-A shortcut which is handled by Flutter automatically.

Issues standing:
- Copying and cutting with the browser context menu is not overridden.
- All Ctrl-Z issues.


Patch:
diff --git a/lib/src/code_field/actions/copy.dart b/lib/src/code_field/actions/copy.dart
new file mode 100644
index 0000000..b17d29c
--- /dev/null
+++ b/lib/src/code_field/actions/copy.dart
@@ -0,0 +1,28 @@
+import 'package:flutter/services.dart';
+import 'package:flutter/widgets.dart';
+
+import '../code_controller.dart';
+import '../text_editing_value.dart';
+
+class CopyAction extends Action<CopySelectionTextIntent> {
+  final CodeController controller;
+
+  CopyAction({
+    required this.controller,
+  });
+
+  @override
+  Future<void> invoke(CopySelectionTextIntent intent) async {
+    final selection = controller.code.hiddenRanges.recoverSelection(
+      controller.value.selection,
+    );
+
+    await Clipboard.setData(
+      ClipboardData(text: selection.textInside(controller.code.text)),
+    );
+
+    if (intent.collapseSelection) {
+      controller.value = controller.value.deleteSelection();
+    }
+  }
+}
diff --git a/lib/src/code_field/code_controller.dart b/lib/src/code_field/code_controller.dart
index 3c2e677..be6a0dd 100644
--- a/lib/src/code_field/code_controller.dart
+++ b/lib/src/code_field/code_controller.dart
@@ -19,6 +19,7 @@ import '../code_theme/code_theme.dart';
 import '../code_theme/code_theme_data.dart';
 import '../named_sections/parsers/abstract.dart';
 import '../wip/autocomplete/popup_controller.dart';
+import 'actions/copy.dart';
 import 'editor_params.dart';
 import 'span_builder.dart';
 
@@ -102,6 +103,10 @@ class CodeController extends TextEditingController {
   late PopupController popupController;
   final autocompleter = Autocompleter();
 
+  late final actions = {
+    CopySelectionTextIntent: CopyAction(controller: this),
+  };
+
   CodeController({
     String? text,
     Mode? language,
diff --git a/lib/src/code_field/code_field.dart b/lib/src/code_field/code_field.dart
index b4c22dd..cec2e83 100644
--- a/lib/src/code_field/code_field.dart
+++ b/lib/src/code_field/code_field.dart
@@ -2,6 +2,7 @@ import 'dart:async';
 import 'dart:math';
 
 import 'package:flutter/material.dart';
+import 'package:flutter/services.dart';
 import 'package:linked_scroll_controller/linked_scroll_controller.dart';
 
 import '../code_theme/code_theme.dart';
@@ -11,6 +12,36 @@ import '../sizes.dart';
 import '../wip/autocomplete/popup.dart';
 import 'code_controller.dart';
 
+final _shortcuts = {
+  // Copy
+  LogicalKeySet(
+    LogicalKeyboardKey.control,
+    LogicalKeyboardKey.keyC,
+  ): CopySelectionTextIntent.copy,
+  LogicalKeySet(
+    LogicalKeyboardKey.meta,
+    LogicalKeyboardKey.keyC,
+  ): CopySelectionTextIntent.copy,
+  LogicalKeySet(
+    LogicalKeyboardKey.control,
+    LogicalKeyboardKey.insert,
+  ): CopySelectionTextIntent.copy,
+
+  // Cut
+  LogicalKeySet(
+    LogicalKeyboardKey.control,
+    LogicalKeyboardKey.keyX,
+  ): const CopySelectionTextIntent.cut(SelectionChangedCause.keyboard),
+    LogicalKeySet(
+    LogicalKeyboardKey.meta,
+    LogicalKeyboardKey.keyX,
+  ): const CopySelectionTextIntent.cut(SelectionChangedCause.keyboard),
+  LogicalKeySet(
+    LogicalKeyboardKey.shift,
+    LogicalKeyboardKey.delete,
+  ): const CopySelectionTextIntent.cut(SelectionChangedCause.keyboard),
+};
+
 class CodeField extends StatefulWidget {
   /// {@macro flutter.widgets.textField.minLines}
   final int? minLines;
@@ -294,33 +325,37 @@ class CodeFieldState extends State<CodeField> {
       ),
     );
 
-    return Container(
-      decoration: widget.decoration,
-      color: backgroundCol,
-      key: _codeFieldKey,
-      padding: !widget.lineNumbers ? const EdgeInsets.only(left: 8) : null,
-      child: Row(
-        crossAxisAlignment: CrossAxisAlignment.start,
-        children: [
-          if (widget.lineNumbers && numberCol != null) numberCol,
-          Expanded(
-            child: Stack(
-              children: [
-                editingField,
-                if (widget.controller.popupController.isPopupShown)
-                  Popup(
-                    normalOffset: _normalPopupOffset,
-                    flippedOffset: _flippedPopupOffset,
-                    controller: widget.controller.popupController,
-                    editingWindowSize: windowSize,
-                    style: textStyle,
-                    backgroundColor: backgroundCol,
-                    parentFocusNode: _focusNode!,
-                  ),
-              ],
+    return FocusableActionDetector(
+      actions: widget.controller.actions,
+      shortcuts: _shortcuts,
+      child: Container(
+        decoration: widget.decoration,
+        color: backgroundCol,
+        key: _codeFieldKey,
+        padding: !widget.lineNumbers ? const EdgeInsets.only(left: 8) : null,
+        child: Row(
+          crossAxisAlignment: CrossAxisAlignment.start,
+          children: [
+            if (widget.lineNumbers && numberCol != null) numberCol,
+            Expanded(
+              child: Stack(
+                children: [
+                  editingField,
+                  if (widget.controller.popupController.isPopupShown)
+                    Popup(
+                      normalOffset: _normalPopupOffset,
+                      flippedOffset: _flippedPopupOffset,
+                      controller: widget.controller.popupController,
+                      editingWindowSize: windowSize,
+                      style: textStyle,
+                      backgroundColor: backgroundCol,
+                      parentFocusNode: _focusNode!,
+                    ),
+                ],
+              ),
             ),
-          ),
-        ],
+          ],
+        ),
       ),
     );
   }
diff --git a/lib/src/code_field/text_editing_value.dart b/lib/src/code_field/text_editing_value.dart
index 8e14565..615a483 100644
--- a/lib/src/code_field/text_editing_value.dart
+++ b/lib/src/code_field/text_editing_value.dart
@@ -70,6 +70,10 @@ extension TextEditingValueExtension on TextEditingValue {
         : null;
   }
 
+  TextEditingValue deleteSelection() {
+    return replaced(selection, '');
+  }
+
   TextEditingValue replacedSelection(String value) {
     return replaced(selection, value);
   }


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 87dfbc3..c3b24d6 100644
--- a/test/src/code_field/code_controller_folding_test.dart
+++ b/test/src/code_field/code_controller_folding_test.dart
@@ -1,9 +1,6 @@
 import 'package:flutter/widgets.dart';
-import 'package:flutter_code_editor/src/code_field/code_controller.dart';
 import 'package:flutter_code_editor/src/code_field/text_editing_value.dart';
-import 'package:flutter_code_editor/src/named_sections/parsers/brackets_start_end.dart';
 import 'package:flutter_test/flutter_test.dart';
-import 'package:highlight/languages/java.dart';
 
 import '../common/create_app.dart';
 import '../common/widget_tester.dart';
@@ -41,27 +38,10 @@ private class MyClass {
 ''';
 
 void main() {
-  late FocusNode focusNode;
-
   setUp(() {
     focusNode = FocusNode();
   });
 
-  CodeController createController(String text) {
-    return CodeController(
-      text: text,
-      language: java,
-      namedSectionParser: const BracketsStartEndNamedSectionParser(),
-    );
-  }
-
-  Future<CodeController> pumpController(WidgetTester wt, String text) async {
-    final controller = createController(text);
-    await wt.pumpWidget(createApp(controller, focusNode));
-    focusNode.requestFocus();
-    return controller;
-  }
-
   group('CodeController. Folding.', () {
     group('Trivial.', () {
       test('With no folding blocks, nothing changes', () {
diff --git a/test/src/code_field/code_controller_shortcut_test.dart b/test/src/code_field/code_controller_shortcut_test.dart
new file mode 100644
index 0000000..1e915c9
--- /dev/null
+++ b/test/src/code_field/code_controller_shortcut_test.dart
@@ -0,0 +1,135 @@
+import 'package:flutter/services.dart';
+import 'package:flutter_test/flutter_test.dart';
+
+import '../common/create_app.dart';
+import '../common/widget_tester.dart';
+
+const _text = '''
+class MyClass {
+  void method() {// [START section1]
+  }// [END section1]
+}
+''';
+
+const _visibleText = '''
+class MyClass {
+  void method() {
+}
+''';
+
+const _copiedText = '''
+void method() {// [START section1]
+  }// [END section1]
+''';
+
+const _visibleTextAfterCut = '''
+class MyClass {
+  }
+''';
+
+void main() {
+  final calls = <MethodCall>[];
+
+  void mockClipboardHandler() {
+    calls.clear();
+
+    SystemChannels.platform.setMockMethodCallHandler((MethodCall call) async {
+      calls.add(call);
+    });
+  }
+
+  group('CodeController. Shortcuts.', () {
+    testWidgets('Select all', (WidgetTester wt) async {
+      final controller = await pumpController(wt, _text);
+      controller.foldAt(1);
+
+      await wt.sendKeyDownEvent(LogicalKeyboardKey.control);
+      await wt.sendKeyEvent(LogicalKeyboardKey.keyA);
+      await wt.sendKeyUpEvent(LogicalKeyboardKey.control);
+
+      expect(
+        controller.value,
+        const TextEditingValue(
+          text: _visibleText,
+          selection: TextSelection(
+            baseOffset: 0,
+            extentOffset: _visibleText.length,
+          ),
+        ),
+      );
+    });
+
+    testWidgets('Copy, Cut', (WidgetTester wt) async {
+      final examples = [
+        //
+        _CopyExample(
+          'Copy with Ctrl-C',
+          act: () async {
+            await wt.sendKeyDownEvent(LogicalKeyboardKey.control);
+            await wt.sendKeyEvent(LogicalKeyboardKey.keyC);
+            await wt.sendKeyUpEvent(LogicalKeyboardKey.control);
+          },
+          visibleTextAfter: _visibleText,
+        ),
+
+        _CopyExample(
+          'Copy with Ctrl-Insert',
+          act: () async {
+            await wt.sendKeyDownEvent(LogicalKeyboardKey.control);
+            await wt.sendKeyEvent(LogicalKeyboardKey.insert);
+            await wt.sendKeyUpEvent(LogicalKeyboardKey.control);
+          },
+          visibleTextAfter: _visibleText,
+        ),
+
+        _CopyExample(
+          'Cut with Ctrl-X',
+          act: () async {
+            await wt.sendKeyDownEvent(LogicalKeyboardKey.control);
+            await wt.sendKeyEvent(LogicalKeyboardKey.keyX);
+            await wt.sendKeyUpEvent(LogicalKeyboardKey.control);
+          },
+          visibleTextAfter: _visibleTextAfterCut,
+        ),
+
+        _CopyExample(
+          'Cut with Shift-Delete',
+          act: () async {
+            await wt.sendKeyDownEvent(LogicalKeyboardKey.shift);
+            await wt.sendKeyEvent(LogicalKeyboardKey.delete);
+            await wt.sendKeyUpEvent(LogicalKeyboardKey.shift);
+          },
+          visibleTextAfter: _visibleTextAfterCut,
+        ),
+      ];
+
+      final controller = await pumpController(wt, '');
+
+      for (final example in examples) {
+        controller.value = const TextEditingValue(text: _text);
+        controller.foldAt(1);
+        await wt.selectFromHome(18, offset: 16);
+        mockClipboardHandler();
+
+        await example.act();
+
+        expect(calls.length, 1);
+        expect(calls[0].method, 'Clipboard.setData');
+        expect(calls[0].arguments, {'text': _copiedText});
+        expect(controller.text, example.visibleTextAfter);
+      }
+    });
+  });
+}
+
+class _CopyExample {
+  final String name;
+  final Future<void> Function() act;
+  final String visibleTextAfter;
+
+  const _CopyExample(
+    this.name, {
+    required this.act,
+    required this.visibleTextAfter,
+  });
+}
diff --git a/test/src/common/create_app.dart b/test/src/common/create_app.dart
index 47cbcdb..65f6db0 100644
--- a/test/src/common/create_app.dart
+++ b/test/src/common/create_app.dart
@@ -1,5 +1,9 @@
 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';
+
+FocusNode focusNode = FocusNode();
 
 MaterialApp createApp(CodeController controller, FocusNode focusNode) {
   return MaterialApp(
@@ -11,3 +15,21 @@ MaterialApp createApp(CodeController controller, FocusNode focusNode) {
     ),
   );
 }
+
+Future<CodeController> pumpController(WidgetTester wt, String text) async {
+  final controller = createController(text);
+
+  focusNode = FocusNode();
+  await wt.pumpWidget(createApp(controller, focusNode));
+  focusNode.requestFocus();
+
+  return controller;
+}
+
+CodeController createController(String text) {
+  return CodeController(
+    text: text,
+    language: java,
+    namedSectionParser: const BracketsStartEndNamedSectionParser(),
+  );
+}