Skip to content

Commit

Permalink
Support keyboard selection in SelectabledRegion (flutter#112584)
Browse files Browse the repository at this point in the history
* Support keyboard selection in selectable region

* fix some comments

* addressing comments
  • Loading branch information
chunhtai committed Nov 4, 2022
1 parent cfb2f15 commit 80bf355
Show file tree
Hide file tree
Showing 12 changed files with 1,893 additions and 117 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
230 changes: 223 additions & 7 deletions packages/flutter/lib/src/rendering/paragraph.dart
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,17 @@

import 'dart:collection';
import 'dart:math' as math;
import 'dart:ui' as ui show BoxHeightStyle, BoxWidthStyle, Gradient, PlaceholderAlignment, Shader, TextBox, TextHeightBehavior;
import 'dart:ui' as ui show BoxHeightStyle, BoxWidthStyle, Gradient, LineMetrics, PlaceholderAlignment, Shader, TextBox, TextHeightBehavior;

import 'package:flutter/foundation.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/scheduler.dart';
import 'package:flutter/semantics.dart';
import 'package:flutter/services.dart';

import 'box.dart';
import 'debug.dart';
import 'editable.dart';
import 'layer.dart';
import 'object.dart';
import 'selection.dart';
Expand Down Expand Up @@ -151,11 +153,11 @@ class RenderParagraph extends RenderBox
_cachedCombinedSemanticsInfos = null;
_extractPlaceholderSpans(value);
markNeedsLayout();
_removeSelectionRegistrarSubscription();
_disposeSelectableFragments();
_updateSelectionRegistrarSubscription();
break;
}
_removeSelectionRegistrarSubscription();
_disposeSelectableFragments();
_updateSelectionRegistrarSubscription();
}

/// The ongoing selections in this paragraph.
Expand Down Expand Up @@ -226,7 +228,7 @@ class RenderParagraph extends RenderBox
if (end == -1) {
end = plainText.length;
}
result.add(_SelectableFragment(paragraph: this, range: TextRange(start: start, end: end)));
result.add(_SelectableFragment(paragraph: this, range: TextRange(start: start, end: end), fullText: plainText));
start = end;
}
start += 1;
Expand Down Expand Up @@ -439,6 +441,10 @@ class RenderParagraph extends RenderBox
return getOffsetForCaret(position, Rect.zero) + Offset(0, getFullHeightForCaret(position) ?? 0.0);
}

List<ui.LineMetrics> _computeLineMetrics() {
return _textPainter.computeLineMetrics();
}

