Skip to content

Conversation

@Renzo-Olivares
Copy link
Contributor

@Renzo-Olivares Renzo-Olivares commented May 23, 2024

Adds SelectionListener which allows a user to listen to selection changes under the subtree it wraps given their is an ancestor SelectionArea or SelectableRegion. These selection changes are delivered in the form of SelectedContentRange which provides more information about the active selection under a SelectionArea and SelectableRegion such as the content under the selection, and start and end offsets relative to the content. This is accessible through the onSelectionChanged member of SelectionListener.

Screen.Recording.2024-05-23.at.2.12.04.PM.mov

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: material design flutter/packages/flutter/material repository. d: api docs Issues with https://api.flutter.dev/ d: examples Sample code and demos labels May 23, 2024
@Renzo-Olivares Renzo-Olivares force-pushed the selectedcontent-controller branch 2 times, most recently from b5f7d01 to f16372f Compare May 23, 2024 23:37
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.

Here's my high level review of the approach here.

Just wondering, would it be possible to use a more declarative pattern instead of the imperative SelectedContentController and SelectionController? Like updating values and calling setState instead of calling imperative methods on the controllers.

I also generally worry about multiple sources of truth here, if there is anything that the controllers are duplicating. I mentioned this a little bit in a comment.


class _MyHomePageState extends State<MyHomePage> {
static const String _aboutSteve =
'Steve is a dog whose personality is as unique as his name. Not your average canine, '
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 get this riveting story from Gemini or something? 🐶

afterSelection = _trimPlaceholders(afterSelection);
final List<InlineSpan> concreteSpans = <InlineSpan>[];
final TextSpan beforeSpan = TextSpan(text: beforeSelection);
final TextSpan selectionSpan = TextSpan(text: collectedSelection, style: const TextStyle(color: Colors.red));
Copy link
Contributor

Choose a reason for hiding this comment

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

I know this is just an example, but does this have the possibility of accumulating deeply nested TextSpans if the user keeps emphasizing the same text? One more reason it would be nice to have annoted strings @LongCatIsLooong .

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 it depends on how the user chooses to implement this type of feature. I chose to work with the plain text in this example, so not a lot of nesting happens.

///
/// A user can set the value on this controller to modify the content under
/// a selection created by [SelectionArea] or [SelectableRegion].
abstract class SelectedContentController<T extends Object> extends ValueNotifier<T> {
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 type parameter be an InlineSpan or can it really be anything?

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 made it generic here in case we plan to add support for a different type of format down the road. For example, if Text widgets move from InlineSpan to some new type of annotated string, or if someone has a Selectable state that they only track as a String.

///
/// Children of a given controller enable more granular modification of the
/// selection.
List<SelectedContentController<Object>> children = <SelectedContentController<Object>>[];
Copy link
Contributor

Choose a reason for hiding this comment

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

How exactly do the children relate to the value span and its children? Is children supposed to contain all SelectedContentControllers of all other spans in value's subtree?

(Assuming I'm understanding this correctly) I wonder if we can do this in a way that maintains more of a single source of truth.

Copy link
Contributor Author

@Renzo-Olivares Renzo-Olivares May 28, 2024

Choose a reason for hiding this comment

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

Correct, the children contain all the SelectedContentControllers that are within the values subtree. For example selecting from "some" to "Bullet 3" in the Text widget below would result in a SelectedContentController with 3 children controllers representing the individual bullet Text widgets,

Text.rich(
  TextSpan(
    text: 'This is some bulleted list:\n',
    children: <InlineSpan>[
      WidgetSpan(
        child: Column(
          children: <Widget>[
            for (int i = 1; i <= 7; i += 1)
              Padding(
                padding: const EdgeInsets.only(left: 20.0),
                child: Text('• Bullet $i'),
              )
          ],
        ),
      ),
    ],
  ),
),

I think if we had no children controllers, it would become difficult to edit something like the bulleted text widget in the example, as a user would have to dig into the contents of a WidgetSpan manually using (WidgetSpan.child as Column).children[index] and we would still to communicate the selection ranges for each of the bullets. Open to ideas here.

Copy link
Contributor

Choose a reason for hiding this comment

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

Ok I see... My main concern is the controllers getting out of sync with the spans. Can we use immutability somehow to prevent them from getting out of sync?

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 have made the SelectedContentController.children immutable as well now.

_TextSpanContentController(super.value);

@override
TextSpan buildContents() {
Copy link
Contributor

Choose a reason for hiding this comment

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

What is buildContents supposed to be used for? Maybe include an example that overrides 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.

I removed it, I think I was imagining something similar to TextEditingController.buildTextSpan when I first wrote this but it's not needed.

Comment on lines 1244 to 1271
// Question: Should we add the root text controller, if only the children
// are selected? For example, say we have the following:
// Text.rich(
// TextSpan(
// text: 'This is some bulleted list:\n',
// children: <InlineSpan>[
// WidgetSpan(
// child: Column(
// children: <Widget>[
// for (int i = 1; i <= 7; i += 1)
// Padding(
// padding: const EdgeInsets.only(left: 20.0),
// child: Text('• Bullet $i'),
// )
// ],
// ),
// ),
// ],
// ),
// ),
// and we select only text inside the widgetspan, should we return the
// root text controller in this case, as well as the children controllers
// that are selected? Or should we only return the selected children controllers.
// The former might be more confusing to the user?
Copy link
Contributor

Choose a reason for hiding this comment

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

What if a user wants to change something in the part of the text that is not selected? If we want to support that use case then I think we'd have to return the root controller.

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.

I only took a quick glance, but I am a bit concern about how you can have mismatched SelectedContentController.value and what is declared in the widget tree. Something I am worry is that if somehow a rebuild happen after SelectedContentController.value is changed, the modification will be lost? Why not just have customer update their textspan that they passed into the text widget in the callback and make the SelectedContentController immutable?

///
/// Utilize this controller to programatically clear the content of a
/// [SelectionArea] or [SelectableRegion], or select all the contents under it.
class SelectionController with ChangeNotifier {
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 we should probably separate this out to its own pr

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 still the plan?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

After talking with @chunhtai, I think for now it might be less intrusive to expose SelectableRegionState and make its method _clearSelection public. This way we don't tie ourselves to any specific method in this PR.

return text.replaceAll(String.fromCharCode(PlaceholderSpan.placeholderCodeUnit), '');
}

void _emphasizeText(List<SelectedContentController<Object>>? controllers) {
Copy link
Contributor

Choose a reason for hiding this comment

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

This method seems quite complex, I hope we can have a more generic api to modify textspan. In android, you can do something like SpannableString.setSpan(start, end, certainAttribute).

@Renzo-Olivares Renzo-Olivares force-pushed the selectedcontent-controller branch 2 times, most recently from 55c9340 to 4b1f060 Compare June 11, 2024 18:23
@Renzo-Olivares
Copy link
Contributor Author

Renzo-Olivares commented Jun 11, 2024

This is ready for another look. The main changes are that SelectedContentController is now immutable (thinking of a new name for it), and selectable widgets are now identified through an optional selectableId. A user can request a new id through SelectableRegionState.nextSelectableId. While I'm not too fond of adding more selection parameters to Text, I think it will be difficult for the customer to map their widgets state to the selection without it.

edit: I plan to separate SelectionController into a separate PR

@Renzo-Olivares
Copy link
Contributor Author

This is ready for another look. The main changes are that SelectedContentController is now immutable (thinking of a new name for it), and selectable widgets are now identified through an optional selectableId. A user can request a new id through SelectableRegionState.nextSelectableId. While I'm not too fond of adding more selection parameters to Text, I think it will be difficult for the customer to map their widgets state to the selection without it.

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.

Left a few comments about the approach at a high level. I do like the declarative SelectedContentController pattern now.

Why do you need to have selectableId instead of its index in a list?


/// A list of [SelectedContentController]s that represent the selection, and
/// can be used to modify the contents of the selection.
final List<SelectedContentController<Object>>? controllers;
Copy link
Contributor

Choose a reason for hiding this comment

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

I guess the SelectedContentControllers here are only those covered by selection, so there will never be a controller that isn't selected here?

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 you will never get a controller that doesn't include a selection.

///
/// Children of a given controller enable more granular modification of the
/// selection.
List<SelectedContentController<Object>> children = <SelectedContentController<Object>>[];
Copy link
Contributor

Choose a reason for hiding this comment

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

Ok I see... My main concern is the controllers getting out of sync with the spans. Can we use immutability somehow to prevent them from getting out of sync?

@Renzo-Olivares Renzo-Olivares force-pushed the selectedcontent-controller branch from cadfb02 to c2f5bc9 Compare June 13, 2024 20:05
@Renzo-Olivares Renzo-Olivares requested a review from justinmc June 13, 2024 21:06
///
/// A user can set the value on this controller to modify the content under
/// a selection created by [SelectionArea] or [SelectableRegion].
abstract class SelectedContentController<T extends Object> {
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 @LongCatIsLooong, this is the relevant code that we were discussing in the text input sync. A list of SelectedContentControllers is provided in SelectionArea.onSelectionChanged representing the active selection. The discussion was regarding whether this should be generic or specifically TextSpan since at the moment the frameworks only selectable widget is Text.

@Renzo-Olivares Renzo-Olivares changed the title Add SelectedContentController to SelectedContent Add SelectedContentRange to SelectedContent Jun 18, 2024
@Renzo-Olivares Renzo-Olivares force-pushed the selectedcontent-controller branch from 3cd2551 to f7f695d Compare June 18, 2024 20:30
@Renzo-Olivares
Copy link
Contributor Author

Friendly bump for review, changes today mainly include refactoring where the _TextSpanContentRange is created and the name change from SelectedContentController -> SelectedContentRange (open to suggestions on the name).

@Renzo-Olivares Renzo-Olivares marked this pull request as ready for review June 18, 2024 21:00
@Renzo-Olivares Renzo-Olivares force-pushed the selectedcontent-controller branch from 4eb2ef2 to 44f56c0 Compare June 18, 2024 23:59
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.

I have a bunch of questions about SelectedContent and SelectedContentRange. Sorry for all the nitpicking here. I'm starting to understand this PR more each time I review it so I'm going back to some basics a bit. I think the main thing to do here is just make sure the docs explain the sort of things that I'm asking about.

}
expect(find.textContaining('This is some text in a text widget.'), findsOneWidget);
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's worth it to also test selecting and turning some text red, just because it covers so much of this PR's code in a way that's very realistic. Same for the other example too.

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 and same for the selection_area.2_test.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

TODO: still need to write these tests.

/// The content that contains the selection.
final T content;

/// The start of the selection relative to 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 [content]." => "relative to the start of the [content]."

Is that more accurate? Same for endOffset below.

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 have been updated?)

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 to @Renzo-Olivares offline about this but yeah I still think this change is important if I'm understanding correctly. I think it helps me be clear on how all of this works. The content could be a smaller part of a larger span tree, so this helps make it clear we're not talking about an offset after the end of the content or something.

Consider:

Screenshot from 2024-07-12 15-59-24

Say SpanA contains "0" and SpanB, and SpanB contains the rest of the text. If content is SpanB, then startOffset is 8 and endOffset is 9, not 9 and 10, because SpanB doesn't include the first character.

This wording makes it clearer to me that's what we're talking about.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Thank you for the explanation! That is clearer.

Comment on lines 206 to 207
/// A list of [SelectedContentRange]s that represent the content under
/// the active selection.
Copy link
Contributor

Choose a reason for hiding this comment

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

Are these ranges supposed to cover the entirety of the content that this SelectedContent covers? In my example in my previous comment, that means they should cover "world" but not "hello ", if my assumptions are correct there.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

The SelectedContentRange always includes all the content, and provides a start/end offset relative to the content, so in your example you'll always be given "hello world" and the start: 6/ end: 11.

Comment on lines 1525 to 1555
class _TextSpanContentRange extends SelectedContentRange<TextSpan> {
_TextSpanContentRange({
this.start = -1,
this.end = -1,
super.selectableId,
required super.content,
super.children,
});

@override
int get startOffset => start;
final int start;

@override
int get endOffset => end;
final int end;
}
Copy link
Contributor

Choose a reason for hiding this comment

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

I wonder if you could effectively get the functionality of _TextSpanContentRange with a type parameter instead of a subclass? Since the only interesting thing here is really the TextSpan type.

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 have made SelectedContentRange non-abstract so we can get rid of these sublasses.

@chunhtai
Copy link
Contributor

chunhtai commented Jul 24, 2024

I also agree repurposing key is not a good idea. Can we force developer to provide the data model, ie textspan, that forms the selection subtree under the SelectionListener? It seems to me things starts to get weird if they are using SelectionListener without knowing the textspans in the subtree, and thus we try to use key to disambiguate them. However, should that be a valid use case?

If they have a subtree look like this.

Column(children: [TextA, UnKnownSelectable, TextB] )

Instead of wrapping a SelectionListener over entire column and try to disambiguate away the UnKnownSelectable, should they just do this instead

Column(children: [
  SelectionListener(
    dataModel: textSpan1,
    onChange: (){},
    builder: (data) => Text.rich(data)
  ),
  UnKnownSelectable,
  SelectionListener(
    dataModel: textSpan2, 
    onChange: (){},
    builder: (data) => Text.rich(data)
  )
])

I don't really know if this can solve your use case, but WDYT?

@Renzo-Olivares Renzo-Olivares requested a review from justinmc July 24, 2024 23:34
@Renzo-Olivares
Copy link
Contributor Author

I don't have a strong opinion on using key as the identifier, I originally added a new parameter to Text called selectableId, but went the key route to avoid bloating the Text widget with more selection related fields and it seemed reasonable since the key is considered an identifier for the widget.

I do think we should consider including some identifier so the user does not have to use one particular method like multiple SelectionListeners and callbacks in the example, and can have the option to use a global SelectionListener and callback instead. I think the former adds overhead (especially with nested listeners and widgetspans) that we can provide a solution for with selectableId.

One issue I ran into when removing selectableId and updating the color selection red example is that it becomes difficult to know when to clear the selection. Let’s say the selection is spread across a column of 3 text widgets, if we clear the selection in the first text widgets listener then the operations we do in text widget 2 and 3 will not work since the selection will be cleared. This is because text 1 will receive the finalized selection first, do its logic and then clear the selection, not giving text 2 and text 3 a chance to do their logic. The onSelectionChanged from text 1 would need to know if text 2 and text 3 have a selection so it knows not to clear it. This can likely be done by saving the selectionDetails from text 2 and text 3. To me this seems like it adds more cognitive load to the user when compared to having one listener for a subtree, where they can clear the selection at the end of the onSelectionChanged callback.

I think the issue of when to clear the selection would also come up if we we force the developer to provide the data model like in @chunhtai example, since they still have to track all the selections from each listener.

Renzo Olivares added 2 commits July 25, 2024 01:25
…rator for SelectedContentRange, attempt not call onSelectionChanged when selection finalized is received but the selection has not changed. SelectionFinalizedSelectionEvent does not affect geometry so it is difficult to know if the selection changed between selection finalized events, to fix I keep track of the lastFinalizedSelectionDetails
@chunhtai
Copy link
Contributor

column of 3 text widgets,

In this example you want to apply the same effect across 3 widgets (color selection red). In this case, you would wrap one selectionListener for all 3 widgets right?

Unless you have condition like widget 2 is some unknown stuff but still selectable and you want to disambiguate widget 2 out. Do you really have a use case like this? e.g. i only want to color the selection widget1 and widget 3 but not widget 2.

If you want to react to selection in a part of the screen, you have to know what's inside and thus you can just wrap one listener over the the widget subtree.

I may be missing some use cases, so I may be wrong here.

@Renzo-Olivares
Copy link
Contributor Author

Renzo-Olivares commented Jul 25, 2024

Yeah it would be preferable in the case of the color selection red example to wrap the entire subtree with one listener. I was mainly exploring using multiple to see how the usage of the API changed with multiple.

In the example of color selection red, let’s say part of the 3rd widget is selected. Then the user will only receive one SelectedContentRange. Is it reasonable to say that since the user is providing the data model to that subtree that they will know what part of their data model the offsets of the SelectedContentRange belong to? This sounds reasonable to me, though I think if we had the example with unselectable widgets interweaved it might get tricky. I think its also tricky because the offsets you will get in SelectedContentRange are relative to the text widget they came from. For example if we select some text in widget 3 and widget 3 is part of some overall data model, the offsets given will still be relative to widget 3 and not the data model.

Select 'text' in text widget 3:

dataModel = ['text widget 1', 'text widget 2', 'text widget 3'].

Text(dataModel[0])
Text(dataModel[1])
Text(dataModel[2])

returned ranges: [SelectedContentRange(start: 0, end: 4, contentLength: 13)].

I'm not sure in the example above how the user would know where the range came from, and how to update their data model accordingly.

@LongCatIsLooong
Copy link
Contributor

Let’s say the selection is spread across a column of 3 text widgets, if we clear the selection in the first text widgets listener then the operations we do in text widget 2 and 3 will not work since the selection will be cleared. This is because text 1 will receive the finalized selection first, do its logic and then clear the selection, not giving text 2 and text 3 a chance to do their logic. The onSelectionChanged from text 1 would need to know if text 2 and text 3 have a selection so it knows not to clear it. This can likely be done by saving the selectionDetails from text 2 and text 3. To me this seems like it adds more cognitive load to the user when compared to having one listener for a subtree, where they can clear the sele...

That's the case we talked about yesterday right? My thought was that in this case you need an API that query the current SelectionDetails from each segment (by that I mean each listener) so you can find out the SelectionDetails when the user clicks the "paint me red" button, instead of the ability of listening for SelectionDetails changes in each segment. But now that I think about it, if you want to implement a listener api, wouldn't you need the ability to query the current SelectionDetails anyway? The moment the user starts subscribing to the SelectionDetails of a selection listener, they should get an event that represents the current SelectionDetails (or at least there has to be a way to ask for it). Think of it as a ValueNotifier<SelectionDetails>, if the user subscribes to such a ValueNotifier, they should be able to extract the value, or they will have to wait for the first time the value changes after the subscription.

@Renzo-Olivares
Copy link
Contributor Author

Is the pseudocode below kind of what you're pointing at @LongCatIsLooong?

onPressed: () {
    final SelectionDetails? selectionText1 = selectionListener1.value;
    final SelectionDetails? selectionText2 = selectionListener2.value;
    final SelectionDetails? selectionText3 = selectionListener3.value.
    if (selectionText1 != null) {
        _colorRed(index: 0, selectionText1.ranges);
    }
    if (selectionText2 != null) {
        _colorRed(index: 1, selectionText2.ranges);
    }
    if (selectionText3 != null) {
        _colorRed(index: 2, selectionText3.ranges);
    }
    _clearSelection();
}

I think this works when using multiple listeners, but am not sure how a user would know if the selection is finalized with this approach. Maybe we can have a SelectionListener.onSelectionFinalized callback, but that seems a little tricky since a user would have to know if the selection has been finalized / is ongoing in SelectionListeners adjacent to it.

In the color red example, where it makes more sense to use one listener to wrap the entire subtree, I think we still run into the issue where a user won't know where the selection came from, using the query approach.

@LongCatIsLooong
Copy link
Contributor

I think this works when using multiple listeners, but am not sure how a user would know if the selection is finalized with this approach

Do you mean if the user is dragging to expand the selection and at the same time the onPressed callback is invoked, the callback should wait for the next finalize event? That doesn't seem a common thing to do. Or if the user just wants the next finalized event, they can just return early in the listener callback if details.isFinalized is false?

@Renzo-Olivares
Copy link
Contributor Author

Yeah they could wait for the next finalized event before showing the context menu. I wasn't sure with the query approach where exactly they would listen to that finalized event, but the user can wrap the subtree they're listening to with another SelectionListener and check if the selection is finalized that way and then trigger the context menu. Or they can make another SelectionContainer that just listens for the finalized event themselves.

@Renzo-Olivares
Copy link
Contributor Author

I added variants of both examples with/without selectableId. Curious to hear any thoughts regarding pros and cons of either approach.

IMO having no selectableId adds another layer of management that the user has to worry about since they now have to track each SelectionDetails coming from every listener. Also having to wrap every Text widget with a SelectionListener looks a little verbose to me, especially for simpler cases like the color selection red example, where it might make more sense to just wrap the subtree of 3 text widgets with a single SelectionListener. A querying approach would also run into the same verbosity I think since we would still have to wrap each individual widget with a listener.

required this.contentLength,
required this.startOffset,
required this.endOffset,
this.children,
Copy link
Contributor

Choose a reason for hiding this comment

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

why does this have children? shouldn't the SelectedContentRange in SelectionDetails.ranges just a flatten list?

Copy link
Contributor

Choose a reason for hiding this comment

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

I assume this is for widgetspan, but if they care about selection inside the widgetspan, they should provide a selectionId in the widgetspan themselves

Copy link
Contributor Author

@Renzo-Olivares Renzo-Olivares Jul 31, 2024

Choose a reason for hiding this comment

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

I think the reason I had this as a tree was because a flat list felt discontinuous to me. By that I mean a single Text widget could have multiple SelectedContentRanges if there were multiple WidgetSpans that broke it up vs with a tree structure where each Text widget would have a single SelectedContentRange associated with it and the children of that range are any sub-selections within it.

For example, for the text widget below, with a flattened list selecting all the text would return 3 SelectedContentRanges vs with a tree it only returns 1 SelectedContentRange with 1 child range:

const Text.rich(
 TextSpan(
   text: 'Hello world, ',
   children: <InlineSpan>[
     WidgetSpan(
       child: Text('how are you today?'),
     ),
     TextSpan(
       text: 'More text after widgetspan',
     ),
   ],
 ),
)

I'm not sure which approach is better? but I think it makes more sense that a Text widget only reports 1 SelectedContentRange since I don't think the user expects a Text widgets selection to be split into multiple segments.

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 we should reflect selection range structure with text span tree unless we want to couple selectionListener with text widget. Otherwise, it would be confusing that a certain datamodel will produce selectedcontentrange format in a certain way. It feels like magic.

other way is if we find a way to couple datamodel and selection id, maybe we can tag selection id on per textspan basis.

Another thing is whether the widget span should be included. It feels weird and tricky to include that as one selected content range since widget span can be anything.

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 don't think we should reflect selection range structure with text span tree unless we want to couple selectionListener with text widget. Otherwise, it would be confusing that a certain datamodel will produce selectedcontentrange format in a certain way. It feels like magic.

I was more thinking this reflected a structure where a leaf widget that is selectable might have children selectables. In most cases this children list will be empty, but in the cases where a selectable does have children then a user could look into this list to see more details. A user could always choose not to look into the children list and only look at the root.

Another issue that gets trickier is ids, if we do return multiple SelectedContentRanges for a single selectable widget then a user will have to likely need to map multiple ranges to a single id. Tagging selection id on per textspan basis would solve this but it seems strange to me to do this in cases when there is no widgetspan. For example say a user has a textspan tree, with multiple textspans it seems odd to require them to tag each individual textspan when the underlying selectable tree would only create one selectable for this case.

Another thing is whether the widget span should be included. It feels weird and tricky to include that as one selected content range since widget span can be anything.

Can you explain more on this? If the widgetspan contains a column of N selectable widgets, then there would be N SelectedContentRanges reported.

@chunhtai
Copy link
Contributor

I think adding selectionId is probably ok, though I think we should figure out a way to

  1. not drill it down from widget like Text(), we can consider a composition approach like a wrapper widget
  2. if we do 1, we may need a way to declare relation between datamodel and selectionId.

As of using multiple Listener vs one. I don't really know, I guess we will only figure out when we have more use case.

Yeah they could wait for the next finalized event before showing the context menu. I wasn't sure with the query approach where exactly they would listen to that finalized event, but the user can wrap the subtree they're listening to with another SelectionListener and check if the selection is finalized that way and then trigger the context menu. Or they can make another SelectionContainer that just listens for the finalized event themselves.

For cases of multipleListener couldn't the isFinalized boolean to be include in the onSelectionChange callback?

@chunhtai
Copy link
Contributor

I also think there are a lot of different approach that target different use case are proposed here. I think which ever solution we choose should be flexible enough to be able to build on top later.

For example, the decision may be that we don't necessary selectionId for now, but we should keep in mind we may need it in the future. so the implementation should not write us into a dead end

Copy link
Contributor

Choose a reason for hiding this comment

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

Quick bug report on this example:

  1. Select an entire bullet line, so like "* Bullet 2".
  2. Tap to make it red.

The bullet list disappears.

Just want to call this out in case it's a deeper problem with this approach, but otherwise I'm assuming it's just a small logic error in _colorSelectionRed.

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.

Not sure if you've thought about this idea yet or not but wanted to make sure.

result = MouseRegion(
cursor: DefaultSelectionStyle.of(context).mouseCursor ?? SystemMouseCursors.text,
child: _SelectableTextContainer(
selectableId: key,
Copy link
Contributor

Choose a reason for hiding this comment

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

Instead of key, what if you pass this as the selectableId? Then use only a single SelectionListener and:

  1. Walk the tree until you find where selectionDetails.ranges.first.selectableId == textWidget.
  2. Color that subtree red given the matching SelectedContentRange. I think you could do that with a static recursive method like _makeRed below, which might help break up the size of your logic in _colorSelectionRed.
  3. If there are still more ranges in selectionDetails.ranges, continue walking the tree with the new range.
  4. Repeat until you've called _makeRed on all the ranges.
  5. Return your new tree, which is the old one but with the selected part red.

I'm assuming that selectionDeteails.ranges is ordered in traversal order.

static InlineSpan _makeRed(TextSpan textSpan, SelectedContentRange range) {
  assert(textSpan == range.selectableId);
  // I leave the rest as an exercise for the reader.
}

Copy link
Contributor

Choose a reason for hiding this comment

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

Benefits, if it works:

  • No explicit selectionId needed on Text.
  • Only one SelectionListener is needed for your examples.
  • No separate data structure needed in your examples (dataSourceMap etc.).

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Talked offline, this would not work unless we held a reference to the widget, which would still require us to keep some map.

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.

Some quick thoughts after we just met to talk about this with @chunhtai and @LongCatIsLooong.

// Rebuild column contents.
for (final MapEntry<int, TextSpan> entry in dataSourceMap.entries) {
newText.add(
SelectionListener(
Copy link
Contributor

Choose a reason for hiding this comment

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

What is the order that nested SelectionListeners will have their onSelectionChanged callbacks called? Maybe depth first traversal order or something like that? We should document this so it's clear for users.

This is probably less important if we go with the flattened indices approach, because users will probably just use one top-level SelectionListener. But we should still document it.

onSelectionChanged: (SelectionDetails selectionDetails) {
selectionDetailsMap[entry.key] = selectionDetails;
},
child: Text.rich(
Copy link
Contributor

Choose a reason for hiding this comment

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

Just for my own curiosity, what was the thought process behind making SelectionListener its own widget vs. adding an onSelectionChanged callback to Text or to SelectionArea? Feel free to just give me a link if this was already discussed elsewhere.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Mainly because given that any complex widget tree could be under SelectionArea we decided it was best to let the user decide if they only want to listen to a portion of thats trees selection changes rather than the entire tree.

Comment on lines +45 to +48
Map<Key, TextSpan> dataSourceMap = <Key, TextSpan>{};
Map<Key, TextSpan> bulletSourceMap = <Key, TextSpan>{};
late final Map<Key, TextSpan> originSourceData;
late final Map<Key, TextSpan> originBulletSourceData;
Copy link
Contributor

Choose a reason for hiding this comment

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

Back to my ranting about trying to get rid of these maps... So assuming that we go with the "flattened" approach, where onSelectionChanged is called with 2 indices into a flat string, I think you could take the following approach to this example:

  • The only state you need is a list of ranges representing the parts of the flat string that have been turned red. No need for any of these Maps.
  • Somewhere store your original widget tree under the SelectionListener, with nothing red.
  • Write a method static Widget _makeRed(Widget child, Iterable<TextRange> ranges);. It will walk the child tree and build a new tree with the given ranges made red. Assert if ranges exceeds the number of characters you find in child.
  • In your build method: return _makeRed(selectionListenerChild, redRanges);
  • onSelectionChanged: (TextRange selection) { setState(() { redRanges.add(selection); }); }. Also, you probably would want to merge overlapping ranges.

Would that work?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Talked offline, but I think that's reasonable. I worry though that a user would technically have to walk two trees in this scenarios, first the Widget tree and then the TextSpan tree for the Text widgets. Walking one tree seems simpler to me.

@Liloupar
Copy link

@Renzo-Olivares @chunhtai
Can this be merged now?

@Renzo-Olivares
Copy link
Contributor Author

Closing this, discussion will continue in #154202.

github-merge-queue bot pushed a commit that referenced this pull request Nov 26, 2024
https://github.com/user-attachments/assets/59225cf7-5506-414e-87da-aa4d3227e7f6

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
`Selectable`s content.
* `SelectionHandler.contentLength`, to describe the length of the
content contained by a selectable.

Original PR & Discussion: #148998

Fixes: #110594

## Pre-launch Checklist

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

---------

Co-authored-by: Renzo Olivares <roliv@google.com>
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. f: scrolling Viewports, list views, slivers, etc. framework flutter/packages/flutter repository. See also f: labels.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants