Skip to content

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

Merged
merged 91 commits into from
Nov 26, 2024

Conversation

Renzo-Olivares
Copy link
Contributor

@Renzo-Olivares Renzo-Olivares commented Aug 27, 2024

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 ancestor SelectionArea or SelectableRegion. These selection changes can be listened to through the SelectionListenerNotifier that is provided to a SelectionListener.
  • SelectionListenerNotifier, used with SelectionListener, allows a user listen to selection changes for the subtree of the SelectionListener it was provided to. Provides access to individual selection values through the SelectionDetails object selection.
  • SelectableRegionSelectionStatusScope, allows the user to listen to when a parent SelectableRegion is changing or finalizing the selection.
  • SelectedContentRange, provides information about the selection range under a SelectionHandler or Selectable through the getSelection() method. This includes a start and end offset relative to the Selectables content.
  • SelectionHandler.contentLength, to describe the length of the content contained by a selectable.

Original PR & Discussion: #148998

Fixes: #110594

Pre-launch Checklist

  • I read the [Contributor Guide] and followed the process outlined there for submitting PRs.
  • I read the [Tree Hygiene] wiki page, which explains my responsibilities.
  • I read and followed the [Flutter Style Guide], including [Features we expect every widget to implement].
  • I signed the [CLA].
  • I listed at least one issue that this PR fixes in the description above.
  • I updated/added relevant documentation (doc comments with ///).
  • I added new tests to check the change I am making, or this PR is [test-exempt].
  • I followed the [breaking change policy] and added [Data Driven Fixes] where supported.
  • All existing and new tests are passing.

@github-actions github-actions bot added a: text input Entering text in a text field or keyboard related problems framework flutter/packages/flutter repository. See also f: labels. f: scrolling Viewports, list views, slivers, etc. d: api docs Issues with https://api.flutter.dev/ d: examples Sample code and demos labels Aug 27, 2024
@Renzo-Olivares Renzo-Olivares changed the title Add SelectionListener/SelectedContentRange/SelectionDetails API Add SelectionListener/SelectedContentRange/SelectionDetails Aug 27, 2024
@Renzo-Olivares Renzo-Olivares marked this pull request as ready for review August 27, 2024 21:19
startOffset: -1,
endOffset: -1,
contentStart: -1,
contentLength: contentLength ?? -1,
Copy link
Contributor

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?

Copy link
Contributor

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?

Copy link
Contributor

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.

Copy link
Contributor

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

Copy link
Contributor Author

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;
Copy link
Contributor

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.

Copy link
Contributor Author

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.

Copy link
Contributor

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,
Copy link
Contributor

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.

Copy link
Contributor

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?

Copy link
Contributor

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 👍

Copy link
Contributor Author

@Renzo-Olivares Renzo-Olivares Sep 9, 2024

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 Selectables only produce one SelectedContentRange.

if (identical(this, other)) {
return true;
}
if (other.runtimeType != runtimeType) {
Copy link
Contributor

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.

Copy link
Contributor

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.

Copy link
Contributor

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 ⬆️

Copy link
Contributor Author

@Renzo-Olivares Renzo-Olivares Sep 10, 2024

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.

Copy link
Contributor

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].
Copy link
Contributor

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

Copy link
Contributor

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?

Copy link
Contributor

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.

Copy link
Contributor Author

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.
Copy link
Contributor

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.

Copy link
Contributor

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.

Copy link
Contributor

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.

Copy link
Contributor Author

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]
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: Returns

Copy link
Contributor

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?

Copy link
Contributor Author

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 contentLengths of Selectables 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;
Copy link
Contributor

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?

Copy link
Contributor Author

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>[];
Copy link
Contributor

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>[];
Copy link
Contributor

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?

Copy link
Contributor

@justinmc justinmc left a 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);
});
}
Copy link
Contributor

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.

Copy link
Contributor Author

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.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reminder for this comment 🎗️

Copy link
Contributor

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!

Comment on lines 95 to 97
/// The order of this list follows the same
/// order as the [Selectable]s contained under
/// this [SelectionHandler].
Copy link
Contributor

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).

Copy link
Contributor Author

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.

Comment on lines +132 to +125
required this.startOffset,
required this.endOffset,
Copy link
Contributor

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.

Copy link
Contributor

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.

Copy link
Contributor

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

Copy link
Contributor

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) {
Copy link
Contributor

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".

Copy link
Contributor

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?

Copy link
Contributor Author

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.

),
);
}
}
Copy link
Contributor

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

Comment on lines 180 to 181
/// end of the '?' in the [WidgetSpan], we will receive
/// two [SelectedContentRange]s from [SelectionHandler.getSelections].
Copy link
Contributor

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.

Copy link
Contributor Author

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]
Copy link
Contributor

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?

Copy link
Contributor Author

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.

Comment on lines +132 to +125
required this.startOffset,
required this.endOffset,
Copy link
Contributor

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,
Copy link
Contributor

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,
Copy link
Contributor

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

Copy link
Contributor Author

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();
Copy link
Contributor

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.

Copy link
Contributor Author

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.

Copy link
Contributor Author

@Renzo-Olivares Renzo-Olivares Sep 13, 2024

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

