diff --git a/bin/internal/engine.version b/bin/internal/engine.version index a613a375d2..988017b07a 100644 --- a/bin/internal/engine.version +++ b/bin/internal/engine.version @@ -1 +1 @@ -f888186e50f20fdb49ceea0dae74b6443a22ddaa +9d5cd4b12ec7fc29ad2117bf7851844be861b74d diff --git a/packages/flutter/lib/src/material/text_field.dart b/packages/flutter/lib/src/material/text_field.dart index b96f8ea0da..cbcce410c4 100644 --- a/packages/flutter/lib/src/material/text_field.dart +++ b/packages/flutter/lib/src/material/text_field.dart @@ -387,10 +387,18 @@ class _TextFieldState extends State { ); } - return new GestureDetector( - behavior: HitTestBehavior.opaque, - onTap: _requestKeyboard, - child: child, + return new Semantics( + onTap: () { + if (!_controller.selection.isValid) + _controller.selection = new TextSelection.collapsed(offset: _controller.text.length); + _requestKeyboard(); + }, + child: new GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: _requestKeyboard, + child: child, + excludeFromSemantics: true, + ), ); } } diff --git a/packages/flutter/lib/src/painting/text_painter.dart b/packages/flutter/lib/src/painting/text_painter.dart index 53f680aafa..f3134034e9 100644 --- a/packages/flutter/lib/src/painting/text_painter.dart +++ b/packages/flutter/lib/src/painting/text_painter.dart @@ -401,6 +401,26 @@ class TextPainter { return value & 0xF800 == 0xD800; } + /// Returns the closest offset after `offset` at which the inout cursor can be + /// positioned. + int getOffsetAfter(int offset) { + final int nextCodeUnit = _text.codeUnitAt(offset); + if (nextCodeUnit == null) + return null; + // TODO(goderbauer): doesn't handle flag emojis (https://github.com/flutter/flutter/issues/13404). + return _isUtf16Surrogate(nextCodeUnit) ? offset + 2 : offset + 1; + } + + /// Returns the closest offset before `offset` at which the inout cursor can + /// be positioned. + int getOffsetBefore(int offset) { + final int prevCodeUnit = _text.codeUnitAt(offset - 1); + if (prevCodeUnit == null) + return null; + // TODO(goderbauer): doesn't handle flag emojis (https://github.com/flutter/flutter/issues/13404). + return _isUtf16Surrogate(prevCodeUnit) ? offset - 2 : offset - 1; + } + Offset _getOffsetFromUpstream(int offset, Rect caretPrototype) { final int prevCodeUnit = _text.codeUnitAt(offset - 1); if (prevCodeUnit == null) diff --git a/packages/flutter/lib/src/rendering/custom_paint.dart b/packages/flutter/lib/src/rendering/custom_paint.dart index 4a70375767..ed942efed2 100644 --- a/packages/flutter/lib/src/rendering/custom_paint.dart +++ b/packages/flutter/lib/src/rendering/custom_paint.dart @@ -845,6 +845,12 @@ class RenderCustomPaint extends RenderProxyBox { if (properties.onDecrease != null) { config.addAction(SemanticsAction.decrease, properties.onDecrease); } + if (properties.onMoveCursorForwardByCharacter != null) { + config.addAction(SemanticsAction.moveCursorForwardByCharacter, properties.onMoveCursorForwardByCharacter); + } + if (properties.onMoveCursorBackwardByCharacter != null) { + config.addAction(SemanticsAction.moveCursorBackwardByCharacter, properties.onMoveCursorBackwardByCharacter); + } newChild.updateWith( config: config, diff --git a/packages/flutter/lib/src/rendering/editable.dart b/packages/flutter/lib/src/rendering/editable.dart index 587c715a8b..6a3ea8fb1a 100644 --- a/packages/flutter/lib/src/rendering/editable.dart +++ b/packages/flutter/lib/src/rendering/editable.dart @@ -316,6 +316,7 @@ class RenderEditable extends RenderBox { _selection = value; _selectionRects = null; markNeedsPaint(); + markNeedsSemanticsUpdate(); } /// The offset at which the text should be painted. @@ -346,6 +347,30 @@ class RenderEditable extends RenderBox { ..textDirection = textDirection ..isFocused = hasFocus ..isTextField = true; + + if (_selection?.isValid == true) { + if (_textPainter.getOffsetBefore(_selection.extentOffset) != null) { + config.addAction(SemanticsAction.moveCursorBackwardByCharacter, () { + final int offset = _textPainter.getOffsetBefore(_selection.extentOffset); + if (offset == null) + return; + onSelectionChanged( + new TextSelection.collapsed(offset: offset), this, SelectionChangedCause.keyboard, + ); + }); + } + + if (_textPainter.getOffsetAfter(_selection.extentOffset) != null) { + config.addAction(SemanticsAction.moveCursorForwardByCharacter, () { + final int offset = _textPainter.getOffsetAfter(_selection.extentOffset); + if (offset == null) + return; + onSelectionChanged( + new TextSelection.collapsed(offset: offset), this, SelectionChangedCause.keyboard, + ); + }); + } + } } @override diff --git a/packages/flutter/lib/src/rendering/proxy_box.dart b/packages/flutter/lib/src/rendering/proxy_box.dart index e1edd974eb..064ca8ef42 100644 --- a/packages/flutter/lib/src/rendering/proxy_box.dart +++ b/packages/flutter/lib/src/rendering/proxy_box.dart @@ -2830,6 +2830,8 @@ class RenderSemanticsAnnotations extends RenderProxyBox { VoidCallback onScrollDown, VoidCallback onIncrease, VoidCallback onDecrease, + VoidCallback onMoveCursorForwardByCharacter, + VoidCallback onMoveCursorBackwardByCharacter, }) : assert(container != null), _container = container, _explicitChildNodes = explicitChildNodes, @@ -2850,6 +2852,8 @@ class RenderSemanticsAnnotations extends RenderProxyBox { _onScrollDown = onScrollDown, _onIncrease = onIncrease, _onDecrease = onDecrease, + _onMoveCursorForwardByCharacter = onMoveCursorForwardByCharacter, + _onMoveCursorBackwardByCharacter = onMoveCursorBackwardByCharacter, super(child); /// If 'container' is true, this [RenderObject] will introduce a new @@ -3162,6 +3166,42 @@ class RenderSemanticsAnnotations extends RenderProxyBox { markNeedsSemanticsUpdate(); } + /// The handler for [SemanticsAction.onMoveCursorForwardByCharacter]. + /// + /// This handler is invoked when the user wants to move the cursor in a + /// text field forward by one character. + /// + /// TalkBack users can trigger this by pressing the volume up key while the + /// input focus is in a text field. + VoidCallback get onMoveCursorForwardByCharacter => _onMoveCursorForwardByCharacter; + VoidCallback _onMoveCursorForwardByCharacter; + set onMoveCursorForwardByCharacter(VoidCallback handler) { + if (_onMoveCursorForwardByCharacter == handler) + return; + final bool hadValue = _onMoveCursorForwardByCharacter != null; + _onMoveCursorForwardByCharacter = handler; + if ((handler != null) != hadValue) + markNeedsSemanticsUpdate(); + } + + /// The handler for [SemanticsAction.onMoveCursorBackwardByCharacter]. + /// + /// This handler is invoked when the user wants to move the cursor in a + /// text field backward by one character. + /// + /// TalkBack users can trigger this by pressing the volume down key while the + /// input focus is in a text field. + VoidCallback get onMoveCursorBackwardByCharacter => _onMoveCursorBackwardByCharacter; + VoidCallback _onMoveCursorBackwardByCharacter; + set onMoveCursorBackwardByCharacter(VoidCallback handler) { + if (_onMoveCursorBackwardByCharacter == handler) + return; + final bool hadValue = _onMoveCursorBackwardByCharacter != null; + _onMoveCursorBackwardByCharacter = handler; + if ((handler != null) != hadValue) + markNeedsSemanticsUpdate(); + } + @override void describeSemanticsConfiguration(SemanticsConfiguration config) { config.isSemanticBoundary = container; @@ -3204,6 +3244,10 @@ class RenderSemanticsAnnotations extends RenderProxyBox { config.addAction(SemanticsAction.increase, _performIncrease); if (onDecrease != null) config.addAction(SemanticsAction.decrease, _performDecrease); + if (onMoveCursorForwardByCharacter != null) + config.addAction(SemanticsAction.moveCursorForwardByCharacter, _performMoveCursorForwardByCharacter); + if (onMoveCursorBackwardByCharacter != null) + config.addAction(SemanticsAction.moveCursorBackwardByCharacter, _performMoveCursorBackwardByCharacter); } void _performTap() { @@ -3245,6 +3289,16 @@ class RenderSemanticsAnnotations extends RenderProxyBox { if (onDecrease != null) onDecrease(); } + + void _performMoveCursorForwardByCharacter() { + if (onMoveCursorForwardByCharacter != null) + onMoveCursorForwardByCharacter(); + } + + void _performMoveCursorBackwardByCharacter() { + if (onMoveCursorBackwardByCharacter != null) + onMoveCursorBackwardByCharacter(); + } } /// Causes the semantics of all earlier render objects below the same semantic diff --git a/packages/flutter/lib/src/semantics/semantics.dart b/packages/flutter/lib/src/semantics/semantics.dart index e3f83d67e7..6f3e37ae2f 100644 --- a/packages/flutter/lib/src/semantics/semantics.dart +++ b/packages/flutter/lib/src/semantics/semantics.dart @@ -252,6 +252,8 @@ class SemanticsProperties extends DiagnosticableTree { this.onScrollDown, this.onIncrease, this.onDecrease, + this.onMoveCursorForwardByCharacter, + this.onMoveCursorBackwardByCharacter, }); /// If non-null, indicates that this subtree represents a checkbox @@ -436,6 +438,24 @@ class SemanticsProperties extends DiagnosticableTree { /// volume down button. final VoidCallback onDecrease; + /// The handler for [SemanticsAction.onMoveCursorForwardByCharacter]. + /// + /// This handler is invoked when the user wants to move the cursor in a + /// text field forward by one character. + /// + /// TalkBack users can trigger this by pressing the volume up key while the + /// input focus is in a text field. + final VoidCallback onMoveCursorForwardByCharacter; + + /// The handler for [SemanticsAction.onMoveCursorBackwardByCharacter]. + /// + /// This handler is invoked when the user wants to move the cursor in a + /// text field backward by one character. + /// + /// TalkBack users can trigger this by pressing the volume down key while the + /// input focus is in a text field. + final VoidCallback onMoveCursorBackwardByCharacter; + @override void debugFillProperties(DiagnosticPropertiesBuilder description) { super.debugFillProperties(description); diff --git a/packages/flutter/lib/src/widgets/basic.dart b/packages/flutter/lib/src/widgets/basic.dart index 390b879c74..258b41539a 100644 --- a/packages/flutter/lib/src/widgets/basic.dart +++ b/packages/flutter/lib/src/widgets/basic.dart @@ -4726,6 +4726,8 @@ class Semantics extends SingleChildRenderObjectWidget { VoidCallback onScrollDown, VoidCallback onIncrease, VoidCallback onDecrease, + VoidCallback onMoveCursorForwardByCharacter, + VoidCallback onMoveCursorBackwardByCharacter, }) : this.fromProperties( key: key, child: child, @@ -4749,6 +4751,8 @@ class Semantics extends SingleChildRenderObjectWidget { onScrollDown: onScrollDown, onIncrease: onIncrease, onDecrease: onDecrease, + onMoveCursorForwardByCharacter: onMoveCursorForwardByCharacter, + onMoveCursorBackwardByCharacter: onMoveCursorBackwardByCharacter, ), ); @@ -4814,6 +4818,8 @@ class Semantics extends SingleChildRenderObjectWidget { onScrollDown: properties.onScrollDown, onIncrease: properties.onIncrease, onDecrease: properties.onDecrease, + onMoveCursorForwardByCharacter: properties.onMoveCursorForwardByCharacter, + onMoveCursorBackwardByCharacter: properties.onMoveCursorBackwardByCharacter, ); } @@ -4849,7 +4855,9 @@ class Semantics extends SingleChildRenderObjectWidget { ..onScrollUp = properties.onScrollUp ..onScrollDown = properties.onScrollDown ..onIncrease = properties.onIncrease - ..onDecrease = properties.onDecrease; + ..onDecrease = properties.onDecrease + ..onMoveCursorForwardByCharacter = properties.onMoveCursorForwardByCharacter + ..onMoveCursorBackwardByCharacter = properties.onMoveCursorForwardByCharacter; } @override diff --git a/packages/flutter/test/widgets/editable_text_test.dart b/packages/flutter/test/widgets/editable_text_test.dart index 1baddc9fc7..4abd090c1e 100644 --- a/packages/flutter/test/widgets/editable_text_test.dart +++ b/packages/flutter/test/widgets/editable_text_test.dart @@ -356,4 +356,122 @@ void main() { expect(textState.selectionOverlay.handlesAreVisible, isFalse); expect(textState.selectionOverlay.textEditingValue.selection, const TextSelection.collapsed(offset: 10)); }); + + testWidgets('exposes correct cursor movement semantics', (WidgetTester tester) async { + final SemanticsTester semantics = new SemanticsTester(tester); + + controller.text = 'test'; + + await tester.pumpWidget(new MaterialApp( + home: new EditableText( + controller: controller, + focusNode: focusNode, + style: textStyle, + cursorColor: cursorColor, + ), + )); + + expect(semantics, includesNodeWith( + value: 'test', + )); + + controller.selection = new TextSelection.collapsed(offset: controller.text.length); + await tester.pumpAndSettle(); + + // At end, can only go backwards. + expect(semantics, includesNodeWith( + value: 'test', + actions: [ + SemanticsAction.moveCursorBackwardByCharacter, + ], + )); + + controller.selection = new TextSelection.collapsed(offset: controller.text.length - 2); + await tester.pumpAndSettle(); + + // Somewhere in the middle, can go in both directions. + expect(semantics, includesNodeWith( + value: 'test', + actions: [ + SemanticsAction.moveCursorBackwardByCharacter, + SemanticsAction.moveCursorForwardByCharacter, + ], + )); + + controller.selection = const TextSelection.collapsed(offset: 0); + await tester.pumpAndSettle(); + + // At beginning, can only go forward. + expect(semantics, includesNodeWith( + value: 'test', + actions: [ + SemanticsAction.moveCursorForwardByCharacter, + ], + )); + + semantics.dispose(); + }); + + testWidgets('can move cursor with a11y means', (WidgetTester tester) async { + final SemanticsTester semantics = new SemanticsTester(tester); + + controller.text = 'test'; + controller.selection = new TextSelection.collapsed(offset: controller.text.length); + + await tester.pumpWidget(new MaterialApp( + home: new EditableText( + controller: controller, + focusNode: focusNode, + style: textStyle, + cursorColor: cursorColor, + ), + )); + + expect(semantics, includesNodeWith( + value: 'test', + actions: [ + SemanticsAction.moveCursorBackwardByCharacter, + ], + )); + + final RenderEditable render = tester.allRenderObjects.firstWhere((RenderObject o) => o.runtimeType == RenderEditable); + final int semanticsId = render.debugSemantics.id; + + expect(controller.selection.baseOffset, 4); + expect(controller.selection.extentOffset, 4); + + tester.binding.pipelineOwner.semanticsOwner.performAction(semanticsId, SemanticsAction.moveCursorBackwardByCharacter); + await tester.pumpAndSettle(); + + expect(controller.selection.baseOffset, 3); + expect(controller.selection.extentOffset, 3); + + expect(semantics, includesNodeWith( + value: 'test', + actions: [ + SemanticsAction.moveCursorBackwardByCharacter, + SemanticsAction.moveCursorForwardByCharacter, + ], + )); + + tester.binding.pipelineOwner.semanticsOwner.performAction(semanticsId, SemanticsAction.moveCursorBackwardByCharacter); + await tester.pumpAndSettle(); + tester.binding.pipelineOwner.semanticsOwner.performAction(semanticsId, SemanticsAction.moveCursorBackwardByCharacter); + await tester.pumpAndSettle(); + tester.binding.pipelineOwner.semanticsOwner.performAction(semanticsId, SemanticsAction.moveCursorBackwardByCharacter); + await tester.pumpAndSettle(); + + expect(controller.selection.baseOffset, 0); + expect(controller.selection.extentOffset, 0); + + await tester.pumpAndSettle(); + expect(semantics, includesNodeWith( + value: 'test', + actions: [ + SemanticsAction.moveCursorForwardByCharacter, + ], + )); + + semantics.dispose(); + }); } diff --git a/packages/flutter/test/widgets/semantics_test.dart b/packages/flutter/test/widgets/semantics_test.dart index 40ebc221fe..718ee520db 100644 --- a/packages/flutter/test/widgets/semantics_test.dart +++ b/packages/flutter/test/widgets/semantics_test.dart @@ -388,6 +388,8 @@ void main() { onScrollDown: () => performedActions.add(SemanticsAction.scrollDown), onIncrease: () => performedActions.add(SemanticsAction.increase), onDecrease: () => performedActions.add(SemanticsAction.decrease), + onMoveCursorForwardByCharacter: () => performedActions.add(SemanticsAction.moveCursorForwardByCharacter), + onMoveCursorBackwardByCharacter: () => performedActions.add(SemanticsAction.moveCursorBackwardByCharacter), ) );