From 74d54351211ae3eb5acdb1d0c8b79b38331b4ce0 Mon Sep 17 00:00:00 2001 From: Alexey Inkin Date: Fri, 28 Oct 2022 18:21:41 +0400 Subject: [PATCH 1/6] Custom undo/redo implementation (#97) --- lib/src/code_field/actions/redo.dart | 17 + lib/src/code_field/actions/undo.dart | 17 + lib/src/code_field/code_controller.dart | 32 +- lib/src/code_field/code_field.dart | 24 +- lib/src/code_field/text_editing_value.dart | 12 +- lib/src/code_field/text_selection.dart | 8 + lib/src/history/code_history_controller.dart | 140 +++++++ lib/src/history/code_history_record.dart | 20 + pubspec.yaml | 1 + .../code_controller_shortcut_test.dart | 26 +- test/src/common/snippets.dart | 21 + test/src/common/widget_tester.dart | 14 + .../history/code_history_controller_test.dart | 369 ++++++++++++++++++ 13 files changed, 674 insertions(+), 27 deletions(-) create mode 100644 lib/src/code_field/actions/redo.dart create mode 100644 lib/src/code_field/actions/undo.dart create mode 100644 lib/src/history/code_history_controller.dart create mode 100644 lib/src/history/code_history_record.dart create mode 100644 test/src/common/snippets.dart create mode 100644 test/src/history/code_history_controller_test.dart diff --git a/lib/src/code_field/actions/redo.dart b/lib/src/code_field/actions/redo.dart new file mode 100644 index 00000000..d4f37b64 --- /dev/null +++ b/lib/src/code_field/actions/redo.dart @@ -0,0 +1,17 @@ +import 'package:flutter/widgets.dart'; + +import '../code_controller.dart'; + +class RedoAction extends Action { + final CodeController controller; + + RedoAction({ + required this.controller, + }); + + @override + Object? invoke(RedoTextIntent intent) { + controller.historyController.redo(); + return null; + } +} diff --git a/lib/src/code_field/actions/undo.dart b/lib/src/code_field/actions/undo.dart new file mode 100644 index 00000000..fe135683 --- /dev/null +++ b/lib/src/code_field/actions/undo.dart @@ -0,0 +1,17 @@ +import 'package:flutter/widgets.dart'; + +import '../code_controller.dart'; + +class UndoAction extends Action { + final CodeController controller; + + UndoAction({ + required this.controller, + }); + + @override + Object? invoke(UndoTextIntent intent) { + controller.historyController.undo(); + return null; + } +} diff --git a/lib/src/code_field/code_controller.dart b/lib/src/code_field/code_controller.dart index be6a0dd3..fed64525 100644 --- a/lib/src/code_field/code_controller.dart +++ b/lib/src/code_field/code_controller.dart @@ -17,9 +17,13 @@ import '../code_modifiers/indent_code_modifier.dart'; import '../code_modifiers/tab_code_modifier.dart'; import '../code_theme/code_theme.dart'; import '../code_theme/code_theme_data.dart'; +import '../history/code_history_controller.dart'; +import '../history/code_history_record.dart'; import '../named_sections/parsers/abstract.dart'; import '../wip/autocomplete/popup_controller.dart'; import 'actions/copy.dart'; +import 'actions/redo.dart'; +import 'actions/undo.dart'; import 'editor_params.dart'; import 'span_builder.dart'; @@ -102,9 +106,12 @@ class CodeController extends TextEditingController { RegExp? styleRegExp; late PopupController popupController; final autocompleter = Autocompleter(); + late final historyController = CodeHistoryController(codeController: this); - late final actions = { + late final actions = >{ CopySelectionTextIntent: CopyAction(controller: this), + RedoTextIntent: RedoAction(controller: this), + UndoTextIntent: UndoAction(controller: this), }; CodeController({ @@ -301,7 +308,14 @@ class CodeController extends TextEditingController { @override set value(TextEditingValue newValue) { - if (newValue.text != super.value.text) { + final hasTextChanged = newValue.text != super.value.text; + final hasSelectionChanged = newValue.selection != super.value.selection; + + if (!hasTextChanged && !hasSelectionChanged) { + return; + } + + if (hasTextChanged) { final loc = _insertedLoc(text, newValue.text); if (loc != null) { @@ -339,9 +353,6 @@ class CodeController extends TextEditingController { //print('\n\n${_code.text}'); } - bool hasTextChanged = newValue.text != super.value.text; - bool hasSelectionChanged = newValue.selection != super.value.selection; - //Because of this part of code ctrl + z dont't work. But maybe it's important, so please don't delete. // Now fix the textfield for web // if (_webSpaceFix) { @@ -352,7 +363,9 @@ class CodeController extends TextEditingController { _webSpaceFix ? _middleDotsToSpaces(newValue.text) : newValue.text, ); + historyController.beforeChanged(_code, newValue.selection); super.value = newValue; + if (hasTextChanged) { autocompleter.blacklist = [newValue.wordAtCursor ?? '']; autocompleter.setText(this, text); @@ -362,6 +375,15 @@ class CodeController extends TextEditingController { } } + void applyHistoryRecord(CodeHistoryRecord record) { + _code = record.code; + + super.value = TextEditingValue( + text: code.visibleText, + selection: record.selection, + ); + } + Code get code => _code; CodeEditResult? _getEditResultNotBreakingReadOnly(TextEditingValue newValue) { diff --git a/lib/src/code_field/code_field.dart b/lib/src/code_field/code_field.dart index cec2e837..3b3a406c 100644 --- a/lib/src/code_field/code_field.dart +++ b/lib/src/code_field/code_field.dart @@ -32,7 +32,7 @@ final _shortcuts = { LogicalKeyboardKey.control, LogicalKeyboardKey.keyX, ): const CopySelectionTextIntent.cut(SelectionChangedCause.keyboard), - LogicalKeySet( + LogicalKeySet( LogicalKeyboardKey.meta, LogicalKeyboardKey.keyX, ): const CopySelectionTextIntent.cut(SelectionChangedCause.keyboard), @@ -40,6 +40,28 @@ final _shortcuts = { LogicalKeyboardKey.shift, LogicalKeyboardKey.delete, ): const CopySelectionTextIntent.cut(SelectionChangedCause.keyboard), + + // Undo + LogicalKeySet( + LogicalKeyboardKey.control, + LogicalKeyboardKey.keyZ, + ): const UndoTextIntent(SelectionChangedCause.keyboard), + LogicalKeySet( + LogicalKeyboardKey.meta, + LogicalKeyboardKey.keyZ, + ): const UndoTextIntent(SelectionChangedCause.keyboard), + + // Redo + LogicalKeySet( + LogicalKeyboardKey.shift, + LogicalKeyboardKey.control, + LogicalKeyboardKey.keyZ, + ): const RedoTextIntent(SelectionChangedCause.keyboard), + LogicalKeySet( + LogicalKeyboardKey.shift, + LogicalKeyboardKey.meta, + LogicalKeyboardKey.keyZ, + ): const RedoTextIntent(SelectionChangedCause.keyboard), }; class CodeField extends StatefulWidget { diff --git a/lib/src/code_field/text_editing_value.dart b/lib/src/code_field/text_editing_value.dart index 615a4830..b986230d 100644 --- a/lib/src/code_field/text_editing_value.dart +++ b/lib/src/code_field/text_editing_value.dart @@ -74,8 +74,8 @@ extension TextEditingValueExtension on TextEditingValue { return replaced(selection, ''); } - TextEditingValue replacedSelection(String value) { - return replaced(selection, value); + TextEditingValue replacedSelection(String text) { + return replaced(selection, text); } TextEditingValue replacedText(String newText) { @@ -94,6 +94,14 @@ extension TextEditingValueExtension on TextEditingValue { ); } + TextEditingValue typed(String text) { + final lengthDiff = text.length - selected.length; + + return replaced(selection, text).copyWith( + selection: TextSelection.collapsed(offset: selection.end + lengthDiff), + ); + } + TextEditingValue tabsToSpaces(int spaceCount) { final replacedBefore = beforeSelection.tabsToSpaces(spaceCount); final replacedSelected = selected.tabsToSpaces(spaceCount); diff --git a/lib/src/code_field/text_selection.dart b/lib/src/code_field/text_selection.dart index 4a2ebd38..e1d87670 100644 --- a/lib/src/code_field/text_selection.dart +++ b/lib/src/code_field/text_selection.dart @@ -13,4 +13,12 @@ extension TextSelectionExtension on TextSelection { bool get isSelectionNormalized { return baseOffset <= extentOffset; } + + bool hasMovedOneCharacterRight(TextSelection old) { + if (!old.isCollapsed || !isCollapsed) { + return false; + } + + return start == old.start + 1; + } } diff --git a/lib/src/history/code_history_controller.dart b/lib/src/history/code_history_controller.dart new file mode 100644 index 00000000..9f7eee63 --- /dev/null +++ b/lib/src/history/code_history_controller.dart @@ -0,0 +1,140 @@ +import 'dart:async'; + +import 'package:flutter/widgets.dart'; + +import '../code/code.dart'; +import '../code_field/code_controller.dart'; +import '../code_field/text_selection.dart'; +import 'code_history_record.dart'; + +/// A custom undo/redo implementation for [CodeController]. +/// +/// This is needed because the built-in implementation listens to the +/// visible text changes in [TextEditingController] and sets that on undo/redo. +/// This would delete hidden ranges and folded blocks. +/// +/// With this controller, new records are created: +/// - If the line count has changed. +/// - After the [idle] duration if the text has changed since the last record. +/// - On any selection change other than that of inserting a single +/// character, if the text has changed since the last record. +class CodeHistoryController { + final CodeController codeController; + Code lastCode; + TextSelection lastSelection; + int _currentRecordIndex = 0; + bool _isTextChanged = false; + Timer? _debounceTimer; + + @visibleForTesting + final stack = []; + + static const idle = Duration(seconds: 5); + static const limit = 100; + + CodeHistoryController({ + required this.codeController, + }) : lastCode = codeController.code, + lastSelection = codeController.value.selection { + _push(); + } + + void beforeChanged(Code code, TextSelection selection) { + _dropRedoIfNeed(); + bool save = false; + + if (_isTextChanged) { + save = code.lines.lines.length != lastCode.lines.lines.length; + } + + if (!save) { + if (lastCode.text != code.text) { + _isTextChanged = true; + } + + final isTextOneCharLonger = code.text.length == lastCode.text.length + 1; + final isSelectionChangeImportant = !isTextOneCharLonger || + !selection.hasMovedOneCharacterRight(lastSelection); + + if (_isTextChanged) { + if (isSelectionChangeImportant) { + save = true; + } else { + _setTimer(); + } + } + } + + if (save) { + _push(); + } + + lastCode = code; + lastSelection = selection; + } + + void _dropRedoIfNeed() { + stack.removeRange(_currentRecordIndex + 1, stack.length); + } + + void undo() { + if (_isTextChanged) { + _push(); + } + + if (_currentRecordIndex == 0) { + return; + } + + _applyHistoryRecord(stack[--_currentRecordIndex]); + } + + void redo() { + if (_currentRecordIndex == stack.length - 1) { + return; + } + + _applyHistoryRecord(stack[++_currentRecordIndex]); + } + + void _applyHistoryRecord(CodeHistoryRecord record) { + lastCode = record.code; + lastSelection = record.selection; + + codeController.applyHistoryRecord(record); + } + + void _push() { + _debounceTimer?.cancel(); + _pushRecord(_createRecord()); + _isTextChanged = false; + } + + void _setTimer() { + _debounceTimer?.cancel(); + _debounceTimer = Timer(idle, _push); + } + + CodeHistoryRecord _createRecord() { + return CodeHistoryRecord( + code: lastCode, + selection: lastSelection, + ); + } + + void _pushRecord(CodeHistoryRecord record) { + stack.add(record); + _currentRecordIndex = stack.length - 1; + + if (stack.length > limit) { + stack.removeRange(0, stack.length - limit); + _currentRecordIndex = limit - 1; + } + } + + void deleteHistory() { + stack.clear(); + _push(); + _currentRecordIndex = 0; + } +} diff --git a/lib/src/history/code_history_record.dart b/lib/src/history/code_history_record.dart new file mode 100644 index 00000000..35f40eda --- /dev/null +++ b/lib/src/history/code_history_record.dart @@ -0,0 +1,20 @@ +import 'package:equatable/equatable.dart'; +import 'package:flutter/services.dart'; + +import '../code/code.dart'; + +class CodeHistoryRecord with EquatableMixin { + final Code code; + final TextSelection selection; + + const CodeHistoryRecord({ + required this.code, + required this.selection, + }); + + @override + List get props => [ + code, + selection, + ]; +} diff --git a/pubspec.yaml b/pubspec.yaml index a7ae326e..e51a695b 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -22,6 +22,7 @@ dependencies: tuple: ^2.0.1 dev_dependencies: + fake_async: ^1.3.1 flutter_test: { sdk: flutter } total_lints: ^2.18.0 diff --git a/test/src/code_field/code_controller_shortcut_test.dart b/test/src/code_field/code_controller_shortcut_test.dart index 1e915c98..2af07b2d 100644 --- a/test/src/code_field/code_controller_shortcut_test.dart +++ b/test/src/code_field/code_controller_shortcut_test.dart @@ -2,21 +2,9 @@ import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import '../common/create_app.dart'; +import '../common/snippets.dart'; import '../common/widget_tester.dart'; -const _text = ''' -class MyClass { - void method() {// [START section1] - }// [END section1] -} -'''; - -const _visibleText = ''' -class MyClass { - void method() { -} -'''; - const _copiedText = ''' void method() {// [START section1] }// [END section1] @@ -40,7 +28,7 @@ void main() { group('CodeController. Shortcuts.', () { testWidgets('Select all', (WidgetTester wt) async { - final controller = await pumpController(wt, _text); + final controller = await pumpController(wt, MethodSnippet.full); controller.foldAt(1); await wt.sendKeyDownEvent(LogicalKeyboardKey.control); @@ -50,10 +38,10 @@ void main() { expect( controller.value, const TextEditingValue( - text: _visibleText, + text: MethodSnippet.visibleFolded1, selection: TextSelection( baseOffset: 0, - extentOffset: _visibleText.length, + extentOffset: MethodSnippet.visibleFolded1.length, ), ), ); @@ -69,7 +57,7 @@ void main() { await wt.sendKeyEvent(LogicalKeyboardKey.keyC); await wt.sendKeyUpEvent(LogicalKeyboardKey.control); }, - visibleTextAfter: _visibleText, + visibleTextAfter: MethodSnippet.visibleFolded1, ), _CopyExample( @@ -79,7 +67,7 @@ void main() { await wt.sendKeyEvent(LogicalKeyboardKey.insert); await wt.sendKeyUpEvent(LogicalKeyboardKey.control); }, - visibleTextAfter: _visibleText, + visibleTextAfter: MethodSnippet.visibleFolded1, ), _CopyExample( @@ -106,7 +94,7 @@ void main() { final controller = await pumpController(wt, ''); for (final example in examples) { - controller.value = const TextEditingValue(text: _text); + controller.value = const TextEditingValue(text: MethodSnippet.full); controller.foldAt(1); await wt.selectFromHome(18, offset: 16); mockClipboardHandler(); diff --git a/test/src/common/snippets.dart b/test/src/common/snippets.dart new file mode 100644 index 00000000..d95f4357 --- /dev/null +++ b/test/src/common/snippets.dart @@ -0,0 +1,21 @@ +class MethodSnippet { + static const full = ''' +class MyClass { + void method() {// [START section1] + }// [END section1] +} +'''; + + static const visible = ''' +class MyClass { + void method() { + } +} +'''; + + static const visibleFolded1 = ''' +class MyClass { + void method() { +} +'''; +} diff --git a/test/src/common/widget_tester.dart b/test/src/common/widget_tester.dart index 50953df8..8637220c 100644 --- a/test/src/common/widget_tester.dart +++ b/test/src/common/widget_tester.dart @@ -39,4 +39,18 @@ extension WidgetTesterExtension on WidgetTester { } } } + + Future sendUndo() async { + await sendKeyDownEvent(LogicalKeyboardKey.control); + await sendKeyEvent(LogicalKeyboardKey.keyZ); + await sendKeyUpEvent(LogicalKeyboardKey.control); + } + + Future sendRedo() async { + await sendKeyDownEvent(LogicalKeyboardKey.shift); + await sendKeyDownEvent(LogicalKeyboardKey.control); + await sendKeyEvent(LogicalKeyboardKey.keyZ); + await sendKeyUpEvent(LogicalKeyboardKey.control); + await sendKeyUpEvent(LogicalKeyboardKey.shift); + } } diff --git a/test/src/history/code_history_controller_test.dart b/test/src/history/code_history_controller_test.dart new file mode 100644 index 00000000..62ed1939 --- /dev/null +++ b/test/src/history/code_history_controller_test.dart @@ -0,0 +1,369 @@ +// ignore_for_file: prefer_interpolation_to_compose_strings + +import 'package:fake_async/fake_async.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_code_editor/flutter_code_editor.dart'; +import 'package:flutter_code_editor/src/history/code_history_controller.dart'; +import 'package:flutter_code_editor/src/history/code_history_record.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../common/create_app.dart'; +import '../common/snippets.dart'; +import '../common/widget_tester.dart'; + +void main() { + group('CodeHistoryController.', () { + group('Creating records.', () { + testWidgets('Initial record', (WidgetTester wt) async { + final controller = await pumpController(wt, MethodSnippet.full); + + expect( + controller.historyController.stack, + [ + CodeHistoryRecord( + code: controller.code, + selection: const TextSelection.collapsed(offset: -1), + ), + ], + ); + }); + + testWidgets( + 'Typing, Same value, Folding/Unfolding do not create', + (WidgetTester wt) async { + final controller = await pumpController(wt, MethodSnippet.full); + await wt.cursorEnd(); + + controller.value = controller.value; + controller.value = controller.value.typed('a'); + controller.value = controller.value.typed('b'); + controller.value = controller.value; + controller.foldAt(0); + controller.unfoldAt(0); + + expect(controller.historyController.stack.length, 1); + await wt.moveCursor(-1); // Clear timer. + }, + ); + + testWidgets('Selection change does not create', (WidgetTester wt) async { + final controller = await pumpController(wt, MethodSnippet.full); + + await wt.moveCursor(-1); + await wt.moveCursor(1); + + expect(controller.historyController.stack.length, 1); + }); + + testWidgets('Typing + Selection change', (WidgetTester wt) async { + final controller = await pumpController(wt, MethodSnippet.full); + await wt.cursorEnd(); + + controller.value = controller.value.typed('a'); + final code1 = controller.code; + await wt.moveCursor(-1); // Creates. + + controller.value = controller.value.typed('b'); + final code2 = controller.code; + await wt.moveCursor(1); // Creates. + + expect(controller.historyController.stack.length, 3); + expect( + controller.historyController.stack[1], + CodeHistoryRecord( + code: code1, + selection: const TextSelection.collapsed( + offset: MethodSnippet.visible.length + 1, + ), + ), + ); + expect( + controller.historyController.stack[2], + CodeHistoryRecord( + code: code2, + selection: const TextSelection.collapsed( + offset: MethodSnippet.visible.length + 1, + ), + ), + ); + }); + + testWidgets('Line count change', (WidgetTester wt) async { + final controller = await pumpController(wt, MethodSnippet.full); + await wt.cursorEnd(); + + controller.value = controller.value.typed('\n'); + final code1 = controller.code; + controller.value = controller.value.typed('\n'); // Creates. + + final code2 = controller.code; + await wt.sendKeyEvent(LogicalKeyboardKey.backspace); // Creates. + + expect(controller.historyController.stack.length, 3); + expect( + controller.historyController.stack[1], + CodeHistoryRecord( + code: code1, + selection: const TextSelection.collapsed( + offset: MethodSnippet.visible.length + 1, + ), + ), + ); + expect( + controller.historyController.stack[2], + CodeHistoryRecord( + code: code2, + selection: const TextSelection.collapsed( + offset: MethodSnippet.visible.length + 2, + ), + ), + ); + }); + + testWidgets('Typing + Timeout', (WidgetTester wt) async { + final controller = await pumpController(wt, MethodSnippet.full); + Code? code1; + int? recordCountAfterFirstIdle; + await wt.cursorEnd(); + + fakeAsync((async) { + controller.value = controller.value.typed('a'); + + async.elapse( + CodeHistoryController.idle - const Duration(microseconds: 1), + ); + + controller.value = controller.value.typed('b'); + code1 = controller.code; + + async.elapse(CodeHistoryController.idle); // Creates. + recordCountAfterFirstIdle = controller.historyController.stack.length; + + async.elapse(CodeHistoryController.idle * 2); + }); + + expect(recordCountAfterFirstIdle, 2); + expect(controller.historyController.stack.length, 2); + expect( + controller.historyController.stack[1], + CodeHistoryRecord( + code: code1!, + selection: const TextSelection.collapsed( + offset: MethodSnippet.visible.length + 2, + ), + ), + ); + }); + }); + + group('Undo/Redo.', () { + testWidgets('Cannot initially', (WidgetTester wt) async { + final controller = await pumpController(wt, MethodSnippet.full); + await wt.cursorEnd(); + + await wt.sendUndo(); // No effect. + + expect(controller.fullText, MethodSnippet.full); + expect( + controller.selection, + const TextSelection.collapsed( + offset: MethodSnippet.visible.length, + affinity: TextAffinity.upstream, + ), + ); + + await wt.sendRedo(); // No effect. + + expect(controller.fullText, MethodSnippet.full); + expect( + controller.selection, + const TextSelection.collapsed( + offset: MethodSnippet.visible.length, + affinity: TextAffinity.upstream, + ), + ); + }); + + testWidgets('Undo to bottom, then redo', (WidgetTester wt) async { + final controller = await pumpController(wt, MethodSnippet.full); + await wt.cursorEnd(); + + controller.value = controller.value.typed('a'); + await wt.moveCursor(-1); // Creates. + + controller.value = controller.value.typed('b'); + await wt.moveCursor(-1); // Creates. + + controller.value = controller.value.typed('c'); + + await wt.sendUndo(); // Creates. + + expect(controller.historyController.stack.length, 4); + expect(controller.fullText, MethodSnippet.full + 'ba'); + expect( + controller.selection, + const TextSelection.collapsed( + offset: MethodSnippet.visible.length + 1, + ), + ); + + await wt.sendUndo(); + + expect(controller.fullText, MethodSnippet.full + 'a'); + + await wt.sendUndo(); // To initial. + + expect(controller.fullText, MethodSnippet.full); + expect( + controller.selection, + const TextSelection.collapsed(offset: -1), + ); + + await wt.sendUndo(); // No effect. + + expect(controller.fullText, MethodSnippet.full); + + await wt.sendRedo(); + + expect(controller.fullText, MethodSnippet.full + 'a'); + expect( + controller.selection, + const TextSelection.collapsed( + offset: MethodSnippet.visible.length + 1, + ), + ); + + await wt.sendRedo(); + + expect(controller.fullText, MethodSnippet.full + 'ba'); + + await wt.sendRedo(); + + expect(controller.fullText, MethodSnippet.full + 'cba'); + + await wt.sendRedo(); // No effect. + + expect(controller.fullText, MethodSnippet.full + 'cba'); + }); + + testWidgets('Changing disables redo', (WidgetTester wt) async { + final controller = await pumpController(wt, MethodSnippet.full); + await wt.cursorEnd(); + + controller.value = controller.value.typed('a'); + await wt.moveCursor(-1); // Creates. + + controller.value = controller.value.typed('b'); + + await wt.sendUndo(); // Creates. + + expect(controller.fullText, MethodSnippet.full + 'a'); + expect( + controller.selection, + const TextSelection.collapsed( + offset: MethodSnippet.visible.length + 1, + ), + ); + + controller.value = controller.value.typed('b'); // Deletes redo records. + + expect(controller.historyController.stack.length, 2); + + await wt.sendRedo(); // No effect. + + expect(controller.historyController.stack.length, 2); + + await wt.moveCursor(-1); // Creates. + + expect(controller.fullText, MethodSnippet.full + 'ab'); + expect( + controller.selection, + const TextSelection.collapsed( + offset: MethodSnippet.visible.length + 1, + ), + ); + + await wt.sendUndo(); + await wt.sendUndo(); + + expect(controller.fullText, MethodSnippet.full); + + await wt.sendRedo(); + await wt.sendRedo(); + + expect(controller.fullText, MethodSnippet.full + 'ab'); + }); + + testWidgets('Limit depth', (WidgetTester wt) async { + final controller = await pumpController(wt, MethodSnippet.full); + await wt.cursorEnd(); + + for (int i = 0; i < CodeHistoryController.limit - 1; i++) { + controller.value = controller.value.typed('a'); + await wt.moveCursor(-1); // Creates. + } + + expect( + controller.historyController.stack.length, + CodeHistoryController.limit, + ); + + controller.value = controller.value.typed('a'); + await wt.moveCursor(-1); // Creates, drops the oldest. + + expect( + controller.historyController.stack.length, + CodeHistoryController.limit, + ); + + // One too many. + for (int i = 0; i < CodeHistoryController.limit; i++) { + await wt.sendUndo(); + } + + expect(controller.fullText, MethodSnippet.full + 'a'); //Last not undone + expect( + controller.selection, + const TextSelection.collapsed( + offset: MethodSnippet.visible.length + 1, + ), + ); + + // One too many. + for (int i = 0; i < CodeHistoryController.limit; i++) { + await wt.sendRedo(); + } + + expect(controller.fullText, MethodSnippet.full + 'a' * 100); + expect( + controller.selection, + const TextSelection.collapsed( + offset: MethodSnippet.visible.length + 1, + ), + ); + }); + }); + + testWidgets('deleteHistory', (WidgetTester wt) async { + final controller = await pumpController(wt, MethodSnippet.full); + await wt.cursorEnd(); + + controller.value = controller.value.typed('a'); + await wt.moveCursor(-1); // Creates. + + controller.historyController.deleteHistory(); + + expect(controller.historyController.stack.length, 1); + expect( + controller.historyController.stack[0].code.text, + MethodSnippet.full + 'a', + ); + expect( + controller.historyController.stack[0].selection, + const TextSelection.collapsed( + offset: MethodSnippet.visible.length, + ), + ); + }); + }); +} From 0c23cc68f39ed1cc03ea375ac6063bd051bbde77 Mon Sep 17 00:00:00 2001 From: Alexey Inkin Date: Mon, 31 Oct 2022 12:24:26 +0400 Subject: [PATCH 2/6] Fix after review (#97) --- lib/src/code_field/text_editing_value.dart | 26 ++++++++++--------- lib/src/history/code_history_controller.dart | 14 +++++----- .../history/code_history_controller_test.dart | 8 ++++++ 3 files changed, 29 insertions(+), 19 deletions(-) diff --git a/lib/src/code_field/text_editing_value.dart b/lib/src/code_field/text_editing_value.dart index b986230d..b274794a 100644 --- a/lib/src/code_field/text_editing_value.dart +++ b/lib/src/code_field/text_editing_value.dart @@ -74,10 +74,6 @@ extension TextEditingValueExtension on TextEditingValue { return replaced(selection, ''); } - TextEditingValue replacedSelection(String text) { - return replaced(selection, text); - } - TextEditingValue replacedText(String newText) { if (newText == text) { return this; @@ -94,14 +90,6 @@ extension TextEditingValueExtension on TextEditingValue { ); } - TextEditingValue typed(String text) { - final lengthDiff = text.length - selected.length; - - return replaced(selection, text).copyWith( - selection: TextSelection.collapsed(offset: selection.end + lengthDiff), - ); - } - TextEditingValue tabsToSpaces(int spaceCount) { final replacedBefore = beforeSelection.tabsToSpaces(spaceCount); final replacedSelected = selected.tabsToSpaces(spaceCount); @@ -164,4 +152,18 @@ extension TextEditingValueExtension on TextEditingValue { } return text.substring(selection.end); } + + // These are used in tests only. + + TextEditingValue replacedSelection(String text) { + return replaced(selection, text); + } + + TextEditingValue typed(String text) { + final lengthDiff = text.length - selected.length; + + return replaced(selection, text).copyWith( + selection: TextSelection.collapsed(offset: selection.end + lengthDiff), + ); + } } diff --git a/lib/src/history/code_history_controller.dart b/lib/src/history/code_history_controller.dart index 9f7eee63..b1b493ed 100644 --- a/lib/src/history/code_history_controller.dart +++ b/lib/src/history/code_history_controller.dart @@ -52,15 +52,15 @@ class CodeHistoryController { _isTextChanged = true; } - final isTextOneCharLonger = code.text.length == lastCode.text.length + 1; - final isSelectionChangeImportant = !isTextOneCharLonger || - !selection.hasMovedOneCharacterRight(lastSelection); - if (_isTextChanged) { - if (isSelectionChangeImportant) { - save = true; - } else { + final isText1CharLonger = code.text.length == lastCode.text.length + 1; + final isTypingContinuous = isText1CharLonger && + selection.hasMovedOneCharacterRight(lastSelection); + + if (isTypingContinuous) { _setTimer(); + } else { + save = true; } } } diff --git a/test/src/history/code_history_controller_test.dart b/test/src/history/code_history_controller_test.dart index 62ed1939..e13d3a5e 100644 --- a/test/src/history/code_history_controller_test.dart +++ b/test/src/history/code_history_controller_test.dart @@ -298,6 +298,8 @@ void main() { final controller = await pumpController(wt, MethodSnippet.full); await wt.cursorEnd(); + // 1. Fill the limit. + for (int i = 0; i < CodeHistoryController.limit - 1; i++) { controller.value = controller.value.typed('a'); await wt.moveCursor(-1); // Creates. @@ -308,6 +310,8 @@ void main() { CodeHistoryController.limit, ); + // 2. Overflow drops the oldest record. + controller.value = controller.value.typed('a'); await wt.moveCursor(-1); // Creates, drops the oldest. @@ -316,6 +320,8 @@ void main() { CodeHistoryController.limit, ); + // 3. Can undo to bottom. + // One too many. for (int i = 0; i < CodeHistoryController.limit; i++) { await wt.sendUndo(); @@ -329,6 +335,8 @@ void main() { ), ); + // 4. Can redo. + // One too many. for (int i = 0; i < CodeHistoryController.limit; i++) { await wt.sendRedo(); From 78a0ee9422bbd9ff051302aa7012b5c56c3c61b2 Mon Sep 17 00:00:00 2001 From: Alexey Inkin Date: Mon, 31 Oct 2022 12:31:02 +0400 Subject: [PATCH 3/6] Fix after review (#97) --- lib/src/code_field/text_editing_value.dart | 14 -------------- .../code_field/code_controller_folding_test.dart | 2 +- .../code_controller_hidden_ranges_test.dart | 1 + .../code_controller_readonly_test.dart | 1 + test/src/common/text_editing_value.dart | 16 ++++++++++++++++ .../history/code_history_controller_test.dart | 1 + 6 files changed, 20 insertions(+), 15 deletions(-) create mode 100644 test/src/common/text_editing_value.dart diff --git a/lib/src/code_field/text_editing_value.dart b/lib/src/code_field/text_editing_value.dart index b274794a..b79ff844 100644 --- a/lib/src/code_field/text_editing_value.dart +++ b/lib/src/code_field/text_editing_value.dart @@ -152,18 +152,4 @@ extension TextEditingValueExtension on TextEditingValue { } return text.substring(selection.end); } - - // These are used in tests only. - - TextEditingValue replacedSelection(String text) { - return replaced(selection, text); - } - - TextEditingValue typed(String text) { - final lengthDiff = text.length - selected.length; - - return replaced(selection, text).copyWith( - selection: TextSelection.collapsed(offset: selection.end + lengthDiff), - ); - } } diff --git a/test/src/code_field/code_controller_folding_test.dart b/test/src/code_field/code_controller_folding_test.dart index c3b24d6d..b43e6397 100644 --- a/test/src/code_field/code_controller_folding_test.dart +++ b/test/src/code_field/code_controller_folding_test.dart @@ -1,8 +1,8 @@ import 'package:flutter/widgets.dart'; -import 'package:flutter_code_editor/src/code_field/text_editing_value.dart'; import 'package:flutter_test/flutter_test.dart'; import '../common/create_app.dart'; +import '../common/text_editing_value.dart'; import '../common/widget_tester.dart'; const _code = ''' diff --git a/test/src/code_field/code_controller_hidden_ranges_test.dart b/test/src/code_field/code_controller_hidden_ranges_test.dart index 75fe7ab8..8a5f5b54 100644 --- a/test/src/code_field/code_controller_hidden_ranges_test.dart +++ b/test/src/code_field/code_controller_hidden_ranges_test.dart @@ -5,6 +5,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:highlight/languages/java.dart'; import '../common/create_app.dart'; +import '../common/text_editing_value.dart'; const _text = ''' // [START section2] diff --git a/test/src/code_field/code_controller_readonly_test.dart b/test/src/code_field/code_controller_readonly_test.dart index 6f289b6e..a518822f 100644 --- a/test/src/code_field/code_controller_readonly_test.dart +++ b/test/src/code_field/code_controller_readonly_test.dart @@ -5,6 +5,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:highlight/languages/java.dart'; import '../common/create_app.dart'; +import '../common/text_editing_value.dart'; const _editableBeginningEnd = ''' abc diff --git a/test/src/common/text_editing_value.dart b/test/src/common/text_editing_value.dart new file mode 100644 index 00000000..d326d294 --- /dev/null +++ b/test/src/common/text_editing_value.dart @@ -0,0 +1,16 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_code_editor/flutter_code_editor.dart'; + +extension TextEditingValueTestExtension on TextEditingValue { + TextEditingValue replacedSelection(String text) { + return replaced(selection, text); + } + + TextEditingValue typed(String text) { + final lengthDiff = text.length - selected.length; + + return replaced(selection, text).copyWith( + selection: TextSelection.collapsed(offset: selection.end + lengthDiff), + ); + } +} diff --git a/test/src/history/code_history_controller_test.dart b/test/src/history/code_history_controller_test.dart index e13d3a5e..fef0e893 100644 --- a/test/src/history/code_history_controller_test.dart +++ b/test/src/history/code_history_controller_test.dart @@ -9,6 +9,7 @@ import 'package:flutter_test/flutter_test.dart'; import '../common/create_app.dart'; import '../common/snippets.dart'; +import '../common/text_editing_value.dart'; import '../common/widget_tester.dart'; void main() { From d66f7899e3aa5f54ee13ed5143388a209e4ea90a Mon Sep 17 00:00:00 2001 From: Alexey Inkin Date: Mon, 31 Oct 2022 13:03:12 +0400 Subject: [PATCH 4/6] Fix after review (#97) --- lib/src/history/code_history_controller.dart | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/lib/src/history/code_history_controller.dart b/lib/src/history/code_history_controller.dart index b1b493ed..87c97d36 100644 --- a/lib/src/history/code_history_controller.dart +++ b/lib/src/history/code_history_controller.dart @@ -41,13 +41,13 @@ class CodeHistoryController { void beforeChanged(Code code, TextSelection selection) { _dropRedoIfNeed(); - bool save = false; + bool shouldSave = false; if (_isTextChanged) { - save = code.lines.lines.length != lastCode.lines.lines.length; + shouldSave = code.lines.lines.length != lastCode.lines.lines.length; } - if (!save) { + if (!shouldSave) { if (lastCode.text != code.text) { _isTextChanged = true; } @@ -60,12 +60,12 @@ class CodeHistoryController { if (isTypingContinuous) { _setTimer(); } else { - save = true; + shouldSave = true; } } } - if (save) { + if (shouldSave) { _push(); } From 80deb07aeab8b22f3b55a49ffc84f08f02cd2564 Mon Sep 17 00:00:00 2001 From: Alexey Inkin Date: Mon, 31 Oct 2022 13:23:53 +0400 Subject: [PATCH 5/6] Fix after review (#97) --- lib/src/history/code_history_controller.dart | 15 ++++----- lib/src/history/limit_stack.dart | 34 ++++++++++++++++++++ 2 files changed, 40 insertions(+), 9 deletions(-) create mode 100644 lib/src/history/limit_stack.dart diff --git a/lib/src/history/code_history_controller.dart b/lib/src/history/code_history_controller.dart index 87c97d36..6d839c9d 100644 --- a/lib/src/history/code_history_controller.dart +++ b/lib/src/history/code_history_controller.dart @@ -1,11 +1,13 @@ import 'dart:async'; -import 'package:flutter/widgets.dart'; +import 'package:flutter/services.dart'; +import 'package:meta/meta.dart'; import '../code/code.dart'; import '../code_field/code_controller.dart'; import '../code_field/text_selection.dart'; import 'code_history_record.dart'; +import 'limit_stack.dart'; /// A custom undo/redo implementation for [CodeController]. /// @@ -27,7 +29,7 @@ class CodeHistoryController { Timer? _debounceTimer; @visibleForTesting - final stack = []; + final stack = LimitStack(maxLength: limit); static const idle = Duration(seconds: 5); static const limit = 100; @@ -74,7 +76,7 @@ class CodeHistoryController { } void _dropRedoIfNeed() { - stack.removeRange(_currentRecordIndex + 1, stack.length); + stack.removeAfter(_currentRecordIndex + 1); } void undo() { @@ -123,13 +125,8 @@ class CodeHistoryController { } void _pushRecord(CodeHistoryRecord record) { - stack.add(record); + stack.push(record); _currentRecordIndex = stack.length - 1; - - if (stack.length > limit) { - stack.removeRange(0, stack.length - limit); - _currentRecordIndex = limit - 1; - } } void deleteHistory() { diff --git a/lib/src/history/limit_stack.dart b/lib/src/history/limit_stack.dart new file mode 100644 index 00000000..096637ae --- /dev/null +++ b/lib/src/history/limit_stack.dart @@ -0,0 +1,34 @@ +class LimitStack extends Iterable { + final int maxLength; + final _items = []; + + LimitStack({ + required this.maxLength, + }); + + @override + int get length => _items.length; + + void push(T value) { + _items.add(value); + + if (_items.length > maxLength) { + _items.removeRange(0, _items.length - maxLength); + } + } + + void removeAfter(int n) { + _items.removeRange(n, _items.length); + } + + void clear() { + _items.clear(); + } + + T operator [] (int n) { + return _items[n]; + } + + @override + Iterator get iterator => _items.iterator; +} From f80ec4db02ec1568009ab3b7bff0e260dffaf05d Mon Sep 17 00:00:00 2001 From: Alexey Inkin Date: Mon, 31 Oct 2022 13:25:55 +0400 Subject: [PATCH 6/6] Auto-format (#97) --- lib/src/history/limit_stack.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/src/history/limit_stack.dart b/lib/src/history/limit_stack.dart index 096637ae..911dbb77 100644 --- a/lib/src/history/limit_stack.dart +++ b/lib/src/history/limit_stack.dart @@ -25,7 +25,7 @@ class LimitStack extends Iterable { _items.clear(); } - T operator [] (int n) { + T operator [](int n) { return _items[n]; }