Copy link
Contributor Author

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 {
Copy link
Contributor

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

Copy link
Contributor Author

@Renzo-Olivares Renzo-Olivares Sep 9, 2024

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.
Copy link
Contributor

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?

Copy link
Contributor Author

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?

Copy link
Contributor

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

@Renzo-Olivares Renzo-Olivares force-pushed the global-selectionlistener branch 3 times, most recently from 7a9e948 to 6da9401 Compare September 9, 2024 17:27
@Renzo-Olivares
Copy link
Contributor Author

This is ready for another review, the main change is that a Selectable/SelectionHandler now only return a single SelectedContentRange instead of a list of ranges and other comments have been addressed.

Copy link
Contributor

@justinmc justinmc left a 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(
Copy link
Contributor

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.
Copy link
Contributor

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.

Comment on lines +132 to +125
required this.startOffset,
required this.endOffset,
Copy link
Contributor

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) {
Copy link
Contributor

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].
Copy link
Contributor

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.
Copy link
Contributor

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"

Comment on lines 2961 to 3253
/// This widget should have an ancestor [SelectionArea] or [SelectableRegion]
/// to be able to listen to selection changes in this widgets subtree.
Copy link
Contributor

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) {
Copy link
Contributor

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(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

intentation

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What should change here?

Copy link
Contributor

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
Copy link
Contributor

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.
Copy link
Contributor

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?

Copy link
Contributor Author

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.
Copy link
Contributor

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;
Copy link
Contributor

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?

Copy link
Contributor Author

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 {
Copy link
Contributor

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?

Copy link
Contributor Author

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]
Copy link
Contributor

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.
Copy link
Contributor

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.
Copy link
Contributor

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?

Copy link
Contributor Author

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) {
Copy link
Contributor

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?

Copy link
Contributor Author

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.

Copy link
Contributor Author

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.

Copy link
Contributor

@chunhtai chunhtai left a 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.
Copy link
Contributor

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?

Copy link
Contributor

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

Copy link
Contributor Author

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?

Copy link
Contributor

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?

Copy link
Contributor Author

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].
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
/// delimited by [PlaceholderSpan.placeholderCodeUnit].
/// delimited by [PlaceholderSpan]s or [WidgetSpan]s.

Comment on lines 131 to 132
const SelectedContentRange.empty({int contentLength = 0})
: this(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
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
Copy link
Contributor

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) {
Copy link
Contributor

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();
Copy link
Contributor

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?

Copy link
Contributor Author

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?

Copy link
Contributor

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.

Copy link
Contributor Author

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.
Copy link
Contributor

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) {
Copy link
Contributor

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.

Copy link
Contributor Author

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.

Copy link
Contributor Author

@Renzo-Olivares Renzo-Olivares Oct 1, 2024

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.

@Renzo-Olivares Renzo-Olivares force-pushed the global-selectionlistener branch from e80922e to 00b43cd Compare October 1, 2024 20:40
@chunhtai
Copy link
Contributor

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 .

@vergaraSC
Copy link

vergaraSC commented Oct 16, 2024

@Renzo-Olivares This will go into production (we have an estimated date)?, it is a very good feature.

@Renzo-Olivares Renzo-Olivares force-pushed the global-selectionlistener branch from 06c9bd6 to d226dcd Compare October 16, 2024 22:27
@github-actions github-actions bot removed the f: scrolling Viewports, list views, slivers, etc. label Oct 16, 2024
@Renzo-Olivares Renzo-Olivares force-pushed the global-selectionlistener branch from d03e261 to 030a219 Compare November 25, 2024 21:43
Copy link
Contributor

@justinmc justinmc left a 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.

@Renzo-Olivares Renzo-Olivares added the autosubmit Merge PR when tree becomes green via auto submit App label Nov 26, 2024
@auto-submit auto-submit bot added this pull request to the merge queue Nov 26, 2024
Merged via the queue into flutter:master with commit f3f72ed Nov 26, 2024
79 checks passed
@flutter-dashboard flutter-dashboard bot removed the autosubmit Merge PR when tree becomes green via auto submit App label Nov 26, 2024
engine-flutter-autoroll added a commit to engine-flutter-autoroll/packages that referenced this pull request Nov 26, 2024
engine-flutter-autoroll added a commit to engine-flutter-autoroll/packages that referenced this pull request Nov 26, 2024
engine-flutter-autoroll added a commit to engine-flutter-autoroll/packages that referenced this pull request Nov 26, 2024
engine-flutter-autoroll added a commit to engine-flutter-autoroll/packages that referenced this pull request Nov 26, 2024
@reidbaker reidbaker mentioned this pull request Dec 13, 2024
11 tasks
engine-flutter-autoroll added a commit to engine-flutter-autoroll/packages that referenced this pull request Feb 12, 2025
engine-flutter-autoroll added a commit to engine-flutter-autoroll/packages that referenced this pull request Feb 13, 2025
engine-flutter-autoroll added a commit to engine-flutter-autoroll/packages that referenced this pull request Feb 13, 2025
engine-flutter-autoroll added a commit to engine-flutter-autoroll/packages that referenced this pull request Mar 6, 2025
engine-flutter-autoroll added a commit to engine-flutter-autoroll/packages that referenced this pull request Mar 7, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
a: text input Entering text in a text field or keyboard related problems d: api docs Issues with https://api.flutter.dev/ d: examples Sample code and demos f: material design flutter/packages/flutter/material repository. framework flutter/packages/flutter repository. See also f: labels.
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Make onSelectionChanged of SelectionArea equivalent to that of SelectableText
5 participants