Repository: akvelon/flutter-code-editor

Issue:
Read-only by end-of-line comments
Support read-only parts of code, designated by end-of-line comments.
If the comment contains lowercase `readonly`, this line is read-only.

At this point, do not parse strings like `var a = "//readonly"`, this is allowed to be false-positive read-only.



Fix patch:
diff --git a/lib/code_text_field.dart b/lib/code_text_field.dart
index e183a76f..8770e394 100644
--- a/lib/code_text_field.dart
+++ b/lib/code_text_field.dart
@@ -1,6 +1,12 @@
+export 'src/code/code.dart';
+export 'src/code/code_line.dart';
+export 'src/code/text_range.dart';
+export 'src/code/tokens.dart';
+
 export 'src/code_field/code_controller.dart';
 export 'src/code_field/code_field.dart';
 export 'src/code_field/editor_params.dart';
+export 'src/code_field/text_editing_value.dart';
 
 export 'src/code_modifiers/close_block_code_modifier.dart';
 export 'src/code_modifiers/code_modifier.dart';
diff --git a/lib/src/code/code.dart b/lib/src/code/code.dart
new file mode 100644
index 00000000..c18ad5eb
--- /dev/null
+++ b/lib/src/code/code.dart
@@ -0,0 +1,149 @@
+import 'package:collection/collection.dart';
+import 'package:flutter/material.dart';
+
+import 'code_line.dart';
+import 'text_range.dart';
+import 'tokens.dart';
+
+class Code {
+  final String text;
+  final List<CodeLine> lines;
+
+  factory Code({
+    required String text,
+    required List<String> singleLineComments,
+  }) {
+    final lines = _textToCodeLines(text, singleLineComments);
+    return Code._(text: text, lines: lines);
+  }
+
+  const Code._({
+    required this.text,
+    required this.lines,
+  });
+
+  static const empty = Code._(text: '', lines: []);
+
+  static List<CodeLine> _textToCodeLines(
+    String text,
+    List<String> singleLineComments,
+  ) {
+    final result = <CodeLine>[];
+    final lines = text.split('\n');
+    int linesLeft = lines.length;
+    int charIndex = 0;
+
+    for (String line in lines) {
+      final words = _getCommentWords(line, singleLineComments);
+
+      String lineText = '$line\n';
+      bool isReadOnly = words.contains(Tokens.readonly);
+
+      if (--linesLeft == 0) {
+        // The last line is special. It has no newline at the end.
+        // If it's empty, it inherits isReadOnly from the previous line.
+        // Otherwise, if we wanted a read-only document end, we could
+        // not use newline at the end of it as POSIX requires.
+        lineText = line;
+        if (line == '') {
+          isReadOnly = result.lastOrNull?.isReadOnly ?? false;
+        }
+      }
+
+      result.add(
+        CodeLine.fromTextAndStart(
+          lineText,
+          charIndex,
+          isReadOnly: isReadOnly,
+        ),
+      );
+      charIndex += line.length + 1;
+    }
+
+    return result;
+  }
+
+  static List<String> _getCommentWords(
+    String str,
+    List<String> singleLineComments,
+  ) {
+    for (final singleLineComment in singleLineComments) {
+      final commentIndex = str.indexOf(singleLineComment);
+
+      if (commentIndex == -1) {
+        continue;
+      }
+
+      return str
+          .substring(commentIndex + singleLineComment.length)
+          .split(RegExp(r'\s+')); // Split by any whitespaces.
+    }
+
+    return [];
+  }
+
+  /// Returns the 0-based line number of the character at [characterIndex].
+  ///
+  /// [characterIndex] can be from 0 to length inclusive.
+  /// If it is equal to length, it means the cursor at the end of the text.
+  ///
+  /// Other values throw [RangeError].
+  int characterIndexToLineIndex(int characterIndex) {
+    int lowerLine = 0;
+    int upperLine = lines.length - 1;
+
+    while (true) {
+      int lowerCharacter = lines[lowerLine].textRange.start;
+      int upperCharacter = lines[upperLine].textRange.end;
+
+      if (upperCharacter == lowerCharacter) {
+        return lowerLine; // Empty line case, avoid division by zero.
+      }
+
+      // Linear interpolation search.
+      int lineIndex = lowerLine +
+          ((upperLine - lowerLine) * (characterIndex - lowerCharacter) /
+              (upperCharacter - lowerCharacter)).floor();
+
+      final line = lines[lineIndex];
+
+      if (characterIndex < line.textRange.start) {
+        // Character is before the current line. Next search before it.
+        upperLine = lineIndex - 1;
+        continue;
+      }
+
+      if (characterIndex > line.textRange.end) {
+        // Character is after this line. Next search after it.
+        lowerLine = lineIndex + 1;
+        continue;
+      }
+
+      if (characterIndex == line.textRange.end) {
+        if (line.text.characters.lastOrNull == '\n') {
+          // Character is just after this string's \n, it is the next line.
+          return lineIndex + 1;
+        }
+      }
+
+      return lineIndex;
+    }
+  }
+
+  /// Returns whether the current selection has any read-only part.
+  bool isReadOnlySelected(TextRange range) {
+    final startChar = range.normalized.start;
+    final endChar = range.normalized.end;
+
+    final startLine = characterIndexToLineIndex(startChar);
+    final endLine = characterIndexToLineIndex(endChar);
+
+    for (int line = startLine; line <= endLine; line++) {
+      if (lines[line].isReadOnly) {
+        return true;
+      }
+    }
+
+    return false;
+  }
+}
diff --git a/lib/src/code/code_line.dart b/lib/src/code/code_line.dart
new file mode 100644
index 00000000..65f49461
--- /dev/null
+++ b/lib/src/code/code_line.dart
@@ -0,0 +1,42 @@
+import 'dart:ui';
+
+import 'package:meta/meta.dart';
+
+@immutable
+class CodeLine {
+  final String text;
+
+  final TextRange textRange;
+  final bool isReadOnly;
+
+  const CodeLine({
+    required this.text,
+    required this.textRange,
+    this.isReadOnly = false,
+  });
+
+  CodeLine.fromTextAndStart(
+    this.text,
+    int start, {
+    this.isReadOnly = false,
+  }) : textRange = TextRange(start: start, end: start + text.length);
+
+  @override
+  String toString() =>
+      'CodeLine(ro: $isReadOnly, textRange: $textRange, text: "$text")';
+
+  @override
+  bool operator ==(Object other) {
+    return other is CodeLine &&
+        text == other.text &&
+        textRange == other.textRange &&
+        isReadOnly == other.isReadOnly;
+  }
+
+  @override
+  int get hashCode => Object.hash(
+        text,
+        textRange,
+        isReadOnly,
+      );
+}
diff --git a/lib/src/code/text_range.dart b/lib/src/code/text_range.dart
new file mode 100644
index 00000000..e8330d71
--- /dev/null
+++ b/lib/src/code/text_range.dart
@@ -0,0 +1,7 @@
+import 'dart:ui';
+
+extension Normalized on TextRange {
+  TextRange get normalized {
+    return isNormalized ? this : TextRange(start: end, end: start);
+  }
+}
diff --git a/lib/src/code/tokens.dart b/lib/src/code/tokens.dart
new file mode 100644
index 00000000..a94665ce
--- /dev/null
+++ b/lib/src/code/tokens.dart
@@ -0,0 +1,3 @@
+class Tokens {
+  static const readonly = 'readonly';
+}
diff --git a/lib/src/code_field/code_controller.dart b/lib/src/code_field/code_controller.dart
index de0e1330..fbc98635 100644
--- a/lib/src/code_field/code_controller.dart
+++ b/lib/src/code_field/code_controller.dart
@@ -5,6 +5,8 @@ import 'package:flutter/material.dart';
 import 'package:flutter/services.dart';
 import 'package:highlight/highlight_core.dart';
 
