Unverified Commit 9dce4b82 authored by jslavitz's avatar jslavitz Committed by GitHub

Shortcuts (#21558)

* added shortcuts and delete functionality

* added first test

*  afew chnages

* a few changes

* hope this works

* small change

* small changes

* fixed nits

* final changes:

* fixed initializing formals

* update comment

* minor change:

* added line

* final changes

* random change

* changed function to void

* one more

* removed line

* dis betta work

* final change

* actual final chnge
parent 8ebd3525
...@@ -135,6 +135,7 @@ class RenderEditable extends RenderBox { ...@@ -135,6 +135,7 @@ class RenderEditable extends RenderBox {
Locale locale, Locale locale,
double cursorWidth = 1.0, double cursorWidth = 1.0,
Radius cursorRadius, Radius cursorRadius,
@required this.textSelectionDelegate,
}) : assert(textAlign != null), }) : assert(textAlign != null),
assert(textDirection != null, 'RenderEditable created without a textDirection.'), assert(textDirection != null, 'RenderEditable created without a textDirection.'),
assert(maxLines == null || maxLines > 0), assert(maxLines == null || maxLines > 0),
...@@ -142,7 +143,8 @@ class RenderEditable extends RenderBox { ...@@ -142,7 +143,8 @@ class RenderEditable extends RenderBox {
assert(offset != null), assert(offset != null),
assert(ignorePointer != null), assert(ignorePointer != null),
assert(obscureText != null), assert(obscureText != null),
_textPainter = new TextPainter( assert(textSelectionDelegate != null),
_textPainter = new TextPainter(
text: text, text: text,
textAlign: textAlign, textAlign: textAlign,
textDirection: textDirection, textDirection: textDirection,
...@@ -196,12 +198,24 @@ class RenderEditable extends RenderBox { ...@@ -196,12 +198,24 @@ class RenderEditable extends RenderBox {
markNeedsSemanticsUpdate(); markNeedsSemanticsUpdate();
} }
/// The object that controls the text selection, used by this render object
/// for implementing cut, copy, and paste keyboard shortcuts.
///
/// It must not be null. It will make cut, copy and paste functionality work
/// with the most recently set [TextSelectionDelegate].
TextSelectionDelegate textSelectionDelegate;
Rect _lastCaretRect; Rect _lastCaretRect;
static const int _kLeftArrowCode = 21; static const int _kLeftArrowCode = 21;
static const int _kRightArrowCode = 22; static const int _kRightArrowCode = 22;
static const int _kUpArrowCode = 19; static const int _kUpArrowCode = 19;
static const int _kDownArrowCode = 20; static const int _kDownArrowCode = 20;
static const int _kXKeyCode = 52;
static const int _kCKeyCode = 31;
static const int _kVKeyCode = 50;
static const int _kAKeyCode = 29;
static const int _kDelKeyCode = 112;
// The extent offset of the current selection // The extent offset of the current selection
int _extentOffset = -1; int _extentOffset = -1;
...@@ -221,7 +235,7 @@ class RenderEditable extends RenderBox { ...@@ -221,7 +235,7 @@ class RenderEditable extends RenderBox {
static const int _kControlMask = 1 << 12; // https://developer.android.com/reference/android/view/KeyEvent.html#META_CTRL_ON static const int _kControlMask = 1 << 12; // https://developer.android.com/reference/android/view/KeyEvent.html#META_CTRL_ON
// TODO(goderbauer): doesn't handle extended grapheme clusters with more than one Unicode scalar value (https://github.com/flutter/flutter/issues/13404). // TODO(goderbauer): doesn't handle extended grapheme clusters with more than one Unicode scalar value (https://github.com/flutter/flutter/issues/13404).
void _handleKeyEvent(RawKeyEvent keyEvent){ void _handleKeyEvent(RawKeyEvent keyEvent) {
if (defaultTargetPlatform != TargetPlatform.android) if (defaultTargetPlatform != TargetPlatform.android)
return; return;
...@@ -246,6 +260,11 @@ class RenderEditable extends RenderBox { ...@@ -246,6 +260,11 @@ class RenderEditable extends RenderBox {
final bool upArrow = pressedKeyCode == _kUpArrowCode; final bool upArrow = pressedKeyCode == _kUpArrowCode;
final bool downArrow = pressedKeyCode == _kDownArrowCode; final bool downArrow = pressedKeyCode == _kDownArrowCode;
final bool arrow = leftArrow || rightArrow || upArrow || downArrow; final bool arrow = leftArrow || rightArrow || upArrow || downArrow;
final bool aKey = pressedKeyCode == _kAKeyCode;
final bool xKey = pressedKeyCode == _kXKeyCode;
final bool vKey = pressedKeyCode == _kVKeyCode;
final bool cKey = pressedKeyCode == _kCKeyCode;
final bool del = pressedKeyCode == _kDelKeyCode;
// We will only move select or more the caret if an arrow is pressed // We will only move select or more the caret if an arrow is pressed
if (arrow) { if (arrow) {
...@@ -262,7 +281,12 @@ class RenderEditable extends RenderBox { ...@@ -262,7 +281,12 @@ class RenderEditable extends RenderBox {
newOffset = _handleShift(rightArrow, leftArrow, shift, newOffset); newOffset = _handleShift(rightArrow, leftArrow, shift, newOffset);
_extentOffset = newOffset; _extentOffset = newOffset;
} else if (ctrl && (xKey || vKey || cKey || aKey)) {
// _handleShortcuts depends on being started in the same stack invocation as the _handleKeyEvent method
_handleShortcuts(pressedKeyCode);
} }
if (del)
_handleDelete();
} }
// Handles full word traversal using control. // Handles full word traversal using control.
...@@ -368,7 +392,7 @@ class RenderEditable extends RenderBox { ...@@ -368,7 +392,7 @@ class RenderEditable extends RenderBox {
onSelectionChanged( onSelectionChanged(
new TextSelection.fromPosition( new TextSelection.fromPosition(
new TextPosition( new TextPosition(
offset: newOffset offset: newOffset
) )
), ),
this, this,
...@@ -378,6 +402,75 @@ class RenderEditable extends RenderBox { ...@@ -378,6 +402,75 @@ class RenderEditable extends RenderBox {
return newOffset; return newOffset;
} }
// Handles shortcut functionality including cut, copy, paste and select all
// using control + (X, C, V, A).
void _handleShortcuts(int pressedKeyCode) async {
switch (pressedKeyCode) {
case _kCKeyCode:
if (!selection.isCollapsed) {
Clipboard.setData(
new ClipboardData(text: selection.textInside(text.text)));
}
break;
case _kXKeyCode:
if (!selection.isCollapsed) {
Clipboard.setData(
new ClipboardData(text: selection.textInside(text.text)));
textSelectionDelegate.textEditingValue = new TextEditingValue(
text: selection.textBefore(text.text)
+ selection.textAfter(text.text),
selection: new TextSelection.collapsed(offset: selection.start),
);
}
break;
case _kVKeyCode:
// Snapshot the input before using `await`.
// See https://github.com/flutter/flutter/issues/11427
final TextEditingValue value = textSelectionDelegate.textEditingValue;
final ClipboardData data = await Clipboard.getData(Clipboard.kTextPlain);
if (data != null) {
textSelectionDelegate.textEditingValue = new TextEditingValue(
text: value.selection.textBefore(value.text)
+ data.text
+ value.selection.textAfter(value.text),
selection: new TextSelection.collapsed(
offset: value.selection.start + data.text.length
),
);
}
break;
case _kAKeyCode:
_baseOffset = 0;
_extentOffset = textSelectionDelegate.textEditingValue.text.length;
onSelectionChanged(
new TextSelection(
baseOffset: 0,
extentOffset: textSelectionDelegate.textEditingValue.text.length,
),
this,
SelectionChangedCause.keyboard,
);
break;
default:
assert(false);
}
}
void _handleDelete() {
if (selection.textAfter(text.text).isNotEmpty) {
textSelectionDelegate.textEditingValue = new TextEditingValue(
text: selection.textBefore(text.text)
+ selection.textAfter(text.text).substring(1),
selection: new TextSelection.collapsed(offset: selection.start)
);
} else {
textSelectionDelegate.textEditingValue = new TextEditingValue(
text: selection.textBefore(text.text),
selection: new TextSelection.collapsed(offset: selection.start)
);
}
}
/// Marks the render object as needing to be laid out again and have its text /// Marks the render object as needing to be laid out again and have its text
/// metrics recomputed. /// metrics recomputed.
/// ///
......
...@@ -534,6 +534,23 @@ class TextEditingValue { ...@@ -534,6 +534,23 @@ class TextEditingValue {
); );
} }
/// An interface for manipulating the selection, to be used by the implementor
/// of the toolbar widget.
abstract class TextSelectionDelegate {
/// Gets the current text input.
TextEditingValue get textEditingValue;
/// Sets the current text input (replaces the whole line).
set textEditingValue(TextEditingValue value);
/// Hides the text selection toolbar.
void hideToolbar();
/// Brings the provided [TextPosition] into the visible area of the text
/// input.
void bringIntoView(TextPosition position);
}
/// An interface to receive information from [TextInput]. /// An interface to receive information from [TextInput].
/// ///
/// See also: /// See also:
......
...@@ -904,6 +904,7 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien ...@@ -904,6 +904,7 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
rendererIgnoresPointer: widget.rendererIgnoresPointer, rendererIgnoresPointer: widget.rendererIgnoresPointer,
cursorWidth: widget.cursorWidth, cursorWidth: widget.cursorWidth,
cursorRadius: widget.cursorRadius, cursorRadius: widget.cursorRadius,
textSelectionDelegate: this,
), ),
), ),
); );
...@@ -967,6 +968,7 @@ class _Editable extends LeafRenderObjectWidget { ...@@ -967,6 +968,7 @@ class _Editable extends LeafRenderObjectWidget {
this.rendererIgnoresPointer = false, this.rendererIgnoresPointer = false,
this.cursorWidth, this.cursorWidth,
this.cursorRadius, this.cursorRadius,
this.textSelectionDelegate,
}) : assert(textDirection != null), }) : assert(textDirection != null),
assert(rendererIgnoresPointer != null), assert(rendererIgnoresPointer != null),
super(key: key); super(key: key);
...@@ -990,6 +992,7 @@ class _Editable extends LeafRenderObjectWidget { ...@@ -990,6 +992,7 @@ class _Editable extends LeafRenderObjectWidget {
final bool rendererIgnoresPointer; final bool rendererIgnoresPointer;
final double cursorWidth; final double cursorWidth;
final Radius cursorRadius; final Radius cursorRadius;
final TextSelectionDelegate textSelectionDelegate;
@override @override
RenderEditable createRenderObject(BuildContext context) { RenderEditable createRenderObject(BuildContext context) {
...@@ -1012,6 +1015,7 @@ class _Editable extends LeafRenderObjectWidget { ...@@ -1012,6 +1015,7 @@ class _Editable extends LeafRenderObjectWidget {
obscureText: obscureText, obscureText: obscureText,
cursorWidth: cursorWidth, cursorWidth: cursorWidth,
cursorRadius: cursorRadius, cursorRadius: cursorRadius,
textSelectionDelegate: textSelectionDelegate,
); );
} }
...@@ -1035,6 +1039,7 @@ class _Editable extends LeafRenderObjectWidget { ...@@ -1035,6 +1039,7 @@ class _Editable extends LeafRenderObjectWidget {
..ignorePointer = rendererIgnoresPointer ..ignorePointer = rendererIgnoresPointer
..obscureText = obscureText ..obscureText = obscureText
..cursorWidth = cursorWidth ..cursorWidth = cursorWidth
..cursorRadius = cursorRadius; ..cursorRadius = cursorRadius
..textSelectionDelegate = textSelectionDelegate;
} }
} }
...@@ -16,6 +16,8 @@ import 'gesture_detector.dart'; ...@@ -16,6 +16,8 @@ import 'gesture_detector.dart';
import 'overlay.dart'; import 'overlay.dart';
import 'transitions.dart'; import 'transitions.dart';
export 'package:flutter/services.dart' show TextSelectionDelegate;
/// Which type of selection handle to be displayed. /// Which type of selection handle to be displayed.
/// ///
/// With mixed-direction text, both handles may be the same type. Examples: /// With mixed-direction text, both handles may be the same type. Examples:
...@@ -60,23 +62,6 @@ enum _TextSelectionHandlePosition { start, end } ...@@ -60,23 +62,6 @@ enum _TextSelectionHandlePosition { start, end }
/// Used by [TextSelectionOverlay.onSelectionOverlayChanged]. /// Used by [TextSelectionOverlay.onSelectionOverlayChanged].
typedef void TextSelectionOverlayChanged(TextEditingValue value, Rect caretRect); typedef void TextSelectionOverlayChanged(TextEditingValue value, Rect caretRect);
/// An interface for manipulating the selection, to be used by the implementor
/// of the toolbar widget.
abstract class TextSelectionDelegate {
/// Gets the current text input.
TextEditingValue get textEditingValue;
/// Sets the current text input (replaces the whole line).
set textEditingValue(TextEditingValue value);
/// Hides the text selection toolbar.
void hideToolbar();
/// Brings the provided [TextPosition] into the visible area of the text
/// input.
void bringIntoView(TextPosition position);
}
/// An interface for building the selection UI, to be provided by the /// An interface for building the selection UI, to be provided by the
/// implementor of the toolbar widget. /// implementor of the toolbar widget.
/// ///
......
...@@ -1919,6 +1919,255 @@ void main() { ...@@ -1919,6 +1919,255 @@ void main() {
}); });
}); });
const int _kXKeyCode = 52;
const int _kCKeyCode = 31;
const int _kVKeyCode = 50;
const int _kAKeyCode = 29;
const int _kDelKeyCode = 112;
testWidgets('Copy paste test', (WidgetTester tester) async{
final FocusNode focusNode = new FocusNode();
final TextEditingController controller = new TextEditingController();
final TextField textField =
new TextField(
controller: controller,
maxLines: 3,
);
String clipboardContent = '';
SystemChannels.platform
.setMockMethodCallHandler((MethodCall methodCall) async {
if (methodCall.method == 'Clipboard.setData')
clipboardContent = methodCall.arguments['text'];
else if (methodCall.method == 'Clipboard.getData')
return <String, dynamic>{'text': clipboardContent};
return null;
});
await tester.pumpWidget(
new MaterialApp(
home: new Material(
child: new RawKeyboardListener(
focusNode: focusNode,
onKey: null,
child: textField,
),
),
),
);
const String testValue = 'a big house\njumped over a mouse'; // 11 \n 19
await tester.enterText(find.byType(TextField), testValue);
await tester.idle();
await tester.tap(find.byType(TextField));
await tester.pumpAndSettle();
// Select the first 5 characters
for (int i = 0; i < 5; i += 1) {
sendKeyEventWithCode(22, true, true, false); // RIGHT_ARROW keydown shift
await tester.pumpAndSettle();
sendKeyEventWithCode(22, false, false, false); // RIGHT_ARROW keyup
await tester.pumpAndSettle();
}
// Copy them
sendKeyEventWithCode(_kCKeyCode, true, false, true); // keydown control
await tester.pumpAndSettle();
sendKeyEventWithCode(_kCKeyCode, false, false, false); // keyup control
await tester.pumpAndSettle();
expect(clipboardContent, 'a big');
sendKeyEventWithCode(22, true, false, false); // RIGHT_ARROW keydown
await tester.pumpAndSettle();
sendKeyEventWithCode(22, false, false, false); // RIGHT_ARROW keyup
await tester.pumpAndSettle();
// Paste them
sendKeyEventWithCode(_kVKeyCode, true, false, true); // Control V keydown
await tester.pumpAndSettle();
await tester.pump(const Duration(milliseconds: 200));
sendKeyEventWithCode(_kVKeyCode, false, false, false); // Control V keyup
await tester.pumpAndSettle();
const String expected = 'a biga big house\njumped over a mouse';
expect(find.text(expected), findsOneWidget);
});
testWidgets('Cut test', (WidgetTester tester) async{
final FocusNode focusNode = new FocusNode();
final TextEditingController controller = new TextEditingController();
final TextField textField =
new TextField(
controller: controller,
maxLines: 3,
);
String clipboardContent = '';
SystemChannels.platform
.setMockMethodCallHandler((MethodCall methodCall) async {
if (methodCall.method == 'Clipboard.setData')
clipboardContent = methodCall.arguments['text'];
else if (methodCall.method == 'Clipboard.getData')
return <String, dynamic>{'text': clipboardContent};
return null;
});
await tester.pumpWidget(
new MaterialApp(
home: new Material(
child: new RawKeyboardListener(
focusNode: focusNode,
onKey: null,
child: textField,
),
),
),
);
const String testValue = 'a big house\njumped over a mouse'; // 11 \n 19
await tester.enterText(find.byType(TextField), testValue);
await tester.idle();
await tester.tap(find.byType(TextField));
await tester.pumpAndSettle();
// Select the first 5 characters
for (int i = 0; i < 5; i += 1) {
sendKeyEventWithCode(22, true, true, false); // RIGHT_ARROW keydown shift
await tester.pumpAndSettle();
sendKeyEventWithCode(22, false, false, false); // RIGHT_ARROW keyup
await tester.pumpAndSettle();
}
// Cut them
sendKeyEventWithCode(_kXKeyCode, true, false, true); // keydown control X
await tester.pumpAndSettle();
sendKeyEventWithCode(_kXKeyCode, false, false, false); // keyup control X
await tester.pumpAndSettle();
expect(clipboardContent, 'a big');
for (int i = 0; i < 5; i += 1) {
sendKeyEventWithCode(22, true, false, false); // RIGHT_ARROW keydown
await tester.pumpAndSettle();
sendKeyEventWithCode(22, false, false, false); // RIGHT_ARROW keyup
await tester.pumpAndSettle();
}
// Paste them
sendKeyEventWithCode(_kVKeyCode, true, false, true); // Control V keydown
await tester.pumpAndSettle();
await tester.pump(const Duration(milliseconds: 200));
sendKeyEventWithCode(_kVKeyCode, false, false, false); // Control V keyup
await tester.pumpAndSettle();
const String expected = ' housa bige\njumped over a mouse';
expect(find.text(expected), findsOneWidget);
});
testWidgets('Select all test', (WidgetTester tester) async{
final FocusNode focusNode = new FocusNode();
final TextEditingController controller = new TextEditingController();
final TextField textField =
new TextField(
controller: controller,
maxLines: 3,
);
await tester.pumpWidget(
new MaterialApp(
home: new Material(
child: new RawKeyboardListener(
focusNode: focusNode,
onKey: null,
child: textField,
),
),
),
);
const String testValue = 'a big house\njumped over a mouse'; // 11 \n 19
await tester.enterText(find.byType(TextField), testValue);
await tester.idle();
await tester.tap(find.byType(TextField));
await tester.pumpAndSettle();
// Select All
sendKeyEventWithCode(_kAKeyCode, true, false, true); // keydown control A
await tester.pumpAndSettle();
sendKeyEventWithCode(_kAKeyCode, false, false, true); // keyup control A
await tester.pumpAndSettle();
// Delete them
sendKeyEventWithCode(_kDelKeyCode, true, false, false); // DEL keydown
await tester.pumpAndSettle();
await tester.pump(const Duration(milliseconds: 200));
sendKeyEventWithCode(_kDelKeyCode, false, false, false); // DEL keyup
await tester.pumpAndSettle();
const String expected = '';
expect(find.text(expected), findsOneWidget);
});
testWidgets('Delete test', (WidgetTester tester) async{
final FocusNode focusNode = new FocusNode();
final TextEditingController controller = new TextEditingController();
final TextField textField =
new TextField(
controller: controller,
maxLines: 3,
);
await tester.pumpWidget(
new MaterialApp(
home: new Material(
child: new RawKeyboardListener(
focusNode: focusNode,
onKey: null,
child: textField,
),
),
),
);
const String testValue = 'a big house\njumped over a mouse'; // 11 \n 19
await tester.enterText(find.byType(TextField), testValue);
await tester.idle();
await tester.tap(find.byType(TextField));
await tester.pumpAndSettle();
// Delete
for (int i = 0; i < 6; i += 1) {
sendKeyEventWithCode(_kDelKeyCode, true, false, false); // keydown DEL
await tester.pumpAndSettle();
sendKeyEventWithCode(_kDelKeyCode, false, false, false); // keyup DEL
await tester.pumpAndSettle();
}
const String expected = 'house\njumped over a mouse';
expect(find.text(expected), findsOneWidget);
sendKeyEventWithCode(_kAKeyCode, true, false, true); // keydown control A
await tester.pumpAndSettle();
sendKeyEventWithCode(_kAKeyCode, false, false, true); // keyup control A
await tester.pumpAndSettle();
sendKeyEventWithCode(_kDelKeyCode, true, false, false); // keydown DEL
await tester.pumpAndSettle();
sendKeyEventWithCode(_kDelKeyCode, false, false, false); // keyup DEL
await tester.pumpAndSettle();
const String expected2 = '';
expect(find.text(expected2), findsOneWidget);
});
testWidgets('Changing positions of text fields', (WidgetTester tester) async{ testWidgets('Changing positions of text fields', (WidgetTester tester) async{
final FocusNode focusNode = new FocusNode(); final FocusNode focusNode = new FocusNode();
......
...@@ -4,9 +4,25 @@ ...@@ -4,9 +4,25 @@
import 'package:flutter/rendering.dart'; import 'package:flutter/rendering.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:flutter/services.dart';
class FakeEditableTextState extends TextSelectionDelegate {
@override
TextEditingValue get textEditingValue { return const TextEditingValue(); }
@override
set textEditingValue(TextEditingValue value) {}
@override
void hideToolbar() {}
@override
void bringIntoView(TextPosition position) {}
}
void main() { void main() {
test('editable intrinsics', () { test('editable intrinsics', () {
final TextSelectionDelegate delegate = new FakeEditableTextState();
final RenderEditable editable = new RenderEditable( final RenderEditable editable = new RenderEditable(
text: const TextSpan( text: const TextSpan(
style: TextStyle(height: 1.0, fontSize: 10.0, fontFamily: 'Ahem'), style: TextStyle(height: 1.0, fontSize: 10.0, fontFamily: 'Ahem'),
...@@ -16,6 +32,7 @@ void main() { ...@@ -16,6 +32,7 @@ void main() {
textDirection: TextDirection.ltr, textDirection: TextDirection.ltr,
locale: const Locale('ja', 'JP'), locale: const Locale('ja', 'JP'),
offset: new ViewportOffset.zero(), offset: new ViewportOffset.zero(),
textSelectionDelegate: delegate,
); );
expect(editable.getMinIntrinsicWidth(double.infinity), 50.0); expect(editable.getMinIntrinsicWidth(double.infinity), 50.0);
expect(editable.getMaxIntrinsicWidth(double.infinity), 50.0); expect(editable.getMaxIntrinsicWidth(double.infinity), 50.0);
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment