-
Notifications
You must be signed in to change notification settings - Fork 28.6k
Add SelectionListener
/SelectedContentRange
#154202
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Add SelectionListener
/SelectedContentRange
#154202
Conversation
SelectionListener
/SelectedContentRange
/SelectionDetails
APISelectionListener
/SelectedContentRange
/SelectionDetails
startOffset: -1, | ||
endOffset: -1, | ||
contentStart: -1, | ||
contentLength: contentLength ?? -1, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Consider documenting the defaults and why -1. What's the difference between -1 and null?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This can be another constructor right?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
+1 about documenting the -1s since that has tripped us up so much elsewhere.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
+1 and also convert to const constructor
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Converted to const constructor and added some docs about -1.
/// [WidgetSpan], the [startOffset] will be 0, | ||
/// and [endOffset] will be 18. | ||
/// {@endtemplate} | ||
final int startOffset; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: maybe use TextRange so you don't have to document what -1
means for this field.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I didn't use TextRange
because I think it implies this would only cover and apply to text, when a Selectable
can be anything and not just text.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I was wondering the same. I see what you mean but I think I don't really have an ideological problem with that. At the end of the day this does map to a String, even if parts of that String might represent something else.
Also I looked through the docs for TextRange and I didn't see anything in there that seems out of place with this.
But either way works!
/// end of the '?' in the [WidgetSpan], we will receive | ||
/// two [SelectedContentRange]s from [SelectionHandler.getSelections]. | ||
/// The first range will be relative to the root text | ||
/// of the [TextSpan], the [startOffset] will be 6, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The range is relative content start, this will probably be easier to understand if the example also shows how contentStart
fits in.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
+1. I'm really glad you have this example though, it makes understanding this a lot easier. Maybe consider pointing to [startOffset] in a "See also" elsewhere in these docs so more people who are trying to understand this class see it?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Well I guess you have used it as a template/macro elsewhere 👍
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
contentStart
is now removed, and the Text
widget and other Selectable
s only produce one SelectedContentRange
.
if (identical(this, other)) { | ||
return true; | ||
} | ||
if (other.runtimeType != runtimeType) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Should the class be final? That way you don't have to check the runtime type.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ah nice use case for final class
. Makes sense to me.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Make sure you didn't miss this ⬆️
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I wonder if this can be left not final. That way someone could extend this class and add supplementary identifiers, if they wanted to go that route. For example, someone could wrap a Text
widget with their own SelectionContainer
with a delegate that overrides getSelection()
and returns the range with an added identifier. SelectionDetails
has been made final.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
we could open it when people does ask, but I am fine either way.
@@ -287,6 +419,11 @@ enum SelectionEventType { | |||
/// Used by [SelectionEdgeUpdateEvent]. | |||
endEdgeUpdate, | |||
|
|||
/// An event to indicate the selection is finalized. | |||
/// | |||
/// Used by [SelectionFinalizedSelectionEvent]. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Document: what does it mean for a selection to be considered finalized
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
+1 to this as well. Point to the description of finalized in SelectionFinalizedSelectionEvent?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
selectionFinalized
below also kind of explains this.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I was following the conventions of the other enums here where they link to the event, that describes when the event happens in the framework. Is Used by [SelectionFinalizedSelectionEvent].
a sufficient link?
/// true if the selection is finalized. | ||
final bool selectionFinalized; | ||
|
||
/// The global start offset. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't think this is "global" within the app right? This should probably use a more accurate term if that's the case.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Also consider using existing classes TextRange
.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I agree about "global". At the very least it should be clearly documented what you mean by it.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Changed to local*Offset
, but open to other suggestions.
/// order as the [Selectable]s contained under | ||
/// this [SelectionHandler]. | ||
/// | ||
/// Return a list of [SelectedContentRange.empty] |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: Returns
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Also: why doesn't it return an empty list?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We need to be able to add up the contentLength
s of Selectable
s even if they are not currently selected. For example if we have [A,B,C,D]
, and only [B,C,D]
are selected, I still need the length of A
to calculate the offsets.
@@ -1476,6 +1481,21 @@ class _SelectableFragment with Selectable, Diagnosticable, ChangeNotifier implem | |||
); | |||
} | |||
|
|||
@override | |||
List<SelectedContentRange> getSelections() { | |||
final int contentLength = range.textInside(fullText).length; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
You don't really need the fullText?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Do you mean I don't need the fullText
to calculate this? I think I can also do range.end - range.start
.
/// Copies the selections of all [Selectable]s. | ||
@override | ||
List<SelectedContentRange> getSelections() { | ||
final List<SelectedContentRange> selections = <SelectedContentRange>[]; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: consider using the list spread opreator ...
?
true, | ||
'This selection container delegate has an active selection, indicated by its currentSelectionStartIndex and currentSelectionEndIndex, but it provides no SelectedContentRanges to represent this selection.', | ||
); | ||
return <SelectedContentRange>[]; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why constructing a new empty list?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Overall I like this approach a lot. My comments are all small stuff.
If I can give one general thought it's that the relationship between SelectionContentRange, SelectionDetails, SelectedContent etc. took me awhile to understand. They all seem like they cover pretty similar but not-quite-the-same kind of stuff, and there is no clear relationship between them all. But I don't have any better ideas there, and I don't think that's a blocker at all. Just writing down some thoughts as I read through this.
I think I'd be happy to approve this if the small comments I left are fixed.
expect(find.textContaining(' This is some more text in the same text widget.'), findsOneWidget); | ||
expect(find.textContaining('This is some text in another text widget.'), findsOneWidget); | ||
}); | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think it would be really valuable to add some tests here and for 2_test.dart
that make some selections, change them red, and then expect that the text is red.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Note to self to write these.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Reminder for this comment 🎗️
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
One last reminder for this if you still want to add tests here before merge!
/// The order of this list follows the same | ||
/// order as the [Selectable]s contained under | ||
/// this [SelectionHandler]. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Maybe mention that the order is based on the startOffset
(assuming that's true).
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This no longer returns a list.
required this.startOffset, | ||
required this.endOffset, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Did you consider using a TextRange for this?
Edit: This is discussed in another comment further down.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Also, maybe its better to say "startIndex" etc. instead of "startOffset" to avoid confusion with Offset.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I am more toward using offset, it is what used in web and also desktop a11y bridge in engine
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ah ok, offset sounds good to me.
return true; | ||
} | ||
|
||
({int startOffset, int endOffset}) _calculateGlobalOffsets(List<SelectedContentRange> ranges) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Another place to maybe renamed "offset" to "index".
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Also could maybe be static?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This one can't be static, it uses a few instance variables.
), | ||
); | ||
} | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I talked offline with @Renzo-Olivares but leaving this dartpad link here as an alternative way of going about these examples. Not sure if there's anything useful in it now. https://dartpad.dev/?id=97bd3f3cec6f90e3ca3791f085ac3b2c
/// end of the '?' in the [WidgetSpan], we will receive | ||
/// two [SelectedContentRange]s from [SelectionHandler.getSelections]. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
At first I was confused about how this appears to be "non-flat". But it seems a SelectionListener passes you a SelectionDetails that is totally flat. So I guess these docs here are referring to a lower level part and SelectionListener is the high level interface that most users will see.
Probably nothing to really clarify about that and it's just my problem with wrapping my head around this, but I wanted to point it out in case there's any way to make that more clear.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This Text widget now produces just one SelectedContentRange
.
/// order as the [Selectable]s contained under | ||
/// this [SelectionHandler]. | ||
/// | ||
/// Returns a list of [SelectedContentRange.empty] |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
any reason that we prefer this instead of a const empty list?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This now just returns one SelectedContentRange
instead of a list.
required this.startOffset, | ||
required this.endOffset, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I am more toward using offset, it is what used in web and also desktop a11y bridge in engine
startOffset: -1, | ||
endOffset: -1, | ||
contentStart: -1, | ||
contentLength: contentLength ?? -1, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
+1 and also convert to const constructor
/// of the [TextSpan], the [startOffset] will be 6, | ||
/// and [endOffset] will be 13. The second range | ||
/// will be relative to the content inside the | ||
/// [WidgetSpan], the [startOffset] will be 0, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why not use global content length? and selection start also in global offset.
The current implementation will tie us into how text are broke into piece. If we changes it in the future the the select
if we use relative content range, they have to know the widget subtree and also how text will be break into multiple selectable. It will also change SelectedContentRange
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I see the concern. I think this is cleared up now, with returning one SelectedContentRange instead of a list.
/// | ||
/// Returns an empty list only if there is no | ||
/// content under this [SelectionHandler]. | ||
List<SelectedContentRange> getSelections(); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
is there a cases where the selected content range is not continuous? can we just use one range to indicate the current range?
This issue I think with this approach is that we will be tied in with how we break text.rich into smaller selectable pieces, or when we add selectioncontainer in some widgets.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think we can just return one range, I can't think of a case where the selection would be discontinuous. This is now changed.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I ran into an edge case where the selection is discontinuous in the middle of collapsing the selection. This is because we send two edge update events, one to move the start and the other to move the end, so somewhere in between these two edge move operations we may have a discontinuous selection. This leaves the SelectionDetails
in a weird state during that time, with two offsets that are not correct.
We can solve for this by creating a CollapseSelectionEvent
which sets both edges at the same time in one event operation instead of the current two event implementation to collapse the selection. What do you think about this? PR that adds event for reference: #155182
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
cc @chunhtai, for any further thoughts on this thread.
/// for the selection under its subtree in its [SelectionListener.onSelectionChanged] | ||
/// callback. | ||
@immutable | ||
class SelectionDetails { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
is the SelectionDetails meant to be the public facing API? if so we should try to make getSelectionList protected and SelectedContentRange internal
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes, SelectionDetails is what the user will be consuming. By internal do you mean private?
/// | ||
/// This widget does not listen to selection changes of nested [SelectionArea]s | ||
/// or [SelectableRegion]s in its subtree because those widgets create their own | ||
/// [SelectionRegistrar]s that do not register to any ancestor registrar. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think the doc here is reaching implementation detail. just say because the SelectableRegion is self-contain and do not bubble up selection.
Although I think at some point we want to support nested SelectableRegion, the selection start outside the SelectableRegion can still select into the SelectableRegion, but the selection starts inside the SelectableRegion can not move outside of it. Have you thought about how selectionListener should behave in this scenario?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
In that scenario, I think we can just treat the nested region as a SelectionContainer
(it is already) and process its selection like any other child selectable. Does this sound reasonable?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
yes that was my original plan if we were to start nest selectableregion
7a9e948
to
6da9401
Compare
This is ready for another review, the main change is that a |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
LGTM with nits 👍
I think the single SelectedContentRange is an improvement.
Don't forget to write the example tests mentioned in https://github.com/flutter/flutter/pull/154202/files#r1737083169.
const String endOfBulletTree = ' This is the end of the text widget.'; | ||
|
||
int currentOffset = 0; | ||
dataSourceMap[(startOffset: 0, endOffset: bulletListTitle.length + bullets.join().length + textAfterBulletList.length + emphasizedText.length + endOfBulletTree.length)] = TextSpan( |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nit: Maybe put this on multiple lines or factor endOffset out as a variable?
/// | ||
/// When nothing is selected, subclasses should return a [SelectedContentRange.empty], | ||
/// with a [SelectedContentRange.contentLength] that represents the length | ||
/// of the content under it. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nit: "under it" => "under it in the widget tree"
Or something more specific like that.
required this.startOffset, | ||
required this.endOffset, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ah ok, offset sounds good to me.
if (identical(this, other)) { | ||
return true; | ||
} | ||
if (other.runtimeType != runtimeType) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Make sure you didn't miss this ⬆️
@@ -287,6 +419,11 @@ enum SelectionEventType { | |||
/// Used by [SelectionEdgeUpdateEvent]. | |||
endEdgeUpdate, | |||
|
|||
/// An event to indicate the selection is finalized. | |||
/// | |||
/// Used by [SelectionFinalizedSelectionEvent]. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
+1 to this as well. Point to the description of finalized in SelectionFinalizedSelectionEvent?
@@ -2573,6 +2657,16 @@ abstract class MultiSelectableSelectionContainerDelegate extends SelectionContai | |||
return SelectionResult.none; | |||
} | |||
|
|||
/// Indicates the selection is finalized to all of the [Selectable]s this delegate manages. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Another place to consider explaining "finalized"
/// This widget should have an ancestor [SelectionArea] or [SelectableRegion] | ||
/// to be able to listen to selection changes in this widgets subtree. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Would it be possible/desirable to log warning in that case?
@@ -1228,6 +1226,70 @@ class _SelectableTextContainerDelegate extends MultiSelectableSelectionContainer | |||
return a.right > b.right ? 1 : -1; | |||
} | |||
|
|||
SelectedContentRange _calculateLocalRange(List<SelectedContentRange> ranges) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nit: Maybe add a comment here. (I also left a comment saying the same thing on the SelectableRegion version of this.)
/// A selected content range that represents an empty selection, i.e. nothing | ||
/// is selected. | ||
const SelectedContentRange.empty({int contentLength = 0}) | ||
: this( |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
intentation
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What should change here?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The indentation should be 2 spaces I think
endOffset: -1, | ||
); | ||
|
||
/// The length of the content in the [Selectable] or [SelectionHandler] that |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: The maximum selectable length of ...?
/// [TextSpan] tree, with [WidgetSpan] content being flattened. The [startOffset] | ||
/// will be 6, and [endOffset] will be 38. This takes into account the | ||
/// length of the content in the [WidgetSpan], which is 19, so the overall | ||
/// [contentLength] will be 56. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
That's not very intuitive IMO. I'd say it's not very obvious to most people that widget span is always length 1. If you know the full content length then how come the end offset is not 38 - 1 + 19?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Oh I think I forgot to update this, the endOffset in this case will be 56 not 38.
/// {@endtemplate} | ||
final int startOffset; | ||
|
||
/// The end of the selection relative to the end of the content. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
relative to the start of the content?
// Determining selection direction is innacurate if currentSelectionStartIndex == currentSelectionEndIndex. | ||
// Use the range from the selectable within the selection as the source of truth for selection direction. | ||
final SelectedContentRange rangeAtSelectableInSelection = selectables[currentSelectionStartIndex].getSelection(); | ||
forwardSelection = rangeAtSelectableInSelection.endOffset >= rangeAtSelectableInSelection.startOffset; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Are the selectables's selection directions guaranteed consistent?
- If they are why do we need to normalize first?
- If they are not why can we use that of the selectable at
currentSelectionStartIndex
?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
They are not if we have LTR mixed with RTL. We only use the currentSelectionStartIndex
when currentSelectionStartIndex == currentSelectionEndIndex
, i.e. there's only one selectable in the selection so that can be thought of as the source of truth for the selection direction. When currentSelectionStartIndex
and currentSelectionEndIndex
are different (multiple selectables are under the selection), then we can use currentSelectionEndIndex >= currentSelectionStartIndex
as the indicator for selection direction.
/// for the selection under its subtree in its [SelectionListener.onSelectionChanged] | ||
/// callback. | ||
@immutable | ||
final class SelectionDetails with Diagnosticable { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is this used in the rendering library or it's only used in the widgets library? If it's only used in widgets should this live in widgets for now?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is only used by SelectionListener
, so I think it'd be okay to live in the widgets library alongside it.
required this.localEndOffset, | ||
}); | ||
|
||
/// The status of the selection under the [Selectable] or [SelectionHandler] |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: the docs from the eum seems to be easier to understand to me, i.e.: whether there is a selection and whether the selection is collapsed
/// that created this object. | ||
final SelectionStatus status; | ||
|
||
/// Whether the selection is ongoing. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This doesn't seem to be consistent with the field name?
/// local to the [SelectionListener] that reports this object, and whether | ||
/// the selection is ongoing. | ||
/// | ||
/// This object is created by callers of the [SelectionListenerSelectionChangedCallback] callback. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Can the constructor be private if we move this to the widgets library?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah, that works.
SelectionResult dispatchSelectionEvent(SelectionEvent event) { | ||
final SelectionGeometry lastSelectionGeometry = value; | ||
final SelectionResult result = super.dispatchSelectionEvent(event); | ||
if (value.status == SelectionStatus.none) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why can we short-circuit here? How do we get remove selection events?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Good point, i'll think about this case again.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Removed this so users can receive remove selection events.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
overall the selection listener workflow looks good to me. just some question around API surface. The selection finalized event bothers me a little. It feels a bit unecessary if the sole purpose is to expose this state to SelectionDetails.
@@ -0,0 +1,377 @@ | |||
// Copyright 2014 The Flutter Authors. All rights reserved. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't think an example needs to be this complex, it will be overwhelming for people who may only want to take a quick peak on how to use the api.
How about we just have a maybe one or two level deep of textspan? or is there a specific things you want to show case?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
same for selction_area.2.dart
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think for selection_area.1
I was trying to make sure it worked for complex cases, more for my own testing, but I also wanted to the example to show how one can deal with a WidgetSpan
in the tree. By two levels deep, did you mean for the TextSpan tree that includes the bulleted list, we should remove everything after the bulleted list?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It seems like this example has been simplified while 2
contains the more complex stuff?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah 1
is a simple example, while 2
is a complex one.
@@ -463,6 +463,9 @@ class RenderParagraph extends RenderBox with ContainerRenderObjectMixin<RenderBo | |||
|
|||
/// Determines whether the given [Selectable] was created by this | |||
/// [RenderParagraph]. | |||
/// | |||
/// The [RenderParagraph] splits its text into multiple [Selectable]s, | |||
/// delimited by [PlaceholderSpan.placeholderCodeUnit]. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
/// delimited by [PlaceholderSpan.placeholderCodeUnit]. | |
/// delimited by [PlaceholderSpan]s or [WidgetSpan]s. |
const SelectedContentRange.empty({int contentLength = 0}) | ||
: this( |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
const SelectedContentRange.empty({int contentLength = 0}) | |
: this( | |
const SelectedContentRange.empty({ | |
int contentLength = 0 | |
}) : this( |
/// length of the content in the [WidgetSpan], which is 19, so the overall | ||
/// [contentLength] will be 56. | ||
/// | ||
/// If [startOffset] and [endOffset] are both -1, the selected content range is |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I will probably expose a isEmpty or isValid getter instead of ask consumer of this class to check for -1
if (identical(this, other)) { | ||
return true; | ||
} | ||
if (other.runtimeType != runtimeType) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
we could open it when people does ask, but I am fine either way.
/// When nothing is selected, subclasses should return a [SelectedContentRange.empty], | ||
/// with a [SelectedContentRange.contentLength] that represents the length | ||
/// of the content under this [SelectionHandler]. | ||
SelectedContentRange getSelection(); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Have you thought about extending the getSelectedContent API instead of creating a new one?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I thought about this, we would have to change the contract of getSelectedContent
since it returns null
when there is nothing selected, while the new API returns an empty
representation of the selection. Also we would be exposing the start
and end
offsets through SelectionArea.onSelectionChanged
. I think exposing those offsets through SelectionArea.onSelectionChanged
might make them difficult to consume depending on the complexity of the Selectable
tree, SelectionListener
solves for this by allowing the user to get offsets for a specific subtree. I do think these APIs are similar/related but not exactly sure how to merge them unless we are okay with exposing the start and end offsets at the SelectionArea
level. What do you think about this?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
exposing start and end at selectionArea level seems fine to me. They don't have to use it. I would choose have less similar API if possible. The only thing that I would consider not to is that if merging these API will cause performance draw back. e.g. if we have to recalculate a lot of thing to just to have all data to be in one callback.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
getSelectedContent
is called somewhat often since it used by SelectableRegion.onSelectionChanged
, at each selection gesture/keyboard shortcut step. Meaning we would have to tree walk and calculate the ranges each time. We currently guard against excessive onSelectionChanged
calls by checking if the last plainText
of SelectedContent
is different from the current one, but to do that we have to call the getSelectedContent
API itself. One thing we might be able to do here is instead of check plainText
we can check the _lastSelectionGeometry
, and then check plainText
? _lastSelectionGeometry
guards against excessive tree walks and plainText
guards against excessive onSelectionChanged
calls. Another simple guard we can do add is against calling the API at all when the callback onSelectionChanged
is not provided to SelectableRegion
. Despite these guards the calculation will still happen if the selection changes.
getSelection
currently is called on demand, it needs to be explicitly requested by the user by calling SelectionListenerNotifier.range
. I'm leaning towards this option since there are no performance implications at all here, but what do you think given the context?
/// This event can be sent as the result of a mouse drag end, touch | ||
/// long press drag end, a single click to collapse the selection, a | ||
/// double click/tap to select a word, ctrl + A / cmd + A to select all, | ||
/// or a triple click/tap to select a paragraph. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
maybe mention selection can still change through keyboard even if a selectable received this event
ContextMenuController.removeAny(); | ||
} | ||
if (selectionDetails.status != SelectionStatus.uncollapsed | ||
|| !selectionDetails.selectionFinalized) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I believe this is the only reason selectionFinalized is introduce right? Have you thought about exposing the state through selectableResgion instead.
for example you can do selectionAreaKey.selectableRegion.isSelectionOnGoing
or something like that.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I will try this out, am also not the biggest fan of the SelectionEvent
.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I tried this but it did not work out as well. When we end a drag or a long press we do not dispatch any selection events, so with isSelectionOnGoing
, when we receive the last event for a given drag, it is still set to true. Another thought is to make isSelectionOnGoing
, a ValueNotifier<bool>
, and a user can listen to it and so something like below:
selectionAreaKey.selectableRegion.isSelectionOnGoing.addListener(_handleSelectionOnGoing);
void _handleSelectionOnGoing() {
if (selectionAreaKey.selectableRegion.isSelectionOnGoing.value) {
return;
}
// Show color me red context menu. This requires the user to save the last `SelectionDetails` they received in
// SelectionListener.onSelectionChanged.
}
This would also require a post frame callback to add the listener.
e80922e
to
00b43cd
Compare
Talked to @Renzo-Olivares offline, the current idea is to use a listen/notify API something like TextEditingController to listen to selection changes, Ideally this can also get rid of the SelectionFinalizedEvent if the controller can listen to the SelectableRegion's state changes above the current widget, and it sounds good to me. Since I will be out for another 2 weeks, this PR will LGTM if it can be implemented without SelectionfinalizedEvent and also LGT @justinmc and @LongCatIsLooong . |
@Renzo-Olivares This will go into production (we have an estimated date)?, it is a very good feature. |
06c9bd6
to
d226dcd
Compare
d03e261
to
030a219
Compare
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks for adding the example tests.
Screen.Recording.2024-11-25.at.4.12.47.PM.mov
Adds:
SelectionListener
, allows a user to listen to selection changes under the subtree it wraps given their is an ancestorSelectionArea
orSelectableRegion
. These selection changes can be listened to through theSelectionListenerNotifier
that is provided to aSelectionListener
.SelectionListenerNotifier
, used withSelectionListener
, allows a user listen to selection changes for the subtree of theSelectionListener
it was provided to. Provides access to individual selection values through theSelectionDetails
objectselection
.SelectableRegionSelectionStatusScope
, allows the user to listen to when a parentSelectableRegion
is changing or finalizing the selection.SelectedContentRange
, provides information about the selection range under aSelectionHandler
orSelectable
through thegetSelection()
method. This includes a start and end offset relative to theSelectable
s content.SelectionHandler.contentLength
, to describe the length of the content contained by a selectable.Original PR & Discussion: #148998
Fixes: #110594
Pre-launch Checklist
///
).