@override
double computeMinIntrinsicWidth(double height) {
if (!_canComputeIntrinsics()) {
Expand Down Expand Up @@ -1027,6 +1033,28 @@ class RenderParagraph extends RenderBox
return _textPainter.getWordBoundary(position);
}

TextRange _getLineAtOffset(TextPosition position) => _textPainter.getLineBoundary(position);

TextPosition _getTextPositionAbove(TextPosition position) {
// -0.5 of preferredLineHeight points to the middle of the line above.
final double preferredLineHeight = _textPainter.preferredLineHeight;
final double verticalOffset = -0.5 * preferredLineHeight;
return _getTextPositionVertical(position, verticalOffset);
}

TextPosition _getTextPositionBelow(TextPosition position) {
// 1.5 of preferredLineHeight points to the middle of the line below.
final double preferredLineHeight = _textPainter.preferredLineHeight;
final double verticalOffset = 1.5 * preferredLineHeight;
return _getTextPositionVertical(position, verticalOffset);
}

TextPosition _getTextPositionVertical(TextPosition position, double verticalOffset) {
final Offset caretOffset = _textPainter.getOffsetForCaret(position, Rect.zero);
final Offset caretOffsetTranslated = caretOffset.translate(0.0, verticalOffset);
return _textPainter.getPositionForOffset(caretOffsetTranslated);
}

/// Returns the size of the text as laid out.
///
/// This can differ from [size] if the text overflowed or if the [constraints]
Expand Down Expand Up @@ -1271,16 +1299,18 @@ class RenderParagraph extends RenderBox
/// [PlaceHolderSpan]. The [RenderParagraph] splits itself on [PlaceHolderSpan]
/// to create multiple `_SelectableFragment`s so that they can be selected
/// separately.
class _SelectableFragment with Selectable, ChangeNotifier {
class _SelectableFragment with Selectable, ChangeNotifier implements TextLayoutMetrics {
_SelectableFragment({
required this.paragraph,
required this.fullText,
required this.range,
}) : assert(range.isValid && !range.isCollapsed && range.isNormalized) {
_selectionGeometry = _getSelectionGeometry();
}

final TextRange range;
final RenderParagraph paragraph;
final String fullText;

TextPosition? _textSelectionStart;
TextPosition? _textSelectionEnd;
Expand Down Expand Up @@ -1356,6 +1386,22 @@ class _SelectableFragment with Selectable, ChangeNotifier {
final SelectWordSelectionEvent selectWord = event as SelectWordSelectionEvent;
result = _handleSelectWord(selectWord.globalPosition);
break;
case SelectionEventType.granularlyExtendSelection:
final GranularlyExtendSelectionEvent granularlyExtendSelection = event as GranularlyExtendSelectionEvent;
result = _handleGranularlyExtendSelection(
granularlyExtendSelection.forward,
granularlyExtendSelection.isEnd,
granularlyExtendSelection.granularity,
);
break;
case SelectionEventType.directionallyExtendSelection:
final DirectionallyExtendSelectionEvent directionallyExtendSelection = event as DirectionallyExtendSelectionEvent;
result = _handleDirectionallyExtendSelection(
directionallyExtendSelection.dx,
directionallyExtendSelection.isEnd,
directionallyExtendSelection.direction,
);
break;
}

if (existingSelectionStart != _textSelectionStart ||
Expand All @@ -1373,7 +1419,7 @@ class _SelectableFragment with Selectable, ChangeNotifier {
final int start = math.min(_textSelectionStart!.offset, _textSelectionEnd!.offset);
final int end = math.max(_textSelectionStart!.offset, _textSelectionEnd!.offset);
return SelectedContent(
plainText: paragraph.text.toPlainText(includeSemanticsLabels: false).substring(start, end),
plainText: fullText.substring(start, end),
);
}

Expand Down Expand Up @@ -1466,6 +1512,155 @@ class _SelectableFragment with Selectable, ChangeNotifier {
return SelectionResult.end;
}

SelectionResult _handleDirectionallyExtendSelection(double horizontalBaseline, bool isExtent, SelectionExtendDirection movement) {
final Matrix4 transform = paragraph.getTransformTo(null);
if (transform.invert() == 0.0) {
switch(movement) {
case SelectionExtendDirection.previousLine:
case SelectionExtendDirection.backward:
return SelectionResult.previous;
case SelectionExtendDirection.nextLine:
case SelectionExtendDirection.forward:
return SelectionResult.next;
}
}
final double baselineInParagraphCoordinates = MatrixUtils.transformPoint(transform, Offset(horizontalBaseline, 0)).dx;
assert(!baselineInParagraphCoordinates.isNaN);
final TextPosition newPosition;
final SelectionResult result;
switch(movement) {
case SelectionExtendDirection.previousLine:
case SelectionExtendDirection.nextLine:
assert(_textSelectionEnd != null && _textSelectionStart != null);
final TextPosition targetedEdge = isExtent ? _textSelectionEnd! : _textSelectionStart!;
final MapEntry<TextPosition, SelectionResult> moveResult = _handleVerticalMovement(
targetedEdge,
horizontalBaselineInParagraphCoordinates: baselineInParagraphCoordinates,
below: movement == SelectionExtendDirection.nextLine,
);
newPosition = moveResult.key;
result = moveResult.value;
break;
case SelectionExtendDirection.forward:
case SelectionExtendDirection.backward:
_textSelectionEnd ??= movement == SelectionExtendDirection.forward
? TextPosition(offset: range.start)
: TextPosition(offset: range.end, affinity: TextAffinity.upstream);
_textSelectionStart ??= _textSelectionEnd;
final TextPosition targetedEdge = isExtent ? _textSelectionEnd! : _textSelectionStart!;
final Offset edgeOffsetInParagraphCoordinates = paragraph._getOffsetForPosition(targetedEdge);
final Offset baselineOffsetInParagraphCoordinates = Offset(
baselineInParagraphCoordinates,
// Use half of line height to point to the middle of the line.
edgeOffsetInParagraphCoordinates.dy - paragraph._textPainter.preferredLineHeight / 2,
);
newPosition = paragraph.getPositionForOffset(baselineOffsetInParagraphCoordinates);
result = SelectionResult.end;
break;
}
if (isExtent) {
_textSelectionEnd = newPosition;
} else {
_textSelectionStart = newPosition;
}
return result;
}

SelectionResult _handleGranularlyExtendSelection(bool forward, bool isExtent, TextGranularity granularity) {
_textSelectionEnd ??= forward
? TextPosition(offset: range.start)
: TextPosition(offset: range.end, affinity: TextAffinity.upstream);
_textSelectionStart ??= _textSelectionEnd;
final TextPosition targetedEdge = isExtent ? _textSelectionEnd! : _textSelectionStart!;
if (forward && (targetedEdge.offset == range.end)) {
return SelectionResult.next;
}
if (!forward && (targetedEdge.offset == range.start)) {
return SelectionResult.previous;
}
final SelectionResult result;
final TextPosition newPosition;
switch (granularity) {
case TextGranularity.character:
final String text = range.textInside(fullText);
newPosition = _getNextPosition(CharacterBoundary(text), targetedEdge, forward);
result = SelectionResult.end;
break;
case TextGranularity.word:
final String text = range.textInside(fullText);
newPosition = _getNextPosition(WhitespaceBoundary(text) + WordBoundary(this), targetedEdge, forward);
result = SelectionResult.end;
break;
case TextGranularity.line:
newPosition = _getNextPosition(LineBreak(this), targetedEdge, forward);
result = SelectionResult.end;
break;
case TextGranularity.document:
final String text = range.textInside(fullText);
newPosition = _getNextPosition(DocumentBoundary(text), targetedEdge, forward);
if (forward && newPosition.offset == range.end) {
result = SelectionResult.next;
} else if (!forward && newPosition.offset == range.start) {
result = SelectionResult.previous;
} else {
result = SelectionResult.end;
}
break;
}

if (isExtent) {
_textSelectionEnd = newPosition;
} else {
_textSelectionStart = newPosition;
}
return result;
}

TextPosition _getNextPosition(TextBoundary boundary, TextPosition position, bool forward) {
if (forward) {
return _clampTextPosition(
(PushTextPosition.forward + boundary).getTrailingTextBoundaryAt(position)
);
}
return _clampTextPosition(
(PushTextPosition.backward + boundary).getLeadingTextBoundaryAt(position),
);
}

MapEntry<TextPosition, SelectionResult> _handleVerticalMovement(TextPosition position, {required double horizontalBaselineInParagraphCoordinates, required bool below}) {
final List<ui.LineMetrics> lines = paragraph._computeLineMetrics();
final Offset offset = paragraph.getOffsetForCaret(position, Rect.zero);
int currentLine = lines.length - 1;
for (final ui.LineMetrics lineMetrics in lines) {
if (lineMetrics.baseline > offset.dy) {
currentLine = lineMetrics.lineNumber;
break;
}
}
final TextPosition newPosition;
if (below && currentLine == lines.length - 1) {
newPosition = TextPosition(offset: range.end, affinity: TextAffinity.upstream);
} else if (!below && currentLine == 0) {
newPosition = TextPosition(offset: range.start);
} else {
final int newLine = below ? currentLine + 1 : currentLine - 1;
newPosition = _clampTextPosition(
paragraph.getPositionForOffset(Offset(horizontalBaselineInParagraphCoordinates, lines[newLine].baseline))
);
}
final SelectionResult result;
if (newPosition.offset == range.start) {
result = SelectionResult.previous;
} else if (newPosition.offset == range.end) {
result = SelectionResult.next;
} else {
result = SelectionResult.end;
}
assert(result != SelectionResult.next || below);
assert(result != SelectionResult.previous || !below);
return MapEntry<TextPosition, SelectionResult>(newPosition, result);
}

/// Whether the given text position is contained in current selection
/// range.
///
Expand Down Expand Up @@ -1596,4 +1791,25 @@ class _SelectableFragment with Selectable, ChangeNotifier {
);
}
}

@override
TextSelection getLineAtOffset(TextPosition position) {
final TextRange line = paragraph._getLineAtOffset(position);
final int start = line.start.clamp(range.start, range.end); // ignore_clamp_double_lint
final int end = line.end.clamp(range.start, range.end); // ignore_clamp_double_lint
return TextSelection(baseOffset: start, extentOffset: end);
}

@override
TextPosition getTextPositionAbove(TextPosition position) {
return _clampTextPosition(paragraph._getTextPositionAbove(position));
}

@override
TextPosition getTextPositionBelow(TextPosition position) {
return _clampTextPosition(paragraph._getTextPositionBelow(position));
}

@override
TextRange getWordBoundary(TextPosition position) => paragraph.getWordBoundary(position);
}

0 comments on commit 80bf355

Please sign in to comment.