+import '../code/code.dart';
+import '../code/text_range.dart';
 import '../code_modifiers/close_block_code_modifier.dart';
 import '../code_modifiers/code_modifier.dart';
 import '../code_modifiers/indent_code_modifier.dart';
@@ -18,6 +20,9 @@ import 'editor_params.dart';
 
 const _middleDot = '·';
 
+// TODO(alexeyinkin): Derive comments from language https://github.com/akvelon/flutter-code-editor/issues/34
+const _singleLineComments = ['//', '#'];
+
 class CodeController extends TextEditingController {
   Mode? _language;
 
@@ -81,6 +86,8 @@ class CodeController extends TextEditingController {
 
   String get languageId => _languageId;
 
+  Code _lastCode;
+
   final styleList = <TextStyle>[];
   final modifierMap = <String, CodeModifier>{};
   bool isPopupShown = false;
@@ -103,7 +110,12 @@ class CodeController extends TextEditingController {
     ],
     this.webSpaceFix = true,
     this.onChange,
-  })  : _theme = theme,
+  })
+      : _theme = theme,
+        _lastCode = Code(
+          text: text ?? '',
+          singleLineComments: _singleLineComments,
+        ),
         super(text: text) {
     this.language = language;
 
@@ -169,27 +181,74 @@ class CodeController extends TextEditingController {
     }
   }
 
