Unverified Commit 80bf3551 authored by chunhtai's avatar chunhtai Committed by GitHub

Support keyboard selection in SelectabledRegion (#112584)

* Support keyboard selection in selectable region

* fix some comments

* addressing comments
parent cfb2f158
......@@ -194,6 +194,80 @@ class _RenderSelectableAdapter extends RenderProxyBox with Selectable, Selection
_start = Offset.zero;
_end = Offset.infinite;
break;
case SelectionEventType.granularlyExtendSelection:
result = SelectionResult.end;
final GranularlyExtendSelectionEvent extendSelectionEvent = event as GranularlyExtendSelectionEvent;
// Initialize the offset it there is no ongoing selection.
if (_start == null || _end == null) {
if (extendSelectionEvent.forward) {
_start = _end = Offset.zero;
} else {
_start = _end = Offset.infinite;
}
}
// Move the corresponding selection edge.
final Offset newOffset = extendSelectionEvent.forward ? Offset.infinite : Offset.zero;
if (extendSelectionEvent.isEnd) {
if (newOffset == _end) {
result = extendSelectionEvent.forward ? SelectionResult.next : SelectionResult.previous;
}
_end = newOffset;
} else {
if (newOffset == _start) {
result = extendSelectionEvent.forward ? SelectionResult.next : SelectionResult.previous;
}
_start = newOffset;
}
break;
case SelectionEventType.directionallyExtendSelection:
result = SelectionResult.end;
final DirectionallyExtendSelectionEvent extendSelectionEvent = event as DirectionallyExtendSelectionEvent;
// Convert to local coordinates.
final double horizontalBaseLine = globalToLocal(Offset(event.dx, 0)).dx;
final Offset newOffset;
final bool forward;
switch(extendSelectionEvent.direction) {
case SelectionExtendDirection.backward:
case SelectionExtendDirection.previousLine:
forward = false;
// Initialize the offset it there is no ongoing selection.
if (_start == null || _end == null) {
_start = _end = Offset.infinite;
}
// Move the corresponding selection edge.
if (extendSelectionEvent.direction == SelectionExtendDirection.previousLine || horizontalBaseLine < 0) {
newOffset = Offset.zero;
} else {
newOffset = Offset.infinite;
}
break;
case SelectionExtendDirection.nextLine:
case SelectionExtendDirection.forward:
forward = true;
// Initialize the offset it there is no ongoing selection.
if (_start == null || _end == null) {
_start = _end = Offset.zero;
}
// Move the corresponding selection edge.
if (extendSelectionEvent.direction == SelectionExtendDirection.nextLine || horizontalBaseLine > size.width) {
newOffset = Offset.infinite;
} else {
newOffset = Offset.zero;
}
break;
}
if (extendSelectionEvent.isEnd) {
if (newOffset == _end) {
result = forward ? SelectionResult.next : SelectionResult.previous;
}
_end = newOffset;
} else {
if (newOffset == _start) {
result = forward ? SelectionResult.next : SelectionResult.previous;
}
_start = newOffset;
}
break;
}
_updateGeometry();
return result;
......
......@@ -296,6 +296,30 @@ enum SelectionEventType {
///
/// Used by [SelectWordSelectionEvent].
selectWord,
/// An event that extends the selection by a specific [TextGranularity].
granularlyExtendSelection,
/// An event that extends the selection in a specific direction.
directionallyExtendSelection,
}
/// The unit of how selection handles move in text.
///
/// The [GranularlyExtendSelectionEvent] uses this enum to describe how
/// [Selectable] should extend its selection.
enum TextGranularity {
/// Treats each character as an atomic unit when moving the selection handles.
character,
/// Treats word as an atomic unit when moving the selection handles.
word,
/// Treats each line break as an atomic unit when moving the selection handles.
line,
/// Treats the entire document as an atomic unit when moving the selection handles.
document,
}
/// An abstract base class for selection events.
......@@ -375,6 +399,127 @@ class SelectionEdgeUpdateEvent extends SelectionEvent {
final Offset globalPosition;
}
/// Extends the start or end of the selection by a given [TextGranularity].
///
/// To handle this event, move the associated selection edge, as dictated by
/// [isEnd], according to the [granularity].
class GranularlyExtendSelectionEvent extends SelectionEvent {
/// Creates a [GranularlyExtendSelectionEvent].
///
/// All parameters are required and must not be null.
const GranularlyExtendSelectionEvent({
required this.forward,
required this.isEnd,
required this.granularity,
}) : super._(SelectionEventType.granularlyExtendSelection);
/// Whether to extend the selection forward.
final bool forward;
/// Whether this event is updating the end selection edge.
final bool isEnd;
/// The granularity for which the selection extend.
final TextGranularity granularity;
}
/// The direction to extend a selection.
///
/// The [DirectionallyExtendSelectionEvent] uses this enum to describe how
/// [Selectable] should extend their selection.
enum SelectionExtendDirection {
/// Move one edge of the selection vertically to the previous adjacent line.
///
/// For text selection, it should consider both soft and hard linebreak.
///
/// See [DirectionallyExtendSelectionEvent.dx] on how to
/// calculate the horizontal offset.
previousLine,
/// Move one edge of the selection vertically to the next adjacent line.
///
/// For text selection, it should consider both soft and hard linebreak.
///
/// See [DirectionallyExtendSelectionEvent.dx] on how to
/// calculate the horizontal offset.
nextLine,
/// Move the selection edges forward to a certain horizontal offset in the
/// same line.
///
/// If there is no on-going selection, the selection must start with the first
/// line (or equivalence of first line in a non-text selectable) and select
/// toward the horizontal offset in the same line.
///
/// The selectable that receives [DirectionallyExtendSelectionEvent] with this
/// enum must return [SelectionResult.end].
///
/// See [DirectionallyExtendSelectionEvent.dx] on how to
/// calculate the horizontal offset.
forward,
/// Move the selection edges backward to a certain horizontal offset in the
/// same line.
///
/// If there is no on-going selection, the selection must start with the last
/// line (or equivalence of last line in a non-text selectable) and select
/// backward the horizontal offset in the same line.
///
/// The selectable that receives [DirectionallyExtendSelectionEvent] with this
/// enum must return [SelectionResult.end].
///
/// See [DirectionallyExtendSelectionEvent.dx] on how to
/// calculate the horizontal offset.
backward,
}
/// Extends the current selection with respect to a [direction].
///
/// To handle this event, move the associated selection edge, as dictated by
/// [isEnd], according to the [direction].
///
/// The movements are always based on [dx]. The value is in
/// global coordinates and is the horizontal offset the selection edge should
/// move to when moving to across lines.
class DirectionallyExtendSelectionEvent extends SelectionEvent {
/// Creates a [DirectionallyExtendSelectionEvent].
///
/// All parameters are required and must not be null.
const DirectionallyExtendSelectionEvent({
required this.dx,
required this.isEnd,
required this.direction,
}) : super._(SelectionEventType.directionallyExtendSelection);
/// The horizontal offset the selection should move to.
///
/// The offset is in global coordinates.
final double dx;
/// Whether this event is updating the end selection edge.
final bool isEnd;
/// The directional movement of this event.
///
/// See also:
/// * [SelectionExtendDirection], which explains how to handle each enum.
final SelectionExtendDirection direction;
/// Makes a copy of this object with its property replaced with the new
/// values.
DirectionallyExtendSelectionEvent copyWith({
double? dx,
bool? isEnd,
SelectionExtendDirection? direction,
}) {
return DirectionallyExtendSelectionEvent(
dx: dx ?? this.dx,
isEnd: isEnd ?? this.isEnd,
direction: direction ?? this.direction,
);
}
}
/// A registrar that keeps track of [Selectable]s in the subtree.
///
/// A [Selectable] is only included in the [SelectableRegion] if they are
......
......@@ -295,8 +295,8 @@ class DefaultTextEditingShortcuts extends StatelessWidget {
const SingleActivator(LogicalKeyboardKey.arrowLeft, shift: true, alt: true): const ExtendSelectionToNextWordBoundaryOrCaretLocationIntent(forward: false),
const SingleActivator(LogicalKeyboardKey.arrowRight, shift: true, alt: true): const ExtendSelectionToNextWordBoundaryOrCaretLocationIntent(forward: true),
const SingleActivator(LogicalKeyboardKey.arrowUp, shift: true, alt: true): const ExtendSelectionToLineBreakIntent(forward: false, collapseSelection: false, collapseAtReversal: true),
const SingleActivator(LogicalKeyboardKey.arrowDown, shift: true, alt: true): const ExtendSelectionToLineBreakIntent(forward: true, collapseSelection: false, collapseAtReversal: true),
const SingleActivator(LogicalKeyboardKey.arrowUp, shift: true, alt: true): const ExtendSelectionVerticallyToAdjacentLineIntent(forward: false, collapseSelection: false),
const SingleActivator(LogicalKeyboardKey.arrowDown, shift: true, alt: true): const ExtendSelectionVerticallyToAdjacentLineIntent(forward: true, collapseSelection: false),
const SingleActivator(LogicalKeyboardKey.arrowLeft, meta: true): const ExtendSelectionToLineBreakIntent(forward: false, collapseSelection: true),
const SingleActivator(LogicalKeyboardKey.arrowRight, meta: true): const ExtendSelectionToLineBreakIntent(forward: true, collapseSelection: true),
......
......@@ -1248,11 +1248,11 @@ class _ScrollableSelectionContainerDelegate extends MultiSelectableSelectionCont
/// selection is triggered by none drag events. The
/// [_currentDragStartRelatedToOrigin] and [_currentDragEndRelatedToOrigin]
/// are essential to handle future [SelectionEdgeUpdateEvent]s.
void _updateDragLocationsFromGeometries() {
void _updateDragLocationsFromGeometries({bool forceUpdateStart = true, bool forceUpdateEnd = true}) {
final Offset deltaToOrigin = _getDeltaToScrollOrigin(state);
final RenderBox box = state.context.findRenderObject()! as RenderBox;
final Matrix4 transform = box.getTransformTo(null);
if (currentSelectionStartIndex != -1) {
if (currentSelectionStartIndex != -1 && (_currentDragStartRelatedToOrigin == null || forceUpdateStart)) {
final SelectionGeometry geometry = selectables[currentSelectionStartIndex].value;
assert(geometry.hasSelection);
final SelectionPoint start = geometry.startSelectionPoint!;
......@@ -1263,7 +1263,7 @@ class _ScrollableSelectionContainerDelegate extends MultiSelectableSelectionCont
);
_currentDragStartRelatedToOrigin = MatrixUtils.transformPoint(transform, localDragStart + deltaToOrigin);
}
if (currentSelectionEndIndex != -1) {
if (currentSelectionEndIndex != -1 && (_currentDragEndRelatedToOrigin == null || forceUpdateEnd)) {
final SelectionGeometry geometry = selectables[currentSelectionEndIndex].value;
assert(geometry.hasSelection);
final SelectionPoint end = geometry.endSelectionPoint!;
......@@ -1295,6 +1295,116 @@ class _ScrollableSelectionContainerDelegate extends MultiSelectableSelectionCont
return result;
}
@override
SelectionResult handleGranularlyExtendSelection(GranularlyExtendSelectionEvent event) {
final SelectionResult result = super.handleGranularlyExtendSelection(event);
// The selection geometry may not have the accurate offset for the edges
// that are outside of the viewport whose transform may not be valid. Only
// the edge this event is updating is sure to be accurate.
_updateDragLocationsFromGeometries(
forceUpdateStart: !event.isEnd,
forceUpdateEnd: event.isEnd,
);
if (_selectionStartsInScrollable) {
_jumpToEdge(event.isEnd);
}
return result;
}
@override
SelectionResult handleDirectionallyExtendSelection(DirectionallyExtendSelectionEvent event) {
final SelectionResult result = super.handleDirectionallyExtendSelection(event);
// The selection geometry may not have the accurate offset for the edges
// that are outside of the viewport whose transform may not be valid. Only
// the edge this event is updating is sure to be accurate.
_updateDragLocationsFromGeometries(
forceUpdateStart: !event.isEnd,
forceUpdateEnd: event.isEnd,
);
if (_selectionStartsInScrollable) {
_jumpToEdge(event.isEnd);
}
return result;
}
void _jumpToEdge(bool isExtent) {
final Selectable selectable;
final double? lineHeight;
final SelectionPoint? edge;
if (isExtent) {
selectable = selectables[currentSelectionEndIndex];
edge = selectable.value.endSelectionPoint;
lineHeight = selectable.value.endSelectionPoint!.lineHeight;
} else {
selectable = selectables[currentSelectionStartIndex];
edge = selectable.value.startSelectionPoint;
lineHeight = selectable.value.startSelectionPoint?.lineHeight;
}
if (lineHeight == null || edge == null) {
return;
}
final RenderBox scrollableBox = state.context.findRenderObject()! as RenderBox;
final Matrix4 transform = selectable.getTransformTo(scrollableBox);
final Offset edgeOffsetInScrollableCoordinates = MatrixUtils.transformPoint(transform, edge.localPosition);
final Rect scrollableRect = Rect.fromLTRB(0, 0, scrollableBox.size.width, scrollableBox.size.height);
switch (state.axisDirection) {
case AxisDirection.up:
final double edgeBottom = edgeOffsetInScrollableCoordinates.dy;
final double edgeTop = edgeOffsetInScrollableCoordinates.dy - lineHeight;
if (edgeBottom >= scrollableRect.bottom && edgeTop <= scrollableRect.top) {
return;
}
if (edgeBottom > scrollableRect.bottom) {
position.jumpTo(position.pixels + scrollableRect.bottom - edgeBottom);
return;
}
if (edgeTop < scrollableRect.top) {
position.jumpTo(position.pixels + scrollableRect.top - edgeTop);
}
return;
case AxisDirection.right:
final double edge = edgeOffsetInScrollableCoordinates.dx;
if (edge >= scrollableRect.right && edge <= scrollableRect.left) {
return;
}
if (edge > scrollableRect.right) {
position.jumpTo(position.pixels + edge - scrollableRect.right);
return;
}
if (edge < scrollableRect.left) {
position.jumpTo(position.pixels + edge - scrollableRect.left);
}
return;
case AxisDirection.down:
final double edgeBottom = edgeOffsetInScrollableCoordinates.dy;
final double edgeTop = edgeOffsetInScrollableCoordinates.dy - lineHeight;
if (edgeBottom >= scrollableRect.bottom && edgeTop <= scrollableRect.top) {
return;
}
if (edgeBottom > scrollableRect.bottom) {
position.jumpTo(position.pixels + edgeBottom - scrollableRect.bottom);
return;
}
if (edgeTop < scrollableRect.top) {
position.jumpTo(position.pixels + edgeTop - scrollableRect.top);
}
return;
case AxisDirection.left:
final double edge = edgeOffsetInScrollableCoordinates.dx;
if (edge >= scrollableRect.right && edge <= scrollableRect.left) {
return;
}
if (edge > scrollableRect.right) {
position.jumpTo(position.pixels + scrollableRect.right - edge);
return;
}
if (edge < scrollableRect.left) {
position.jumpTo(position.pixels + scrollableRect.left - edge);
}
return;
}
}
bool _globalPositionInScrollable(Offset globalPosition) {
final RenderBox box = state.context.findRenderObject()! as RenderBox;
final Offset localPosition = box.globalToLocal(globalPosition);
......@@ -1317,6 +1427,12 @@ class _ScrollableSelectionContainerDelegate extends MultiSelectableSelectionCont
_selectableEndEdgeUpdateRecords[selectable] = state.position.pixels;
ensureChildUpdated(selectable);
break;
case SelectionEventType.granularlyExtendSelection:
case SelectionEventType.directionallyExtendSelection:
ensureChildUpdated(selectable);
_selectableStartEdgeUpdateRecords[selectable] = state.position.pixels;
_selectableEndEdgeUpdateRecords[selectable] = state.position.pixels;
break;
case SelectionEventType.clear:
_selectableEndEdgeUpdateRecords.remove(selectable);
_selectableStartEdgeUpdateRecords.remove(selectable);
......
......@@ -7,26 +7,7 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
Future<void> sendKeyCombination(
WidgetTester tester,
SingleActivator activator,
) async {
final List<LogicalKeyboardKey> modifiers = <LogicalKeyboardKey>[
if (activator.control) LogicalKeyboardKey.control,
if (activator.shift) LogicalKeyboardKey.shift,
if (activator.alt) LogicalKeyboardKey.alt,
if (activator.meta) LogicalKeyboardKey.meta,
];
for (final LogicalKeyboardKey modifier in modifiers) {
await tester.sendKeyDownEvent(modifier);
}
await tester.sendKeyDownEvent(activator.trigger);
await tester.sendKeyUpEvent(activator.trigger);
await tester.pump();
for (final LogicalKeyboardKey modifier in modifiers.reversed) {
await tester.sendKeyUpEvent(modifier);
}
}
import 'keyboard_utils.dart';
void main() {
Widget buildSpyAboveEditableText({
......
......@@ -8,27 +8,7 @@ import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'clipboard_utils.dart';
Future<void> sendKeyCombination(
WidgetTester tester,
SingleActivator activator,
) async {
final List<LogicalKeyboardKey> modifiers = <LogicalKeyboardKey>[
if (activator.control) LogicalKeyboardKey.control,
if (activator.shift) LogicalKeyboardKey.shift,
if (activator.alt) LogicalKeyboardKey.alt,
if (activator.meta) LogicalKeyboardKey.meta,
];
for (final LogicalKeyboardKey modifier in modifiers) {
await tester.sendKeyDownEvent(modifier);
}
await tester.sendKeyDownEvent(activator.trigger);
await tester.sendKeyUpEvent(activator.trigger);
await tester.pump();
for (final LogicalKeyboardKey modifier in modifiers.reversed) {
await tester.sendKeyUpEvent(modifier);
}
}
import 'keyboard_utils.dart';
Iterable<SingleActivator> allModifierVariants(LogicalKeyboardKey trigger) {
const Iterable<bool> trueFalse = <bool>[false, true];
......
// Copyright 2014 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
Future<void> sendKeyCombination(
WidgetTester tester,
SingleActivator activator,
) async {
final List<LogicalKeyboardKey> modifiers = <LogicalKeyboardKey>[
if (activator.control) LogicalKeyboardKey.control,
if (activator.shift) LogicalKeyboardKey.shift,
if (activator.alt) LogicalKeyboardKey.alt,
if (activator.meta) LogicalKeyboardKey.meta,
];
for (final LogicalKeyboardKey modifier in modifiers) {
await tester.sendKeyDownEvent(modifier);
}
await tester.sendKeyDownEvent(activator.trigger);
await tester.sendKeyUpEvent(activator.trigger);
await tester.pump();
for (final LogicalKeyboardKey modifier in modifiers.reversed) {
await tester.sendKeyUpEvent(modifier);
}
}
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