diff --git a/AUTHORS b/AUTHORS index ef0b8bee0302c..109246ce0d490 100644 --- a/AUTHORS +++ b/AUTHORS @@ -98,3 +98,4 @@ Elsabe Ros Nguyễn Phúc Lợi Jingyi Chen Junhua Lin <1075209054@qq.com> +Tomasz Gucio diff --git a/packages/flutter/lib/src/rendering/editable.dart b/packages/flutter/lib/src/rendering/editable.dart index 2b1407f24ad76..08da752aa6e5e 100644 --- a/packages/flutter/lib/src/rendering/editable.dart +++ b/packages/flutter/lib/src/rendering/editable.dart @@ -108,10 +108,13 @@ class TextSelectionPoint { /// false. Similarly the [moveNext] method moves the caret to the next line, and /// returns false if the caret is already on the last line. /// +/// The [moveByOffset] method takes a pixel offset from the current position to move +/// the caret up or down. +/// /// If the underlying paragraph's layout changes, [isValid] becomes false and /// the [VerticalCaretMovementRun] must not be used. The [isValid] property must -/// be checked before calling [movePrevious] and [moveNext], or accessing -/// [current]. +/// be checked before calling [movePrevious], [moveNext] and [moveByOffset], +/// or accessing [current]. class VerticalCaretMovementRun extends Iterator { VerticalCaretMovementRun._( this._editable, @@ -134,8 +137,8 @@ class VerticalCaretMovementRun extends Iterator { /// A [VerticalCaretMovementRun] run is valid if the underlying text layout /// hasn't changed. /// - /// The [current] value and the [movePrevious] and [moveNext] methods must not - /// be accessed when [isValid] is false. + /// The [current] value and the [movePrevious], [moveNext] and [moveByOffset] + /// methods must not be accessed when [isValid] is false. bool get isValid { if (!_isValid) { return false; @@ -200,6 +203,30 @@ class VerticalCaretMovementRun extends Iterator { _currentTextPosition = position.value; return true; } + + /// Move forward or backward by a number of elements determined + /// by pixel [offset]. + /// + /// If [offset] is negative, move backward; otherwise move forward. + /// + /// Returns true and updates [current] if successful. + bool moveByOffset(double offset) { + final Offset initialOffset = _currentOffset; + if (offset >= 0.0) { + while (_currentOffset.dy < initialOffset.dy + offset) { + if (!moveNext()) { + break; + } + } + } else { + while (_currentOffset.dy > initialOffset.dy + offset) { + if (!movePrevious()) { + break; + } + } + } + return initialOffset != _currentOffset; + } } /// Displays some text in a scrollable container with a potentially blinking diff --git a/packages/flutter/lib/src/widgets/default_text_editing_shortcuts.dart b/packages/flutter/lib/src/widgets/default_text_editing_shortcuts.dart index 11b6fc95e8bc5..b8c9cb41f9532 100644 --- a/packages/flutter/lib/src/widgets/default_text_editing_shortcuts.dart +++ b/packages/flutter/lib/src/widgets/default_text_editing_shortcuts.dart @@ -171,13 +171,13 @@ class DefaultTextEditingShortcuts extends StatelessWidget { SingleActivator(LogicalKeyboardKey.delete, alt: true, shift: pressShift): const DeleteToLineBreakIntent(forward: true), }, - // Arrow: Move Selection. + // Arrow: Move selection. const SingleActivator(LogicalKeyboardKey.arrowLeft): const ExtendSelectionByCharacterIntent(forward: false, collapseSelection: true), const SingleActivator(LogicalKeyboardKey.arrowRight): const ExtendSelectionByCharacterIntent(forward: true, collapseSelection: true), const SingleActivator(LogicalKeyboardKey.arrowUp): const ExtendSelectionVerticallyToAdjacentLineIntent(forward: false, collapseSelection: true), const SingleActivator(LogicalKeyboardKey.arrowDown): const ExtendSelectionVerticallyToAdjacentLineIntent(forward: true, collapseSelection: true), - // Shift + Arrow: Extend Selection. + // Shift + Arrow: Extend selection. const SingleActivator(LogicalKeyboardKey.arrowLeft, shift: true): const ExtendSelectionByCharacterIntent(forward: false, collapseSelection: false), const SingleActivator(LogicalKeyboardKey.arrowRight, shift: true): const ExtendSelectionByCharacterIntent(forward: true, collapseSelection: false), const SingleActivator(LogicalKeyboardKey.arrowUp, shift: true): const ExtendSelectionVerticallyToAdjacentLineIntent(forward: false, collapseSelection: false), @@ -199,6 +199,14 @@ class DefaultTextEditingShortcuts extends StatelessWidget { const SingleActivator(LogicalKeyboardKey.arrowLeft, shift: true, control: true): const ExtendSelectionToNextWordBoundaryIntent(forward: false, collapseSelection: false), const SingleActivator(LogicalKeyboardKey.arrowRight, shift: true, control: true): const ExtendSelectionToNextWordBoundaryIntent(forward: true, collapseSelection: false), + // Page Up / Down: Move selection by page. + const SingleActivator(LogicalKeyboardKey.pageUp): const ExtendSelectionVerticallyToAdjacentPageIntent(forward: false, collapseSelection: true), + const SingleActivator(LogicalKeyboardKey.pageDown): const ExtendSelectionVerticallyToAdjacentPageIntent(forward: true, collapseSelection: true), + + // Shift + Page Up / Down: Extend selection by page. + const SingleActivator(LogicalKeyboardKey.pageUp, shift: true): const ExtendSelectionVerticallyToAdjacentPageIntent(forward: false, collapseSelection: false), + const SingleActivator(LogicalKeyboardKey.pageDown, shift: true): const ExtendSelectionVerticallyToAdjacentPageIntent(forward: true, collapseSelection: false), + const SingleActivator(LogicalKeyboardKey.keyX, control: true): const CopySelectionTextIntent.cut(SelectionChangedCause.keyboard), const SingleActivator(LogicalKeyboardKey.keyC, control: true): CopySelectionTextIntent.copy, const SingleActivator(LogicalKeyboardKey.keyV, control: true): const PasteTextIntent(SelectionChangedCause.keyboard), @@ -258,10 +266,7 @@ class DefaultTextEditingShortcuts extends StatelessWidget { // macOS document shortcuts: https://support.apple.com/en-us/HT201236. // The macOS shortcuts uses different word/line modifiers than most other // platforms. - static final Map _macShortcuts = _iOSShortcuts; - - // There is no complete documentation of iOS shortcuts. - static final Map _iOSShortcuts = { + static final Map _macShortcuts = { for (final bool pressShift in const [true, false]) ...{ SingleActivator(LogicalKeyboardKey.backspace, shift: pressShift): const DeleteCharacterIntent(forward: false), @@ -277,7 +282,7 @@ class DefaultTextEditingShortcuts extends StatelessWidget { const SingleActivator(LogicalKeyboardKey.arrowUp): const ExtendSelectionVerticallyToAdjacentLineIntent(forward: false, collapseSelection: true), const SingleActivator(LogicalKeyboardKey.arrowDown): const ExtendSelectionVerticallyToAdjacentLineIntent(forward: true, collapseSelection: true), - // Shift + Arrow: Extend Selection. + // Shift + Arrow: Extend selection. const SingleActivator(LogicalKeyboardKey.arrowLeft, shift: true): const ExtendSelectionByCharacterIntent(forward: false, collapseSelection: false), const SingleActivator(LogicalKeyboardKey.arrowRight, shift: true): const ExtendSelectionByCharacterIntent(forward: true, collapseSelection: false), const SingleActivator(LogicalKeyboardKey.arrowUp, shift: true): const ExtendSelectionVerticallyToAdjacentLineIntent(forward: false, collapseSelection: false), @@ -310,6 +315,9 @@ class DefaultTextEditingShortcuts extends StatelessWidget { const SingleActivator(LogicalKeyboardKey.home, shift: true): const ExpandSelectionToDocumentBoundaryIntent(forward: false), const SingleActivator(LogicalKeyboardKey.end, shift: true): const ExpandSelectionToDocumentBoundaryIntent(forward: true), + const SingleActivator(LogicalKeyboardKey.pageUp, shift: true): const ExtendSelectionVerticallyToAdjacentPageIntent(forward: false, collapseSelection: false), + const SingleActivator(LogicalKeyboardKey.pageDown, shift: true): const ExtendSelectionVerticallyToAdjacentPageIntent(forward: true, collapseSelection: false), + const SingleActivator(LogicalKeyboardKey.keyX, meta: true): const CopySelectionTextIntent.cut(SelectionChangedCause.keyboard), const SingleActivator(LogicalKeyboardKey.keyC, meta: true): CopySelectionTextIntent.copy, const SingleActivator(LogicalKeyboardKey.keyV, meta: true): const PasteTextIntent(SelectionChangedCause.keyboard), @@ -335,6 +343,8 @@ class DefaultTextEditingShortcuts extends StatelessWidget { // * Control + shift? + Z }; + // There is no complete documentation of iOS shortcuts: use macOS ones. + static final Map _iOSShortcuts = _macShortcuts; // The following key combinations have no effect on text editing on this // platform: @@ -350,6 +360,8 @@ class DefaultTextEditingShortcuts extends StatelessWidget { // * Meta + backspace static final Map _windowsShortcuts = { ..._commonShortcuts, + const SingleActivator(LogicalKeyboardKey.pageUp): const ExtendSelectionVerticallyToAdjacentPageIntent(forward: false, collapseSelection: true), + const SingleActivator(LogicalKeyboardKey.pageDown): const ExtendSelectionVerticallyToAdjacentPageIntent(forward: true, collapseSelection: true), const SingleActivator(LogicalKeyboardKey.home): const ExtendSelectionToLineBreakIntent(forward: false, collapseSelection: true, continuesAtWrap: true), const SingleActivator(LogicalKeyboardKey.end): const ExtendSelectionToLineBreakIntent(forward: true, collapseSelection: true, continuesAtWrap: true), const SingleActivator(LogicalKeyboardKey.home, shift: true): const ExtendSelectionToLineBreakIntent(forward: false, collapseSelection: false, continuesAtWrap: true), @@ -385,7 +397,6 @@ class DefaultTextEditingShortcuts extends StatelessWidget { const SingleActivator(LogicalKeyboardKey.keyA, meta: true): const DoNothingAndStopPropagationTextIntent(), }; - static const Map _commonDisablingTextShortcuts = { SingleActivator(LogicalKeyboardKey.arrowDown, alt: true): DoNothingAndStopPropagationTextIntent(), SingleActivator(LogicalKeyboardKey.arrowLeft, alt: true): DoNothingAndStopPropagationTextIntent(), @@ -407,6 +418,8 @@ class DefaultTextEditingShortcuts extends StatelessWidget { SingleActivator(LogicalKeyboardKey.arrowLeft, shift: true): DoNothingAndStopPropagationTextIntent(), SingleActivator(LogicalKeyboardKey.arrowRight, shift: true): DoNothingAndStopPropagationTextIntent(), SingleActivator(LogicalKeyboardKey.arrowUp, shift: true): DoNothingAndStopPropagationTextIntent(), + SingleActivator(LogicalKeyboardKey.pageUp, shift: true): DoNothingAndStopPropagationTextIntent(), + SingleActivator(LogicalKeyboardKey.pageDown, shift: true): DoNothingAndStopPropagationTextIntent(), SingleActivator(LogicalKeyboardKey.end, shift: true): DoNothingAndStopPropagationTextIntent(), SingleActivator(LogicalKeyboardKey.home, shift: true): DoNothingAndStopPropagationTextIntent(), SingleActivator(LogicalKeyboardKey.arrowDown): DoNothingAndStopPropagationTextIntent(), @@ -417,6 +430,8 @@ class DefaultTextEditingShortcuts extends StatelessWidget { SingleActivator(LogicalKeyboardKey.arrowRight, control: true): DoNothingAndStopPropagationTextIntent(), SingleActivator(LogicalKeyboardKey.arrowLeft, shift: true, control: true): DoNothingAndStopPropagationTextIntent(), SingleActivator(LogicalKeyboardKey.arrowRight, shift: true, control: true): DoNothingAndStopPropagationTextIntent(), + SingleActivator(LogicalKeyboardKey.pageUp): DoNothingAndStopPropagationTextIntent(), + SingleActivator(LogicalKeyboardKey.pageDown): DoNothingAndStopPropagationTextIntent(), SingleActivator(LogicalKeyboardKey.end): DoNothingAndStopPropagationTextIntent(), SingleActivator(LogicalKeyboardKey.home): DoNothingAndStopPropagationTextIntent(), SingleActivator(LogicalKeyboardKey.end, control: true): DoNothingAndStopPropagationTextIntent(), @@ -545,8 +560,8 @@ Intent? intentForMacOSSelector(String selectorName) { // TODO(knopp): Page Up/Down intents are missing (https://github.com/flutter/flutter/pull/105497) 'scrollPageUp:': ScrollToDocumentBoundaryIntent(forward: false), 'scrollPageDown:': ScrollToDocumentBoundaryIntent(forward: true), - 'pageUpAndModifySelection': ExpandSelectionToDocumentBoundaryIntent(forward: false), - 'pageDownAndModifySelection:': ExpandSelectionToDocumentBoundaryIntent(forward: true), + 'pageUpAndModifySelection:': ExtendSelectionVerticallyToAdjacentPageIntent(forward: false, collapseSelection: false), + 'pageDownAndModifySelection:': ExtendSelectionVerticallyToAdjacentPageIntent(forward: true, collapseSelection: false), // Escape key when there's no IME selection popup. 'cancelOperation:': DismissIntent(), diff --git a/packages/flutter/lib/src/widgets/editable_text.dart b/packages/flutter/lib/src/widgets/editable_text.dart index 3da0cf7b303de..8307aa7bc99e3 100644 --- a/packages/flutter/lib/src/widgets/editable_text.dart +++ b/packages/flutter/lib/src/widgets/editable_text.dart @@ -479,6 +479,7 @@ class _DiscreteKeyFrameSimulation extends Simulation { /// | [ExtendSelectionToNextWordBoundaryOrCaretLocationIntent](`collapseSelection: true`) | Collapses the selection to the word boundary before/after the selection's [TextSelection.extent] position, or [TextSelection.base], whichever is closest in the given direction | Moves the caret to the previous/next word boundary. | /// | [ExtendSelectionToLineBreakIntent](`collapseSelection: true`) | Collapses the selection to the start/end of the line at the selection's [TextSelection.extent] position | Moves the caret to the start/end of the current line .| /// | [ExtendSelectionVerticallyToAdjacentLineIntent](`collapseSelection: true`) | Collapses the selection to the position closest to the selection's [TextSelection.extent], on the previous/next adjacent line | Moves the caret to the closest position on the previous/next adjacent line. | +/// | [ExtendSelectionVerticallyToAdjacentPageIntent](`collapseSelection: true`) | Collapses the selection to the position closest to the selection's [TextSelection.extent], on the previous/next adjacent page | Moves the caret to the closest position on the previous/next adjacent page. | /// | [ExtendSelectionToDocumentBoundaryIntent](`collapseSelection: true`) | Collapses the selection to the start/end of the document | Moves the caret to the start/end of the document. | /// /// #### Intents for Extending the Selection @@ -490,6 +491,7 @@ class _DiscreteKeyFrameSimulation extends Simulation { /// | [ExtendSelectionToNextWordBoundaryOrCaretLocationIntent](`collapseSelection: false`) | Moves the selection's [TextSelection.extent] to the previous/next word boundary, or [TextSelection.base] whichever is closest in the given direction | Moves the selection's [TextSelection.extent] to the previous/next word boundary. | /// | [ExtendSelectionToLineBreakIntent](`collapseSelection: false`) | Moves the selection's [TextSelection.extent] to the start/end of the line | /// | [ExtendSelectionVerticallyToAdjacentLineIntent](`collapseSelection: false`) | Moves the selection's [TextSelection.extent] to the closest position on the previous/next adjacent line | +/// | [ExtendSelectionVerticallyToAdjacentPageIntent](`collapseSelection: false`) | Moves the selection's [TextSelection.extent] to the closest position on the previous/next adjacent page | /// | [ExtendSelectionToDocumentBoundaryIntent](`collapseSelection: false`) | Moves the selection's [TextSelection.extent] to the start/end of the document | /// | [SelectAllTextIntent] | Selects the entire document | /// @@ -3106,7 +3108,7 @@ class EditableTextState extends State with AutomaticKeepAliveClien // TODO(abarth): Teach RenderEditable about ValueNotifier // to avoid this setState(). setState(() { /* We use widget.controller.value in build(). */ }); - _adjacentLineAction.stopCurrentVerticalRunIfSelectionChanges(); + _verticalSelectionUpdateAction.stopCurrentVerticalRunIfSelectionChanges(); } void _handleFocusChanged() { @@ -3589,7 +3591,8 @@ class EditableTextState extends State with AutomaticKeepAliveClien } late final Action _updateSelectionAction = CallbackAction(onInvoke: _updateSelection); - late final _UpdateTextSelectionToAdjacentLineAction _adjacentLineAction = _UpdateTextSelectionToAdjacentLineAction(this); + late final _UpdateTextSelectionVerticallyAction _verticalSelectionUpdateAction = + _UpdateTextSelectionVerticallyAction(this); void _expandSelectionToDocumentBoundary(ExpandSelectionToDocumentBoundaryIntent intent) { final TextBoundary textBoundary = _documentBoundary(intent); @@ -3717,7 +3720,8 @@ class EditableTextState extends State with AutomaticKeepAliveClien ExtendSelectionToLineBreakIntent: _makeOverridable(_UpdateTextSelectionAction(this, true, _linebreak)), ExpandSelectionToLineBreakIntent: _makeOverridable(CallbackAction(onInvoke: _expandSelectionToLinebreak)), ExpandSelectionToDocumentBoundaryIntent: _makeOverridable(CallbackAction(onInvoke: _expandSelectionToDocumentBoundary)), - ExtendSelectionVerticallyToAdjacentLineIntent: _makeOverridable(_adjacentLineAction), + ExtendSelectionVerticallyToAdjacentLineIntent: _makeOverridable(_verticalSelectionUpdateAction), + ExtendSelectionVerticallyToAdjacentPageIntent: _makeOverridable(_verticalSelectionUpdateAction), ExtendSelectionToDocumentBoundaryIntent: _makeOverridable(_UpdateTextSelectionAction(this, true, _documentBoundary)), ExtendSelectionToNextWordBoundaryOrCaretLocationIntent: _makeOverridable(_ExtendSelectionOrCaretPositionAction(this, _nextWordBoundary)), ScrollToDocumentBoundaryIntent: _makeOverridable(CallbackAction(onInvoke: _scrollToDocumentBoundary)), @@ -4604,8 +4608,8 @@ class _ExtendSelectionOrCaretPositionAction extends ContextAction state.widget.selectionEnabled && state._value.selection.isValid; } -class _UpdateTextSelectionToAdjacentLineAction extends ContextAction { - _UpdateTextSelectionToAdjacentLineAction(this.state); +class _UpdateTextSelectionVerticallyAction extends ContextAction { + _UpdateTextSelectionVerticallyAction(this.state); final EditableTextState state; @@ -4647,10 +4651,12 @@ class _UpdateTextSelectionToAdjacentLineAction{TargetPlatform.iOS, TargetPlatform.macOS})); // intended: on macOS Page Up/Down only scrolls + testWidgets('run can be interrupted by layout changes', (WidgetTester tester) async { controller.text = 'aa\n' // 3 diff --git a/packages/flutter/test/widgets/editable_text_test.dart b/packages/flutter/test/widgets/editable_text_test.dart index 84d623c1441ec..632e4a3c32b99 100644 --- a/packages/flutter/test/widgets/editable_text_test.dart +++ b/packages/flutter/test/widgets/editable_text_test.dart @@ -6164,6 +6164,88 @@ void main() { reason: 'on $platform', ); + // Move down by page. + await sendKeys( + tester, + [ + LogicalKeyboardKey.pageDown, + ], + targetPlatform: defaultTargetPlatform, + ); + + // On macOS, pageDown/Up don't change selection. + expect( + selection, + equals( + defaultTargetPlatform == TargetPlatform.macOS + || defaultTargetPlatform == TargetPlatform.iOS + ? const TextSelection.collapsed(offset: 0) + : const TextSelection.collapsed(offset: 55), + ), + reason: 'on $platform', + ); + + // Move up by page (to start). + await sendKeys( + tester, + [ + LogicalKeyboardKey.pageUp, + ], + targetPlatform: defaultTargetPlatform, + ); + + expect( + selection, + equals( + const TextSelection.collapsed( + offset: 0, + ), + ), + reason: 'on $platform', + ); + + // Select towards end by page. + await sendKeys( + tester, + [ + LogicalKeyboardKey.pageDown, + ], + shift: true, + targetPlatform: defaultTargetPlatform, + ); + + expect( + selection, + equals( + const TextSelection( + baseOffset: 0, + extentOffset: 55, + affinity: TextAffinity.upstream, + ), + ), + reason: 'on $platform', + ); + + // Change selection extent towards start by page. + await sendKeys( + tester, + [ + LogicalKeyboardKey.pageUp, + ], + shift: true, + targetPlatform: defaultTargetPlatform, + ); + + expect( + selection, + equals( + const TextSelection.collapsed( + offset: 0, + ), + ), + reason: 'on $platform', + ); + // Jump forward three words. await sendKeys( tester,