-  KeyEventResult onKey(RawKeyEvent event) {
-    if (event.isKeyPressed(LogicalKeyboardKey.tab)) {
+  KeyEventResult onKey(KeyEvent event) {
+    if (event is KeyDownEvent || event is KeyRepeatEvent) {
+      return _onKeyDownRepeat(event);
+    }
+
+    return KeyEventResult.ignored; // The framework will handle.
+  }
+
+  KeyEventResult _onKeyDownRepeat(KeyEvent event) {
+    if (!_shouldAllowKeyDownRepeat(event)) {
+      return KeyEventResult.handled; // Prevent the framework from handling.
+    }
+
+    // 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.isKeyPressed(LogicalKeyboardKey.arrowUp)) {
+      if (event.logicalKey == LogicalKeyboardKey.arrowUp) {
         popupController.scrollByArrow(ScrollDirection.up);
         return KeyEventResult.handled;
       }
-      if (event.isKeyPressed(LogicalKeyboardKey.arrowDown)) {
+      if (event.logicalKey == LogicalKeyboardKey.arrowDown) {
         popupController.scrollByArrow(ScrollDirection.down);
         return KeyEventResult.handled;
       }
-      if (event.isKeyPressed(LogicalKeyboardKey.enter)) {
+      if (event.logicalKey == LogicalKeyboardKey.enter) {
         insertSelectedWord();
         return KeyEventResult.handled;
       }
     }
-    return KeyEventResult.ignored;
+
+    return KeyEventResult.ignored; // The framework will handle.
+  }
+
+  bool _shouldAllowKeyDownRepeat(KeyEvent event) {
+    if (event.logicalKey == LogicalKeyboardKey.backspace) {
+      return _shouldAllowBackspace();
+    }
+
+    if (event.logicalKey == LogicalKeyboardKey.delete) {
+      return _shouldAllowDelete();
+    }
+
+    return true;
+  }
+
+  bool _shouldAllowBackspace() {
+    final start = super.value.selection.normalized.start - 1;
+    if (start < 0) {
+      return false;
+    }
+
+    final line = _lastCode.characterIndexToLineIndex(start);
+    return !_lastCode.lines[line].isReadOnly;
+  }
+
+  bool _shouldAllowDelete() {
+    // If we are at the end of the line, see after the \n.
+    final end = value.selection.normalized.end + 1;
+    if (end >= value.text.length) {
+      return false;
+    }
+
+    final line = _lastCode.characterIndexToLineIndex(end);
+    return !_lastCode.lines[line].isReadOnly;
   }
 
   /// Inserts the word selected from the list of completions
@@ -253,6 +312,17 @@ class CodeController extends TextEditingController {
 
   @override
   set value(TextEditingValue newValue) {
+    if (newValue.text != super.value.text) {
+      if (_lastCode.isReadOnlySelected(super.value.selection)) {
+        return;
+      }
+
+      _lastCode = Code(
+        text: newValue.text,
+        singleLineComments: _singleLineComments,
+      );
+    }
+
     final loc = _insertedLoc(text, newValue.text);
 
     if (loc != null) {
diff --git a/lib/src/code_field/code_field.dart b/lib/src/code_field/code_field.dart
index 2c08a4bd..be580073 100644
--- a/lib/src/code_field/code_field.dart
+++ b/lib/src/code_field/code_field.dart
@@ -123,7 +123,7 @@ class CodeFieldState extends State<CodeField> {
     });
     _horizontalCodeScroll = ScrollController();
     _focusNode = widget.focusNode ?? FocusNode();
-    _focusNode!.attach(context, onKey: _onKey);
+    _focusNode!.attach(context, onKeyEvent: _onKeyEvent);
 
     WidgetsBinding.instance.addPostFrameCallback((_) {
       double width = _codeFieldKey.currentContext!.size!.width;
@@ -133,7 +133,7 @@ class CodeFieldState extends State<CodeField> {
     _onTextChanged();
   }
 
-  KeyEventResult _onKey(FocusNode node, RawKeyEvent event) {
+  KeyEventResult _onKeyEvent(FocusNode node, KeyEvent event) {
     return widget.controller.onKey(event);
   }
 
@@ -154,9 +154,14 @@ class CodeFieldState extends State<CodeField> {
   void rebuild() {
     setState(() {
       WidgetsBinding.instance.addPostFrameCallback((_) {
-        double width = _codeFieldKey.currentContext!.size!.width;
-        double height = _codeFieldKey.currentContext!.size!.height;
-        windowSize = Size(width - _lineNumberWidth, height);
+        // For some reason _codeFieldKey.currentContext is null in tests
+        // so check first.
+        final context = _codeFieldKey.currentContext;
+        if (context != null) {
+          double width = context.size!.width;
+          double height = context.size!.height;
+          windowSize = Size(width - _lineNumberWidth, height);
+        }
       });
     });
   }
diff --git a/lib/src/code_field/text_editing_value.dart b/lib/src/code_field/text_editing_value.dart
new file mode 100644
index 00000000..0c1377a9
--- /dev/null
+++ b/lib/src/code_field/text_editing_value.dart
@@ -0,0 +1,7 @@
+import 'package:flutter/widgets.dart';
+
+extension ReplacedSelection on TextEditingValue {
+  TextEditingValue replacedSelection(String value) {
+    return replaced(selection, value);
+  }
+}
diff --git a/pubspec.yaml b/pubspec.yaml
index 2fb5ff17..d191a648 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -4,16 +4,18 @@ version: 1.0.2
 homepage: https://github.com/BertrandBev/code_field
 
 environment:
-  sdk: ">=2.12.0 <3.0.0"
-  flutter: ">=1.26.0"
+  sdk: ">=2.17.0 <3.0.0"
+  flutter: ">=3.0.0"
 
 dependencies:
+  autotrie: ^2.0.0
+  collection: ^1.16.0
   flutter:
     sdk: flutter
-  linked_scroll_controller: ^0.2.0-nullsafety.0
-  highlight: ^0.7.0
   flutter_highlight: ^0.7.0
-  autotrie: ^2.0.0
+  highlight: ^0.7.0
+  linked_scroll_controller: ^0.2.0-nullsafety.0
+  meta: ^1.7.0
   scrollable_positioned_list: ^0.2.3
   tuple: ^2.0.0
 


Test patch:
diff --git a/test/src/code/code_test.dart b/test/src/code/code_test.dart
new file mode 100644
index 00000000..336afb5d
--- /dev/null
+++ b/test/src/code/code_test.dart
@@ -0,0 +1,194 @@
+import 'dart:ui';
+
+import 'package:code_text_field/src/code/code.dart';
+import 'package:code_text_field/src/code/code_line.dart';
+import 'package:collection/collection.dart';
+import 'package:flutter_test/flutter_test.dart';
+
+const singleLineComments = ['//', '#'];
+
+const text = '''
+1 Lorem ipsum dolor sit amet,
+2 consectetur adipiscing elit,
+3 sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.
+4 Ut enim ad minim veniam,
+5 quis nostrud exercitation ullamco laboris nisi
+6 ut aliquip
+7 ex
+
+9 ea commodo consequat.
+10 Duis aute irure dolor in
+11 reprehenderit in voluptate velit esse cillum dolore
+12 eu fugiat nulla pariatur.
+13 Excepteur sint occaecat cupidatat
+14 non proident,
+15 sunt in culpa qui officia deserunt mollit anim id est laborum.''';
+
+void main() {
+  group('Code', () {
+    test('parses single line', () {
+      const texts = ['', 'no-newline'];
+
+      for (final text in texts) {
+        final code = Code(text: text, singleLineComments: singleLineComments);
+
+        expect(
+          const ListEquality().equals(
+            code.lines,
+            [
+              CodeLine(
+                text: text,
+                textRange: TextRange(start: 0, end: text.length),
+                isReadOnly: false, // ignore: avoid_redundant_argument_values
+              ),
+            ],
+          ),
+          true,
+        );
+      }
+    });
+
+    test('parses lines', () {
+      final code = Code(text: text, singleLineComments: singleLineComments);
+
+      expect(
+        const ListEquality().equals(
+          code.lines,
+          [
+            CodeLine.fromTextAndStart('1 Lorem ipsum dolor sit amet,\n', 0), // 30
+            CodeLine.fromTextAndStart('2 consectetur adipiscing elit,\n', 30), // 31
+            CodeLine.fromTextAndStart('3 sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.\n', 61), // 69
+            CodeLine.fromTextAndStart('4 Ut enim ad minim veniam,\n', 130), // 27
+            CodeLine.fromTextAndStart('5 quis nostrud exercitation ullamco laboris nisi\n', 157), // 49
+            CodeLine.fromTextAndStart('6 ut aliquip\n', 206), // 13
+            CodeLine.fromTextAndStart('7 ex\n', 219), // 5
+            CodeLine.fromTextAndStart('\n', 224), // 1
+            CodeLine.fromTextAndStart('9 ea commodo consequat.\n', 225), // 24
+            CodeLine.fromTextAndStart('10 Duis aute irure dolor in\n', 249), // 28
+            CodeLine.fromTextAndStart('11 reprehenderit in voluptate velit esse cillum dolore\n', 277), // 55
+            CodeLine.fromTextAndStart('12 eu fugiat nulla pariatur.\n', 332), // 29
+            CodeLine.fromTextAndStart('13 Excepteur sint occaecat cupidatat\n', 361), // 37
+            CodeLine.fromTextAndStart('14 non proident,\n', 398), // 17
+            CodeLine.fromTextAndStart('15 sunt in culpa qui officia deserunt mollit anim id est laborum.', 415), // 65
+          ],
+        ),
+        true,
+      );
+      expect(code.lines.last.textRange.end, 480);
+    });
+
+    test('characterIndexToLineIndex', () {
+      const tails = ['', '\n'];
+      for (final tail in tails) {
+        final textWithTail = text + tail;
+        final code = Code(
+          text: textWithTail,
+          singleLineComments: singleLineComments,
+        );
+
+        final map = {
+          0: 0,
+          29: 0,
+          30: 1,
+          60: 1,
+          61: 2,
+          129: 2,
+          130: 3,
+          156: 3,
+          157: 4,
+          205: 4,
+          206: 5,
+          218: 5,
+          219: 6,
+          223: 6,
+          224: 7,
+          225: 8,
+          248: 8,
+          249: 9,
+          276: 9,
+          277: 10,
+          331: 10,
+          332: 11,
+          360: 11,
+          361: 12,
+          397: 12,
+          398: 13,
+          414: 13,
+          415: 14,
+          480: 14, // Equal to length without tail
+          if (tail == '\n') 481: 15, // Equal to length with tail
+        };
+
+        for (final entry in map.entries) {
+          final line = code.characterIndexToLineIndex(entry.key);
+
+          expect(
+            line,
+            entry.value,
+            reason: 'Position: ${entry.key}, Tail: "$tail"',
+          );
+        }
+
+        expect(() => code.characterIndexToLineIndex(-1), throwsRangeError);
+        expect(
+          () => code.characterIndexToLineIndex(textWithTail.length + 1),
+          throwsRangeError,
+          reason: 'Tail: "$tail"',
+        );
+      }
+    });
+
+    test('characterIndexToLineIndex on a single line', () {
+      const texts = ['', 'no-newline'];
+      for (final text in texts) {
+        final code = Code(text: text, singleLineComments: singleLineComments);
+
+        for (int i = 0; i <= texts.length; i++) {
+          expect(code.characterIndexToLineIndex(i), 0, reason: '"$text" at $i');
+        }
+      }
+    });
+
+    test('parse read-only lines by end comments', () {
+      const dataSets = [
+        {
+          'text': '''
+            readonly
+            editable// readonly
+''',                                        // Empty line inherits readonly
+          'readonly': [false, true, true],
+        },
+        {
+          'text': '''
+            readonly
+            readonly //readonly
+            readonly // a readonly b'''
+            '\n\n'
+            '''
+            The above line is empty but not last, so does not inherit readonly
+            ''',
+          'readonly': [false, true, true, false, false, false],
+        },
+        {
+          'text': '''
+            readonly
+            editable// readonly
+            ''', // Last after read only, but not empty, so editable.
+          'readonly': [false, true, false],
+        },
+      ];
+
+      for (final data in dataSets) {
+        final code = Code(
+          text: data['text']! as String,
+          singleLineComments: singleLineComments,
+        );
+
+        final readonly = data['readonly']! as List<bool>;
+        for (int i = code.lines.length; --i >= 0;) {
+          expect(code.lines[i].isReadOnly, readonly[i], reason: 'Line #$i');
+        }
+      }
+    });
+  });
+}
diff --git a/test/src/code_field/code_controller_test.dart b/test/src/code_field/code_controller_test.dart
new file mode 100644
index 00000000..12a7af3c
--- /dev/null
+++ b/test/src/code_field/code_controller_test.dart
@@ -0,0 +1,356 @@
+import 'package:code_text_field/code_text_field.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter/services.dart';
+import 'package:flutter_test/flutter_test.dart';
+
+const _editableBeginningEnd = '''
+abc
+ro//readonly
+ro//readonly
+abc
+''';
+
+const _editableBetween = '''
+ro//readonly
+abc
+ro//readonly
+''';
+
+void main() {
+  group('CodeController Read-only', () {
+    testWidgets(
+      'Can edit at editable beginning of the document',
+      (WidgetTester wt) async {
+        final controller = CodeController(text: _editableBeginningEnd);
+        final focusNode = FocusNode();
+
+        await wt.pumpWidget(_createApp(controller, focusNode));
+        focusNode.requestFocus();
+
+        // Go to the beginning.
+        await wt.sendKeyDownEvent(LogicalKeyboardKey.alt);
+        await wt.sendKeyEvent(LogicalKeyboardKey.arrowUp);
+        await wt.sendKeyUpEvent(LogicalKeyboardKey.alt);
+
+        await wt.sendKeyEvent(LogicalKeyboardKey.backspace);
+
+        expect(
+          controller.value,
+          const TextEditingValue(
+            text: 'abc\nro//readonly\nro//readonly\nabc\n',
+            //     \ cursor
+            selection: TextSelection.collapsed(offset: 0),
+          ),
+          reason: 'Backspace - No effect',
+        );
+
+        await wt.sendKeyEvent(LogicalKeyboardKey.delete);
+
+        expect(
+          controller.value,
+          const TextEditingValue(
+            text: 'bc\nro//readonly\nro//readonly\nabc\n',
+            //     \ cursor
+            selection: TextSelection.collapsed(offset: 0),
+          ),
+          reason: 'Delete - OK',
+        );
+
+        // TODO(alexeyinkin): Simulate keyboard entry, https://github.com/akvelon/flutter-code-editor/issues/30
+        controller.value = controller.value.replacedSelection('a\n');
+
+        expect(
+          controller.value,
+          const TextEditingValue(
+            text: 'a\nbc\nro//readonly\nro//readonly\nabc\n',
+            //        \ cursor
+            selection: TextSelection.collapsed(offset: 2),
+          ),
+          reason: 'Type - OK',
+        );
+      },
+    );
+
+    testWidgets(
+      'Can edit at editable end of the document',
+      (WidgetTester wt) async {
+        final controller = CodeController(text: _editableBeginningEnd);
+        final focusNode = FocusNode();
+
+        await wt.pumpWidget(_createApp(controller, focusNode));
+        focusNode.requestFocus();
+
+        // Go to the beginning.
+        await wt.sendKeyDownEvent(LogicalKeyboardKey.alt);
+        await wt.sendKeyEvent(LogicalKeyboardKey.arrowDown);
+        await wt.sendKeyUpEvent(LogicalKeyboardKey.alt);
+
+        await wt.sendKeyEvent(LogicalKeyboardKey.delete);
+
+        expect(
+          controller.value,
+          const TextEditingValue(
+            text: 'abc\nro//readonly\nro//readonly\nabc\n',
+            //                                   cursor /
+            selection: TextSelection.collapsed(
+              offset: 34,
+              affinity: TextAffinity.upstream,
+            ),
+          ),
+          reason: 'Delete - no effect',
+        );
+
+        await wt.sendKeyEvent(LogicalKeyboardKey.backspace);
+
+        expect(
+          controller.value,
+          const TextEditingValue(
+            text: 'abc\nro//readonly\nro//readonly\nabc',
+            //                                 cursor /
+            selection: TextSelection.collapsed(offset: 33),
+          ),
+          reason: 'Backspace - OK',
+        );
+
+        // TODO(alexeyinkin): Simulate keyboard entry, https://github.com/akvelon/flutter-code-editor/issues/30
+        controller.value = controller.value.replacedSelection('d\n');
+
+        expect(
+          controller.value,
+          const TextEditingValue(
+            text: 'abc\nro//readonly\nro//readonly\nabcd\n',
+            //                                    cursor /
+            selection: TextSelection.collapsed(offset: 35),
+          ),
+          reason: 'Type - OK',
+        );
+      },
+    );
+
+    testWidgets(
+      'Junction of editable and read-only',
+      (WidgetTester wt) async {
+        final controller = CodeController(text: _editableBeginningEnd);
+        final focusNode = FocusNode();
+
+        await wt.pumpWidget(_createApp(controller, focusNode));
+        focusNode.requestFocus();
+
+        // Go to the end of the editable line before a read-only line.
+        await wt.sendKeyDownEvent(LogicalKeyboardKey.alt);
+        await wt.sendKeyEvent(LogicalKeyboardKey.arrowUp);
+        await wt.sendKeyUpEvent(LogicalKeyboardKey.alt);
+
+        await wt.sendKeyEvent(LogicalKeyboardKey.arrowRight);
+        await wt.sendKeyEvent(LogicalKeyboardKey.arrowRight);
+        await wt.sendKeyEvent(LogicalKeyboardKey.arrowRight);
+
+        await wt.sendKeyEvent(LogicalKeyboardKey.delete);
+
+        expect(
+          controller.value,
+          const TextEditingValue(
+            text: 'abc\nro//readonly\nro//readonly\nabc\n',
+            //        \ cursor
+            selection: TextSelection.collapsed(offset: 3),
+          ),
+          reason: 'Delete EOL before readonly - No effect',
+        );
+
+        await wt.sendKeyEvent(LogicalKeyboardKey.backspace);
+
+        expect(
+          controller.value,
+          const TextEditingValue(
+            text: 'ab\nro//readonly\nro//readonly\nabc\n',
+            //       \ cursor
+            selection: TextSelection.collapsed(offset: 2),
+          ),
+          reason: 'Backspace before EOL before readonly - OK',
+        );
+
+        // TODO(alexeyinkin): Simulate keyboard entry, https://github.com/akvelon/flutter-code-editor/issues/30
+        controller.value = controller.value.replacedSelection('c\n');
+
+        expect(
+          controller.value,
+          const TextEditingValue(
+            text: 'abc\n\nro//readonly\nro//readonly\nabc\n',
+            //          \ cursor
+            selection: TextSelection.collapsed(offset: 4),
+          ),
+          reason: 'Type before EOL before readonly - OK',
+        );
+
+        await wt.sendKeyEvent(LogicalKeyboardKey.arrowRight);
+
+        await wt.sendKeyEvent(LogicalKeyboardKey.delete);
+        await wt.sendKeyEvent(LogicalKeyboardKey.backspace);
+
+        // TODO(alexeyinkin): Simulate keyboard entry, https://github.com/akvelon/flutter-code-editor/issues/30
+        controller.value = controller.value.replacedSelection('y\n');
+
+        expect(
+          controller.value,
+          const TextEditingValue(
+            text: 'abc\n\nro//readonly\nro//readonly\nabc\n',
+            //          \ cursor
+            selection: TextSelection.collapsed(offset: 5),
+          ),
+          reason: 'Delete Backspace Type at beginning of readonly - No effect',
+        );
+      },
+    );
+
+    testWidgets(
+      'Junction of and read-only and editable',
+      (WidgetTester wt) async {
+        final controller = CodeController(text: _editableBeginningEnd);
+        final focusNode = FocusNode();
+
+        await wt.pumpWidget(_createApp(controller, focusNode));
+        focusNode.requestFocus();
+
+        // Go to the beginning of the editable line after a read-only line.
+        await wt.sendKeyDownEvent(LogicalKeyboardKey.alt);
+        await wt.sendKeyEvent(LogicalKeyboardKey.arrowDown);
+        await wt.sendKeyUpEvent(LogicalKeyboardKey.alt);
+
+        await wt.sendKeyEvent(LogicalKeyboardKey.arrowLeft);
+        await wt.sendKeyEvent(LogicalKeyboardKey.arrowLeft);
+        await wt.sendKeyEvent(LogicalKeyboardKey.arrowLeft);
+        await wt.sendKeyEvent(LogicalKeyboardKey.arrowLeft);
+
+        await wt.sendKeyEvent(LogicalKeyboardKey.delete);
+
+        expect(
+          controller.value,
+          const TextEditingValue(
+            text: 'abc\nro//readonly\nro//readonly\nbc\n',
+            //                              cursor /
+            selection: TextSelection.collapsed(offset: 30),
+          ),
+          reason: 'Delete at beginning of first editable line - OK',
+        );
+
+        await wt.sendKeyEvent(LogicalKeyboardKey.backspace);
+
+        expect(
+          controller.value,
+          const TextEditingValue(
+            text: 'abc\nro//readonly\nro//readonly\nbc\n',
+            //                              cursor /
+            selection: TextSelection.collapsed(offset: 30),
+          ),
+          reason: 'Backspace at beginning of first editable line - No effect',
+        );
+
+        // TODO(alexeyinkin): Simulate keyboard entry, https://github.com/akvelon/flutter-code-editor/issues/30
+        controller.value = controller.value.replacedSelection('a\n');
+
+        expect(
+          controller.value,
+          const TextEditingValue(
+            text: 'abc\nro//readonly\nro//readonly\na\nbc\n',
+            //                                 cursor /
+            selection: TextSelection.collapsed(offset: 32),
+          ),
+          reason: 'Type at beginning of first editable line - OK',
+        );
+      },
+    );
+
+    testWidgets(
+      'Read-only beginning of the document',
+      (WidgetTester wt) async {
+        final controller = CodeController(text: _editableBetween);
+        final focusNode = FocusNode();
+
+        await wt.pumpWidget(_createApp(controller, focusNode));
+        focusNode.requestFocus();
+
+        // Go to the beginning.
+        await wt.sendKeyDownEvent(LogicalKeyboardKey.alt);
+        await wt.sendKeyEvent(LogicalKeyboardKey.arrowUp);
+        await wt.sendKeyUpEvent(LogicalKeyboardKey.alt);
+
+        await wt.sendKeyEvent(LogicalKeyboardKey.backspace);
+        await wt.sendKeyEvent(LogicalKeyboardKey.delete);
+
+        // TODO(alexeyinkin): Simulate keyboard entry, https://github.com/akvelon/flutter-code-editor/issues/30
+        controller.value = controller.value.replacedSelection('a\n');
+
+        expect(
+          controller.value,
+          const TextEditingValue(
+            text: 'ro//readonly\nabc\nro//readonly\n',
+            //     \ cursor
+            selection: TextSelection.collapsed(offset: 0),
+          ),
+          reason: 'Backspace Delete Type - No effect',
+        );
+      },
+    );
+
+    testWidgets(
+      'Read-only end of the document',
+        (WidgetTester wt) async {
+        final controller = CodeController(text: _editableBetween);
+        final focusNode = FocusNode();
+
+        await wt.pumpWidget(_createApp(controller, focusNode));
+        focusNode.requestFocus();
+
+        // Go to the end.
+        await wt.sendKeyDownEvent(LogicalKeyboardKey.alt);
+        await wt.sendKeyEvent(LogicalKeyboardKey.arrowDown);
+        await wt.sendKeyUpEvent(LogicalKeyboardKey.alt);
+
+        await wt.sendKeyEvent(LogicalKeyboardKey.backspace);
+        await wt.sendKeyEvent(LogicalKeyboardKey.delete);
+
+        // TODO(alexeyinkin): Simulate keyboard entry, https://github.com/akvelon/flutter-code-editor/issues/30
+        controller.value = controller.value.replacedSelection('a\n');
+
+        expect(
+          controller.value,
+          const TextEditingValue(
+            text: 'ro//readonly\nabc\nro//readonly\n',
+            //                              cursor /
+            selection: TextSelection.collapsed(offset: 30, affinity: TextAffinity.upstream),
+          ),
+          reason: 'Backspace Delete Type at last empty line - No effect',
+        );
+
+        await wt.sendKeyEvent(LogicalKeyboardKey.arrowLeft);
+
+        await wt.sendKeyEvent(LogicalKeyboardKey.backspace);
+        await wt.sendKeyEvent(LogicalKeyboardKey.delete);
+
+        // TODO(alexeyinkin): Simulate keyboard entry, https://github.com/akvelon/flutter-code-editor/issues/30
+        controller.value = controller.value.replacedSelection('a\n');
+
+        expect(
+          controller.value,
+          const TextEditingValue(
+            text: 'ro//readonly\nabc\nro//readonly\n',
+            //                            cursor /
+            selection: TextSelection.collapsed(offset: 29),
+          ),
+          reason: 'Backspace Delete Type before last empty line - No effect',
+        );
+      },
+    );
+  });
+}
+
+MaterialApp _createApp(CodeController controller, FocusNode focusNode) {
+  return MaterialApp(
+    home: Scaffold(
+      body: CodeField(
+        controller: controller,
+        focusNode: focusNode,
+      ),
+    ),
+  );
+}