Repository: akvelon/flutter-code-editor
Pull request #85 (2022-10-17 08:02:51)

Block folding (#26)
- Resolves: #26
- Resolves: #58




Patch:
diff --git a/lib/src/code/code.dart b/lib/src/code/code.dart
index 403665e..81b63fd 100644
--- a/lib/src/code/code.dart
+++ b/lib/src/code/code.dart
@@ -5,9 +5,13 @@ import 'package:flutter/material.dart';
 import 'package:highlight/highlight_core.dart';
 
 import '../folding/foldable_block.dart';
+import '../folding/foldable_block_matcher.dart';
 import '../folding/parsers/parser_factory.dart';
+import '../hidden_ranges/hidden_line_ranges.dart';
+import '../hidden_ranges/hidden_line_ranges_builder.dart';
 import '../hidden_ranges/hidden_range.dart';
 import '../hidden_ranges/hidden_ranges.dart';
+import '../hidden_ranges/hidden_ranges_builder.dart';
 import '../issues/issue.dart';
 import '../named_sections/named_section.dart';
 import '../named_sections/parsers/abstract.dart';
@@ -17,6 +21,7 @@ import '../single_line_comments/parser/single_line_comments.dart';
 import '../single_line_comments/single_line_comment.dart';
 import 'code_edit_result.dart';
 import 'code_line.dart';
+import 'code_lines.dart';
 import 'code_lines_builder.dart';
 import 'string.dart';
 import 'text_range.dart';
@@ -24,14 +29,18 @@ import 'text_range.dart';
 class Code {
   final String text;
   final List<FoldableBlock> foldableBlocks;
+  final Set<FoldableBlock> foldedBlocks;
+  final HiddenLineRanges hiddenLineRanges;
   final HiddenRanges hiddenRanges;
   final Result? highlighted;
   final List<Issue> issues;
-  final List<CodeLine> lines;
+  final CodeLines lines;
   final Map<String, NamedSection> namedSections;
   final Result? visibleHighlighted;
   final String visibleText;
 
+  final HiddenRangesBuilder _hiddenRangesBuilder;
+
   factory Code({
     required String text,
     Result? highlighted,
@@ -84,49 +93,65 @@ class Code {
     final sectionsMap = {for (final s in sections) s.name: s};
 
     _applyNamedSectionsToLines(
-      lines: lines,
+      lines: lines.lines,
       sections: sectionsMap,
       readOnlySectionNames: readOnlySectionNames,
     );
 
-    final hiddenRanges = HiddenRanges(
-      ranges: _commentsToHiddenRanges(serviceComments),
+    final hiddenRangesBuilder = HiddenRangesBuilder.fromMaps(
+      {
+        int: _commentsToHiddenRanges(serviceComments),
+      },
       textLength: text.length,
     );
+    final hiddenRanges = hiddenRangesBuilder.ranges;
+
+    final hiddenLineRangesBuilder = HiddenLineRangesBuilder(
+      codeLines: lines,
+      hiddenRanges: hiddenRanges,
+    );
 
     return Code._(
       text: text,
       foldableBlocks: foldableBlocks,
+      foldedBlocks: {},
+      hiddenLineRanges: hiddenLineRangesBuilder.hiddenLineRanges,
       hiddenRanges: hiddenRanges,
+      hiddenRangesBuilder: hiddenRangesBuilder,
       highlighted: highlighted,
       issues: issues,
       lines: lines,
       namedSections: sectionsMap,
-      visibleHighlighted:
-          highlighted == null ? null : hiddenRanges.cutHighlighted(highlighted),
-      visibleText: hiddenRanges.cutString(text, start: 0),
+      visibleHighlighted: hiddenRanges.cutHighlighted(highlighted),
+      visibleText: hiddenRanges.cutString(text),
     );
   }
 
   const Code._({
     required this.text,
     required this.foldableBlocks,
+    required this.foldedBlocks,
+    required this.hiddenLineRanges,
     required this.hiddenRanges,
+    required HiddenRangesBuilder hiddenRangesBuilder,
     required this.highlighted,
     required this.issues,
     required this.lines,
     required this.namedSections,
     required this.visibleHighlighted,
     required this.visibleText,
-  });
+  }) : _hiddenRangesBuilder = hiddenRangesBuilder;
 
   static const empty = Code._(
     text: '',
     foldableBlocks: [],
+    foldedBlocks: {},
+    hiddenLineRanges: HiddenLineRanges.empty,
     hiddenRanges: HiddenRanges.empty,
+    hiddenRangesBuilder: HiddenRangesBuilder.empty,
     highlighted: null,
     issues: [],
-    lines: [],
+    lines: CodeLines.empty,
     namedSections: {},
     visibleHighlighted: null,
     visibleText: '',
@@ -152,64 +177,19 @@ class Code {
     }
   }
 
-  static List<HiddenRange> _commentsToHiddenRanges(
+  static Map<int, HiddenRange> _commentsToHiddenRanges(
     Iterable<SingleLineComment> comments,
   ) {
-    return comments
-        .map(
-          (c) => HiddenRange.fromStartAndText(c.characterIndex, c.outerContent),
-        )
-        .toList(growable: false);
-  }
-
-  /// 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) {
-      final lowerCharacter = lines[lowerLine].textRange.start;
-      final upperCharacter = lines[upperLine].textRange.end;
-
-      if (upperCharacter == lowerCharacter) {
-        return lowerLine; // Empty line case, avoid division by zero.
-      }
-
-      // Linear interpolation search.
-      final 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;
-    }
+    return <int, HiddenRange>{
+      for (final comment in comments)
+        comment.characterIndex: HiddenRange(
+          comment.characterIndex,
+          comment.characterIndex + comment.outerContent.length,
+          firstLine: comment.lineIndex,
+          lastLine: comment.lineIndex,
+          wholeFirstLine: false,
+        ),
+    };
   }
 
   /// Returns whether the current selection has any read-only part.
@@ -223,8 +203,8 @@ class Code {
 
     return isReadOnlyInLineRange(
       TextRange(
-        start: characterIndexToLineIndex(startChar),
-        end: characterIndexToLineIndex(endChar),
+        start: lines.characterIndexToLineIndex(startChar),
+        end: lines.characterIndexToLineIndex(endChar),
       ),
     );
   }
@@ -232,7 +212,7 @@ class Code {
   /// Returns whether any of the lines of this range is read-only.
   bool isReadOnlyInLineRange(TextRange lineRange) {
     for (int line = lineRange.start; line <= lineRange.end; line++) {
-      if (lines[line].isReadOnly) {
+      if (lines.lines[line].isReadOnly) {
         return true;
       }
     }
@@ -305,8 +285,8 @@ class Code {
             : 0);
 
     final linesChanged = TextRange(
-      start: characterIndexToLineIndex(rangeBefore.start),
-      end: characterIndexToLineIndex(max(lastChar, rangeBefore.start)),
+      start: lines.characterIndexToLineIndex(rangeBefore.start),
+      end: lines.characterIndexToLineIndex(max(lastChar, rangeBefore.start)),
     );
 
     return CodeEditResult(
@@ -314,4 +294,103 @@ class Code {
       linesChanged: linesChanged,
     );
   }
+
+  Code foldedAt(int line) {
+    final block = _getFoldableBlockByStartLine(line);
+    if (block == null || foldedBlocks.contains(block)) {
+      return this;
+    }
+
+    final hiddenRange = foldableBlockToHiddenRange(block);
+    final newHiddenRangesBuilder = _hiddenRangesBuilder.copyWithRange(
+      block,
+      hiddenRange,
+    );
+
+    return _copyWithFolding(
+      foldedBlocks: {...foldedBlocks, block},
+      hiddenRangesBuilder: newHiddenRangesBuilder,
+    );
+  }
+
+  Code unfoldedAt(int line) {
+    final block = _getFoldableBlockByStartLine(line);
+    if (block == null || !foldedBlocks.contains(block)) {
+      return this;
+    }
+
+    return _copyWithFolding(
+      foldedBlocks: {...foldedBlocks}..remove(block),
+      hiddenRangesBuilder: _hiddenRangesBuilder.copyWithoutRange(block),
+    );
+  }
+
+  FoldableBlock? _getFoldableBlockByStartLine(int line) {
+    return foldableBlocks.firstWhereOrNull(
+      (block) => block.firstLine == line,
+    );
+  }
+
+  HiddenRange foldableBlockToHiddenRange(FoldableBlock block) {
+    final firstLine = lines.lines[block.firstLine + 1]; //Keep 1st line visible.
+    final lastLine = lines.lines[block.lastLine];
+
+    return HiddenRange(
+      firstLine.textRange.start - 1, // Includes '\n' before.
+      lastLine.textRange.end - 1, // Excludes '\n' after.
+      firstLine: block.firstLine,
+      lastLine: block.lastLine,
+      wholeFirstLine: false, // Some characters of the first line are visible.
+    );
+  }
+
+  /// Folds this code at the same blocks as the [oldCode] is.
+  Code foldedAs(Code oldCode) {
+    final matcher = FoldableBlockMatcher(
+      oldBlocks: oldCode.foldableBlocks,
+      oldLines: oldCode.lines.lines,
+      newBlocks: foldableBlocks,
+      newLines: lines.lines,
+      oldFoldedBlocks: oldCode.foldedBlocks,
+    );
+
+    final newHiddenRangesBuilder = _hiddenRangesBuilder.copyMergingSourceMap({
+      FoldableBlock: {
+        for (final block in matcher.newFoldedBlocks)
+          block: foldableBlockToHiddenRange(block),
+      },
+    });
+
+    return _copyWithFolding(
+      foldedBlocks: matcher.newFoldedBlocks,
+      hiddenRangesBuilder: newHiddenRangesBuilder,
+    );
+  }
+
+  Code _copyWithFolding({
+    required Set<FoldableBlock> foldedBlocks,
+    required HiddenRangesBuilder hiddenRangesBuilder,
+  }) {
+    final hiddenRanges = hiddenRangesBuilder.ranges;
+
+    final hiddenLineRangesBuilder = HiddenLineRangesBuilder(
+      codeLines: lines,
+      hiddenRanges: hiddenRanges,
+    );
+
+    return Code._(
+      text: text,
+      foldableBlocks: foldableBlocks,
+      foldedBlocks: foldedBlocks,
+      hiddenLineRanges: hiddenLineRangesBuilder.hiddenLineRanges,
+      hiddenRanges: hiddenRanges,
+      hiddenRangesBuilder: hiddenRangesBuilder,
+      highlighted: highlighted,
+      issues: issues,
+      lines: lines,
+      namedSections: namedSections,
+      visibleHighlighted: hiddenRanges.cutHighlighted(highlighted),
+      visibleText: hiddenRanges.cutString(text),
+    );
+  }
 }
diff --git a/lib/src/code/code_lines.dart b/lib/src/code/code_lines.dart
new file mode 100644
index 0000000..cb569aa
--- /dev/null
+++ b/lib/src/code/code_lines.dart
@@ -0,0 +1,70 @@
+import 'package:characters/characters.dart';
+import 'package:collection/collection.dart';
+import 'package:equatable/equatable.dart';
+import 'package:meta/meta.dart';
+
+import 'code_line.dart';
+
+@immutable
+class CodeLines with EquatableMixin {
+  final List<CodeLine> lines;
+
+  const CodeLines(this.lines);
+
+  static const empty = CodeLines([]);
+
+  /// 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) {
+      final lowerCharacter = lines[lowerLine].textRange.start;
+      final upperCharacter = lines[upperLine].textRange.end;
+
+      if (upperCharacter == lowerCharacter) {
+        return lowerLine; // Empty line case, avoid division by zero.
+      }
+
+      // Linear interpolation search.
+      final 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;
+    }
+  }
+
+  @override
+  List<Object> get props => [
+        lines,
+      ];
+}
diff --git a/lib/src/code/code_lines_builder.dart b/lib/src/code/code_lines_builder.dart
index bc4409a..e030240 100644
--- a/lib/src/code/code_lines_builder.dart
+++ b/lib/src/code/code_lines_builder.dart
@@ -1,9 +1,10 @@
 import 'package:collection/collection.dart';
 
 import 'code_line.dart';
+import 'code_lines.dart';
 
 class CodeLinesBuilder {
-  static List<CodeLine> textToCodeLines({
+  static CodeLines textToCodeLines({
     required String text,
     required Map<int, bool> readonlyCommentsByLine,
   }) {
@@ -27,7 +28,7 @@ class CodeLinesBuilder {
       charIndex += line.length;
     }
 
-    return result;
+    return CodeLines(result);
   }
 
   static List<String> _splitText(String text) {
diff --git a/lib/src/code_field/code_controller.dart b/lib/src/code_field/code_controller.dart
index 58045d9..901b80a 100644
--- a/lib/src/code_field/code_controller.dart
+++ b/lib/src/code_field/code_controller.dart
@@ -1,3 +1,5 @@
+// ignore_for_file: parameter_assignments
+
 import 'dart:math';
 
 import 'package:flutter/foundation.dart' show kIsWeb;
@@ -21,8 +23,6 @@ import 'editor_params.dart';
 import 'span_builder.dart';
 
 const _middleDot = '·';
-const _tab = '\t';
-const _space = ' ';
 
 class CodeController extends TextEditingController {
   Mode? _language;
@@ -93,7 +93,7 @@ class CodeController extends TextEditingController {
 
   String get languageId => _languageId;
 
-  Code _lastCode;
+  Code _code;
 
   final styleList = <TextStyle>[];
   final modifierMap = <String, CodeModifier>{};
@@ -121,10 +121,10 @@ class CodeController extends TextEditingController {
     this.onChange,
   })  : _theme = theme,
         _readOnlySectionNames = readOnlySectionNames,
-        _lastCode = Code.empty,
+        _code = Code.empty,
         _isTabReplacementEnabled = modifiers.any((e) => e is TabModifier) {
     this.language = language;
-    _updateLastCode(text ?? '');
+    _code = _createCode(text ?? '');
     fullText = text ?? '';
 
     // Create modifier map
@@ -262,11 +262,11 @@ class CodeController extends TextEditingController {
     return _middleDotsToSpaces(super.text);
   }
 
-  String get fullText => _lastCode.text;
+  String get fullText => _code.text;
 
   set fullText(String fullText) {
-    _updateLastCodeIfChanged(_replaceTabsWithSpacesIfNeeded(fullText));
-    super.value = TextEditingValue(text: _lastCode.visibleText);
+    _updateCodeIfChanged(_replaceTabsWithSpacesIfNeeded(fullText));
+    super.value = TextEditingValue(text: _code.visibleText);
   }
 
   // Private methods
@@ -322,16 +322,16 @@ class CodeController extends TextEditingController {
         return;
       }
 
-      _updateLastCodeIfChanged(editResult.fullTextAfter);
+      _updateCodeIfChanged(editResult.fullTextAfter);
 
-      if (newValue.text != _lastCode.visibleText) {
+      if (newValue.text != _code.visibleText) {
         // Manually typed in a text that has become a hidden range.
-        newValue = newValue.replacedText(_lastCode.visibleText);
+        newValue = newValue.replacedText(_code.visibleText);
       }
 
       // Uncomment this to see the hidden text in the console
       // as you change the visible text.
-      //print('\n\n${_lastCode.text}');
+      //print('\n\n${_code.text}');
     }
 
     bool hasTextChanged = newValue.text != super.value.text;
@@ -357,27 +357,32 @@ class CodeController extends TextEditingController {
     }
   }
 
-  Code get code => _lastCode;
+  Code get code => _code;
 
   CodeEditResult? _getEditResultNotBreakingReadOnly(TextEditingValue newValue) {
-    final editResult = _lastCode.getEditResult(newValue);
-    if (!_lastCode.isReadOnlyInLineRange(editResult.linesChanged)) {
+    final editResult = _code.getEditResult(newValue);
+    if (!_code.isReadOnlyInLineRange(editResult.linesChanged)) {
       return editResult;
     }
 
     return null;
   }
 
-  void _updateLastCodeIfChanged(String text) {
-    if (text != _lastCode.text) {
-      _updateLastCode(text);
+  void _updateCodeIfChanged(String text) {
+    if (text != _code.text) {
+      _updateCode(text);
     }
   }
 
-  void _updateLastCode(String text) {
+  void _updateCode(String text) {
+    final newCode = _createCode(text);
+    _code = newCode.foldedAs(_code);
+  }
+
+  Code _createCode(String text) {
     final rawText = _webSpaceFix ? _middleDotsToSpaces(text) : text;
 
-    _lastCode = Code(
+    return Code(
       text: rawText,
       language: language,
       highlighted: highlight.parse(rawText, language: _languageId),
@@ -388,7 +393,7 @@ class CodeController extends TextEditingController {
 
   String _replaceTabsWithSpacesIfNeeded(String text) {
     if (modifiers.contains(const TabModifier())) {
-      return text.replaceAll(_tab, _space * params.tabSpaces);
+      return text.replaceAll('\t', ' ' * params.tabSpaces);
     }
     return text;
   }
@@ -482,7 +487,7 @@ class CodeController extends TextEditingController {
     return TextSpan(style: style, children: children);
   }
 
-  void generateSuggestions() async {
+  Future<void> generateSuggestions() async {
     final prefix = value.wordToCursor;
     if (prefix == null) {
       popupController.hide();
@@ -499,6 +504,22 @@ class CodeController extends TextEditingController {
     }
   }
 
+  void foldAt(int line) {
+    _code = _code.foldedAt(line);
+    super.value = TextEditingValue(
+      text: _code.visibleText,
+      // TODO(alexeyinkin): Preserve selection, https://github.com/akvelon/flutter-code-editor/issues/81
+    );
+  }
+
+  void unfoldAt(int line) {
+    _code = _code.unfoldedAt(line);
+    super.value = TextEditingValue(
+      text: _code.visibleText,
+      // TODO(alexeyinkin): Preserve selection, https://github.com/akvelon/flutter-code-editor/issues/81
+    );
+  }
+
   @override
   TextSpan buildTextSpan({
     required BuildContext context,
@@ -528,7 +549,7 @@ class CodeController extends TextEditingController {
     // Return parsing
     if (_language != null) {
       return const SpanBuilder().build(
-        code: _lastCode,
+        code: _code,
         theme: _getTheme(context),
         textStyle: style,
       );
diff --git a/lib/src/code_field/code_field.dart b/lib/src/code_field/code_field.dart
index 9c47bb3..b4c22dd 100644
--- a/lib/src/code_field/code_field.dart
+++ b/lib/src/code_field/code_field.dart
@@ -253,7 +253,7 @@ class CodeFieldState extends State<CodeField> {
 
     if (widget.lineNumbers) {
       numberCol = GutterWidget(
-        code: widget.controller.code,
+        codeController: widget.controller,
         style: lineNumberStyle,
       );
     }
diff --git a/lib/src/folding/foldable_block.dart b/lib/src/folding/foldable_block.dart
index f87c8d0..0cb49cc 100644
--- a/lib/src/folding/foldable_block.dart
+++ b/lib/src/folding/foldable_block.dart
@@ -5,31 +5,33 @@ import 'package:equatable/equatable.dart';
 import 'foldable_block_type.dart';
 
 class FoldableBlock with EquatableMixin {
-  final int startLine;
-  final int endLine;
+  final int firstLine;
+  final int lastLine;
   final FoldableBlockType type;
 
   const FoldableBlock({
-    required this.startLine,
-    required this.endLine,
+    required this.firstLine,
+    required this.lastLine,
     required this.type,
   });
 
   @override
   List<Object?> get props => [
-        startLine,
-        endLine,
+        firstLine,
+        lastLine,
         type,
       ];
 
   bool includes(FoldableBlock other) {
-    return startLine <= other.startLine && endLine >= other.endLine;
+    return firstLine <= other.firstLine && lastLine >= other.lastLine;
   }
+
+  int get lineCount => lastLine - firstLine + 1;
 }
 
 extension FoldableBlockList on List<FoldableBlock> {
   void sortByStartLine() {
-    sort((a, b) => a.startLine - b.startLine);
+    sort((a, b) => a.firstLine - b.firstLine);
   }
 
   /// Joins intersecting blocks in list.
@@ -42,12 +44,12 @@ extension FoldableBlockList on List<FoldableBlock> {
     for (int i = 1; i < length; i++) {
       final currentBlock = this[i];
       final previousBlock = this[i - 1];
-      final areIntersected = previousBlock.endLine >= currentBlock.startLine &&
-          previousBlock.endLine < currentBlock.endLine;
+      final areIntersected = previousBlock.lastLine >= currentBlock.firstLine &&
+          previousBlock.lastLine < currentBlock.lastLine;
       if (areIntersected) {
         this[i - 1] = FoldableBlock(
-          startLine: previousBlock.startLine,
-          endLine: currentBlock.endLine,
+          firstLine: previousBlock.firstLine,
+          lastLine: currentBlock.lastLine,
           type: FoldableBlockType.union,
         );
         removeAt(i);
diff --git a/lib/src/folding/foldable_block_matcher.dart b/lib/src/folding/foldable_block_matcher.dart
new file mode 100644
index 0000000..55e403e
--- /dev/null
+++ b/lib/src/folding/foldable_block_matcher.dart
@@ -0,0 +1,91 @@
+import 'dart:math';
+
+import '../code/code_line.dart';
+import 'foldable_block.dart';
+
+/// Matches foldable blocks before and after an edit to preserve their
+/// folding state.
+///
+/// Blocks match if they go in the same order and have the same content.
+class FoldableBlockMatcher {
+  final List<CodeLine> oldLines;
+  final List<FoldableBlock> newBlocks;
+  final List<CodeLine> newLines;
+  final oldToNew = <FoldableBlock, FoldableBlock>{};
+  final newFoldedBlocks = <FoldableBlock>{};
+
+  FoldableBlockMatcher({
+    required List<FoldableBlock> oldBlocks,
+    required this.oldLines,
+    required this.newBlocks,
+    required this.newLines,
+    required Set<FoldableBlock> oldFoldedBlocks,
+  }) {
+    if (oldBlocks.isEmpty || newBlocks.isEmpty) {
+      return;
+    }
+
+    // Walk top to bottom until the first mismatch, if any.
+    final minLength = min(oldBlocks.length, newBlocks.length);
+    int top = 0;
+
+    for (; top < minLength; top++) {
+      final oldBlock = oldBlocks[top];
+      final newBlock = newBlocks[top];
+
+      if (_match(oldBlock: oldBlock, newBlock: newBlock)) {
+        oldToNew[oldBlock] = newBlock;
+      } else {
+        break;
+      }
+    }
+
+    // top is now the first mismatch.
+
+    int oldBottom = oldBlocks.length - 1;
+    int newBottom = newBlocks.length - 1;
+
+    for (; oldBottom >= top && newBottom >= top; oldBottom--, newBottom--) {
+      final oldBlock = oldBlocks[oldBottom];
+      final newBlock = newBlocks[newBottom];
+
+      if (_match(oldBlock: oldBlock, newBlock: newBlock)) {
+        oldToNew[oldBlock] = newBlock;
+      } else {
+        break;
+      }
+    }
+
+    for (final block in oldFoldedBlocks) {
+      final newBlock = oldToNew[block];
+      if (newBlock != null) {
+        newFoldedBlocks.add(newBlock);
+      }
+    }
+  }
+
+  bool _match({
+    required FoldableBlock oldBlock,
+    required FoldableBlock newBlock,
+  }) {
+    if (oldBlock.lineCount != newBlock.lineCount) {
+      return false;
+    }
+
+    // Allow the blocks to differ in the first line.
+    // This keeps a block folded when editing its first line.
+    int oldLineIndex = oldBlock.firstLine + 1;
+    int newLineIndex = newBlock.firstLine + 1;
+
+    while (oldLineIndex <= oldBlock.lastLine) {
+      if (oldLines[oldLineIndex].text != newLines[newLineIndex].text) {
+        return false;
+      }
+
+      oldLineIndex++;
+      newLineIndex++;
+    }
+
+    return true;
+  }
+}
diff --git a/lib/src/folding/parsers/abstract.dart b/lib/src/folding/parsers/abstract.dart
index 38d9c7f..e1cc2ec 100644
--- a/lib/src/folding/parsers/abstract.dart
+++ b/lib/src/folding/parsers/abstract.dart
@@ -2,7 +2,7 @@ import 'package:collection/collection.dart';
 import 'package:highlight/highlight_core.dart';
 import 'package:meta/meta.dart';
 
-import '../../code/code_line.dart';
+import '../../code/code_lines.dart';
 import '../foldable_block.dart';
 import '../foldable_block_type.dart';
 import '../invalid_foldable_block.dart';
@@ -30,7 +30,7 @@ abstract class AbstractFoldableBlockParser {
   void parse({
     required Result highlighted,
     required Set<Object?> serviceCommentsSources,
-    required List<CodeLine> lines,
+    required CodeLines lines,
   });
 
   /// Records that a block has started at [line].
@@ -60,8 +60,8 @@ abstract class AbstractFoldableBlockParser {
     if (line != started.line) {
       blocks.add(
         FoldableBlock(
-          startLine: started.line,
-          endLine: line,
+          firstLine: started.line,
+          lastLine: line,
           type: type,
         ),
       );
@@ -108,8 +108,8 @@ abstract class AbstractFoldableBlockParser {
 
       blocks.add(
         FoldableBlock(
-          startLine: first,
-          endLine: last!,
+          firstLine: first,
+          lastLine: last!,
           type: FoldableBlockType.imports,
         ),
       );
@@ -173,8 +173,8 @@ abstract class AbstractFoldableBlockParser {
 
       blocks.add(
         FoldableBlock(
-          startLine: first,
-          endLine: last!,
+          firstLine: first,
+          lastLine: last!,
           type: FoldableBlockType.singleLineComment,
         ),
       );
diff --git a/lib/src/folding/parsers/highlight.dart b/lib/src/folding/parsers/highlight.dart
index bc10a95..c042898 100644
--- a/lib/src/folding/parsers/highlight.dart
+++ b/lib/src/folding/parsers/highlight.dart
@@ -2,7 +2,7 @@ import 'package:charcode/ascii.dart';
 import 'package:collection/collection.dart';
 import 'package:highlight/highlight_core.dart';
 
-import '../../code/code_line.dart';
+import '../../code/code_lines.dart';
 import '../../highlight/keyword_semantics.dart';
 import '../../highlight/node.dart';
 import '../../highlight/node_classes.dart';
@@ -36,7 +36,7 @@ class HighlightFoldableBlockParser extends AbstractFoldableBlockParser {
   void parse({
     required Result highlighted,
     required Set<Object?> serviceCommentsSources,
-    List<CodeLine> lines = const [],
+    CodeLines lines = CodeLines.empty,
   }) {
     if (highlighted.nodes != null) {
       _processNodes(highlighted.nodes!, serviceCommentsSources);
diff --git a/lib/src/folding/parsers/indent.dart b/lib/src/folding/parsers/indent.dart
index d6e7cf4..74b3936 100644
--- a/lib/src/folding/parsers/indent.dart
+++ b/lib/src/folding/parsers/indent.dart
@@ -1,6 +1,7 @@
 import 'package:highlight/highlight_core.dart';
 
 import '../../code/code_line.dart';
+import '../../code/code_lines.dart';
 import '../foldable_block.dart';
 import '../foldable_block_type.dart';
 import 'abstract.dart';
@@ -14,9 +15,9 @@ class IndentFoldableBlockParser extends AbstractFoldableBlockParser {
   void parse({
     Result? highlighted,
     Set<Object?> serviceCommentsSources = const {},
-    required List<CodeLine> lines,
+    required CodeLines lines,
   }) {
-    _parse(lines);
+    _parse(lines.lines);
     finalize();
   }
 
@@ -97,8 +98,8 @@ class IndentFoldableBlockParser extends AbstractFoldableBlockParser {
     blocks.add(
       FoldableBlock(
         type: FoldableBlockType.indent,
-        startLine: startLine,
-        endLine: endLine,
+        firstLine: startLine,
+        lastLine: endLine,
       ),
     );
   }
@@ -134,9 +135,9 @@ class _SignificantIndentIndexes {
   }
 
   static int? _getNextSignificantIndentIndex(
-      List<int?> indents, {
-        int startIndex = 0,
-      }) {
+    List<int?> indents, {
+    int startIndex = 0,
+  }) {
     for (int i = startIndex; i < indents.length; i++) {
       if (!_isSeparatorLine(indents[i])) {
         return i;
diff --git a/lib/src/folding/parsers/python.dart b/lib/src/folding/parsers/python.dart
index 0614046..083bad0 100644
--- a/lib/src/folding/parsers/python.dart
+++ b/lib/src/folding/parsers/python.dart
@@ -1,6 +1,6 @@
 import 'package:highlight/highlight_core.dart';
 
-import '../../code/code_line.dart';
+import '../../code/code_lines.dart';
 import '../foldable_block.dart';
 import 'abstract.dart';
 import 'highlight.dart';
@@ -12,7 +12,7 @@ class PythonFoldableBlockParser extends AbstractFoldableBlockParser {
   List<FoldableBlock> parse({
     required Result highlighted,
     required Set<Object?> serviceCommentsSources,
-    required List<CodeLine> lines,
+    required CodeLines lines,
   }) {
     final highlightBlocks = _getBlocksFromParser(
       HighlightFoldableBlockParser(),
@@ -41,7 +41,7 @@ class PythonFoldableBlockParser extends AbstractFoldableBlockParser {
     AbstractFoldableBlockParser parser,
     Result highlighted,
     Set<Object?> serviceCommentsSources,
-    List<CodeLine> lines,
+    CodeLines lines,
   ) {
     parser.parse(
       highlighted: highlighted,
@@ -86,7 +86,7 @@ class PythonFoldableBlockParser extends AbstractFoldableBlockParser {
       indentBlockIndex = _addAllPossibleIndentBlocks(
         indentBlockIndex,
         indentBlocks,
-        highlightBlock.startLine,
+        highlightBlock.firstLine,
         result,
       );
 
@@ -129,7 +129,7 @@ class PythonFoldableBlockParser extends AbstractFoldableBlockParser {
     int result = 0;
     for (int i = startIndex; i < blocks.length; i++) {
       final indentBlock = blocks[i];
-      if (indentBlock.startLine < line) {
+      if (indentBlock.firstLine < line) {
         result++;
       } else {
         break;
diff --git a/lib/src/gutter/clickable.dart b/lib/src/gutter/clickable.dart
new file mode 100644
index 0000000..dfcc5c9
--- /dev/null
+++ b/lib/src/gutter/clickable.dart
@@ -0,0 +1,25 @@
+import 'package:flutter/widgets.dart';
+
+/// MouseRegion + GestureDetector.
+class ClickableWidget extends StatelessWidget {
+  final Widget child;
+  final VoidCallback? onTap;
+
+  const ClickableWidget({
+    required this.child,
+    required this.onTap,
+  });
+
+  @override
+  Widget build(BuildContext context) {
+    if (onTap == null) return child;
+
+    return MouseRegion(
+      cursor: SystemMouseCursors.click,
+      child: GestureDetector(
+        onTap: onTap,
+        child: child,
+      ),
+    );
+  }
+}
diff --git a/lib/src/gutter/fold_toggle.dart b/lib/src/gutter/fold_toggle.dart
new file mode 100644
index 0000000..559a89b
--- /dev/null
+++ b/lib/src/gutter/fold_toggle.dart
@@ -0,0 +1,30 @@
+import 'package:flutter/material.dart';
+
+import 'clickable.dart';
+
+class FoldToggle extends StatelessWidget {
+  final Color? color;
+  final bool isFolded;
+  final VoidCallback onTap;
+
+  const FoldToggle({
+    required this.color,
+    required this.isFolded,
+    required this.onTap,
+  });
+
+  @override
+  Widget build(BuildContext context) {
+    return ClickableWidget(
+      onTap: onTap,
+      child: RotatedBox(
+        quarterTurns: isFolded ? 0 : 1,
+        child: Icon(
+          Icons.chevron_right,
+          color: color,
+          size: 16,
+        ),
+      ),
+    );
+  }
+}
diff --git a/lib/src/gutter/gutter.dart b/lib/src/gutter/gutter.dart
index 019d7cd..ef663e3 100644
--- a/lib/src/gutter/gutter.dart
+++ b/lib/src/gutter/gutter.dart
@@ -1,8 +1,9 @@
 import 'package:flutter/material.dart';
 
-import '../code/code.dart';
+import '../code_field/code_controller.dart';
 import '../line_numbers/line_number_style.dart';
 import 'error.dart';
+import 'fold_toggle.dart';
 
 const _issueColumnWidth = 16.0;
 const _foldingColumnWidth = 16.0;
@@ -13,21 +14,30 @@ const _foldingColumn = 2;
 
 class GutterWidget extends StatelessWidget {
   const GutterWidget({
-    required this.code,
+    required this.codeController,
     required this.style,
   });
 
-  final Code code;
+  final CodeController codeController;
   final LineNumberStyle style;
 
   @override
   Widget build(BuildContext context) {
+    return AnimatedBuilder(
+      animation: codeController,
+      builder: _buildOnChange,
+    );
+  }
+
+  Widget _buildOnChange(BuildContext context, Widget? child) {
+    final code = codeController.code;
+
     final tableRows = [
-      for (int i = 1; i <= code.lines.length; i++)
+      for (final i in code.hiddenLineRanges.visibleLineNumbers)
         TableRow(
           children: [
             Text(
-              '$i',
+              '${i + 1}',
               style: style.textStyle,
               textAlign: style.textAlign,
             ),
@@ -37,10 +47,8 @@ class GutterWidget extends StatelessWidget {
         ),
     ];
 
-    for (final issue in code.issues) {
-      final lineIndex = _lineIndexToTableRowIndex(issue.line);
-      tableRows[lineIndex].children![_issueColumn] = const GutterErrorWidget();
-    }
+    _fillIssues(tableRows);
+    _fillFoldToggles(tableRows);
 
     return Container(
       padding: EdgeInsets.only(top: 12, bottom: 12, right: style.margin),
@@ -57,8 +65,41 @@ class GutterWidget extends StatelessWidget {
     );
   }
 
-  int _lineIndexToTableRowIndex(int line) {
-    // TODO(alexeyinkin): Adjust for hidden lines if any.
-    return line;
+  void _fillIssues(List<TableRow> tableRows) {
+    final code = codeController.code;
+
+    for (final issue in code.issues) {
+      final lineIndex = _lineIndexToTableRowIndex(issue.line);
+      if (lineIndex == null) {
+        continue;
+      }
+
+      tableRows[lineIndex].children![_issueColumn] = const GutterErrorWidget();
+    }
+  }
+
+  void _fillFoldToggles(List<TableRow> tableRows) {
+    final code = codeController.code;
+
+    for (final block in code.foldableBlocks) {
+      final lineIndex = _lineIndexToTableRowIndex(block.firstLine);
+      if (lineIndex == null) {
+        continue;
+      }
+
+      final isFolded = code.foldedBlocks.contains(block);
+
+      tableRows[lineIndex].children![_foldingColumn] = FoldToggle(
+        color: style.textStyle?.color,
+        isFolded: isFolded,
+        onTap: isFolded
+            ? () => codeController.unfoldAt(block.firstLine)
+            : () => codeController.foldAt(block.firstLine),
+      );
+    }
+  }
+
+  int? _lineIndexToTableRowIndex(int line) {
+    return codeController.code.hiddenLineRanges.cutLineIndexIfVisible(line);
   }
 }
diff --git a/lib/src/hidden_ranges/hidden_line_ranges.dart b/lib/src/hidden_ranges/hidden_line_ranges.dart
new file mode 100644
index 0000000..3bfa3ec
--- /dev/null
+++ b/lib/src/hidden_ranges/hidden_line_ranges.dart
@@ -0,0 +1,100 @@
+import 'package:equatable/equatable.dart';
+
+import 'line_numbering_breakpoint.dart';
+
+class HiddenLineRanges with EquatableMixin {
+  final List<LineNumberingBreakpoint> breakpoints;
+  final int fullLineCount;
+  final int visibleLineCount;
+
+  const HiddenLineRanges({
+    required this.breakpoints,
+    required this.fullLineCount,
+    required this.visibleLineCount,
+  });
+
+  static const empty = HiddenLineRanges(
+    breakpoints: [],
+    fullLineCount: 1,
+    visibleLineCount: 1,
+  );
+
+  /// Returns the visible line index to which the full [lineIndex] maps
+  /// and null if the line is hidden.
+  ///
+  /// Without breakpoints, returns [lineIndex] unchanged.
+  /// At a breakpoint, returns its `visibleLineIndex`.
+  /// Otherwise finds a next or previous breakpoint
+  /// Before the first breakpoint, returns [lineIndex] unchanged.
+  /// Otherwise finds the first preceding breakpoint and uses subtracts
+  /// its spread from [lineIndex].
+  ///
+  /// [lineIndex] can be any integer including negative or >= [fullLineCount].
+  int? cutLineIndexIfVisible(int lineIndex) {
+    if (breakpoints.isEmpty) {
+      return lineIndex;
+    }
+
+    int lower = 0;
+    int upper = breakpoints.length - 1;
+
+    // Linear interpolation search.
+    while (upper > lower) {
+      final lowerLineIndex = breakpoints[lower].full;
+      final upperLineIndex = breakpoints[upper].full;
+
+      final index = (lower +
+              (lineIndex - lowerLineIndex) /
+                  (upperLineIndex - lowerLineIndex) *
+                  (upper - lower))
+          .floor();
+
+      if (index < lower) {
+        return breakpoints[lower].cutLineIndexIfVisible(lineIndex);
+      }
+      if (index > upper) {
+        return breakpoints[upper].cutLineIndexIfVisible(lineIndex);
+      }
+
+      final breakpoint = breakpoints[index];
+
+      switch ((breakpoint.full - lineIndex).sign) {
+        case -1:
+          lower = index + 1;
+          continue;
+        case 1:
+          upper = index - 1;
+          continue;
+      }
+
+      return breakpoint.visible;
+    }
+
+    // upper == lower
+    final breakpoint = breakpoints[upper];
+    return breakpoint.cutLineIndexIfVisible(lineIndex);
+  }
+
+  Iterable<int> get visibleLineNumbers sync* {
+    int n = 0;
+
+    for (final breakpoint in breakpoints) {
+      final to = breakpoint.fullBefore;
+
+      while (n < to) {
+        yield n++;
+      }
+
+      n = breakpoint.full;
+    }
+
+    while (n < fullLineCount) {
+      yield n++;
+    }
+  }
+
+  @override
+  List<Object> get props => [
+        breakpoints,
+      ];
+}
diff --git a/lib/src/hidden_ranges/hidden_line_ranges_builder.dart b/lib/src/hidden_ranges/hidden_line_ranges_builder.dart
new file mode 100644
index 0000000..8791bdd
--- /dev/null
+++ b/lib/src/hidden_ranges/hidden_line_ranges_builder.dart
@@ -0,0 +1,71 @@
+import '../code/code_lines.dart';
+import 'hidden_line_ranges.dart';
+import 'hidden_ranges.dart';
+import 'line_numbering_breakpoint.dart';
+
+class HiddenLineRangesBuilder {
+  final HiddenLineRanges hiddenLineRanges;
+
+  factory HiddenLineRangesBuilder({
+    required CodeLines codeLines,
+    required HiddenRanges hiddenRanges,
+  }) {
+    final breakpoints = <LineNumberingBreakpoint>[];
+    int spread = 0;
+
+    for (final range in hiddenRanges.ranges) {
+      final startLine = range.firstLine;
+      final endLine = codeLines.characterIndexToLineIndex(range.end);
+      final newlines = endLine - startLine;
+
+      if (newlines == 0) {
+        continue;
+      }
+
+      // If there is anything on the start line before the hidden range,
+      // that content is visible so the line is not affected by the breakpoint,
+      // and the breakpoint happens on the next line instead.
+      //
+      // Example 1 ('abc' is in the first line before the hidden range):
+      //   0  abcde      0  abcde
+      //   1  fghXX  ->  1  fghij
+      //   2  XXXXX      2  klmno
+      //   3  XXXij
+      //   4  klmno
+      // maps as 0 -> 0, 1 -> 1, 4 -> 2
+      //                           ^- this is the only breakpoint here.
+      //
+      // Example 2 (the hidden range starts at the beginning of the line):
+      //   0  abcde      0  abcde
+      //   1  XXXXX  ->  1  ij
+      //   2  XXXXX      2  klmno
+      //   3  XXXij
+      //   4  klmno
+      // maps as 0 -> 0, 3 -> 1, 4 -> 2
+      //                   ^- this is the only breakpoint here.
+      final add = range.wholeFirstLine ? 0 : 1;
+
+      breakpoints.add(
+        LineNumberingBreakpoint(
+          full: endLine + add,
+          visible: endLine + add - spread - newlines,
+          spreadBefore: spread,
+        ),
+      );
+
+      spread += newlines;
+    }
+
+    return HiddenLineRangesBuilder._(
+      hiddenLineRanges: HiddenLineRanges(
+        breakpoints: breakpoints,
+        fullLineCount: codeLines.lines.length,
+        visibleLineCount: codeLines.lines.length - spread,
+      ),
+    );
+  }
+
+  const HiddenLineRangesBuilder._({
+    required this.hiddenLineRanges,
+  });
+}
diff --git a/lib/src/hidden_ranges/hidden_range.dart b/lib/src/hidden_ranges/hidden_range.dart
index 175d655..7e462cc 100644
--- a/lib/src/hidden_ranges/hidden_range.dart
+++ b/lib/src/hidden_ranges/hidden_range.dart
@@ -1,35 +1,46 @@
-import 'package:meta/meta.dart';
+import 'package:equatable/equatable.dart';
 
 import '../code/text_range.dart';
 
-@immutable
-class HiddenRange extends NormalizedTextRange {
-  final String text;
+class HiddenRange extends NormalizedTextRange with EquatableMixin {
+  final int firstLine;
+  final int lastLine;
+  final bool wholeFirstLine;
 
-  const HiddenRange({
-    required super.start,
-    required super.end,
-    required this.text,
+  const HiddenRange(
+    int start,
+    int end, {
+    required this.firstLine,
+    required this.lastLine,
+    required this.wholeFirstLine,
   })  : assert(start >= 0, 'Start should be >= 0, $start given'),
+        assert(end > start, 'Range should not be empty, $start-$end given'),
         assert(
-          text.length == end - start,
-          'Length of $text is not equal to $end - $start',
-        );
+          lastLine >= firstLine,
+          'lastLine must be >= firstLine, $firstLine-$lastLine given',
+        ),
+        super(start: start, end: end);
 
-  const HiddenRange.fromStartAndText(int start, this.text)
-      : super(
-          start: start,
-          end: start + text.length,
-        );
+  int get length => end - start;
 
-  @override
-  int get hashCode => Object.hash(
-        super.hashCode,
-        text,
-      );
+  /// Sorts by [start], then by [end].
+  static int sort(HiddenRange a, HiddenRange b) {
+    switch ((a.start - b.start).sign) {
+      case -1:
+        return -1;
+      case 1:
+        return 1;
+    }
 
-  @override
-  bool operator ==(Object other) {
-    return super == other && other is HiddenRange && text == other.text;
+    return a.end - b.end;
   }
+
+  @override
+  List<Object> get props => [
+        start,
+        end,
+        firstLine,
+        lastLine,
+        wholeFirstLine,
+      ];
 }
diff --git a/lib/src/hidden_ranges/hidden_ranges.dart b/lib/src/hidden_ranges/hidden_ranges.dart
index 86430d9..066dadc 100644
--- a/lib/src/hidden_ranges/hidden_ranges.dart
+++ b/lib/src/hidden_ranges/hidden_ranges.dart
@@ -17,26 +17,16 @@ class HiddenRanges {
   final List<HiddenRange> ranges;
   final int textLength;
 
-  factory HiddenRanges({
-    required List<HiddenRange> ranges,
-    required int textLength,
-  }) {
-    assert(
-      _areSortedAndNotOverlapping(ranges),
-      'Texts must be sorted and not overlap',
-    );
-
-    final nonEmptyRanges =
-        ranges.where((r) => r.text != '').toList(growable: false);
-
-    return HiddenRanges._(
-      hiddenCharactersBeforeRanges: _getHiddenCharactersBeforeRanges(
-        nonEmptyRanges,
-      ),
-      ranges: nonEmptyRanges,
-      textLength: textLength,
-    );
-  }
+  HiddenRanges({
+    required this.ranges,
+    required this.textLength,
+  })  : assert(
+          _areSortedAndNotOverlapping(ranges),
+          'Texts must be sorted and not overlap',
+        ),
+        hiddenCharactersBeforeRanges = _getHiddenCharactersBeforeRanges(
+          ranges,
+        );
 
   const HiddenRanges._({
     required this.hiddenCharactersBeforeRanges,
@@ -50,9 +40,9 @@ class HiddenRanges {
     textLength: 0,
   );
 
-  static bool _areSortedAndNotOverlapping(List<HiddenRange> texts) {
-    for (int i = texts.length; --i >= 1;) {
-      if (!texts[i].isAfter(texts[i - 1])) {
+  static bool _areSortedAndNotOverlapping(List<HiddenRange> ranges) {
+    for (int i = ranges.length; --i >= 1;) {
+      if (!ranges[i].isAfter(ranges[i - 1])) {
         return false;
       }
     }
@@ -65,7 +55,7 @@ class HiddenRanges {
     final result = List.filled(ranges.length + 1, 0);
 
     for (int i = 1; i <= ranges.length; i++) {
-      sum += ranges[i - 1].text.length;
+      sum += ranges[i - 1].length;
       result[i] = sum;
     }
 
@@ -75,7 +65,7 @@ class HiddenRanges {
   /// Cuts the hidden ranges from [str].
   ///
   /// [str] is considered a substring of a full text, starting at [start].
-  String cutString(String str, {required int start}) {
+  String cutString(String str, {int start = 0}) {
     if (ranges.isEmpty || str == '') {
       return str;
     }
@@ -128,7 +118,11 @@ class HiddenRanges {
   }
 
   /// Cuts hidden ranges from [highlighted].
-  Result cutHighlighted(Result highlighted) {
+  Result? cutHighlighted(Result? highlighted) {
+    if (highlighted == null) {
+      return null;
+    }
+
     int fullChar = 0;
 
     String? _cutString(String? nodeValue) {
diff --git a/lib/src/hidden_ranges/hidden_ranges_builder.dart b/lib/src/hidden_ranges/hidden_ranges_builder.dart
new file mode 100644
index 0000000..d7e7e5a
--- /dev/null
+++ b/lib/src/hidden_ranges/hidden_ranges_builder.dart
@@ -0,0 +1,126 @@
+import 'dart:math';
+
+import 'hidden_range.dart';
+import 'hidden_ranges.dart';
+
+/// Merges multiple sets of hidden ranges.
+///
+/// Input hidden ranges come in [sourceMap]. Each hidden range has a key
+/// that later allows to remove it with [copyWithoutRange].
+///
+/// Hidden ranges in [sourceMap] are grouped in 2 levels:
+///  1. Level 1 is the key's `runtimeType`.
+///  2. Level 2 is the key itself.
+///
+/// For instance, hidden ranges derived from service comments may use
+/// integer offsets as their keys. They may come like `sourceMap[int][7]`
+///
+/// Hidden ranges derived from folded blocks may have their block as the key
+/// and come like `sourceMap[FoldableBlock][<block object>]`
+///
+/// This grouping allows to quickly remove all ranges of a particular origin
+/// with [copyMergingSourceMap], e.g. to strip everything coming from
+/// any foldable blocks.
+class HiddenRangesBuilder {
+  final HiddenRanges ranges;
+  final Map<Type, Map<Object, HiddenRange>> sourceMap;
+
+  static const empty = HiddenRangesBuilder._(
+    ranges: HiddenRanges.empty,
+    sourceMap: {},
+  );
+
+  const HiddenRangesBuilder._({
+    required this.ranges,
+    required this.sourceMap,
+  });
+
+  HiddenRangesBuilder.fromMaps(
+    this.sourceMap, {
+    required int textLength,
+  }) : ranges = HiddenRanges(
+          ranges: _merge(sourceMap.values.expand((map) => map.values)),
+          textLength: textLength,
+        );
+
+  static List<HiddenRange> _merge(Iterable<HiddenRange> ranges) {
+    final result = [...ranges]..sort(HiddenRange.sort);
+
+    for (int i = 1; i < result.length; i++) {
+      final current = result[i];
+      final previous = result[i - 1];
+
+      if (previous.end >= current.start) {
+        final end = max(current.end, previous.end);
+
+        result[i - 1] = HiddenRange(
+          previous.start,
+          end,
+          firstLine: previous.firstLine,
+          lastLine: end == current.end ? current.lastLine : previous.lastLine,
+          wholeFirstLine: previous.wholeFirstLine,
+        );
+
+        result.removeAt(i);
+        i--;
+      }
+    }
+
+    return result;
+  }
+
+  HiddenRangesBuilder copyWithRange(Object key, HiddenRange range) {
+    final newRanges = _copySourceMap();
+
+    if (!newRanges.containsKey(key.runtimeType)) {
+      newRanges[key.runtimeType] = {};
+    }
+
+    newRanges[key.runtimeType]![key] = range;
+
+    return HiddenRangesBuilder.fromMaps(
+      newRanges,
+      textLength: ranges.textLength,
+    );
+  }
+
+  HiddenRangesBuilder copyWithoutRange(Object key) {
+    final newRanges = _copySourceMap();
+
+    newRanges[key.runtimeType]?.remove(key);
+
+    return HiddenRangesBuilder.fromMaps(
+      newRanges,
+      textLength: ranges.textLength,
+    );
+  }
+
+  /// Shallow-merges a map by the 1st level (keys' `runtimeType`s).
+  ///
+  /// For each [newSourceMap]'s entry, the new ranges of its key
+  /// fully replace the old ranges in the new object.
+  HiddenRangesBuilder copyMergingSourceMap(
+    Map<Type, Map<Object, HiddenRange>> newSourceMap,
+  ) {
+    final newRanges = _copySourceMap();
+
+    newRanges.addAll(newSourceMap);
+
+    return HiddenRangesBuilder.fromMaps(
+      newRanges,
+      textLength: ranges.textLength,
+    );
+  }
+
+  // Clones the 2-level [sourceMap], deep copy by 1st level and shallow copy
+  // after that.
+  Map<Type, Map<Object, HiddenRange>> _copySourceMap() {
+    final result = <Type, Map<Object, HiddenRange>>{};
+
+    for (final entry in sourceMap.entries) {
+      result[entry.key] = Map<Object, HiddenRange>.from(entry.value);
+    }
+
+    return result;
+  }
+}
diff --git a/lib/src/hidden_ranges/line_numbering_breakpoint.dart b/lib/src/hidden_ranges/line_numbering_breakpoint.dart
new file mode 100644
index 0000000..9f2d657
--- /dev/null
+++ b/lib/src/hidden_ranges/line_numbering_breakpoint.dart
@@ -0,0 +1,78 @@
+import 'package:equatable/equatable.dart';
+
+/// Describes a break in continuous line numbers.
+class LineNumberingBreakpoint with EquatableMixin {
+  /// The full line index.
+  final int full;
+
+  /// The index of a visible line to which [full] maps.
+  final int visible;
+
+  /// The spread between visible and full numbers that was before this.
+  final int spreadBefore;
+
+  const LineNumberingBreakpoint({
+    required this.full,
+    required this.visible,
+    required this.spreadBefore,
+  })  : assert(
+          full >= visible,
+          'fullLineIndex must be >= visibleLineIndex, '
+          'given $full and $visible',
+        ),
+        assert(
+          spreadBefore < full - visible,
+          'A breakpoint must increase the previous spread. '
+          'Old=$spreadBefore, New=($full - $visible)',
+        );
+
+  int get spread => full - visible;
+
+  int get addedSpread => spread - spreadBefore;
+
+  /// The full line index of the visible line immediately before this.
+  int get fullBefore => full - addedSpread;
+
+  /// Returns the visible line index to which the full [lineIndex] maps
+  /// as if this was the only breakpoint.
+  ///
+  /// If the line is invisible, returns the first visible line before it.
+  int cutLineIndex(int lineIndex) {
+    if (lineIndex >= full) {
+      return lineIndex - spread;
+    }
+
+    if (lineIndex < full - addedSpread) {
+      return lineIndex - spreadBefore;
+    }
+
+    return visible - 1;
+  }
+
+  /// Returns the visible line index to which the full [lineIndex] maps
+  /// as if this was the only breakpoint.
+  ///
+  /// If the line is not visible, returns null.
+  int? cutLineIndexIfVisible(int lineIndex) {
+    if (lineIndex >= full) {
+      return lineIndex - spread;
+    }
+
+    if (lineIndex < full - addedSpread) {
+      return lineIndex - spreadBefore;
+    }
+
+    return null;
+  }
+
+  @override
+  String toString() => 'LineNumberingBreakpoint: $full -> $visible '
+      '(spreadBefore = $spreadBefore)';
+
+  @override
+  List<Object> get props => [
+        full,
+        visible,
+        spreadBefore,
+      ];
+}
diff --git a/lib/src/line_numbers/line_number_style.dart b/lib/src/line_numbers/line_number_style.dart
index fb23c7e..6c56363 100644
--- a/lib/src/line_numbers/line_number_style.dart
+++ b/lib/src/line_numbers/line_number_style.dart
@@ -1,5 +1,6 @@
 import 'package:flutter/material.dart';
 
+// TODO(alexeyinkin): Rename to GutterStyle when we break compatibility.
 class LineNumberStyle {
   /// Width of the line number column.
   final double width;
@@ -33,11 +34,12 @@ class LineNumberStyle {
 
   LineNumberStyle copyWith({
     TextStyle? textStyle,
-  }) => LineNumberStyle(
-    width: width,
-    textAlign: textAlign,
-    textStyle: textStyle ?? this.textStyle,
-    background: background,
-    margin: margin,
-  );
+  }) =>
+      LineNumberStyle(
+        width: width,
+        textAlign: textAlign,
+        textStyle: textStyle ?? this.textStyle,
+        background: background,
+        margin: margin,
+      );
 }
diff --git a/lib/src/single_line_comments/parser/abstract_single_line_comment_parser.dart b/lib/src/single_line_comments/parser/abstract_single_line_comment_parser.dart
index bc2c634..678dd7b 100644
--- a/lib/src/single_line_comments/parser/abstract_single_line_comment_parser.dart
+++ b/lib/src/single_line_comments/parser/abstract_single_line_comment_parser.dart
@@ -6,7 +6,6 @@ abstract class AbstractSingleLineCommentParser {
   Map<int, SingleLineComment> getCommentsByLines() =>
       {for (final comment in comments) comment.lineIndex: comment};
 
-
   Map<int, bool> getIfReadonlyCommentByLine() {
     final result = <int, bool>{};
     for (final comment in comments) {
diff --git a/pubspec.yaml b/pubspec.yaml
index 322be22..2628239 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -9,6 +9,7 @@ environment:
 
 dependencies:
   autotrie: ^2.0.0
+  characters: ^1.2.1
   charcode: ^1.3.1
   collection: ^1.16.0
   equatable: ^2.0.5


Test patch:
diff --git a/test/src/code/code_hidden_ranges_test.dart b/test/src/code/code_hidden_ranges_test.dart
index cb4e606..061e767 100644
--- a/test/src/code/code_hidden_ranges_test.dart
+++ b/test/src/code/code_hidden_ranges_test.dart
@@ -6,33 +6,53 @@ import 'package:flutter_test/flutter_test.dart';
 import 'package:highlight/highlight.dart';
 import 'package:highlight/languages/java.dart';
 
-void main() {
-  group('Code. HiddenRanges.', () {
-    test('Section comments and readonly are parsed to hidden ranges', () {
-      const text = '''
+final _language = java;
+const _text = '''
 public class MyClass { // comment
   public void main() { // [START section1]
   } // comment readonly
 }
 ''';
 
-      final highlighted = highlight.parse(text, language: 'java');
-      final code = Code(
-        text: text,
-        highlighted: highlighted,
-        namedSectionParser: const BracketsStartEndNamedSectionParser(),
-        readOnlySectionNames: {'section1', 'nonexistent'},
-        language: java,
-      );
+void main() {
+  late Code code;
 
+  setUp(() {
+    final highlighted = highlight.parse(_text, language: 'java');
+    code = Code(
+      text: _text,
+      highlighted: highlighted,
+      namedSectionParser: const BracketsStartEndNamedSectionParser(),
+      readOnlySectionNames: {'section1', 'nonexistent'},
+      language: _language,
+    );
+  });
+
+  group('Code. Hidden ranges.', () {
+    test('Section comments and readonly are parsed to hidden ranges', () {
       expect(
         code.hiddenRanges,
         HiddenRanges(
           ranges: const [
-            HiddenRange(start: 57, end: 76, text: '// [START section1]'),
-            HiddenRange(start: 81, end: 100, text: '// comment readonly'),
+            // '// [START section1]'
+            HiddenRange(
+              57,
+              76,
+              firstLine: 1,
+              lastLine: 1,
+              wholeFirstLine: false,
+            ),
+
+            // '// comment readonly'
+            HiddenRange(
+              81,
+              100,
+              firstLine: 2,
+              lastLine: 2,
+              wholeFirstLine: false,
+            ),
           ],
-          textLength: text.length,
+          textLength: _text.length,
         ),
       );
 
@@ -46,5 +66,23 @@ public class MyClass { // comment
 ''',
       );
     });
+
+    test('foldableBlockToHiddenRange', () {
+      final hiddenRange = code.foldableBlockToHiddenRange(
+        code.foldableBlocks[1],
+      );
+
+      // '\n} // comment readonly'
+      expect(
+        hiddenRange,
+        const HiddenRange(
+          76,
+          100,
+          firstLine: 1,
+          lastLine: 2,
+          wholeFirstLine: false,
+        ),
+      );
+    });
   });
 }
diff --git a/test/src/code/code_lines_test.dart b/test/src/code/code_lines_test.dart
index 8021f9f..aa0b6ec 100644
--- a/test/src/code/code_lines_test.dart
+++ b/test/src/code/code_lines_test.dart
@@ -1,8 +1,8 @@
 import 'dart:ui';
 
-import 'package:collection/collection.dart';
 import 'package:flutter_code_editor/src/code/code.dart';
 import 'package:flutter_code_editor/src/code/code_line.dart';
+import 'package:flutter_code_editor/src/code/code_lines.dart';
 import 'package:flutter_test/flutter_test.dart';
 import 'package:highlight/languages/java.dart';
 
@@ -19,17 +19,14 @@ void main() {
         final code = Code(text: text, language: _language);
 
         expect(
-          const ListEquality().equals(
-            code.lines,
-            [
-              CodeLine(
-                text: text,
-                textRange: TextRange(start: 0, end: text.length),
-                isReadOnly: false, // ignore: avoid_redundant_argument_values
-              ),
-            ],
-          ),
-          true,
+          code.lines,
+          CodeLines([
+            CodeLine(
+              text: text,
+              textRange: TextRange(start: 0, end: text.length),
+              isReadOnly: false, // ignore: avoid_redundant_argument_values
+            ),
+          ]),
         );
       }
     });
@@ -39,7 +36,7 @@ void main() {
 
       expect(
         code.lines,
-        [
+        CodeLines([
           // 30
           CodeLine.fromTextAndStart(
             '1 Lorem ipsum dolor sit amet,\n',
@@ -129,9 +126,9 @@ void main() {
             '15 sunt in culpa qui officia deserunt mollit anim id est laborum.',
             415,
           ),
-        ],
+        ]),
       );
-      expect(code.lines.last.textRange.end, 480);
+      expect(code.lines.lines.last.textRange.end, 480);
     });
 
     test('characterIndexToLineIndex', () {
@@ -177,7 +174,7 @@ void main() {
         };
 
         for (final entry in map.entries) {
-          final line = code.characterIndexToLineIndex(entry.key);
+          final line = code.lines.characterIndexToLineIndex(entry.key);
 
           expect(
             line,
@@ -186,9 +183,12 @@ void main() {
           );
         }
 
-        expect(() => code.characterIndexToLineIndex(-1), throwsRangeError);
         expect(
-          () => code.characterIndexToLineIndex(textWithTail.length + 1),
+          () => code.lines.characterIndexToLineIndex(-1),
+          throwsRangeError,
+        );
+        expect(
+          () => code.lines.characterIndexToLineIndex(textWithTail.length + 1),
           throwsRangeError,
           reason: 'Tail: "$tail"',
         );
@@ -201,7 +201,11 @@ void main() {
         final code = Code(text: text, language: _language);
 
         for (int i = 0; i <= texts.length; i++) {
-          expect(code.characterIndexToLineIndex(i), 0, reason: '"$text" at $i');
+          expect(
+            code.lines.characterIndexToLineIndex(i),
+            0,
+            reason: '"$text" at $i',
+          );
         }
       }
     });
diff --git a/test/src/code/code_read_only_test.dart b/test/src/code/code_read_only_test.dart
index 0c0d488..26e63df 100644
--- a/test/src/code/code_read_only_test.dart
+++ b/test/src/code/code_read_only_test.dart
@@ -44,8 +44,12 @@ void main() {
         );
 
         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');
+        for (int i = code.lines.lines.length; --i >= 0;) {
+          expect(
+            code.lines.lines[i].isReadOnly,
+            readonly[i],
+            reason: 'Line #$i',
+          );
         }
       }
     });
@@ -57,7 +61,7 @@ void main() {
 
         final code = Code(text: textWithReadonly, language: angelscript);
 
-        expect(code.lines.first.isReadOnly, false);
+        expect(code.lines.lines.first.isReadOnly, false);
       },
     );
 
@@ -94,7 +98,7 @@ public class MyClass {
       );
 
       expect(
-        code.lines.map((line) => line.isReadOnly),
+        code.lines.lines.map((line) => line.isReadOnly),
         expected,
       );
     });
diff --git a/test/src/code_field/code_controller_folding_test.dart b/test/src/code_field/code_controller_folding_test.dart
new file mode 100644
index 0000000..b4136f0
--- /dev/null
+++ b/test/src/code_field/code_controller_folding_test.dart
@@ -0,0 +1,406 @@
+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';
+
+const _code = '''
+private class MyClass {
+  void method1() {
+    if (false) {// [START section1]
+      return;
+    }// [END section1]
+  }
+
+  void method2() {// [START section2]
+    return;
+  }// [END section2]
+}
+''';
+
+const _codeFolded1 = '''
+private class MyClass {
+  void method1() {
+
+  void method2() {
+    return;
+  }
+}
+''';
+
+void main() {
+  late CodeController controller;
+  late FocusNode focusNode;
+
+  setUp(() {
+    controller = CodeController(
+      text: _code,
+      language: java,
+      namedSectionParser: const BracketsStartEndNamedSectionParser(),
+    );
+    focusNode = FocusNode();
+  });
+
+  group('CodeController. Folding.', () {
+    group('Trivial.', () {
+      test('With no folding blocks, nothing changes', () {
+        const text = '''
+int a;
+int b;
+int c;
+''';
+
+        controller = CodeController(
+          text: text,
+          language: java,
+          namedSectionParser: const BracketsStartEndNamedSectionParser(),
+        );
+
+        controller.foldAt(1);
+        controller.unfoldAt(1);
+
+        expect(controller.code.visibleText, text);
+      });
+
+      test('Folding does nothing if no foldable block on the line', () {
+        final oldCode = controller.code;
+
+        controller.foldAt(3);
+
+        expect(controller.code, oldCode);
+      });
+
+      test('Double folding changes nothing', () {
+        controller.foldAt(1);
+        final oldCode = controller.code;
+
+        controller.foldAt(1);
+
+        expect(controller.code, oldCode);
+      });
+
+      test('Unfolding does nothing if no foldable block on the line', () {
+        final oldCode = controller.code;
+
+        controller.unfoldAt(3);
+
+        expect(controller.code, oldCode);
+      });
+
+      test('Unfolding non-folded changes nothing', () {
+        final oldCode = controller.code;
+
+        controller.unfoldAt(1);
+
+        expect(controller.code, oldCode);
+      });
+    });
+
+    group('Hides text.', () {
+      test('Hides highlighted', () {
+        final originalHtml = controller.code.visibleHighlighted?.toHtml();
+
+        controller.foldAt(1);
+        final foldedHtml = controller.code.visibleHighlighted?.toHtml();
+        controller.unfoldAt(1);
+        final unfoldedHtml = controller.code.visibleHighlighted?.toHtml();
+
+        expect(
+          foldedHtml,
+          '''
+<span class="hljs-keyword">private</span> <span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">MyClass</span> </span>{
+  <span class="hljs-function"><span class="hljs-keyword">void</span> <span class="hljs-title">method1</span><span class="hljs-params">()</span> </span>{<span class="hljs-keyword"></span><span class="hljs-keyword"></span><span class="hljs-comment"></span><span class="hljs-keyword"></span><span class="hljs-comment"></span>
+
+  <span class="hljs-function"><span class="hljs-keyword">void</span> <span class="hljs-title">method2</span><span class="hljs-params">()</span> </span>{<span class="hljs-comment"></span>
+    <span class="hljs-keyword">return</span>;
+  }<span class="hljs-comment"></span>
+}
+''',
+        );
+        expect(unfoldedHtml, originalHtml);
+      });
+
+      testWidgets('Cursor before', (WidgetTester wt) async {
+        final textBefore = controller.rawText;
+
+        await wt.pumpWidget(createApp(controller, focusNode));
+        focusNode.requestFocus();
+
+        await wt.selectFromHome(1, offset: 1);
+        controller.foldAt(1);
+
+        expect(
+          controller.value,
+          const TextEditingValue(
+            text: _codeFolded1,
+            // TODO(alexeyinkin): Selection.
+            //selection: TextSelection(baseOffset: 1, extentOffset: 2),
+          ),
+        );
+
+        controller.unfoldAt(1);
+
+        expect(
+          controller.value,
+          TextEditingValue(
+            text: textBefore,
+            // TODO(alexeyinkin): Selection.
+            //selection: TextSelection(baseOffset: 1, extentOffset: 2),
+          ),
+        );
+      });
+    });
+
+    group('Editing', () {
+      testWidgets('above a folded block', (WidgetTester wt) async {
+        await wt.pumpWidget(createApp(controller, focusNode));
+        focusNode.requestFocus();
+
+        controller.foldAt(1);
+        await wt.selectFromHome(0, offset: 7);
+        controller.value = controller.value.replacedSelection('public');
+
+        expect(
+          controller.value,
+          const TextEditingValue(
+            text: '''
+public class MyClass {
+  void method1() {
+
+  void method2() {
+    return;
+  }
+}
+''',
+            selection: TextSelection(baseOffset: 0, extentOffset: 6),
+          ),
+        );
+
+        controller.unfoldAt(1);
+
+        expect(
+          controller.value,
+          const TextEditingValue(
+            text: '''
+public class MyClass {
+  void method1() {
+    if (false) {
+      return;
+    }
+  }
+
+  void method2() {
+    return;
+  }
+}
+''',
+            // TODO(alexeyinkin): Selection.
+            //selection: TextSelection(baseOffset: 1, extentOffset: 2),
+          ),
+        );
+      });
+
+      testWidgets('the first line of a folded block', (WidgetTester wt) async {
+        await wt.pumpWidget(createApp(controller, focusNode));
+        focusNode.requestFocus();
+
+        controller.foldAt(0);
+        await wt.selectFromHome(0, offset: 7);
+        controller.value = controller.value.replacedSelection('public');
+
+        expect(
+          controller.value,
+          const TextEditingValue(
+            text: '''
+public class MyClass {
+''',
+            selection: TextSelection(baseOffset: 0, extentOffset: 6),
+          ),
+        );
+
+        controller.unfoldAt(0);
+
+        expect(
+          controller.value,
+          const TextEditingValue(
+            text: '''
+public class MyClass {
+  void method1() {
+    if (false) {
+      return;
+    }
+  }
+
+  void method2() {
+    return;
+  }
+}
+''',
+            // TODO(alexeyinkin): Selection.
+            //selection: TextSelection(baseOffset: 1, extentOffset: 2),
+          ),
+        );
+      });
+
+      testWidgets('between folded blocks', (WidgetTester wt) async {
+        await wt.pumpWidget(createApp(controller, focusNode));
+        focusNode.requestFocus();
+
+        controller.foldAt(1);
+        controller.foldAt(7);
+
+        // TODO(alexeyinkin): Allow this edit as one pasting, https://github.com/akvelon/flutter-code-editor/issues/82
+        await wt.selectFromHome(43);
+        controller.value = controller.value.replacedSelection('int n;');
+        await wt.selectFromHome(49);
+        controller.value = controller.value.replacedSelection('\n');
+
+        expect(
+          controller.value,
+          const TextEditingValue(
+            text: '''
+private class MyClass {
+  void method1() {
+int n;
+
+  void method2() {
+}
+''',
+            selection: TextSelection.collapsed(offset: 50),
+          ),
+        );
+
+        controller.unfoldAt(1);
+        controller.unfoldAt(8);
+
+        expect(
+          controller.value,
+          const TextEditingValue(
+            text: '''
+private class MyClass {
+  void method1() {
+    if (false) {
+      return;
+    }
+  }
+int n;
+
+  void method2() {
+    return;
+  }
+}
+''',
+            // TODO(alexeyinkin): Selection.
+            //selection: TextSelection(baseOffset: 1, extentOffset: 2),
+          ),
+        );
+      });
+    });
+
+    group('Deleting folded blocks.', () {
+      testWidgets('First block of the same length', (WidgetTester wt) async {
+        await wt.pumpWidget(createApp(controller, focusNode));
+        focusNode.requestFocus();
+
+        controller.foldAt(1);
+
+        await wt.selectFromHome(41, offset: 2);
+        // private class MyClass {\n  void method1() {
+        //                                           \ cursor
+        controller.value = controller.value.replacedSelection(';');
+
+        expect(
+          controller.value,
+          const TextEditingValue(
+            text: '''
+private class MyClass {
+  void method1() ;
+  void method2() {
+    return;
+  }
+}
+''',
+            // TODO(alexeyinkin): Selection.
+            selection: TextSelection(baseOffset: 41, extentOffset: 42),
+          ),
+        );
+      });
+
+      testWidgets('Second block of the same length', (WidgetTester wt) async {
+        await wt.pumpWidget(createApp(controller, focusNode));
+        focusNode.requestFocus();
+
+        controller.foldAt(7);
+
+        await wt.selectFromHome(102, offset: 2);
+        // ...void method2() {
+        //                   \ cursor
+        controller.value = controller.value.replacedSelection(';');
+
+        expect(
+          controller.value,
+          const TextEditingValue(
+            text: '''
+private class MyClass {
+  void method1() {
+    if (false) {
+      return;
+    }
+  }
+
+  void method2() ;}
+''',
+            // TODO(alexeyinkin): Selection.
+            selection: TextSelection(baseOffset: 102, extentOffset: 103),
+          ),
+        );
+      });
+
+      // TODO(alexeyinkin): Fix, https://github.com/akvelon/flutter-code-editor/issues/83
+      testWidgets(
+        'When deleting 2nd identical folded block, 1st one incorrectly folds',
+        (WidgetTester wt) async {
+          controller = CodeController(
+            text: '''
+{
+if (true) {
+}
+if (true) {
+}
+}
+''',
+            language: java,
+            namedSectionParser: const BracketsStartEndNamedSectionParser(),
+          );
+
+          await wt.pumpWidget(createApp(controller, focusNode));
+          focusNode.requestFocus();
+
+          controller.foldAt(3);
+
+          await wt.selectFromHome(26, offset: 2);
+          // {\nif (true) {\n}\nif (true) {}\n\n
+          //                              \ cursor
+          controller.value = controller.value.replacedSelection(';');
+
+          expect(
+            controller.value,
+            const TextEditingValue(
+              text: '''
+{
+if (true) {
+if (true) ;}
+''',
+              // TODO(alexeyinkin): Selection.
+              selection: TextSelection.collapsed(offset: 13),
+            ),
+          );
+        },
+      );
+    });
+  });
+}
diff --git a/test/src/common/widget_tester.dart b/test/src/common/widget_tester.dart
new file mode 100644
index 0000000..50953df
--- /dev/null
+++ b/test/src/common/widget_tester.dart
@@ -0,0 +1,42 @@
+import 'package:flutter/services.dart';
+import 'package:flutter_test/flutter_test.dart';
+
+// ignore_for_file: parameter_assignments
+
+extension WidgetTesterExtension on WidgetTester {
+  /// Moves the cursor to [base] from the beginning of the document,
+  /// then selects [offset] characters from there to the left (negative)
+  /// or to the right (positive).
+  Future<void> selectFromHome(int base, {int offset = 0}) async {
+    await cursorHome();
+    await moveCursor(base);
+
+    await sendKeyDownEvent(LogicalKeyboardKey.shift);
+    await moveCursor(offset);
+    await sendKeyUpEvent(LogicalKeyboardKey.shift);
+  }
+
+  Future<void> cursorHome() async {
+    await sendKeyDownEvent(LogicalKeyboardKey.alt);
+    await sendKeyEvent(LogicalKeyboardKey.arrowUp);
+    await sendKeyUpEvent(LogicalKeyboardKey.alt);
+  }
+
+  Future<void> cursorEnd() async {
+    await sendKeyDownEvent(LogicalKeyboardKey.alt);
+    await sendKeyEvent(LogicalKeyboardKey.arrowDown);
+    await sendKeyUpEvent(LogicalKeyboardKey.alt);
+  }
+
+  Future<void> moveCursor(int steps) async {
+    if (steps > 0) {
+      while (steps-- > 0) {
+        await sendKeyEvent(LogicalKeyboardKey.arrowRight);
+      }
+    } else {
+      while (steps++ < 0) {
+        await sendKeyEvent(LogicalKeyboardKey.arrowLeft);
+      }
+    }
+  }
+}
diff --git a/test/src/folding/foldable_block_test.dart b/test/src/folding/foldable_block_test.dart
index fff1ce5..d36e576 100644
--- a/test/src/folding/foldable_block_test.dart
+++ b/test/src/folding/foldable_block_test.dart
@@ -15,7 +15,7 @@ void main() {
 
     test('Single -> Single', () {
       const blocks = [
-        FoldableBlock(startLine: 0, endLine: 1, type: FBT.braces),
+        FoldableBlock(firstLine: 0, lastLine: 1, type: FBT.braces),
       ];
 
       final actual = [...blocks]..joinIntersecting();
@@ -25,9 +25,9 @@ void main() {
 
     test('Not intersected blocks do not join', () {
       const blocks = [
-        FB(startLine: 0, endLine: 3, type: FBT.braces),
-        FB(startLine: 4, endLine: 5, type: FBT.imports),
-        FB(startLine: 8, endLine: 10, type: FBT.parentheses),
+        FB(firstLine: 0, lastLine: 3, type: FBT.braces),
+        FB(firstLine: 4, lastLine: 5, type: FBT.imports),
+        FB(firstLine: 8, lastLine: 10, type: FBT.parentheses),
       ];
 
       final actual = [...blocks]..joinIntersecting();
@@ -37,13 +37,13 @@ void main() {
 
     test('Mixed blocks join correctly', () {
       const blocks = [
-        FB(startLine: 0, endLine: 3, type: FBT.braces),
-        FB(startLine: 2, endLine: 5, type: FBT.imports),
-        FB(startLine: 6, endLine: 10, type: FBT.parentheses),
+        FB(firstLine: 0, lastLine: 3, type: FBT.braces),
+        FB(firstLine: 2, lastLine: 5, type: FBT.imports),
+        FB(firstLine: 6, lastLine: 10, type: FBT.parentheses),
       ];
       const expected = [
-        FB(startLine: 0, endLine: 5, type: FBT.union),
-        FB(startLine: 6, endLine: 10, type: FBT.parentheses),
+        FB(firstLine: 0, lastLine: 5, type: FBT.union),
+        FB(firstLine: 6, lastLine: 10, type: FBT.parentheses),
       ];
 
       final actual = [...blocks]..joinIntersecting();
@@ -53,12 +53,12 @@ void main() {
 
     test('Multiple intersected blocks will join in single', () {
       const blocks = [
-        FB(startLine: 0, endLine: 3, type: FBT.braces),
-        FB(startLine: 2, endLine: 5, type: FBT.imports),
-        FB(startLine: 4, endLine: 10, type: FBT.parentheses),
+        FB(firstLine: 0, lastLine: 3, type: FBT.braces),
+        FB(firstLine: 2, lastLine: 5, type: FBT.imports),
+        FB(firstLine: 4, lastLine: 10, type: FBT.parentheses),
       ];
       const expected = [
-        FB(startLine: 0, endLine: 10, type: FBT.union),
+        FB(firstLine: 0, lastLine: 10, type: FBT.union),
       ];
 
       final actual = [...blocks]..joinIntersecting();
@@ -68,13 +68,13 @@ void main() {
 
     test('Nested intersected blocks will join', () {
       const blocks = [
-        FB(startLine: 0, endLine: 5, type: FBT.braces),
-        FB(startLine: 1, endLine: 3, type: FBT.imports),
-        FB(startLine: 3, endLine: 5, type: FBT.parentheses),
+        FB(firstLine: 0, lastLine: 5, type: FBT.braces),
+        FB(firstLine: 1, lastLine: 3, type: FBT.imports),
+        FB(firstLine: 3, lastLine: 5, type: FBT.parentheses),
       ];
       const expected = [
-        FB(startLine: 0, endLine: 5, type: FBT.braces),
-        FB(startLine: 1, endLine: 5, type: FBT.union),
+        FB(firstLine: 0, lastLine: 5, type: FBT.braces),
+        FB(firstLine: 1, lastLine: 5, type: FBT.union),
       ];
 
       final actual = [...blocks]..joinIntersecting();
@@ -102,12 +102,12 @@ void main() {
     // blocks[0] and blocks[3] should be joined, but they aren't.
     test('Containing children blocks will not join', () {
       const blocks = [
-        FB(startLine: 0, endLine: 6, type: FBT.braces), //        0
-        FB(startLine: 1, endLine: 3, type: FBT.parentheses), //   1
-        FB(startLine: 4, endLine: 5, type: FBT.parentheses), //   2
-        FB(startLine: 6, endLine: 12, type: FBT.braces), //       3
-        FB(startLine: 7, endLine: 9, type: FBT.parentheses), //   4
-        FB(startLine: 10, endLine: 11, type: FBT.parentheses), // 5
+        FB(firstLine: 0, lastLine: 6, type: FBT.braces), //        0
+        FB(firstLine: 1, lastLine: 3, type: FBT.parentheses), //   1
+        FB(firstLine: 4, lastLine: 5, type: FBT.parentheses), //   2
+        FB(firstLine: 6, lastLine: 12, type: FBT.braces), //       3
+        FB(firstLine: 7, lastLine: 9, type: FBT.parentheses), //   4
+        FB(firstLine: 10, lastLine: 11, type: FBT.parentheses), // 5
       ];
 
       // 0 intersects with 3, not detected.
diff --git a/test/src/folding/parsers/highlight_parser_go_test.dart b/test/src/folding/parsers/highlight_parser_go_test.dart
index 55f6d9f..b5f3d5f 100644
--- a/test/src/folding/parsers/highlight_parser_go_test.dart
+++ b/test/src/folding/parsers/highlight_parser_go_test.dart
@@ -21,7 +21,7 @@ func (
 
 }''';
       const expectedBlocks = [
-        FB(startLine: 0, endLine: 5, type: FBT.union),
+        FB(firstLine: 0, lastLine: 5, type: FBT.union),
       ];
       _Tester.parseAndCheck(mode: go, code: code, expected: expectedBlocks);
     });
@@ -32,7 +32,7 @@ func main() {
 	type Life struct { a, b *Field;w, h int;}
 }''';
       const expectedBlocks = [
-        FB(startLine: 0, endLine: 2, type: FBT.braces),
+        FB(firstLine: 0, lastLine: 2, type: FBT.braces),
       ];
       _Tester.parseAndCheck(mode: go, code: code, expected: expectedBlocks);
     });
@@ -73,7 +73,7 @@ func main() {
 	b := `)]}`
 }''';
       const expectedBlocks = [
-        FB(startLine: 0, endLine: 3, type: FBT.braces),
+        FB(firstLine: 0, lastLine: 3, type: FBT.braces),
       ];
       _Tester.parseAndCheck(mode: go, code: code, expected: expectedBlocks);
     });
@@ -103,12 +103,12 @@ func main() {
 	fmt.Println("Hello, 世界" + a + b)
 }''';
       const expectedBlocks = [
-        FB(startLine: 0, endLine: 1, type: FBT.singleLineComment),
-        FB(startLine: 3, endLine: 5, type: FBT.multilineComment),
-        FB(startLine: 6, endLine: 7, type: FBT.imports),
-        FB(startLine: 9, endLine: 21, type: FBT.braces),
-        FB(startLine: 10, endLine: 11, type: FBT.singleLineComment),
-        FB(startLine: 13, endLine: 17, type: FBT.multilineComment),
+        FB(firstLine: 0, lastLine: 1, type: FBT.singleLineComment),
+        FB(firstLine: 3, lastLine: 5, type: FBT.multilineComment),
+        FB(firstLine: 6, lastLine: 7, type: FBT.imports),
+        FB(firstLine: 9, lastLine: 21, type: FBT.braces),
+        FB(firstLine: 10, lastLine: 11, type: FBT.singleLineComment),
+        FB(firstLine: 13, lastLine: 17, type: FBT.multilineComment),
       ];
       _Tester.parseAndCheck(mode: go, code: code, expected: expectedBlocks);
     });
@@ -129,7 +129,7 @@ import "strings" //  9
 
 func main() {}''';
       const expectedBlocks = [
-        FB(startLine: 0, endLine: 9, type: FBT.imports),
+        FB(firstLine: 0, lastLine: 9, type: FBT.imports),
       ];
       _Tester.parseAndCheck(mode: go, code: code, expected: expectedBlocks);
     });
@@ -146,7 +146,7 @@ import (
 
 func main() {}''';
       const expectedBlocks = [
-        FB(startLine: 0, endLine: 6, type: FBT.union),
+        FB(firstLine: 0, lastLine: 6, type: FBT.union),
       ];
       _Tester.parseAndCheck(mode: go, code: code, expected: expectedBlocks);
     });
@@ -160,8 +160,8 @@ import "fmt"
 import "strings"
 func main() {}''';
       const expectedBlocks = [
-        FB(startLine: 0, endLine: 1, type: FBT.imports),
-        FB(startLine: 3, endLine: 4, type: FBT.imports),
+        FB(firstLine: 0, lastLine: 1, type: FBT.imports),
+        FB(firstLine: 3, lastLine: 4, type: FBT.imports),
       ];
       _Tester.parseAndCheck(mode: go, code: code, expected: expectedBlocks);
     });
@@ -172,7 +172,7 @@ func main() {
 }//comment
 //comment''';
       const expectedBlocks = [
-        FB(startLine: 0, endLine: 1, type: FBT.braces),
+        FB(firstLine: 0, lastLine: 1, type: FBT.braces),
       ];
       _Tester.parseAndCheck(mode: go, code: code, expected: expectedBlocks);
     });
@@ -184,7 +184,7 @@ func main() {
 // [END section2]
 }''';
       const expectedBlocks = [
-        FB(startLine: 0, endLine: 3, type: FBT.braces),
+        FB(firstLine: 0, lastLine: 3, type: FBT.braces),
       ];
       _Tester.parseAndCheck(mode: go, code: code, expected: expectedBlocks);
     });
diff --git a/test/src/folding/parsers/highlight_parser_java_test.dart b/test/src/folding/parsers/highlight_parser_java_test.dart
index 19b72f6..71f08c3 100644
--- a/test/src/folding/parsers/highlight_parser_java_test.dart
+++ b/test/src/folding/parsers/highlight_parser_java_test.dart
@@ -21,8 +21,8 @@ public static void main(
   }
 }''';
       const expectedBlocks = [
-        FB(startLine: 0, endLine: 5, type: FBT.braces),
-        FB(startLine: 1, endLine: 4, type: FBT.union),
+        FB(firstLine: 0, lastLine: 5, type: FBT.braces),
+        FB(firstLine: 1, lastLine: 4, type: FBT.union),
       ];
       _Tester.parseAndCheck(mode: java, code: code, expected: expectedBlocks);
     });
@@ -33,7 +33,7 @@ public class MyClass {
     public static void main(String[] args) {}
 }''';
       const expected = [
-        FB(startLine: 0, endLine: 2, type: FBT.braces),
+        FB(firstLine: 0, lastLine: 2, type: FBT.braces),
       ];
       _Tester.parseAndCheck(mode: java, code: code, expected: expected);
     });
@@ -82,8 +82,8 @@ public class MyClass {
 /// {                    5
 }''';
       const expected = [
-        FB(startLine: 0, endLine: 6, type: FBT.braces),
-        FB(startLine: 2, endLine: 4, type: FBT.multilineComment),
+        FB(firstLine: 0, lastLine: 6, type: FBT.braces),
+        FB(firstLine: 2, lastLine: 4, type: FBT.multilineComment),
       ];
       _Tester.parseAndCheck(mode: java, code: code, expected: expected);
     });
@@ -107,10 +107,10 @@ void method2 // Not the only thing in the line      10
 // Single                                             14
 // Single                                             15''';
       const expected = [
-        FB(startLine: 0, endLine: 1, type: FBT.singleLineComment),
-        FB(startLine: 2, endLine: 13, type: FBT.braces),
-        FB(startLine: 4, endLine: 6, type: FBT.singleLineComment),
-        FB(startLine: 14, endLine: 15, type: FBT.singleLineComment),
+        FB(firstLine: 0, lastLine: 1, type: FBT.singleLineComment),
+        FB(firstLine: 2, lastLine: 13, type: FBT.braces),
+        FB(firstLine: 4, lastLine: 6, type: FBT.singleLineComment),
+        FB(firstLine: 14, lastLine: 15, type: FBT.singleLineComment),
       ];
       _Tester.parseAndCheck(mode: java, code: code, expected: expected);
     });
@@ -131,9 +131,9 @@ import java.lang.Math;
 
 public class MyClass {}''';
       const expected = [
-        FB(startLine: 0, endLine: 6, type: FBT.imports),
-        FB(startLine: 4, endLine: 5, type: FBT.singleLineComment),
-        FB(startLine: 8, endLine: 9, type: FBT.multilineComment),
+        FB(firstLine: 0, lastLine: 6, type: FBT.imports),
+        FB(firstLine: 4, lastLine: 5, type: FBT.singleLineComment),
+        FB(firstLine: 8, lastLine: 9, type: FBT.multilineComment),
       ];
       _Tester.parseAndCheck(mode: java, code: code, expected: expected);
     });
@@ -148,8 +148,8 @@ public class MyClass {}
 import java.lang.Math;
 import java.lang.Exception;''';
       const expected = [
-        FB(startLine: 1, endLine: 2, type: FBT.imports),
-        FB(startLine: 4, endLine: 5, type: FBT.imports),
+        FB(firstLine: 1, lastLine: 2, type: FBT.imports),
+        FB(firstLine: 4, lastLine: 5, type: FBT.imports),
       ];
       _Tester.parseAndCheck(mode: mode, code: code, expected: expected);
     });
@@ -162,8 +162,8 @@ void method() {
 // comment
 }''';
       const expected = [
-        FB(startLine: 0, endLine: 4, type: FBT.braces),
-        FB(startLine: 1, endLine: 2, type: FBT.braces),
+        FB(firstLine: 0, lastLine: 4, type: FBT.braces),
+        FB(firstLine: 1, lastLine: 2, type: FBT.braces),
       ];
       _Tester.parseAndCheck(mode: java, code: code, expected: expected);
     });
@@ -176,7 +176,7 @@ class MyClass {
 // [END section2]
 }''';
       const expected = [
-        FB(startLine: 0, endLine: 3, type: FBT.braces),
+        FB(firstLine: 0, lastLine: 3, type: FBT.braces),
       ];
       _Tester.parseAndCheck(mode: mode, code: code, expected: expected);
     });
diff --git a/test/src/folding/parsers/highlight_parser_scala_test.dart b/test/src/folding/parsers/highlight_parser_scala_test.dart
index aea88fa..0e464cb 100644
--- a/test/src/folding/parsers/highlight_parser_scala_test.dart
+++ b/test/src/folding/parsers/highlight_parser_scala_test.dart
@@ -24,8 +24,8 @@ class MyClass(var myVar1: Int,
   }
 }''';
       const expected = [
-        FB(startLine: 0, endLine: 8, type: FBT.union),
-        FB(startLine: 3, endLine: 7, type: FBT.union),
+        FB(firstLine: 0, lastLine: 8, type: FBT.union),
+        FB(firstLine: 3, lastLine: 7, type: FBT.union),
       ];
       _Tester.parseAndCheck(mode: scala, code: code, expected: expected);
     });
@@ -36,7 +36,7 @@ class MyClass() {
   def main() = {}
 }''';
       const expected = [
-        FB(startLine: 0, endLine: 2, type: FBT.braces),
+        FB(firstLine: 0, lastLine: 2, type: FBT.braces),
       ];
       _Tester.parseAndCheck(mode: scala, code: code, expected: expected);
     });
@@ -73,8 +73,8 @@ class MyClass() {
 /// {                    5
 }''';
       const expected = [
-        FB(startLine: 0, endLine: 6, type: FBT.braces),
-        FB(startLine: 2, endLine: 4, type: FBT.multilineComment),
+        FB(firstLine: 0, lastLine: 6, type: FBT.braces),
+        FB(firstLine: 2, lastLine: 4, type: FBT.multilineComment),
       ];
       _Tester.parseAndCheck(mode: scala, code: code, expected: expected);
     });
@@ -98,10 +98,10 @@ def method2 // Not the only thing in the line        10
 // Single                                            14
 // Single                                            15''';
       const expected = [
-        FB(startLine: 0, endLine: 1, type: FBT.singleLineComment),
-        FB(startLine: 2, endLine: 13, type: FBT.braces),
-        FB(startLine: 4, endLine: 6, type: FBT.singleLineComment),
-        FB(startLine: 14, endLine: 15, type: FBT.singleLineComment),
+        FB(firstLine: 0, lastLine: 1, type: FBT.singleLineComment),
+        FB(firstLine: 2, lastLine: 13, type: FBT.braces),
+        FB(firstLine: 4, lastLine: 6, type: FBT.singleLineComment),
+        FB(firstLine: 14, lastLine: 15, type: FBT.singleLineComment),
       ];
       _Tester.parseAndCheck(mode: scala, code: code, expected: expected);
     });
@@ -120,9 +120,9 @@ import users.{UserPreferences => UPrefs} // import and rename for convenience
 
 class MyClass() {} //                                                           10 ''';
       const expected = [
-        FB(startLine: 0, endLine: 2, type: FBT.imports),
-        FB(startLine: 3, endLine: 5, type: FBT.multilineComment),
-        FB(startLine: 6, endLine: 8, type: FBT.imports),
+        FB(firstLine: 0, lastLine: 2, type: FBT.imports),
+        FB(firstLine: 3, lastLine: 5, type: FBT.multilineComment),
+        FB(firstLine: 6, lastLine: 8, type: FBT.imports),
       ];
       _Tester.parseAndCheck(mode: scala, code: code, expected: expected);
     });
@@ -136,8 +136,8 @@ class MyClass() {}
 import users.User  // import the class User
 import users.{ User, UserPreferences}  // Only imports selected members''';
       const expected = [
-        FB(startLine: 1, endLine: 2, type: FBT.imports),
-        FB(startLine: 4, endLine: 5, type: FBT.imports),
+        FB(firstLine: 1, lastLine: 2, type: FBT.imports),
+        FB(firstLine: 4, lastLine: 5, type: FBT.imports),
       ];
       _Tester.parseAndCheck(mode: scala, code: code, expected: expected);
     });
@@ -152,7 +152,7 @@ import users.{
   UserPreferences
 }  // Only imports selected members''';
       const expected = [
-        FB(startLine: 0, endLine: 4, type: FBT.union),
+        FB(firstLine: 0, lastLine: 4, type: FBT.union),
       ];
       _Tester.parseAndCheck(mode: scala, code: code, expected: expected);
     });
@@ -165,8 +165,8 @@ def method() = {
 // comment
 }''';
       const expected = [
-        FB(startLine: 0, endLine: 4, type: FBT.braces),
-        FB(startLine: 1, endLine: 2, type: FBT.braces),
+        FB(firstLine: 0, lastLine: 4, type: FBT.braces),
+        FB(firstLine: 1, lastLine: 2, type: FBT.braces),
       ];
       _Tester.parseAndCheck(mode: scala, code: code, expected: expected);
     });
@@ -178,7 +178,7 @@ class MyClass() {
 // [END section2]
 }''';
       const expected = [
-        FB(startLine: 0, endLine: 3, type: FBT.braces),
+        FB(firstLine: 0, lastLine: 3, type: FBT.braces),
       ];
       _Tester.parseAndCheck(mode: scala, code: code, expected: expected);
     });
@@ -193,8 +193,8 @@ object IdFactory {
   }
 }''';
       const expected = [
-        FB(startLine: 0, endLine: 6, type: FBT.braces),
-        FB(startLine: 2, endLine: 5, type: FBT.braces),
+        FB(firstLine: 0, lastLine: 6, type: FBT.braces),
+        FB(firstLine: 2, lastLine: 5, type: FBT.braces),
       ];
       _Tester.parseAndCheck(mode: scala, code: code, expected: expected);
     });
@@ -206,7 +206,7 @@ object Main {
     println("Hello, Scala developer!")
 }''';
       const expected = [
-        FB(startLine: 0, endLine: 3, type: FBT.braces),
+        FB(firstLine: 0, lastLine: 3, type: FBT.braces),
       ];
       _Tester.parseAndCheck(mode: scala, code: code, expected: expected);
     });
@@ -221,7 +221,7 @@ val list: List[Any] = List(
   () => "an anonymous function returning a string"
 )''';
       const expected = [
-        FB(startLine: 0, endLine: 6, type: FBT.parentheses),
+        FB(firstLine: 0, lastLine: 6, type: FBT.parentheses),
       ];
       _Tester.parseAndCheck(mode: scala, code: code, expected: expected);
     });
@@ -234,7 +234,7 @@ def matchTest(x: Int): String = x match {
   case _ => "other"
 }''';
       const expected = [
-        FB(startLine: 0, endLine: 4, type: FBT.braces),
+        FB(firstLine: 0, lastLine: 4, type: FBT.braces),
       ];
       _Tester.parseAndCheck(mode: scala, code: code, expected: expected);
     });
diff --git a/test/src/folding/parsers/indent_parser_test.dart b/test/src/folding/parsers/indent_parser_test.dart
index 7924170..a83c664 100644
--- a/test/src/folding/parsers/indent_parser_test.dart
+++ b/test/src/folding/parsers/indent_parser_test.dart
@@ -27,8 +27,8 @@ class Mapping:
         self.items_list = []
         self.__update(iterable)''',
           expected: [
-            _FB(startLine: 0, endLine: 3, type: _T.indent),
-            _FB(startLine: 1, endLine: 3, type: _T.indent),
+            _FB(firstLine: 0, lastLine: 3, type: _T.indent),
+            _FB(firstLine: 1, lastLine: 3, type: _T.indent),
           ],
         ),
 
@@ -44,10 +44,10 @@ class Mapping:
   for item in iterable:
    self.items_list.append(item)''',
           expected: [
-            _FB(startLine: 0, endLine: 7, type: _T.indent),
-            _FB(startLine: 1, endLine: 3, type: _T.indent),
-            _FB(startLine: 5, endLine: 7, type: _T.indent),
-            _FB(startLine: 6, endLine: 7, type: _T.indent),
+            _FB(firstLine: 0, lastLine: 7, type: _T.indent),
+            _FB(firstLine: 1, lastLine: 3, type: _T.indent),
+            _FB(firstLine: 5, lastLine: 7, type: _T.indent),
+            _FB(firstLine: 6, lastLine: 7, type: _T.indent),
           ],
         ),
 
@@ -70,10 +70,10 @@ class Mapping:
 
             self.items_list.append(item)''',
           expected: [
-            _FB(startLine: 0, endLine: 14, type: _T.indent),
-            _FB(startLine: 2, endLine: 6, type: _T.indent),
-            _FB(startLine: 9, endLine: 14, type: _T.indent),
-            _FB(startLine: 12, endLine: 14, type: _T.indent),
+            _FB(firstLine: 0, lastLine: 14, type: _T.indent),
+            _FB(firstLine: 2, lastLine: 6, type: _T.indent),
+            _FB(firstLine: 9, lastLine: 14, type: _T.indent),
+            _FB(firstLine: 12, lastLine: 14, type: _T.indent),
           ],
         ),
 
@@ -94,10 +94,10 @@ class Mapping:
             
 ''',
           expected: [
-            _FB(startLine: 2, endLine: 9, type: _T.indent),
-            _FB(startLine: 3, endLine: 5, type: _T.indent),
-            _FB(startLine: 7, endLine: 9, type: _T.indent),
-            _FB(startLine: 8, endLine: 9, type: _T.indent),
+            _FB(firstLine: 2, lastLine: 9, type: _T.indent),
+            _FB(firstLine: 3, lastLine: 5, type: _T.indent),
+            _FB(firstLine: 7, lastLine: 9, type: _T.indent),
+            _FB(firstLine: 8, lastLine: 9, type: _T.indent),
           ],
         ),
 
@@ -113,11 +113,11 @@ class Foo:
         for item in iterable:
             self.items_list.append(item)''',
           expected: [
-            _FB(startLine: 0, endLine: 3, type: _T.indent),
-            _FB(startLine: 1, endLine: 3, type: _T.indent),
-            _FB(startLine: 4, endLine: 7, type: _T.indent),
-            _FB(startLine: 5, endLine: 7, type: _T.indent),
-            _FB(startLine: 6, endLine: 7, type: _T.indent),
+            _FB(firstLine: 0, lastLine: 3, type: _T.indent),
+            _FB(firstLine: 1, lastLine: 3, type: _T.indent),
+            _FB(firstLine: 4, lastLine: 7, type: _T.indent),
+            _FB(firstLine: 5, lastLine: 7, type: _T.indent),
+            _FB(firstLine: 6, lastLine: 7, type: _T.indent),
           ],
         ),
 
@@ -132,9 +132,9 @@ class Mapping:
          for item in iterable:
         self.items_list.append(item)''',
           expected: [
-            _FB(startLine: 0, endLine: 6, type: _T.indent),
-            _FB(startLine: 1, endLine: 6, type: _T.indent),
-            _FB(startLine: 4, endLine: 6, type: _T.indent),
+            _FB(firstLine: 0, lastLine: 6, type: _T.indent),
+            _FB(firstLine: 1, lastLine: 6, type: _T.indent),
+            _FB(firstLine: 4, lastLine: 6, type: _T.indent),
           ],
         ),
 
@@ -150,10 +150,10 @@ class Mapping:                                # 0
       for item in iterable:                   # 6
         self.items_list.append(item)          # 7''',
           expected: [
-            _FB(startLine: 0, endLine: 7, type: _T.indent),
-            _FB(startLine: 1, endLine: 3, type: _T.indent),
-            _FB(startLine: 5, endLine: 7, type: _T.indent),
-            _FB(startLine: 6, endLine: 7, type: _T.indent),
+            _FB(firstLine: 0, lastLine: 7, type: _T.indent),
+            _FB(firstLine: 1, lastLine: 3, type: _T.indent),
+            _FB(firstLine: 5, lastLine: 7, type: _T.indent),
+            _FB(firstLine: 6, lastLine: 7, type: _T.indent),
           ],
         ),
 
@@ -166,7 +166,7 @@ numbers = [1,
     4, 
     5
 ]''',
-          expected: [_FB(startLine: 0, endLine: 4, type: _T.indent)],
+          expected: [_FB(firstLine: 0, lastLine: 4, type: _T.indent)],
         )
       ];
 
diff --git a/test/src/folding/parsers/python_parser_test.dart b/test/src/folding/parsers/python_parser_test.dart
index 8ea845a..1ba760c 100644
--- a/test/src/folding/parsers/python_parser_test.dart
+++ b/test/src/folding/parsers/python_parser_test.dart
@@ -40,11 +40,11 @@ class Mapping:                      # 0
        8                            # 13
   ]                                 # 14''',
           expected: [
-            _FB(startLine: 0, endLine: 14, type: _T.indent),
-            _FB(startLine: 1, endLine: 3, type: _T.indent),
-            _FB(startLine: 5, endLine: 14, type: _T.indent),
-            _FB(startLine: 6, endLine: 7, type: _T.indent),
-            _FB(startLine: 9, endLine: 14, type: _T.brackets),
+            _FB(firstLine: 0, lastLine: 14, type: _T.indent),
+            _FB(firstLine: 1, lastLine: 3, type: _T.indent),
+            _FB(firstLine: 5, lastLine: 14, type: _T.indent),
+            _FB(firstLine: 6, lastLine: 7, type: _T.indent),
+            _FB(firstLine: 9, lastLine: 14, type: _T.brackets),
           ],
         ),
 
@@ -67,11 +67,11 @@ class Mapping:                                   # 0
             8,
         ]                                        # 14''',
           expected: [
-            _FB(startLine: 0, endLine: 14, type: _T.indent),
-            _FB(startLine: 1, endLine: 3, type: _T.indent),
-            _FB(startLine: 5, endLine: 14, type: _T.indent),
-            _FB(startLine: 6, endLine: 7, type: _T.indent),
-            _FB(startLine: 9, endLine: 14, type: _T.brackets),
+            _FB(firstLine: 0, lastLine: 14, type: _T.indent),
+            _FB(firstLine: 1, lastLine: 3, type: _T.indent),
+            _FB(firstLine: 5, lastLine: 14, type: _T.indent),
+            _FB(firstLine: 6, lastLine: 7, type: _T.indent),
+            _FB(firstLine: 9, lastLine: 14, type: _T.brackets),
           ],
         ),
 
@@ -91,11 +91,11 @@ class Mapping:                               # 0
             ],                               # 10
             ]                                # 11''',
           expected: [
-            _FB(startLine: 0, endLine: 11, type: _T.indent),
-            _FB(startLine: 1, endLine: 11, type: _T.indent),
-            _FB(startLine: 2, endLine: 11, type: _T.brackets),
-            _FB(startLine: 3, endLine: 6, type: _T.brackets),
-            _FB(startLine: 7, endLine: 10, type: _T.brackets),
+            _FB(firstLine: 0, lastLine: 11, type: _T.indent),
+            _FB(firstLine: 1, lastLine: 11, type: _T.indent),
+            _FB(firstLine: 2, lastLine: 11, type: _T.brackets),
+            _FB(firstLine: 3, lastLine: 6, type: _T.brackets),
+            _FB(firstLine: 7, lastLine: 10, type: _T.brackets),
           ],
         ),
 
@@ -125,14 +125,14 @@ class Mapping:                               # 0
             ],                               # 20
         ]                                    # 21''',
           expected: [
-            _FB(startLine: 0, endLine: 21, type: _T.indent),
-            _FB(startLine: 1, endLine: 21, type: _T.indent),
-            _FB(startLine: 2, endLine: 21, type: _T.brackets),
-            _FB(startLine: 3, endLine: 9, type: _T.brackets),
-            _FB(startLine: 4, endLine: 6, type: _T.singleLineComment),
-            _FB(startLine: 10, endLine: 13, type: _T.brackets),
-            _FB(startLine: 14, endLine: 20, type: _T.brackets),
-            _FB(startLine: 17, endLine: 19, type: _T.singleLineComment),
+            _FB(firstLine: 0, lastLine: 21, type: _T.indent),
+            _FB(firstLine: 1, lastLine: 21, type: _T.indent),
+            _FB(firstLine: 2, lastLine: 21, type: _T.brackets),
+            _FB(firstLine: 3, lastLine: 9, type: _T.brackets),
+            _FB(firstLine: 4, lastLine: 6, type: _T.singleLineComment),
+            _FB(firstLine: 10, lastLine: 13, type: _T.brackets),
+            _FB(firstLine: 14, lastLine: 20, type: _T.brackets),
+            _FB(firstLine: 17, lastLine: 19, type: _T.singleLineComment),
           ],
         ),
 
@@ -154,8 +154,8 @@ class Mapping:
         # [END section1]
         a = 5''',
           expected: [
-            _FB(startLine: 0, endLine: 4, type: _T.indent),
-            _FB(startLine: 1, endLine: 4, type: _T.indent),
+            _FB(firstLine: 0, lastLine: 4, type: _T.indent),
+            _FB(firstLine: 1, lastLine: 4, type: _T.indent),
           ],
         ),
 
@@ -173,8 +173,8 @@ def squareNumbers(numbers):                    # 4
 printAgeType(19)                               # 8
 squareNumbers([1, 2, 3, 4, 5])                 # 9''',
           expected: [
-            _FB(startLine: 0, endLine: 2, type: _T.indent),
-            _FB(startLine: 4, endLine: 6, type: _T.indent),
+            _FB(firstLine: 0, lastLine: 2, type: _T.indent),
+            _FB(firstLine: 4, lastLine: 6, type: _T.indent),
           ],
         ),
 
@@ -189,7 +189,7 @@ numbers = [1,
 ]
 squaredNumbers = [x**2 for x in numbers]
 print(squaredNumbers)''',
-          expected: [_FB(startLine: 0, endLine: 5, type: _T.brackets)],
+          expected: [_FB(firstLine: 0, lastLine: 5, type: _T.brackets)],
         ),
 
         _Example(
@@ -205,7 +205,7 @@ import bar
 pie = math.pi
 print("The value of pi is : ",pie)''',
           expected: [
-            _FB(startLine: 0, endLine: 5, type: _T.imports),
+            _FB(firstLine: 0, lastLine: 5, type: _T.imports),
           ],
         ),
 
@@ -225,8 +225,8 @@ import bar                            # 8
 pie = math.pi                         # 10
 print("The value of pi is : ",pie)    # 11''',
           expected: [
-            _FB(startLine: 0, endLine: 2, type: _T.singleLineComment),
-            _FB(startLine: 4, endLine: 8, type: _T.imports),
+            _FB(firstLine: 0, lastLine: 2, type: _T.singleLineComment),
+            _FB(firstLine: 4, lastLine: 8, type: _T.imports),
           ],
         ),
 
@@ -238,7 +238,7 @@ a = [[
   ]
 ]''',
           expected: [
-            _FB(startLine: 0, endLine: 3, type: _T.union),
+            _FB(firstLine: 0, lastLine: 3, type: _T.union),
           ],
         ),
       ];
diff --git a/test/src/hidden_ranges/hidden_line_ranges_builder_test.dart b/test/src/hidden_ranges/hidden_line_ranges_builder_test.dart
new file mode 100644
index 0000000..b3e727b
--- /dev/null
+++ b/test/src/hidden_ranges/hidden_line_ranges_builder_test.dart
@@ -0,0 +1,182 @@
+// ignore_for_file: use_named_constants
+
+import 'package:flutter_code_editor/src/code/code_lines.dart';
+import 'package:flutter_code_editor/src/code/code_lines_builder.dart';
+import 'package:flutter_code_editor/src/hidden_ranges/hidden_line_ranges.dart';
+import 'package:flutter_code_editor/src/hidden_ranges/hidden_line_ranges_builder.dart';
+import 'package:flutter_code_editor/src/hidden_ranges/hidden_range.dart';
+import 'package:flutter_code_editor/src/hidden_ranges/hidden_ranges.dart';
+import 'package:flutter_code_editor/src/hidden_ranges/line_numbering_breakpoint.dart';
+import 'package:flutter_test/flutter_test.dart';
+
+import '../common/lorem_ipsum.dart';
+
+void main() {
+  final codeLines = CodeLinesBuilder.textToCodeLines(
+    text: loremIpsum,
+    readonlyCommentsByLine: {},
+  );
+
+  group('HiddenLineRangesBuilder.', () {
+    test('Empty text -> No breakpoints', () {
+      final builder = HiddenLineRangesBuilder(
+        codeLines: CodeLines.empty,
+        hiddenRanges: HiddenRanges.empty,
+      );
+
+      expect(
+        builder.hiddenLineRanges,
+        const HiddenLineRanges(
+          breakpoints: [],
+          fullLineCount: 1,
+          visibleLineCount: 1,
+        ),
+      );
+    });
+
+    test('No hidden ranges -> No breakpoints', () {
+      final builder = HiddenLineRangesBuilder(
+        codeLines: codeLines,
+        hiddenRanges: HiddenRanges.empty,
+      );
+
+      expect(
+        builder.hiddenLineRanges,
+        const HiddenLineRanges(
+          breakpoints: [],
+          fullLineCount: 15,
+          visibleLineCount: 15,
+        ),
+      );
+    });
+
+    test('Single line ranges only -> No breakpoints', () {
+      final builder = HiddenLineRangesBuilder(
+        codeLines: codeLines,
+        hiddenRanges: HiddenRanges(
+          ranges: const [
+            HiddenRange(
+              10,
+              20,
+              firstLine: 0,
+              lastLine: 0,
+              wholeFirstLine: false,
+            ),
+            HiddenRange(
+              130,
+              150,
+              firstLine: 3,
+              lastLine: 3,
+              wholeFirstLine: false,
+            ),
+            HiddenRange(
+              290,
+              320,
+              firstLine: 10,
+              lastLine: 10,
+              wholeFirstLine: false,
+            ),
+          ],
+          textLength: 0,
+        ),
+      );
+
+      expect(
+        builder.hiddenLineRanges,
+        const HiddenLineRanges(
+          breakpoints: [],
+          fullLineCount: 15,
+          visibleLineCount: 15,
+        ),
+      );
+    });
+
+    test('Mid multiline ranges', () {
+      final builder = HiddenLineRangesBuilder(
+        codeLines: codeLines,
+        hiddenRanges: HiddenRanges(
+          ranges: const [
+            HiddenRange(
+              130,
+              210,
+              firstLine: 3,
+              lastLine: 5,
+              wholeFirstLine: false,
+            ),
+            HiddenRange(
+              230,
+              240,
+              firstLine: 8,
+              lastLine: 8,
+              wholeFirstLine: false,
+            ),
+            HiddenRange(
+              340,
+              400,
+              firstLine: 11,
+              lastLine: 13,
+              wholeFirstLine: false,
+            ),
+          ],
+          textLength: 0,
+        ),
+      );
+
+      expect(
+        builder.hiddenLineRanges,
+        const HiddenLineRanges(
+          breakpoints: [
+            LineNumberingBreakpoint(full: 6, visible: 4, spreadBefore: 0),
+            LineNumberingBreakpoint(full: 14, visible: 10, spreadBefore: 2),
+          ],
+          fullLineCount: 15,
+          visibleLineCount: 11,
+        ),
+      );
+    });
+
+    test('Start+End multiline ranges', () {
+      final builder = HiddenLineRangesBuilder(
+        codeLines: codeLines,
+        hiddenRanges: HiddenRanges(
+          ranges: const [
+            HiddenRange(
+              0,
+              80,
+              firstLine: 0,
+              lastLine: 2,
+              wholeFirstLine: true,
+            ),
+            HiddenRange(
+              230,
+              240,
+              firstLine: 8,
+              lastLine: 8,
+              wholeFirstLine: false,
+            ),
+            HiddenRange(
+              340,
+              480,
+              firstLine: 11,
+              lastLine: 15,
+              wholeFirstLine: false,
+            ),
+          ],
+          textLength: 0,
+        ),
+      );
+
+      expect(
+        builder.hiddenLineRanges,
+        const HiddenLineRanges(
+          breakpoints: [
+            LineNumberingBreakpoint(full: 2, visible: 0, spreadBefore: 0),
+            LineNumberingBreakpoint(full: 15, visible: 10, spreadBefore: 2),
+          ],
+          fullLineCount: 15,
+          visibleLineCount: 9,
+        ),
+      );
+    });
+  });
+}
diff --git a/test/src/hidden_ranges/hidden_line_ranges_test.dart b/test/src/hidden_ranges/hidden_line_ranges_test.dart
new file mode 100644
index 0000000..036aba8
--- /dev/null
+++ b/test/src/hidden_ranges/hidden_line_ranges_test.dart
@@ -0,0 +1,91 @@
+import 'package:flutter_code_editor/src/hidden_ranges/hidden_line_ranges.dart';
+import 'package:flutter_code_editor/src/hidden_ranges/line_numbering_breakpoint.dart';
+import 'package:flutter_test/flutter_test.dart';
+
+void main() {
+  const noBreakpointsRanges = HiddenLineRanges(
+    breakpoints: [],
+    fullLineCount: 10,
+    visibleLineCount: 10,
+  );
+
+  const midBreakpointsRanges = HiddenLineRanges(
+    breakpoints: [
+      LineNumberingBreakpoint(full: 4, visible: 2, spreadBefore: 0),
+      LineNumberingBreakpoint(full: 9, visible: 5, spreadBefore: 2),
+      LineNumberingBreakpoint(full: 100, visible: 12, spreadBefore: 4),
+    ],
+    fullLineCount: 110,
+    visibleLineCount: 22,
+  );
+
+  const startEndHiddenRanges = HiddenLineRanges(
+    breakpoints: [
+      LineNumberingBreakpoint(full: 3, visible: 0, spreadBefore: 0),
+      LineNumberingBreakpoint(full: 10, visible: 5, spreadBefore: 3),
+    ],
+    fullLineCount: 10,
+    visibleLineCount: 5,
+  );
+
+  group('HiddenLineRanges.', () {
+    group('cutLineIndexIfVisible', () {
+      test('No breakpoints -> Continuous', () {
+        for (int i = -1; i <= noBreakpointsRanges.fullLineCount; i++) {
+          expect(noBreakpointsRanges.cutLineIndexIfVisible(i), i);
+        }
+      });
+
+      test('Mid breakpoints', () {
+        expect(midBreakpointsRanges.cutLineIndexIfVisible(-1), -1);
+        expect(midBreakpointsRanges.cutLineIndexIfVisible(0), 0);
+        expect(midBreakpointsRanges.cutLineIndexIfVisible(1), 1);
+        expect(midBreakpointsRanges.cutLineIndexIfVisible(2), null);
+        expect(midBreakpointsRanges.cutLineIndexIfVisible(3), null);
+        expect(midBreakpointsRanges.cutLineIndexIfVisible(4), 2);
+        expect(midBreakpointsRanges.cutLineIndexIfVisible(5), 3);
+        expect(midBreakpointsRanges.cutLineIndexIfVisible(6), 4);
+        expect(midBreakpointsRanges.cutLineIndexIfVisible(7), null);
+        expect(midBreakpointsRanges.cutLineIndexIfVisible(8), null);
+        expect(midBreakpointsRanges.cutLineIndexIfVisible(9), 5);
+        expect(midBreakpointsRanges.cutLineIndexIfVisible(10), 6);
+        expect(midBreakpointsRanges.cutLineIndexIfVisible(11), 7);
+        expect(midBreakpointsRanges.cutLineIndexIfVisible(15), 11);
+        expect(midBreakpointsRanges.cutLineIndexIfVisible(16), null);
+        expect(midBreakpointsRanges.cutLineIndexIfVisible(99), null);
+        expect(midBreakpointsRanges.cutLineIndexIfVisible(100), 12);
+        expect(midBreakpointsRanges.cutLineIndexIfVisible(101), 13);
+        expect(midBreakpointsRanges.cutLineIndexIfVisible(200), 112);
+      });
+    });
+
+    group('visibleLineNumbers', () {
+      test('No breakpoints -> Continuous', () {
+        expect(
+          noBreakpointsRanges.visibleLineNumbers,
+          [0, 1, 2, 3, 4, 5, 6, 7, 8, 9],
+        );
+      });
+
+      test('Mid breakpoints', () {
+        expect(
+          midBreakpointsRanges.visibleLineNumbers,
+          [
+            //
+            0, 1,
+            4, 5, 6,
+            9, 10, 11, 12, 13, 14, 15,
+            100, 101, 102, 103, 104, 105, 106, 107, 108, 109,
+          ],
+        );
+      });
+
+      test('Start+End hidden ranges', () {
+        expect(
+          startEndHiddenRanges.visibleLineNumbers,
+          [3, 4, 5, 6, 7],
+        );
+      });
+    });
+  });
+}
diff --git a/test/src/hidden_ranges/hidden_range_test.dart b/test/src/hidden_ranges/hidden_range_test.dart
index 7c404c9..73866f6 100644
--- a/test/src/hidden_ranges/hidden_range_test.dart
+++ b/test/src/hidden_ranges/hidden_range_test.dart
@@ -5,20 +5,37 @@ import 'package:flutter_test/flutter_test.dart';
 
 void main() {
   group('HiddenText.', () {
-    test('text length = end - stat', () {
-      HiddenRange(start: 0, end: 3, text: '123');
+    test('start >= 0', () {
+      HiddenRange(0, 3, firstLine: 0, lastLine: 0, wholeFirstLine: true);
 
       expect(
-        () => HiddenRange(start: 0, end: 3, text: '12'),
+        () =>
+            HiddenRange(-1, 3, firstLine: 0, lastLine: 0, wholeFirstLine: true),
         throwsAssertionError,
       );
     });
 
-    test('start >= 0', () {
-      HiddenRange(start: 0, end: 3, text: '123');
+    test('end > start', () {
+      HiddenRange(2, 3, firstLine: 0, lastLine: 0, wholeFirstLine: true);
 
       expect(
-        () => HiddenRange(start: -1, end: 3, text: '1234'),
+        () => HiddenRange(
+          2,
+          2,
+          firstLine: 0,
+          lastLine: 0,
+          wholeFirstLine: true,
+        ),
+        throwsAssertionError,
+      );
+      expect(
+        () => HiddenRange(
+          3,
+          2,
+          firstLine: 0,
+          lastLine: 0,
+          wholeFirstLine: true,
+        ),
         throwsAssertionError,
       );
     });
diff --git a/test/src/hidden_ranges/hidden_ranges_builder_test.dart b/test/src/hidden_ranges/hidden_ranges_builder_test.dart
new file mode 100644
index 0000000..0f9a2ee
--- /dev/null
+++ b/test/src/hidden_ranges/hidden_ranges_builder_test.dart
@@ -0,0 +1,217 @@
+import 'package:flutter_code_editor/src/hidden_ranges/hidden_range.dart';
+import 'package:flutter_code_editor/src/hidden_ranges/hidden_ranges_builder.dart';
+import 'package:flutter_test/flutter_test.dart';
+
+void main() {
+  group('HiddenRangesBuilder.', () {
+    group('Merges ranges.', () {
+      test('Overlapping', () {
+        const ranges = {
+          int: {
+            1: HiddenRange(
+              1,
+              3,
+              firstLine: 1,
+              lastLine: 2,
+              wholeFirstLine: false,
+            ),
+            0: HiddenRange(
+              0,
+              2,
+              firstLine: 0,
+              lastLine: 1,
+              wholeFirstLine: false,
+            ),
+          },
+        };
+        final builder = HiddenRangesBuilder.fromMaps(ranges, textLength: 10);
+
+        expect(
+          builder.ranges.ranges,
+          const [
+            HiddenRange(0, 3, firstLine: 0, lastLine: 2, wholeFirstLine: false),
+          ],
+        );
+      });
+
+      test('Touching', () {
+        const ranges = {
+          int: {
+            'a': HiddenRange(
+              4,
+              5,
+              firstLine: 8,
+              lastLine: 10,
+              wholeFirstLine: false,
+            ),
+            'b': HiddenRange(
+              1,
+              2,
+              firstLine: 1,
+              lastLine: 2,
+              wholeFirstLine: true,
+            ),
+          },
+          double: {
+            'c': HiddenRange(
+              2,
+              4,
+              firstLine: 4,
+              lastLine: 5,
+              wholeFirstLine: false,
+            ),
+          },
+        };
+        final builder = HiddenRangesBuilder.fromMaps(ranges, textLength: 10);
+
+        expect(
+          builder.ranges.ranges,
+          const [
+            HiddenRange(1, 5, firstLine: 1, lastLine: 10, wholeFirstLine: true),
+          ],
+        );
+      });
+
+      test('Nesting', () {
+        const ranges = {
+          int: {
+            'a': HiddenRange(
+              6,
+              7,
+              firstLine: 10,
+              lastLine: 20,
+              wholeFirstLine: true,
+            ),
+            'b': HiddenRange(
+              1,
+              2,
+              firstLine: -10,
+              lastLine: -5,
+              wholeFirstLine: true,
+            ),
+          },
+          double: {
+            'c': HiddenRange(
+              5,
+              9,
+              firstLine: 5,
+              lastLine: 15,
+              wholeFirstLine: false,
+            ),
+          },
+        };
+        final builder = HiddenRangesBuilder.fromMaps(ranges, textLength: 10);
+
+        expect(
+          builder.ranges.ranges,
+          const [
+            HiddenRange(
+              1,
+              2,
+              firstLine: -10,
+              lastLine: -5,
+              wholeFirstLine: true,
+            ),
+            HiddenRange(
+              5,
+              9,
+              firstLine: 5,
+              lastLine: 15,
+              wholeFirstLine: false,
+            ),
+          ],
+        );
+      });
+
+      test('Preserves separate', () {
+        const ranges = {
+          int: {
+            1: HiddenRange(
+              7,
+              9,
+              firstLine: 1,
+              lastLine: 2,
+              wholeFirstLine: true,
+            ),
+            2: HiddenRange(
+              1,
+              2,
+              firstLine: 3,
+              lastLine: 4,
+              wholeFirstLine: false,
+            ),
+          },
+          double: {
+            'a': HiddenRange(
+              4,
+              6,
+              firstLine: 5,
+              lastLine: 6,
+              wholeFirstLine: true,
+            ),
+          },
+          String: <Object, HiddenRange>{},
+        };
+        final builder = HiddenRangesBuilder.fromMaps(ranges, textLength: 10);
+
+        expect(
+          builder.ranges.ranges,
+          const [
+            HiddenRange(1, 2, firstLine: 3, lastLine: 4, wholeFirstLine: false),
+            HiddenRange(4, 6, firstLine: 5, lastLine: 6, wholeFirstLine: true),
+            HiddenRange(7, 9, firstLine: 1, lastLine: 2, wholeFirstLine: true),
+          ],
+        );
+      });
+    });
+
+    test('copyWithRange, copyWithoutRange', () {
+      const ranges = {
+        int: {
+          'b': HiddenRange(
+            5,
+            7,
+            firstLine: 1,
+            lastLine: 2,
+            wholeFirstLine: true,
+          ),
+          'a': HiddenRange(
+            1,
+            3,
+            firstLine: 3,
+            lastLine: 4,
+            wholeFirstLine: false,
+          ),
+        },
+      };
+      final builder = HiddenRangesBuilder.fromMaps(ranges, textLength: 10);
+
+      final resultWith = builder.copyWithRange(
+        'key',
+        const HiddenRange(
+          6,
+          9,
+          firstLine: 5,
+          lastLine: 6,
+          wholeFirstLine: false,
+        ),
+      );
+      final resultWithout = builder.copyWithoutRange('key');
+
+      expect(
+        resultWith.ranges.ranges,
+        const [
+          HiddenRange(1, 3, firstLine: 3, lastLine: 4, wholeFirstLine: false),
+          HiddenRange(5, 9, firstLine: 1, lastLine: 6, wholeFirstLine: true),
+        ],
+      );
+      expect(
+        resultWithout.ranges.ranges,
+        const [
+          HiddenRange(1, 3, firstLine: 3, lastLine: 4, wholeFirstLine: false),
+          HiddenRange(5, 7, firstLine: 1, lastLine: 2, wholeFirstLine: true),
+        ],
+      );
+    });
+  });
+}
diff --git a/test/src/hidden_ranges/hidden_ranges_position_test.dart b/test/src/hidden_ranges/hidden_ranges_position_test.dart
index 44ac1e0..83cc9a0 100644
--- a/test/src/hidden_ranges/hidden_ranges_position_test.dart
+++ b/test/src/hidden_ranges/hidden_ranges_position_test.dart
@@ -6,19 +6,19 @@ import 'package:flutter_test/flutter_test.dart';
 
 final _hiddenRanges = HiddenRanges(
   ranges: const [
-    //                How many chars hidden by the beginning of this range:
-    HiddenRange(start: 20, end: 23, text: '123'), //                      0
-    HiddenRange(start: 31, end: 38, text: '1234567'), //                  3
-    HiddenRange(start: 38, end: 42, text: '1234'), //                     10
-    HiddenRange(start: 67, end: 91, text: '123456789012345678901234'), // 14
-    HiddenRange(start: 100, end: 101, text: '1'), //                      38
-    HiddenRange(start: 102, end: 103, text: '1'), //                      39
-    HiddenRange(start: 104, end: 105, text: '1'), //                      40
-    HiddenRange(start: 106, end: 107, text: '1'), //                      41
-    HiddenRange(start: 108, end: 109, text: '1'), //                      42
-    HiddenRange(start: 110, end: 111, text: '1'), //                      43
-    HiddenRange(start: 113, end: 123, text: '1234567890'), //             44
-    //                                                                    54
+    //                     How many chars hidden by the beginning of this range:
+    HiddenRange(20, 23, firstLine: 0, lastLine: 0, wholeFirstLine: true), //   0
+    HiddenRange(31, 38, firstLine: 0, lastLine: 0, wholeFirstLine: true), //   3
+    HiddenRange(38, 42, firstLine: 0, lastLine: 0, wholeFirstLine: true), //  10
+    HiddenRange(67, 91, firstLine: 0, lastLine: 0, wholeFirstLine: true), //  14
+    HiddenRange(100, 101, firstLine: 0, lastLine: 0, wholeFirstLine: true), //38
+    HiddenRange(102, 103, firstLine: 0, lastLine: 0, wholeFirstLine: true), //39
+    HiddenRange(104, 105, firstLine: 0, lastLine: 0, wholeFirstLine: true), //40
+    HiddenRange(106, 107, firstLine: 0, lastLine: 0, wholeFirstLine: true), //41
+    HiddenRange(108, 109, firstLine: 0, lastLine: 0, wholeFirstLine: true), //42
+    HiddenRange(110, 111, firstLine: 0, lastLine: 0, wholeFirstLine: true), //43
+    HiddenRange(113, 123, firstLine: 0, lastLine: 0, wholeFirstLine: true), //44
+    //                                                                        54
   ],
   textLength: 140,
 );
@@ -81,7 +81,7 @@ void main() {
           final placeHiddenRanges = affinities[affinityIndex];
           final expected = example.value[affinityIndex];
 
-          int cutPosition() {
+          int recoverPosition() {
             return _hiddenRanges.recoverPosition(
               example.key,
               placeHiddenRanges: placeHiddenRanges,
@@ -89,8 +89,8 @@ void main() {
           }
 
           final reason = '#$i. $placeHiddenRanges: ${example.key} -> $expected';
-          expect(cutPosition, returnsNormally, reason: reason);
-          expect(cutPosition(), expected, reason: reason);
+          expect(recoverPosition, returnsNormally, reason: reason);
+          expect(recoverPosition(), expected, reason: reason);
         }
 
         i++;
@@ -100,7 +100,7 @@ void main() {
     test('Recover with a single range', () {
       final hiddenRanges = HiddenRanges(
         ranges: const [
-          HiddenRange(start: 5, end: 10, text: '12345'),
+          HiddenRange(5, 10, firstLine: 0, lastLine: 0, wholeFirstLine: true),
         ],
         textLength: 140,
       );
@@ -117,7 +117,7 @@ void main() {
           final placeHiddenRanges = affinities[affinityIndex];
           final expected = example.value[affinityIndex];
 
-          int cutPosition() {
+          int recoverPosition() {
             return hiddenRanges.recoverPosition(
               example.key,
               placeHiddenRanges: placeHiddenRanges,
@@ -125,8 +125,8 @@ void main() {
           }
 
           final reason = '#$i. $placeHiddenRanges: ${example.key} -> $expected';
-          expect(cutPosition, returnsNormally, reason: reason);
-          expect(cutPosition(), expected, reason: reason);
+          expect(recoverPosition, returnsNormally, reason: reason);
+          expect(recoverPosition(), expected, reason: reason);
         }
 
         i++;
diff --git a/test/src/hidden_ranges/hidden_ranges_test.dart b/test/src/hidden_ranges/hidden_ranges_test.dart
index e5a8de6..ab2c4db 100644
--- a/test/src/hidden_ranges/hidden_ranges_test.dart
+++ b/test/src/hidden_ranges/hidden_ranges_test.dart
@@ -12,9 +12,11 @@ public class MyClass {
 
 final _hiddenRanges = HiddenRanges(
   ranges: const [
-    HiddenRange(start: 1, end: 1, text: ''),
-    HiddenRange(start: 9, end: 11, text: 'as'),
-    HiddenRange(start: 40, end: 61, text: 'n() { // comment\n  }\n'),
+    // 'as'
+    HiddenRange(9, 11, firstLine: 0, lastLine: 0, wholeFirstLine: false),
+
+    // 'n() { // comment\n  }\n'
+    HiddenRange(40, 61, firstLine: 1, lastLine: 1, wholeFirstLine: false),
   ],
   textLength: _text.length,
 );
@@ -26,30 +28,12 @@ public cls MyClass {
 
 void main() {
   group('HiddenRanges.', () {
-    test('Empty ranges are not stored', () {
-      expect(
-        HiddenRanges(
-          ranges: const [
-            HiddenRange(start: 1, end: 4, text: '123'),
-            HiddenRange(start: 7, end: 7, text: ''),
-          ],
-          textLength: _text.length,
-        ),
-        HiddenRanges(
-          ranges: const [
-            HiddenRange(start: 1, end: 4, text: '123'),
-          ],
-          textLength: _text.length,
-        ),
-      );
-    });
-
     test('Ranges cannot overlap', () {
       expect(
         () => HiddenRanges(
           ranges: const [
-            HiddenRange(start: 0, end: 3, text: '012'),
-            HiddenRange(start: 1, end: 4, text: '123'),
+            HiddenRange(0, 3, firstLine: 0, lastLine: 0, wholeFirstLine: false),
+            HiddenRange(1, 4, firstLine: 0, lastLine: 0, wholeFirstLine: false),
           ],
           textLength: _text.length,
         ),
@@ -75,9 +59,9 @@ void main() {
       test('Valid array if has ranges', () {
         final ranges = HiddenRanges(
           ranges: const [
-            HiddenRange(start: 2, end: 4, text: '23'),
-            HiddenRange(start: 7, end: 9, text: '78'),
-            HiddenRange(start: 10, end: 13, text: 'ABC'),
+            HiddenRange(2, 4, firstLine: 0, lastLine: 0, wholeFirstLine: true),
+            HiddenRange(6, 8, firstLine: 0, lastLine: 0, wholeFirstLine: true),
+            HiddenRange(9, 12, firstLine: 0, lastLine: 0, wholeFirstLine: true),
           ],
           textLength: 777,
         );
@@ -145,11 +129,17 @@ void main() {
     });
 
     group('cutHighlighted.', () {
+      test('null -> null', () {
+        final result = _hiddenRanges.cutHighlighted(null);
+
+        expect(result, null);
+      });
+
       test('Cuts inner text for hidden parts, keeps the tags', () {
         final highlighted = highlight.parse(_text, language: 'java');
 
         final result = _hiddenRanges.cutHighlighted(highlighted);
-        final html = result.toHtml();
+        final html = result?.toHtml();
 
         expect(
           html,
diff --git a/test/src/hidden_ranges/line_numbering_breakpoint_test.dart b/test/src/hidden_ranges/line_numbering_breakpoint_test.dart
new file mode 100644
index 0000000..f1e22ea
--- /dev/null
+++ b/test/src/hidden_ranges/line_numbering_breakpoint_test.dart
@@ -0,0 +1,78 @@
+import 'package:flutter_code_editor/src/hidden_ranges/line_numbering_breakpoint.dart';
+import 'package:flutter_test/flutter_test.dart';
+
+void main() {
+  group('LineNumberingBreakpoint', () {
+    group('cutLineIndex', () {
+      const breakpoint = LineNumberingBreakpoint(
+        full: 100,
+        visible: 12,
+        spreadBefore: 4,
+      );
+
+      test('way before', () {
+        expect(breakpoint.cutLineIndex(-10), -14);
+      });
+
+      test('last line before', () {
+        expect(breakpoint.cutLineIndex(15), 11);
+      });
+
+      test('first hidden line', () {
+        expect(breakpoint.cutLineIndex(16), 11);
+      });
+
+      test('mid hidden line', () {
+        expect(breakpoint.cutLineIndex(50), 11);
+      });
+
+      test('last hidden line', () {
+        expect(breakpoint.cutLineIndex(99), 11);
+      });
+
+      test('first line after', () {
+        expect(breakpoint.cutLineIndex(100), 12);
+      });
+
+      test('way after', () {
+        expect(breakpoint.cutLineIndex(200), 112);
+      });
+    });
+
+    group('cutLineIndexIfVisible', () {
+      const breakpoint = LineNumberingBreakpoint(
+        full: 100,
+        visible: 12,
+        spreadBefore: 4,
+      );
+
+      test('way before', () {
+        expect(breakpoint.cutLineIndexIfVisible(-10), -14);
+      });
+
+      test('last line before', () {
+        expect(breakpoint.cutLineIndexIfVisible(15), 11);
+      });
+
+      test('first hidden line', () {
+        expect(breakpoint.cutLineIndexIfVisible(16), null);
+      });
+
+      test('mid hidden line', () {
+        expect(breakpoint.cutLineIndexIfVisible(50), null);
+      });
+
+      test('last hidden line', () {
+        expect(breakpoint.cutLineIndexIfVisible(99), null);
+      });
+
+      test('first line after', () {
+        expect(breakpoint.cutLineIndexIfVisible(100), 12);
+      });
+
+      test('way after', () {
+        expect(breakpoint.cutLineIndexIfVisible(200), 112);
+      });
+    });
+  });
+}
diff --git a/test/src/single_line_comment/parser/highlight_single_line_comment_parser_test.dart b/test/src/single_line_comment/parser/highlight_single_line_comment_parser_test.dart
index d24a8c6..8428769 100644
--- a/test/src/single_line_comment/parser/highlight_single_line_comment_parser_test.dart
+++ b/test/src/single_line_comment/parser/highlight_single_line_comment_parser_test.dart
@@ -28,7 +28,7 @@ public class MyClass {
   text // slashed comment2 // still comment2
 }
 ''',
-        comments:[
+        comments: [
           SingleLineComment(
             lineIndex: 0,
             characterIndex: 0,