Skip to content
This repository was archived by the owner on Feb 22, 2023. It is now read-only.

Commit 6c12e39

Browse files
Renzo-OlivaresRenzo Olivares
and
Renzo Olivares
authored
Introduce ParagraphBoundary subclass for text editing (#116549)
* attempt to extend to paragraph * second attempt * clean up implementation * clean up * updates * updates * Fix implementation * remove old * update docs * update docs * fix analyzer * Fix bug where new line character was selected and backwards selection failed * remove print * Add test for paragraph boundary * Add text editing test for extending selection to paragraph for mac and ios * rename to ExtendSelectionToParagraphBoundaryIntent * fix analyzer * Should default to downstream when collapsing selection * get rid of _getParagraphAtOffset and move into getTextBoundaryAt * Search for all line terminators * iterate through code units instead of characters * Address some reviewer comments" * Add separate implementations for leading and trailing paragraph boundary methods * Do not break after a carriage return if it is followed by a line feed * test carriage return followed by a line feed * more tests * Do not continue if the line terminator is at the target text offset * add hack to extend highlight to line terminator * Revert "add hack to extend highlight to line terminator" This reverts commit b4d3c434539b66c3c81c215e87c645b425902825. * Revert "Do not continue if the line terminator is at the target text offset" This reverts commit 789e1b838e54e7c25600bfa8852e59431ccaf5dc. * Update ParagraphBoundary with latest TextBoundary changes * Update implementation to iterate through indexes * update getTrailingTextBoundaryAt to include the line terminator * Updates * more updates * more updates * updates * updates * Lets try this again * clean up * updates * more updates * updates * fix * Re-implement using custom paragraph boundary applying method * Revert "Re-implement using custom paragraph boundary applying method" This reverts commit cd2f7f4b6eb6726b28f82a43708812e06a49df95. * Revert "fix" This reverts commit 8ec1f8f58935cfb3eb86dc6afd2894537af4cf7b. * updates * Revert "updates" This reverts commit 9dcca4a0031fe18ada9d6ffbbe77ba09918e82ae. * Revert "Revert "fix"" This reverts commit 9cc1332cd3041badc472d0d223a106203e46afb8. * Revert "Revert "Re-implement using custom paragraph boundary applying method"" This reverts commit 1acb606fb743fd840da20cca26d9a7c26accb71d. * Fix paragraph boundaries * Add failing test * Address some comments * group tests and fix analyzer * fix typo * fix remaining test * updates * more fixes and logs * clean up and add another test * Fix last test * Add new test * Clean up * more clean up * clean up comments * address comments * updates * return null when position is out of bounds and 0 or end of text if appropriate * Clean up cases * Do not return null when OOB in the direction of iteration * clean up * simplify implementation thanks to LongCatIsLooong feedback * Address comments * Add line and paragraph separator * Use _moveBeyondTextBoundary instead of custom _moveToParagraphBoundary * Change some intent names and revert fromPosition change * clean up docs --------- Co-authored-by: Renzo Olivares <[email protected]>
1 parent 7477d7a commit 6c12e39

File tree

7 files changed

+381
-5
lines changed

7 files changed

+381
-5
lines changed

packages/flutter/lib/src/services/text_boundary.dart

Lines changed: 79 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -123,9 +123,87 @@ class LineBoundary extends TextBoundary {
123123
TextRange getTextBoundaryAt(int position) => _textLayout.getLineAtOffset(TextPosition(offset: max(position, 0)));
124124
}
125125

126+
/// A text boundary that uses paragraphs as logical boundaries.
127+
///
128+
/// A paragraph is defined as the range between line terminators. If no
129+
/// line terminators exist then the paragraph boundary is the entire document.
130+
class ParagraphBoundary extends TextBoundary {
131+
/// Creates a [ParagraphBoundary] with the text.
132+
const ParagraphBoundary(this._text);
133+
134+
final String _text;
135+
136+
/// Returns the [int] representing the start position of the paragraph that
137+
/// bounds the given `position`. The returned [int] is the position of the code unit
138+
/// that follows the line terminator that encloses the desired paragraph.
139+
@override
140+
int? getLeadingTextBoundaryAt(int position) {
141+
if (position < 0 || _text.isEmpty) {
142+
return null;
143+
}
144+
145+
if (position >= _text.length) {
146+
return _text.length;
147+
}
148+
149+
if (position == 0) {
150+
return 0;
151+
}
152+
153+
final List<int> codeUnits = _text.codeUnits;
154+
int index = position;
155+
156+
if (index > 1 && codeUnits[index] == 0xA && codeUnits[index - 1] == 0xD) {
157+
index -= 2;
158+
} else if (TextLayoutMetrics.isLineTerminator(codeUnits[index])) {
159+
index -= 1;
160+
}
161+
162+
while (index > 0) {
163+
if (TextLayoutMetrics.isLineTerminator(codeUnits[index])) {
164+
return index + 1;
165+
}
166+
index -= 1;
167+
}
168+
169+
return max(index, 0);
170+
}
171+
172+
/// Returns the [int] representing the end position of the paragraph that
173+
/// bounds the given `position`. The returned [int] is the position of the
174+
/// code unit representing the trailing line terminator that encloses the
175+
/// desired paragraph.
176+
@override
177+
int? getTrailingTextBoundaryAt(int position) {
178+
if (position >= _text.length || _text.isEmpty) {
179+
return null;
180+
}
181+
182+
if (position < 0) {
183+
return 0;
184+
}
185+
186+
final List<int> codeUnits = _text.codeUnits;
187+
int index = position;
188+
189+
while (!TextLayoutMetrics.isLineTerminator(codeUnits[index])) {
190+
index += 1;
191+
if (index == codeUnits.length) {
192+
return index;
193+
}
194+
}
195+
196+
return index < codeUnits.length - 1
197+
&& codeUnits[index] == 0xD
198+
&& codeUnits[index + 1] == 0xA
199+
? index + 2
200+
: index + 1;
201+
}
202+
}
203+
126204
/// A text boundary that uses the entire document as logical boundary.
127205
class DocumentBoundary extends TextBoundary {
128-
/// Creates a [DocumentBoundary] with the text
206+
/// Creates a [DocumentBoundary] with the text.
129207
const DocumentBoundary(this._text);
130208

131209
final String _text;

packages/flutter/lib/src/services/text_layout_metrics.dart

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,25 @@ abstract class TextLayoutMetrics {
5454
return true;
5555
}
5656

57+
/// Check if the given code unit is a line terminator character.
58+
///
59+
/// Includes newline characters from ASCII
60+
/// (https://www.unicode.org/standard/reports/tr13/tr13-5.html).
61+
static bool isLineTerminator(int codeUnit) {
62+
switch (codeUnit) {
63+
case 0xA: // line feed
64+
case 0xB: // vertical feed
65+
case 0xC: // form feed
66+
case 0xD: // carriage return
67+
case 0x85: // new line
68+
case 0x2028: // line separator
69+
case 0x2029: // paragraph separator
70+
return true;
71+
default:
72+
return false;
73+
}
74+
}
75+
5776
/// {@template flutter.services.TextLayoutMetrics.getLineAtOffset}
5877
/// Return a [TextSelection] containing the line of the given [TextPosition].
5978
/// {@endtemplate}

packages/flutter/lib/src/widgets/default_text_editing_shortcuts.dart

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -303,8 +303,8 @@ class DefaultTextEditingShortcuts extends StatelessWidget {
303303

304304
const SingleActivator(LogicalKeyboardKey.arrowLeft, shift: true, alt: true): const ExtendSelectionToNextWordBoundaryOrCaretLocationIntent(forward: false),
305305
const SingleActivator(LogicalKeyboardKey.arrowRight, shift: true, alt: true): const ExtendSelectionToNextWordBoundaryOrCaretLocationIntent(forward: true),
306-
const SingleActivator(LogicalKeyboardKey.arrowUp, shift: true, alt: true): const ExtendSelectionVerticallyToAdjacentLineIntent(forward: false, collapseSelection: false),
307-
const SingleActivator(LogicalKeyboardKey.arrowDown, shift: true, alt: true): const ExtendSelectionVerticallyToAdjacentLineIntent(forward: true, collapseSelection: false),
306+
const SingleActivator(LogicalKeyboardKey.arrowUp, shift: true, alt: true): const ExtendSelectionToNextParagraphBoundaryOrCaretLocationIntent(forward: false),
307+
const SingleActivator(LogicalKeyboardKey.arrowDown, shift: true, alt: true): const ExtendSelectionToNextParagraphBoundaryOrCaretLocationIntent(forward: true),
308308

309309
const SingleActivator(LogicalKeyboardKey.arrowLeft, meta: true): const ExtendSelectionToLineBreakIntent(forward: false, collapseSelection: true),
310310
const SingleActivator(LogicalKeyboardKey.arrowRight, meta: true): const ExtendSelectionToLineBreakIntent(forward: true, collapseSelection: true),
@@ -560,8 +560,8 @@ Intent? intentForMacOSSelector(String selectorName) {
560560

561561
'moveWordLeftAndModifySelection:': ExtendSelectionToNextWordBoundaryOrCaretLocationIntent(forward: false),
562562
'moveWordRightAndModifySelection:': ExtendSelectionToNextWordBoundaryOrCaretLocationIntent(forward: true),
563-
'moveParagraphBackwardAndModifySelection:': ExtendSelectionToLineBreakIntent(forward: false, collapseSelection: false, collapseAtReversal: true),
564-
'moveParagraphForwardAndModifySelection:': ExtendSelectionToLineBreakIntent(forward: true, collapseSelection: false, collapseAtReversal: true),
563+
'moveParagraphBackwardAndModifySelection:': ExtendSelectionToNextParagraphBoundaryOrCaretLocationIntent(forward: false),
564+
'moveParagraphForwardAndModifySelection:': ExtendSelectionToNextParagraphBoundaryOrCaretLocationIntent(forward: true),
565565

566566
'moveToLeftEndOfLine:': ExtendSelectionToLineBreakIntent(forward: false, collapseSelection: true),
567567
'moveToRightEndOfLine:': ExtendSelectionToLineBreakIntent(forward: true, collapseSelection: true),

packages/flutter/lib/src/widgets/editable_text.dart

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3978,6 +3978,7 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
39783978
TextBoundary _characterBoundary() => widget.obscureText ? _CodeUnitBoundary(_value.text) : CharacterBoundary(_value.text);
39793979
TextBoundary _nextWordBoundary() => widget.obscureText ? _documentBoundary() : renderEditable.wordBoundaries.moveByWordBoundary;
39803980
TextBoundary _linebreak() => widget.obscureText ? _documentBoundary() : LineBoundary(renderEditable);
3981+
TextBoundary _paragraphBoundary() => ParagraphBoundary(_value.text);
39813982
TextBoundary _documentBoundary() => DocumentBoundary(_value.text);
39823983

39833984
Action<T> _makeOverridable<T extends Intent>(Action<T> defaultAction) {
@@ -4218,6 +4219,7 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
42184219
ExtendSelectionToLineBreakIntent: _makeOverridable(_UpdateTextSelectionAction<ExtendSelectionToLineBreakIntent>(this, _linebreak, _moveToTextBoundary, ignoreNonCollapsedSelection: true)),
42194220
ExtendSelectionVerticallyToAdjacentLineIntent: _makeOverridable(_verticalSelectionUpdateAction),
42204221
ExtendSelectionVerticallyToAdjacentPageIntent: _makeOverridable(_verticalSelectionUpdateAction),
4222+
ExtendSelectionToNextParagraphBoundaryOrCaretLocationIntent: _makeOverridable(_UpdateTextSelectionAction<ExtendSelectionToNextParagraphBoundaryOrCaretLocationIntent>(this, _paragraphBoundary, _moveBeyondTextBoundary, ignoreNonCollapsedSelection: true)),
42214223
ExtendSelectionToDocumentBoundaryIntent: _makeOverridable(_UpdateTextSelectionAction<ExtendSelectionToDocumentBoundaryIntent>(this, _documentBoundary, _moveBeyondTextBoundary, ignoreNonCollapsedSelection: true)),
42224224
ExtendSelectionToNextWordBoundaryOrCaretLocationIntent: _makeOverridable(_UpdateTextSelectionAction<ExtendSelectionToNextWordBoundaryOrCaretLocationIntent>(this, _nextWordBoundary, _moveBeyondTextBoundary, ignoreNonCollapsedSelection: true)),
42234225
ScrollToDocumentBoundaryIntent: _makeOverridable(CallbackAction<ScrollToDocumentBoundaryIntent>(onInvoke: _scrollToDocumentBoundary)),

packages/flutter/lib/src/widgets/text_editing_intents.dart

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -221,6 +221,21 @@ class ExtendSelectionVerticallyToAdjacentPageIntent extends DirectionalCaretMove
221221
}) : super(forward, collapseSelection);
222222
}
223223

224+
/// Extends, or moves the current selection from the current
225+
/// [TextSelection.extent] position to the previous or the next paragraph
226+
/// boundary depending on the [forward] parameter.
227+
///
228+
/// This [Intent] collapses the selection when the order of [TextSelection.base]
229+
/// and [TextSelection.extent] would reverse.
230+
///
231+
/// This is typically only used on MacOS.
232+
class ExtendSelectionToNextParagraphBoundaryOrCaretLocationIntent extends DirectionalCaretMovementIntent {
233+
/// Creates an [ExtendSelectionToNextParagraphBoundaryOrCaretLocationIntent].
234+
const ExtendSelectionToNextParagraphBoundaryOrCaretLocationIntent({
235+
required bool forward,
236+
}) : super(forward, false, true);
237+
}
238+
224239
/// Extends, or moves the current selection from the current
225240
/// [TextSelection.extent] position to the start or the end of the document.
226241
///

packages/flutter/test/services/text_boundary_test.dart

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,113 @@ void main() {
146146
expect(boundary.getTextBoundaryAt(3), TestTextLayoutMetrics.lineAt3);
147147
});
148148

149+
group('paragraph boundary', () {
150+
test('works for simple cases', () {
151+
const String textA= 'abcd efg hi\njklmno\npqrstuv';
152+
const ParagraphBoundary boundaryA = ParagraphBoundary(textA);
153+
154+
// Position enclosed inside of paragraph, 'abcd efg h|i\n'.
155+
const int position = 10;
156+
157+
// The range includes the line terminator.
158+
expect(boundaryA.getLeadingTextBoundaryAt(position), 0);
159+
expect(boundaryA.getTrailingTextBoundaryAt(position), 12);
160+
161+
// This text includes a carriage return followed by a line feed.
162+
const String textB = 'abcd efg hi\r\njklmno\npqrstuv';
163+
const ParagraphBoundary boundaryB = ParagraphBoundary(textB);
164+
expect(boundaryB.getLeadingTextBoundaryAt(position), 0);
165+
expect(boundaryB.getTrailingTextBoundaryAt(position), 13);
166+
167+
const String textF = 'Now is the time for\n' // 20
168+
'all good people\n' // 20 + 16 => 36
169+
'to come to the aid\n' // 36 + 19 => 55
170+
'of their country.'; // 55 + 17 => 72
171+
const ParagraphBoundary boundaryF = ParagraphBoundary(textF);
172+
const int positionF = 11;
173+
expect(boundaryF.getLeadingTextBoundaryAt(positionF), 0);
174+
expect(boundaryF.getTrailingTextBoundaryAt(positionF), 20);
175+
});
176+
177+
test('works for consecutive line terminators involving CRLF', () {
178+
const String textI = 'Now is the time for\n' // 20
179+
'all good people\n\r\n' // 20 + 16 => 38
180+
'to come to the aid\n' // 38 + 19 => 57
181+
'of their country.'; // 57 + 17 => 74
182+
const ParagraphBoundary boundaryI = ParagraphBoundary(textI);
183+
const int positionI = 56;// \n at the end of the third line.
184+
const int positionJ = 38;// t at beginning of third line.
185+
const int positionK = 37;// \n at end of second line.
186+
expect(boundaryI.getLeadingTextBoundaryAt(positionI), 38);
187+
expect(boundaryI.getTrailingTextBoundaryAt(positionI), 57);
188+
expect(boundaryI.getLeadingTextBoundaryAt(positionJ), 38);
189+
expect(boundaryI.getTrailingTextBoundaryAt(positionJ), 57);
190+
expect(boundaryI.getLeadingTextBoundaryAt(positionK), 36);
191+
expect(boundaryI.getTrailingTextBoundaryAt(positionK), 38);
192+
});
193+
194+
test('works for consecutive line terminators', () {
195+
const String textI = 'Now is the time for\n' // 20
196+
'all good people\n\n' // 20 + 16 => 37
197+
'to come to the aid\n' // 37 + 19 => 56
198+
'of their country.'; // 56 + 17 => 73
199+
const ParagraphBoundary boundaryI = ParagraphBoundary(textI);
200+
const int positionI = 55;// \n at the end of the third line.
201+
const int positionJ = 37;// t at beginning of third line.
202+
const int positionK = 36;// \n at end of second line.
203+
expect(boundaryI.getLeadingTextBoundaryAt(positionI), 37);
204+
expect(boundaryI.getTrailingTextBoundaryAt(positionI), 56);
205+
expect(boundaryI.getLeadingTextBoundaryAt(positionJ), 37);
206+
expect(boundaryI.getTrailingTextBoundaryAt(positionJ), 56);
207+
expect(boundaryI.getLeadingTextBoundaryAt(positionK), 36);
208+
expect(boundaryI.getTrailingTextBoundaryAt(positionK), 37);
209+
});
210+
211+
test('leading boundary works for consecutive CRLF', () {
212+
// This text includes multiple consecutive carriage returns followed by line feeds (CRLF).
213+
const String textH = 'abcd efg hi\r\n\r\n\r\n\r\n\r\n\r\n\r\n\n\n\n\n\njklmno\npqrstuv';
214+
const ParagraphBoundary boundaryH = ParagraphBoundary(textH);
215+
const int positionH = 18;
216+
expect(boundaryH.getLeadingTextBoundaryAt(positionH), 17);
217+
expect(boundaryH.getTrailingTextBoundaryAt(positionH), 19);
218+
});
219+
220+
test('trailing boundary works for consecutive CRLF', () {
221+
// This text includes multiple consecutive carriage returns followed by line feeds (CRLF).
222+
const String textG = 'abcd efg hi\r\n\n\n\n\n\n\r\n\r\n\r\n\r\n\n\n\n\n\njklmno\npqrstuv';
223+
const ParagraphBoundary boundaryG = ParagraphBoundary(textG);
224+
const int positionG = 18;
225+
expect(boundaryG.getLeadingTextBoundaryAt(positionG), 18);
226+
expect(boundaryG.getTrailingTextBoundaryAt(positionG), 20);
227+
});
228+
229+
test('works when position is between two CRLF', () {
230+
const String textE = 'abcd efg hi\r\nhello\r\n\n';
231+
const ParagraphBoundary boundaryE = ParagraphBoundary(textE);
232+
// Position enclosed inside of paragraph, 'abcd efg hi\r\nhello\r\n\n'.
233+
const int positionE = 16;
234+
expect(boundaryE.getLeadingTextBoundaryAt(positionE), 13);
235+
expect(boundaryE.getTrailingTextBoundaryAt(positionE), 20);
236+
});
237+
238+
test('works for multiple consecutive line terminators', () {
239+
// This text includes multiple consecutive line terminators.
240+
const String textC = 'abcd efg hi\r\n\n\n\n\n\n\n\n\n\n\n\njklmno\npqrstuv';
241+
const ParagraphBoundary boundaryC = ParagraphBoundary(textC);
242+
// Position enclosed inside of paragraph, 'abcd efg hi\r\n\n\n\n\n\n|\n\n\n\n\n\njklmno\npqrstuv'.
243+
const int positionC = 18;
244+
expect(boundaryC.getLeadingTextBoundaryAt(positionC), 18);
245+
expect(boundaryC.getTrailingTextBoundaryAt(positionC), 19);
246+
247+
const String textD = 'abcd efg hi\r\n\n\n\n';
248+
const ParagraphBoundary boundaryD = ParagraphBoundary(textD);
249+
// Position enclosed inside of paragraph, 'abcd efg hi\r\n\n|\n\n'.
250+
const int positionD = 14;
251+
expect(boundaryD.getLeadingTextBoundaryAt(positionD), 14);
252+
expect(boundaryD.getTrailingTextBoundaryAt(positionD), 15);
253+
});
254+
});
255+
149256
test('document boundary works', () {
150257
const String text = 'abcd efg hi\njklmno\npqrstuv';
151258
const DocumentBoundary boundary = DocumentBoundary(text);

0 commit comments

Comments
 (0)