From cd0e532e878ef850b31ca0eb2f7878a804964851 Mon Sep 17 00:00:00 2001 From: Matt Carroll Date: Fri, 15 Jul 2022 19:18:16 -0700 Subject: [PATCH] Filled out test suite for EventSourcedAttributedTextEditingController --- .../infrastructure/editing/commands.dart | 196 ++++ .../editing/event_source_controller.dart | 180 +++- .../editing/event_source_controller_test.dart | 872 ++++++++++++++++-- 3 files changed, 1170 insertions(+), 78 deletions(-) diff --git a/super_editor/lib/src/infrastructure/super_textfield/infrastructure/editing/commands.dart b/super_editor/lib/src/infrastructure/super_textfield/infrastructure/editing/commands.dart index 81d6f841f..39967572e 100644 --- a/super_editor/lib/src/infrastructure/super_textfield/infrastructure/editing/commands.dart +++ b/super_editor/lib/src/infrastructure/super_textfield/infrastructure/editing/commands.dart @@ -5,6 +5,41 @@ import 'package:super_editor/src/infrastructure/super_textfield/infrastructure/a import 'event_source_value.dart'; +/// Changes the text selection from its current value to the given selection. +class ChangeSelectionCommand extends AttributedTextEditingValueCommand { + ChangeSelectionCommand({ + required this.newSelection, + this.newComposingRange, + }); + + final TextSelection newSelection; + final TextRange? newComposingRange; + + TextSelection? _previousSelection; + TextRange? _previousComposingRange; + + @override + AttributedTextEditingValue doExecute(AttributedTextEditingValue previousValue) { + _previousSelection = previousValue.selection; + _previousComposingRange = previousValue.composingRegion; + + return AttributedTextEditingValue( + text: previousValue.text, + selection: newSelection, + composingRegion: newComposingRange ?? TextRange.empty, + ); + } + + @override + AttributedTextEditingValue doUndo(AttributedTextEditingValue currentValue) { + return AttributedTextEditingValue( + text: currentValue.text, + selection: _previousSelection!, + composingRegion: _previousComposingRange!, + ); + } +} + /// Selects all text in the editable. class SelectAllCommand extends AttributedTextEditingValueCommand { TextSelection? _previousSelection; @@ -283,6 +318,110 @@ class DeleteSelectedTextCommand extends AttributedTextEditingValueCommand { } } +/// Deletes text within a given range, and updates the selection and composing +/// region to the given values. +class DeleteCommand extends AttributedTextEditingValueCommand { + DeleteCommand({ + required this.deletionRange, + required this.newSelection, + this.newComposingRegion, + }); + + final TextRange deletionRange; + final TextSelection newSelection; + final TextRange? newComposingRegion; + + AttributedText? _deletedText; + TextSelection? _previousSelection; + TextRange? _previousComposingRegion; + + @override + AttributedTextEditingValue doExecute(AttributedTextEditingValue previousValue) { + _deletedText = previousValue.text.copyText(deletionRange.start, deletionRange.end); + _previousSelection = previousValue.selection; + _previousComposingRegion = previousValue.composingRegion; + + final updatedText = previousValue.text.removeRegion(startOffset: deletionRange.start, endOffset: deletionRange.end); + final updatedSelection = newSelection.isValid + ? newSelection + : _moveSelectionForDeletion( + selection: previousValue.selection, deleteFrom: deletionRange.start, deleteTo: deletionRange.end); + + return AttributedTextEditingValue( + text: updatedText, + selection: updatedSelection, + composingRegion: newComposingRegion ?? TextRange.empty, + ); + } + + @override + AttributedTextEditingValue doUndo(AttributedTextEditingValue currentValue) { + return AttributedTextEditingValue( + text: currentValue.text.insert(textToInsert: _deletedText!, startOffset: deletionRange.start), + selection: _previousSelection!, + composingRegion: _previousComposingRegion!, + ); + } +} + +class ReplaceCommand extends AttributedTextEditingValueCommand { + ReplaceCommand({ + required this.newText, + required this.replacementRange, + this.newSelection, + this.newComposingRegion, + }); + + final AttributedText newText; + final TextRange replacementRange; + final TextSelection? newSelection; + final TextRange? newComposingRegion; + + AttributedText? _replacedText; + TextSelection? _previousSelection; + TextRange? _previousComposingRegion; + + @override + AttributedTextEditingValue doExecute(AttributedTextEditingValue previousValue) { + _replacedText = previousValue.text.copyText(replacementRange.start, replacementRange.end); + _previousSelection = previousValue.selection; + _previousComposingRegion = previousValue.composingRegion; + + AttributedText updatedText = + previousValue.text.removeRegion(startOffset: replacementRange.start, endOffset: replacementRange.end); + TextSelection updatedSelection = newSelection ?? + _moveSelectionForDeletion( + selection: previousValue.selection, + deleteFrom: replacementRange.start, + deleteTo: replacementRange.end, + ); + updatedText = updatedText.insert(textToInsert: newText, startOffset: replacementRange.start); + updatedSelection = newSelection ?? + _moveSelectionForInsertion( + selection: updatedSelection, + insertIndex: replacementRange.start, + newTextLength: newText.text.length, + ); + + return AttributedTextEditingValue( + text: updatedText, + selection: updatedSelection, + composingRegion: newComposingRegion ?? TextRange.empty, + ); + } + + @override + AttributedTextEditingValue doUndo(AttributedTextEditingValue currentValue) { + return AttributedTextEditingValue( + text: currentValue.text + .removeRegion(startOffset: replacementRange.start, endOffset: replacementRange.start + newText.text.length) + .insert(textToInsert: _replacedText!, startOffset: replacementRange.start), + selection: _previousSelection!, + composingRegion: _previousComposingRegion!, + ); + } +} + /// Replaces the text, selection, and composing region in the editable. class ReplaceEverythingCommand extends AttributedTextEditingValueCommand { ReplaceEverythingCommand({ @@ -321,3 +460,60 @@ class ReplaceEverythingCommand extends AttributedTextEditingValueCommand { ); } } + +TextSelection _moveSelectionForInsertion({ + required TextSelection selection, + required int insertIndex, + required int newTextLength, +}) { + int newBaseOffset = selection.baseOffset; + if ((selection.baseOffset == insertIndex && selection.isCollapsed) || (selection.baseOffset > insertIndex)) { + newBaseOffset = selection.baseOffset + newTextLength; + } + + final newExtentOffset = + selection.extentOffset >= insertIndex ? selection.extentOffset + newTextLength : selection.extentOffset; + + return TextSelection( + baseOffset: newBaseOffset, + extentOffset: newExtentOffset, + ); +} + +TextSelection _moveSelectionForDeletion({ + required TextSelection selection, + required int deleteFrom, + required int deleteTo, +}) { + return TextSelection( + baseOffset: _moveCaretForDeletion( + caretOffset: selection.baseOffset, + deleteFrom: deleteFrom, + deleteTo: deleteTo, + ), + extentOffset: _moveCaretForDeletion( + caretOffset: selection.extentOffset, + deleteFrom: deleteFrom, + deleteTo: deleteTo, + ), + ); +} + +int _moveCaretForDeletion({ + required int caretOffset, + required int deleteFrom, + required int deleteTo, +}) { + if (caretOffset <= deleteFrom) { + return caretOffset; + } else if (caretOffset <= deleteTo) { + // The caret is sitting within the deleted text region. + // Move the caret to the beginning of the deleted region. + return deleteFrom; + } else { + // The caret is sitting beyond the deleted text region. + // Move the caret so that its new distance to deleteFrom + // is equal to its current distance from deleteTo. + return deleteFrom + (caretOffset - deleteTo); + } +} diff --git a/super_editor/lib/src/infrastructure/super_textfield/infrastructure/editing/event_source_controller.dart b/super_editor/lib/src/infrastructure/super_textfield/infrastructure/editing/event_source_controller.dart index 1a9366f86..b3f53da3c 100644 --- a/super_editor/lib/src/infrastructure/super_textfield/infrastructure/editing/event_source_controller.dart +++ b/super_editor/lib/src/infrastructure/super_textfield/infrastructure/editing/event_source_controller.dart @@ -5,8 +5,8 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/painting.dart'; import 'package:flutter/services.dart'; import 'package:super_editor/src/core/document_layout.dart'; -import 'package:super_editor/src/infrastructure/_logging.dart'; import 'package:super_editor/src/infrastructure/attributed_text_styles.dart'; +import 'package:super_editor/src/infrastructure/strings.dart'; import 'package:super_editor/src/infrastructure/super_textfield/infrastructure/attributed_text_editing_controller.dart'; import 'package:super_text_layout/super_text_layout.dart'; @@ -84,9 +84,6 @@ class EventSourcedAttributedTextEditingController with ChangeNotifier implements final _composingAttributions = {}; - // TODO: good litmus test - what if a developer wanted composing attribution - // presence to be undo/redo-able? Or the same for some other editing configuration? - /// Attributions that will be applied to the next inserted character(s). @override Set get composingAttributions => Set.from(_composingAttributions); @@ -149,7 +146,87 @@ class EventSourcedAttributedTextEditingController with ChangeNotifier implements required bool moveLeft, required MovementModifier? movementModifier, }) { - // TODO: create a command + int newExtent; + + if (moveLeft) { + if (selection.extentOffset <= 0 && selection.isCollapsed) { + // Can't move further left. + return; + } + + if (!selection.isCollapsed && !expandSelection) { + // The selection isn't collapsed and the user doesn't + // want to continue expanding the selection. Move the + // extent to the left side of the selection. + newExtent = selection.start; + } else if (movementModifier != null && movementModifier == MovementModifier.line) { + newExtent = textLayout.getPositionAtStartOfLine(TextPosition(offset: selection.extentOffset)).offset; + } else if (movementModifier != null && movementModifier == MovementModifier.word) { + final plainText = text.text; + + newExtent = selection.extentOffset; + newExtent -= 1; // we always want to jump at least 1 character. + while (newExtent > 0 && plainText[newExtent - 1] != ' ' && plainText[newExtent - 1] != '\n') { + newExtent -= 1; + } + } else { + newExtent = text.text.moveOffsetUpstreamByCharacter(selection.extentOffset) ?? 0; + } + } else { + if (selection.extentOffset >= text.text.length && selection.isCollapsed) { + // Can't move further right. + return; + } + + if (!selection.isCollapsed && !expandSelection) { + // The selection isn't collapsed and the user doesn't + // want to continue expanding the selection. Move the + // extent to the left side of the selection. + newExtent = selection.end; + } else if (movementModifier != null && movementModifier == MovementModifier.line) { + final endOfLine = textLayout.getPositionAtEndOfLine(TextPosition(offset: selection.extentOffset)); + + final endPosition = TextPosition(offset: text.text.length); + final plainText = text.text; + + // Note: we compare offset values because we don't care if the affinitys are equal + final isAutoWrapLine = endOfLine.offset != endPosition.offset && (plainText[endOfLine.offset] != '\n'); + + // Note: For lines that auto-wrap, moving the cursor to `offset` causes the + // cursor to jump to the next line because the cursor is placed after + // the final selected character. We don't want this, so in this case + // we `-1`. + // + // However, if the line that is selected ends with an explicit `\n`, + // or if the line is the terminal line for the paragraph then we don't + // want to `-1` because that would leave a dangling character after the + // selection. + // TODO: this is the concept of text affinity. Implement support for affinity. + // TODO: with affinity, ensure it works as expected for right-aligned text + // TODO: this logic fails for justified text - find a solution for that (#55) + newExtent = isAutoWrapLine ? endOfLine.offset - 1 : endOfLine.offset; + } else if (movementModifier != null && movementModifier == MovementModifier.word) { + final extentPosition = selection.extent; + final plainText = text.text; + + newExtent = extentPosition.offset; + newExtent += 1; // we always want to jump at least 1 character. + while (newExtent < plainText.length && plainText[newExtent] != ' ' && plainText[newExtent] != '\n') { + newExtent += 1; + } + } else { + newExtent = text.text.moveOffsetDownstreamByCharacter(selection.extentOffset) ?? text.text.length; + } + } + + _value.execute( + ChangeSelectionCommand( + newSelection: TextSelection( + baseOffset: expandSelection ? selection.baseOffset : newExtent, + extentOffset: newExtent, + ), + ), + ); } @override @@ -158,7 +235,30 @@ class EventSourcedAttributedTextEditingController with ChangeNotifier implements required bool expandSelection, required bool moveUp, }) { - // TODO: create a command + int? newExtent; + + if (moveUp) { + newExtent = textLayout.getPositionOneLineUp(TextPosition(offset: selection.start))?.offset; + + // If there is no line above the current selection, move selection + // to the beginning of the available text. + newExtent ??= 0; + } else { + newExtent = textLayout.getPositionOneLineDown(TextPosition(offset: selection.end))?.offset; + + // If there is no line below the current selection, move selection + // to the end of the available text. + newExtent ??= text.text.length; + } + + _value.execute( + ChangeSelectionCommand( + newSelection: TextSelection( + baseOffset: expandSelection ? selection.baseOffset : newExtent, + extentOffset: newExtent, + ), + ), + ); } /// Toggles the presence of each of the given [attributions] within @@ -431,7 +531,16 @@ class EventSourcedAttributedTextEditingController with ChangeNotifier implements TextSelection? newSelection, TextRange? newComposingRegion, }) { - // TODO: create a command + _value.execute( + ReplaceCommand( + newText: newText, + replacementRange: TextRange(start: from, end: to), + newSelection: newSelection, + newComposingRegion: newComposingRegion, + ), + ); + + _updateComposingAttributions(); } /// Deletes all the text on the current line that appears upstream from the @@ -442,15 +551,23 @@ class EventSourcedAttributedTextEditingController with ChangeNotifier implements }) { assert(selection.isCollapsed); - // TODO: create a command + final startOfLinePosition = textLayout.getPositionAtStartOfLine(selection.extent); + _value.execute( + DeleteCommand( + deletionRange: TextSelection( + baseOffset: selection.extentOffset, + extentOffset: startOfLinePosition.offset, + ), + newSelection: TextSelection.collapsed(offset: startOfLinePosition.offset), + ), + ); } // TODO: either this method or the next one should be deleted @override void deleteSelectedText() { assert(!selection.isCollapsed); - - // TODO: create a command + deleteSelection(); } /// Deletes the text within the current [selection]. @@ -464,7 +581,13 @@ class EventSourcedAttributedTextEditingController with ChangeNotifier implements return; } - // TODO: create a command + _value.execute( + DeleteCommand( + deletionRange: TextRange(start: selection.start, end: selection.end + 1), + newSelection: TextSelection.collapsed(offset: selection.start), + newComposingRegion: newComposingRegion, + ), + ); } @override @@ -492,12 +615,16 @@ class EventSourcedAttributedTextEditingController with ChangeNotifier implements if (!selection.isCollapsed) { return; } - if (selection.extentOffset == 0) { return; } - // TODO: create a command + delete( + from: selection.extentOffset - 1, + to: selection.extentOffset, + newSelection: TextSelection.collapsed(offset: selection.extentOffset - 1), + newComposingRegion: newComposingRegion, + ); } /// Deletes the character after the currently collapsed [selection]. @@ -510,12 +637,16 @@ class EventSourcedAttributedTextEditingController with ChangeNotifier implements if (!selection.isCollapsed) { return; } - if (selection.extentOffset >= text.text.length) { return; } - // TODO: create a command + delete( + from: selection.extentOffset, + to: selection.extentOffset + 1, + newSelection: TextSelection.collapsed(offset: selection.extentOffset), + newComposingRegion: newComposingRegion, + ); } /// Removes the text between [from] (inclusive) and [to] (exclusive). @@ -533,7 +664,13 @@ class EventSourcedAttributedTextEditingController with ChangeNotifier implements TextSelection? newSelection, TextRange? newComposingRegion, }) { - // TODO: create a command + _value.execute(DeleteCommand( + deletionRange: TextRange(start: from, end: to), + newSelection: newSelection ?? const TextSelection.collapsed(offset: -1), + newComposingRegion: newComposingRegion, + )); + + _updateComposingAttributions(); } /// Sets the text to empty and removes the selection and composing region. @@ -581,7 +718,16 @@ class EventSourcedAttributedTextEditingController with ChangeNotifier implements if (clipboardData != null && clipboardData.text != null) { final textToPaste = clipboardData.text!; - // TODO: create a command + _value.execute(InsertTextAtOffsetCommand( + textToInsert: text.insertString( + textToInsert: textToPaste, + startOffset: insertionOffset, + ), + insertionOffset: insertionOffset, + selectionAfter: TextSelection.collapsed( + offset: insertionOffset + textToPaste.length, + ), + )); } } diff --git a/super_editor/test/super_textfield/infrastructure/editing/event_source_controller_test.dart b/super_editor/test/super_textfield/infrastructure/editing/event_source_controller_test.dart index 71660473b..26a90e3b6 100644 --- a/super_editor/test/super_textfield/infrastructure/editing/event_source_controller_test.dart +++ b/super_editor/test/super_textfield/infrastructure/editing/event_source_controller_test.dart @@ -1,8 +1,12 @@ +import 'dart:math'; +import 'dart:ui'; + import 'package:attributed_text/attributed_text.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:super_editor/src/default_editor/attributions.dart'; import 'package:super_editor/src/infrastructure/super_textfield/super_textfield.dart'; +import 'package:super_text_layout/super_text_layout.dart'; import '../../super_textfield_text_test_tools.dart'; @@ -61,7 +65,23 @@ void main() { }); test("by clearing everything", () { - // TODO: + final controller = EventSourcedAttributedTextEditingController( + AttributedTextEditingValue( + text: AttributedText( + text: "This is some existing text.", + ), + selection: const TextSelection.collapsed(offset: 27), // end of text + ), + ); + + controller.clear(); + expect(controller.text.text, ""); + expect(controller.selection, const TextSelection.collapsed(offset: -1)); + + // Undo it. + controller.undo(); + expect(controller.text.text, "This is some existing text."); + expect(controller.selection, const TextSelection.collapsed(offset: 27)); }); }); @@ -145,16 +165,224 @@ void main() { }); group("moves the caret", () { - test("to a different position", () { - // TODO: - }); - - test("by expanding the selection", () { - // TODO: + group("horizontally", () { + test("upstream and downstream", () { + final controller = EventSourcedAttributedTextEditingController( + AttributedTextEditingValue( + text: AttributedText( + text: _multilineText.join('\n'), + ), + selection: const TextSelection.collapsed(offset: 5), + ), + ); + + // Move one character downstream + controller.moveCaretHorizontally( + textLayout: _FakeTextLayout(_multilineText), + moveLeft: false, + movementModifier: null, + expandSelection: false, + ); + expect(controller.selection, const TextSelection.collapsed(offset: 6)); + + // Undo it. + controller.undo(); + expect(controller.selection, const TextSelection.collapsed(offset: 5)); + + // Move one character upstream + controller.moveCaretHorizontally( + textLayout: _FakeTextLayout(_multilineText), + moveLeft: true, + movementModifier: null, + expandSelection: false, + ); + expect(controller.selection, const TextSelection.collapsed(offset: 4)); + + // Undo it. + controller.undo(); + expect(controller.selection, const TextSelection.collapsed(offset: 5)); + }); + + test("by expanding the selection", () { + final controller = EventSourcedAttributedTextEditingController( + AttributedTextEditingValue( + text: AttributedText( + text: _multilineText.join('\n'), + ), + selection: const TextSelection.collapsed(offset: 5), + ), + ); + + // Move one character downstream and expand. + controller.moveCaretHorizontally( + textLayout: _FakeTextLayout(_multilineText), + moveLeft: false, + movementModifier: null, + expandSelection: true, + ); + expect(controller.selection, const TextSelection(baseOffset: 5, extentOffset: 6)); + + // Undo it. + controller.undo(); + expect(controller.selection, const TextSelection.collapsed(offset: 5)); + + // Move one character upstream and expand. + controller.moveCaretHorizontally( + textLayout: _FakeTextLayout(_multilineText), + moveLeft: true, + movementModifier: null, + expandSelection: true, + ); + expect(controller.selection, const TextSelection(baseOffset: 5, extentOffset: 4)); + + // Undo it. + controller.undo(); + expect(controller.selection, const TextSelection.collapsed(offset: 5)); + }); + + test("by collapsing the selection", () { + final controller = EventSourcedAttributedTextEditingController( + AttributedTextEditingValue( + text: AttributedText( + text: _multilineText.join('\n'), + ), + selection: const TextSelection(baseOffset: 5, extentOffset: 7), + ), + ); + + // Collapse selection downstream + controller.moveCaretHorizontally( + textLayout: _FakeTextLayout(_multilineText), + moveLeft: false, + movementModifier: null, + expandSelection: false, + ); + expect(controller.selection, const TextSelection.collapsed(offset: 7)); + + // Undo it. + controller.undo(); + expect(controller.selection, const TextSelection(baseOffset: 5, extentOffset: 7)); + + // Move one character upstream + controller.moveCaretHorizontally( + textLayout: _FakeTextLayout(_multilineText), + moveLeft: true, + movementModifier: null, + expandSelection: false, + ); + expect(controller.selection, const TextSelection.collapsed(offset: 5)); + + // Undo it. + controller.undo(); + expect(controller.selection, const TextSelection(baseOffset: 5, extentOffset: 7)); + }); }); - test("by collapsing the selection", () { - // TODO: + group("vertically", () { + test("upstream and downstream", () { + final controller = EventSourcedAttributedTextEditingController( + AttributedTextEditingValue( + text: AttributedText( + text: _multilineText.join('\n'), + ), + selection: const TextSelection.collapsed(offset: 25), // middle of line 2 + ), + ); + + // Move selection down a line. + controller.moveCaretVertically( + textLayout: _FakeTextLayout(_multilineText), + moveUp: false, + expandSelection: false, + ); + expect(controller.selection, const TextSelection.collapsed(offset: 42)); + + // Undo it. + controller.undo(); + expect(controller.selection, const TextSelection.collapsed(offset: 25)); + + // Move selection up a line. + controller.moveCaretVertically( + textLayout: _FakeTextLayout(_multilineText), + moveUp: true, + expandSelection: false, + ); + expect(controller.selection, const TextSelection.collapsed(offset: 8)); + + // Undo it. + controller.undo(); + expect(controller.selection, const TextSelection.collapsed(offset: 25)); + }); + + test("by expanding the selection", () { + final controller = EventSourcedAttributedTextEditingController( + AttributedTextEditingValue( + text: AttributedText( + text: _multilineText.join('\n'), + ), + selection: const TextSelection.collapsed(offset: 25), // middle of line 2 + ), + ); + + // Move selection down a line and expand. + controller.moveCaretVertically( + textLayout: _FakeTextLayout(_multilineText), + moveUp: false, + expandSelection: true, + ); + expect(controller.selection, const TextSelection(baseOffset: 25, extentOffset: 42)); + + // Undo it. + controller.undo(); + expect(controller.selection, const TextSelection.collapsed(offset: 25)); + + // Move selection up a line and expand. + controller.moveCaretVertically( + textLayout: _FakeTextLayout(_multilineText), + moveUp: true, + expandSelection: true, + ); + expect(controller.selection, const TextSelection(baseOffset: 25, extentOffset: 8)); + + // Undo it. + controller.undo(); + expect(controller.selection, const TextSelection.collapsed(offset: 25)); + }); + + test("by collapsing the selection", () { + final controller = EventSourcedAttributedTextEditingController( + AttributedTextEditingValue( + text: AttributedText( + text: _multilineText.join('\n'), + ), + selection: const TextSelection(baseOffset: 25, extentOffset: 42), // line 2 -> 3 + ), + ); + + // Collapse selection down and try to move down a line. + controller.moveCaretVertically( + textLayout: _FakeTextLayout(_multilineText), + moveUp: false, + expandSelection: false, + ); + expect(controller.selection, const TextSelection.collapsed(offset: 55)); + + // Undo it. + controller.undo(); + expect(controller.selection, const TextSelection(baseOffset: 25, extentOffset: 42)); + + // Collapse the selection upward, and try to move up a line. + controller.moveCaretVertically( + textLayout: _FakeTextLayout(_multilineText), + moveUp: true, + expandSelection: false, + ); + expect(controller.selection, const TextSelection.collapsed(offset: 8)); + + // Undo it. + controller.undo(); + expect(controller.selection, const TextSelection(baseOffset: 25, extentOffset: 42)); + }); }); }); @@ -441,12 +669,30 @@ void main() { ), ); - // TODO: - // controller.replace( - // newText: newText, - // from: from, - // to: to, - // ); + controller.replace( + newText: AttributedText(text: "That's"), + from: 0, + to: 7, + ); + expect(controller.text.text, "That's some existing text."); + expect( + controller.selection, + const TextSelection( + baseOffset: 20, + extentOffset: 21, + ), + ); + + // Undo it. + controller.undo(); + expect(controller.text.text, "This is some existing text."); + expect( + controller.selection, + const TextSelection( + baseOffset: 21, + extentOffset: 22, + ), + ); }); test("by replacing arbitrary text that overlaps the caret", () { @@ -455,19 +701,22 @@ void main() { text: AttributedText( text: "This is some existing text.", ), - selection: const TextSelection( - baseOffset: 21, - extentOffset: 22, - ), + selection: const TextSelection.collapsed(offset: 17), ), ); - // TODO: - // controller.replace( - // newText: newText, - // from: from, - // to: to, - // ); + controller.replace( + newText: AttributedText(text: "other"), + from: 8, + to: 21, + ); + expect(controller.text.text, "This is other text."); + expect(controller.selection, const TextSelection.collapsed(offset: 13)); + + // Undo it. + controller.undo(); + expect(controller.text.text, "This is some existing text."); + expect(controller.selection, const TextSelection.collapsed(offset: 17)); }); test("by replacing arbitrary text away from an expanded selection", () { @@ -477,18 +726,36 @@ void main() { text: "This is some existing text.", ), selection: const TextSelection( - baseOffset: 21, - extentOffset: 22, + baseOffset: 0, + extentOffset: 11, ), ), ); - // TODO: - // controller.replace( - // newText: newText, - // from: from, - // to: to, - // ); + controller.replace( + newText: AttributedText(text: "new"), + from: 13, + to: 21, + ); + expect(controller.text.text, "This is some new text."); + expect( + controller.selection, + const TextSelection( + baseOffset: 0, + extentOffset: 11, + ), + ); + + // Undo it. + controller.undo(); + expect(controller.text.text, "This is some existing text."); + expect( + controller.selection, + const TextSelection( + baseOffset: 0, + extentOffset: 11, + ), + ); }); test("by replacing arbitrary text contained within an expanded selection", () { @@ -498,18 +765,36 @@ void main() { text: "This is some existing text.", ), selection: const TextSelection( - baseOffset: 21, - extentOffset: 22, + baseOffset: 8, + extentOffset: 21, ), ), ); - // TODO: - // controller.replace( - // newText: newText, - // from: from, - // to: to, - // ); + controller.replace( + newText: AttributedText(text: "new"), + from: 13, + to: 21, + ); + expect(controller.text.text, "This is some new text."); + expect( + controller.selection, + const TextSelection( + baseOffset: 8, + extentOffset: 16, + ), + ); + + // Undo it. + controller.undo(); + expect(controller.text.text, "This is some existing text."); + expect( + controller.selection, + const TextSelection( + baseOffset: 8, + extentOffset: 21, + ), + ); }); test("by replacing arbitrary text that overlaps an expanded selection", () { @@ -519,18 +804,30 @@ void main() { text: "This is some existing text.", ), selection: const TextSelection( - baseOffset: 21, - extentOffset: 22, + baseOffset: 13, + extentOffset: 26, ), ), ); - // TODO: - // controller.replace( - // newText: newText, - // from: from, - // to: to, - // ); + controller.replace( + newText: AttributedText(text: "thing else"), + from: 12, + to: 26, + ); + expect(controller.text.text, "This is something else."); + expect(controller.selection, const TextSelection.collapsed(offset: 22)); + + // Undo it. + controller.undo(); + expect(controller.text.text, "This is some existing text."); + expect( + controller.selection, + const TextSelection( + baseOffset: 13, + extentOffset: 26, + ), + ); }); }); @@ -539,32 +836,306 @@ void main() { // TODO: }); - test("when its selected", () { + test("between the caret and the end of the line", () { // TODO: }); + test("when it's selected", () { + final controller = EventSourcedAttributedTextEditingController( + AttributedTextEditingValue( + text: AttributedText(text: "before:selected:after"), + selection: const TextSelection( + baseOffset: 7, + extentOffset: 14, + ), + ), + ); + + controller.deleteSelectedText(); + expect(controller.text.text, "before::after"); + expect(controller.selection, const TextSelection.collapsed(offset: 7)); + + // Undo it. + controller.undo(); + expect(controller.text.text, "before:selected:after"); + expect( + controller.selection, + const TextSelection( + baseOffset: 7, + extentOffset: 14, + ), + ); + }); + test("by character", () { - // TODO: + final controller = EventSourcedAttributedTextEditingController( + AttributedTextEditingValue( + text: AttributedText(text: "abcd"), + selection: const TextSelection.collapsed(offset: 2), + ), + ); + + controller.deletePreviousCharacter(); + expect(controller.text.text, "acd"); + expect(controller.selection, const TextSelection.collapsed(offset: 1)); + + controller.deleteNextCharacter(); + expect(controller.text.text, "ad"); + expect(controller.selection, const TextSelection.collapsed(offset: 1)); + + // Undo it. + controller.undo(); + controller.undo(); + expect(controller.text.text, "abcd"); + expect(controller.selection, const TextSelection.collapsed(offset: 2)); }); - test("that sits away from the caret", () { - // TODO: + test('from beginning', () { + final controller = EventSourcedAttributedTextEditingController( + AttributedTextEditingValue( + text: AttributedText(text: "deleteme:existing text"), + ), + ); + + controller.delete(from: 0, to: 8); + expect(controller.text.text, equals(':existing text')); + + // Undo it. + controller.undo(); + expect(controller.text.text, "deleteme:existing text"); }); - test("that overlaps the caret", () { - // TODO: + test('from beginning with caret', () { + final controller = EventSourcedAttributedTextEditingController( + AttributedTextEditingValue( + text: AttributedText(text: "deleteme:existing text"), + selection: const TextSelection.collapsed(offset: 8), + ), + ); + + controller.delete(from: 0, to: 8); + expect(controller.text.text, equals(':existing text')); + expect(controller.selection, equals(const TextSelection.collapsed(offset: 0))); + + // Undo it. + controller.undo(); + expect(controller.text.text, "deleteme:existing text"); + expect(controller.selection, const TextSelection.collapsed(offset: 8)); }); - test("that sits away from an expanded selection", () { - // TODO: + test('from beginning with selection', () { + final controller = EventSourcedAttributedTextEditingController( + AttributedTextEditingValue( + text: AttributedText(text: "deleteme:existing text"), + selection: const TextSelection( + baseOffset: 4, + extentOffset: 17, + ), + ), + ); + + controller.delete(from: 0, to: 8); + expect(controller.text.text, equals(':existing text')); + expect( + controller.selection, + equals( + const TextSelection( + baseOffset: 0, + extentOffset: 9, + ), + ), + ); + + // Undo it. + controller.undo(); + expect(controller.text.text, "deleteme:existing text"); + expect( + controller.selection, + const TextSelection( + baseOffset: 4, + extentOffset: 17, + )); }); - test("that sits within an expanded selection", () { - // TODO: + test('from end', () { + final controller = EventSourcedAttributedTextEditingController( + AttributedTextEditingValue( + text: AttributedText(text: "existing text:deleteme"), + ), + ); + + controller.delete(from: 14, to: 22); + expect(controller.text.text, equals('existing text:')); + + // Undo it. + controller.undo(); + expect(controller.text.text, "existing text:deleteme"); + expect(controller.selection, const TextSelection.collapsed(offset: -1)); + }); + + test('from end with caret', () { + final controller = EventSourcedAttributedTextEditingController( + AttributedTextEditingValue( + text: AttributedText(text: "existing text:deleteme"), + // Caret part of the way into the text that will be deleted. + selection: const TextSelection.collapsed(offset: 18), + ), + ); + + controller.delete(from: 14, to: 22); + expect(controller.text.text, equals('existing text:')); + expect( + controller.selection, + equals( + const TextSelection.collapsed(offset: 14), + ), + ); + + // Undo it. + controller.undo(); + expect(controller.text.text, "existing text:deleteme"); + expect(controller.selection, const TextSelection.collapsed(offset: 18)); + }); + + test('from end with selection', () { + final controller = EventSourcedAttributedTextEditingController( + AttributedTextEditingValue( + text: AttributedText(text: "existing text:deleteme"), + // Selection that starts near the end of remaining text and + // extends part way into text that's deleted. + selection: const TextSelection(baseOffset: 11, extentOffset: 18), + ), + ); + + controller.delete(from: 14, to: 22); + expect(controller.text.text, equals('existing text:')); + expect( + controller.selection, + equals( + const TextSelection( + baseOffset: 11, + extentOffset: 14, + ), + ), + ); + + // Undo it. + controller.undo(); + expect(controller.text.text, "existing text:deleteme"); + expect(controller.selection, const TextSelection(baseOffset: 11, extentOffset: 18)); }); - test("that overlaps an expanded selection", () { - // TODO + test('from middle', () { + final controller = EventSourcedAttributedTextEditingController( + AttributedTextEditingValue( + text: AttributedText(text: "[deleteme]"), + ), + ); + + controller.delete(from: 1, to: 9); + expect(controller.text.text, equals('[]')); + + // Undo it. + controller.undo(); + expect(controller.text.text, "[deleteme]"); + expect(controller.selection, const TextSelection.collapsed(offset: -1)); + }); + + test('from middle with crosscutting selection at beginning', () { + final controller = EventSourcedAttributedTextEditingController( + AttributedTextEditingValue( + text: AttributedText(text: "[deleteme]"), + selection: const TextSelection( + baseOffset: 0, + extentOffset: 5, + ), + ), + ); + + controller.delete(from: 1, to: 9); + expect(controller.text.text, equals('[]')); + expect( + controller.selection, + equals( + const TextSelection( + baseOffset: 0, + extentOffset: 1, + ), + ), + ); + + // Undo it. + controller.undo(); + expect(controller.text.text, "[deleteme]"); + expect( + controller.selection, + const TextSelection( + baseOffset: 0, + extentOffset: 5, + )); + }); + + test('from middle with partial selection in middle', () { + final controller = EventSourcedAttributedTextEditingController( + AttributedTextEditingValue( + text: AttributedText(text: "[deleteme]"), + selection: const TextSelection( + baseOffset: 3, + extentOffset: 6, + ), + ), + ); + + controller.delete(from: 1, to: 9); + expect(controller.text.text, equals('[]')); + expect( + controller.selection, + equals(const TextSelection.collapsed(offset: 1)), + ); + + // Undo it. + controller.undo(); + expect(controller.text.text, "[deleteme]"); + expect( + controller.selection, + const TextSelection( + baseOffset: 3, + extentOffset: 6, + )); + }); + + test('from middle with crosscutting selection at end', () { + final controller = EventSourcedAttributedTextEditingController( + AttributedTextEditingValue( + text: AttributedText(text: "[deleteme]"), + selection: const TextSelection( + baseOffset: 5, + extentOffset: 10, + ), + ), + ); + + controller.delete(from: 1, to: 9); + expect(controller.text.text, equals('[]')); + expect( + controller.selection, + equals( + const TextSelection( + baseOffset: 1, + extentOffset: 2, + ), + ), + ); + + // Undo it. + controller.undo(); + expect(controller.text.text, "[deleteme]"); + expect( + controller.selection, + const TextSelection( + baseOffset: 5, + extentOffset: 10, + )); }); }); @@ -573,3 +1144,182 @@ void main() { }); }); } + +// Line positions: +// 0 -> 18 (upstream) +// 18 (downstream) -> 35 (upstream) +// 36 (upstream) -> 55 +const _multilineText = [ + "This is line one.", // assume a "\n" at the end of the line + "This is line two.", // assume a "\n" at the end of the line + "This is line three.", +]; + +class _FakeTextLayout implements ProseTextLayout { + _FakeTextLayout(this._lines); + + final List _lines; + + @override + double get estimatedLineHeight => 18; + + @override + double getLineHeightAtPosition(TextPosition position) { + return 18; + } + + @override + int getLineCount() { + return _lines.length; + } + + @override + bool isTextAtOffset(Offset localOffset) { + throw UnimplementedError(); + } + + @override + TextSelection expandSelection(TextPosition startingPosition, TextExpansion expansion, TextAffinity affinity) { + throw UnimplementedError(); + } + + @override + List getBoxesForSelection(TextSelection selection) { + throw UnimplementedError(); + } + + @override + TextBox? getCharacterBox(TextPosition position) { + throw UnimplementedError(); + } + + @override + double? getHeightForCaret(TextPosition position) { + return 20; + } + + @override + Offset getOffsetAtPosition(TextPosition position) { + throw UnimplementedError(); + } + + @override + Offset getOffsetForCaret(TextPosition position) { + throw UnimplementedError(); + } + + @override + TextPosition? getPositionAtOffset(Offset localOffset) { + throw UnimplementedError(); + } + + @override + TextPosition getPositionAtEndOfLine(TextPosition textPosition) { + int characterCount = 0; + for (int i = 0; i < _lines.length; i += 1) { + if (characterCount <= textPosition.offset && textPosition.offset < characterCount + _lines[i].length) { + return TextPosition(offset: characterCount + _lines[i].length); + } + characterCount += _lines[i].length; + } + + throw Exception("Invalid text position: $textPosition"); + } + + @override + TextPosition getPositionAtStartOfLine(TextPosition textPosition) { + int characterCount = 0; + for (int i = 0; i < _lines.length; i += 1) { + if (characterCount <= textPosition.offset && textPosition.offset < characterCount + _lines[i].length) { + return TextPosition(offset: characterCount); + } + characterCount += _lines[i].length; + } + + throw Exception("Invalid text position: $textPosition"); + } + + @override + TextPosition getPositionInFirstLineAtX(double x) { + throw UnimplementedError(); + } + + @override + TextPosition getPositionInLastLineAtX(double x) { + throw UnimplementedError(); + } + + @override + TextPosition getPositionNearestToOffset(Offset localOffset) { + throw UnimplementedError(); + } + + @override + TextPosition? getPositionOneLineDown(TextPosition textPosition) { + late int lineWithPosition; + late int positionInLine; + int characterCount = 0; + bool isFound = false; + for (int i = 0; i < _lines.length; i += 1) { + if (characterCount <= textPosition.offset && textPosition.offset < characterCount + _lines[i].length) { + isFound = true; + lineWithPosition = i; + positionInLine = textPosition.offset - characterCount; + } + characterCount += _lines[i].length; + + if (isFound) { + break; + } + } + + if (lineWithPosition == _lines.length - 1) { + return null; + } + + final nextLine = lineWithPosition + 1; + return TextPosition( + offset: min(positionInLine, _lines[nextLine].length - 1) + characterCount, + ); + } + + @override + TextPosition? getPositionOneLineUp(TextPosition textPosition) { + late int lineWithPosition; + late int positionInLine; + int characterCount = 0; + bool isFound = false; + for (int i = 0; i < _lines.length; i += 1) { + if (characterCount <= textPosition.offset && textPosition.offset < characterCount + _lines[i].length) { + isFound = true; + lineWithPosition = i; + positionInLine = textPosition.offset - characterCount; + } + + if (isFound) { + break; + } else { + characterCount += _lines[i].length; + } + } + + if (lineWithPosition == 0) { + return null; + } + + final previousLine = lineWithPosition - 1; + return TextPosition( + offset: min(positionInLine, _lines[previousLine].length - 1) + characterCount - _lines[previousLine].length, + ); + } + + @override + TextSelection getSelectionInRect(Offset baseOffset, Offset extentOffset) { + throw UnimplementedError(); + } + + @override + TextSelection getWordSelectionAt(TextPosition position) { + throw UnimplementedError(); + } +}