Skip to content
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

Fix right-click to select word on macOS (take 2) #1439

Merged
merged 5 commits into from
Aug 9, 2024

Conversation

m-sasha
Copy link

@m-sasha m-sasha commented Jul 11, 2024

There are currently several problems with text selection in SelectionContainer:

  1. When right-clicking a SelectionContainer with no text, the app will crash.
  2. When right-clicking on the padding of a Text in a SelectionContainer, the app will crash or select the wrong text.
  3. The position of the word to select is determined from the position of the context menu, which is not guaranteed to be opened exactly at the clicked position.
  4. The logic doesn't check whether the clicked position is already selected; it only checks whether a selection already exists. Therefore when there is already some selection, it will not select a clicked word that is not already selected.

Fixes JetBrains/compose-multiplatform#4985

Note that I don't plan to merge this immediately. I want to first get feedback, then upstream the common changes to Google, and only then, after cherry-picking from AOSP, merge this PR.

Testing

  • Tested manually, with the Selection page in mpp demo.
  • Added unit tests

This should be tested by QA

Release Notes

Fixes - Desktop

  • [macOS] Fix crash when right-clicking an empty SelectionContainer or on the padding of a Text inside a SelectionContainer.

Comment on lines +68 to +69
* This overload does not trigger the opening of the context menu; it's up to the caller to do so
* by passing a [modifier] that does so.
Copy link
Member

Choose a reason for hiding this comment

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

It will be confusing, we can avoid it by default args

Copy link
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 it's confusing, it's just not very convenient, but since this API is for advanced developers, I think it's better to prefer clarity (who's responsible for what, in this case) over convenience.

Copy link
Collaborator

Choose a reason for hiding this comment

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

Personally, this is confusing for me. One overload looks like a default for another overload. Usual Compose components don't change behaviour between SomeComponent() and SomeComponent(modifier = Modifier) (the default value of the modifier parameter MUST be Modifier)

The suggested modifier: Modifier = Modifier.contextMenuOpenDetector(state, true) looks the same, just as a real default value.

We need to figure out something else.

Copy link
Collaborator

@igordmn igordmn Jul 16, 2024

Choose a reason for hiding this comment

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

We need to figure out something else.

We need something public that can be reused in custom TextContextMenu implementations.

I have 2 suggestions:

  1. Create PlatformTextContextMenuArea (it doesn't reuse ContextMenuArea):
    @Composable
    override fun Area(textManager: TextManager, state: ContextMenuState, content: @Composable () -> Unit) {
        ...
       PlatformTextContextMenuArea({ emptyList() }, textManager, state, content = content)
    }

Why PlatformTextContextMenuArea and not TextContextMenuArea: additional value that it adds to usual ContextMenuArea is opinionated behaviour for Desktop/macOs platform (we probably had to rename platformContextMenuDetector by the same logic)

  1. Add onOpen: (() -> Unit)?
    @Composable
    override fun Area(textManager: TextManager, state: ContextMenuState, content: @Composable () -> Unit) {
        ...
       ContextMenuArea({ emptyList() }, state, onOpen = textManager::selectWordAtPositionIfNotAlreadySelected, content = content)
    }

Comment on lines +81 to +82
state: ContextMenuState,
modifier: Modifier,
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
state: ContextMenuState,
modifier: Modifier,
state: ContextMenuState = remember { ContextMenuState() },
modifier: Modifier = Modifier.contextMenuOpenDetector(state, true),

Copy link
Author

Choose a reason for hiding this comment

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

I'm afraid that this would be confusing, because

  1. The default value for a modifier argument is (nearly?) always Modifier.
  2. contextMenuOpenDetector is not the modifier typically needed when using this overload. If you want contextMenuOpenDetector, you should use the overload right above it, that just takes an enabled flag.

Copy link
Collaborator

@igordmn igordmn left a comment

Choose a reason for hiding this comment

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

Looks good, except we need to agree on desktopMain public API

/**
* Returns whether the given pixel position is inside the selection.
*/
internal fun TextLayoutResult.isPositionInsideSelection(
Copy link
Collaborator

Choose a reason for hiding this comment

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

All commonMain changes looks good to me.

Comment on lines +68 to +69
* This overload does not trigger the opening of the context menu; it's up to the caller to do so
* by passing a [modifier] that does so.
Copy link
Collaborator

Choose a reason for hiding this comment

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

Personally, this is confusing for me. One overload looks like a default for another overload. Usual Compose components don't change behaviour between SomeComponent() and SomeComponent(modifier = Modifier) (the default value of the modifier parameter MUST be Modifier)

The suggested modifier: Modifier = Modifier.contextMenuOpenDetector(state, true) looks the same, just as a real default value.

We need to figure out something else.

Comment on lines +68 to +69
* This overload does not trigger the opening of the context menu; it's up to the caller to do so
* by passing a [modifier] that does so.
Copy link
Collaborator

@igordmn igordmn Jul 16, 2024

Choose a reason for hiding this comment

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

We need to figure out something else.

We need something public that can be reused in custom TextContextMenu implementations.

I have 2 suggestions:

  1. Create PlatformTextContextMenuArea (it doesn't reuse ContextMenuArea):
    @Composable
    override fun Area(textManager: TextManager, state: ContextMenuState, content: @Composable () -> Unit) {
        ...
       PlatformTextContextMenuArea({ emptyList() }, textManager, state, content = content)
    }

Why PlatformTextContextMenuArea and not TextContextMenuArea: additional value that it adds to usual ContextMenuArea is opinionated behaviour for Desktop/macOs platform (we probably had to rename platformContextMenuDetector by the same logic)

  1. Add onOpen: (() -> Unit)?
    @Composable
    override fun Area(textManager: TextManager, state: ContextMenuState, content: @Composable () -> Unit) {
        ...
       ContextMenuArea({ emptyList() }, state, onOpen = textManager::selectWordAtPositionIfNotAlreadySelected, content = content)
    }

@m-sasha
Copy link
Author

m-sasha commented Jul 16, 2024

I think both suggestions are overly complicating things to solve a very minor problem.

I don't like PlatformTextContextMenuArea because if it doesn't reuse ContextMenuArea then it'll just be a copy/paste of most of its code.

I don't like adding onOpen because that ties the API of ContextMenuArea to a mouse event.

Conceptually, the new ContextMenuArea I added is a more general version of the existing one. The existing one is ContextMenuAreaWithDefaultOpenDetector and the new one is ContextMenuAreaWithUserOpenDetector. The problem is that they have the same name, and the "detector" is a Modifier, for which we can't (due to AOSP rules) provide a default value other than Modifier. A simple solution, in my opinion, is to just give the new variant a different name, like CoreContextMenuArea.

@m-sasha m-sasha requested a review from igordmn July 16, 2024 13:42
@m-sasha m-sasha force-pushed the m-sasha/selection-container-right-click2 branch from 9ba6f3b to 41a9bbc Compare July 16, 2024 13:54
@igordmn
Copy link
Collaborator

igordmn commented Jul 16, 2024

ContextMenuAreaWithUserOpenDetector

Maybe we look at it differently. I look that "detector" (how to react) is the same for usual and text menus. It is rather "handler" (what to do) that is different.

CoreContextMenuArea

CoreContextMenuArea looks better ContextMenuArea(Modifier), but I am afraid of creating too much abstraction layers with few differences. Context menu API already suffers from over-abstraction.

How much "Core" should be in it? How users should decide what to use just looking at the available names? Box + content + LocalContextMenuData + LocalContextMenuRepresentation isn't justified to be extracted from Box + detector + content + LocalContextMenuData + LocalContextMenuRepresentation. Just a copy-paste with a few tweaks looks better to me:

(Option 3)

        @ExperimentalFoundationApi
        val Default = object : TextContextMenu {
            @Composable
            override fun Area(...) {
                ...
                Box(
                    Modifier.textContextMenuOpenHandler(state, textManager),
                    propagateMinConstraints = true
                ) {
                    content()
 
                    LocalContextMenuRepresentation.current.Representation(    // make an overload extension
                        state,
                        ContextMenuData(items, LocalContextMenuData.current) // make LocalContextMenuData public
                    )
                }
                ...
            }
        }

This can be refactored to (more controversial, not sure that it is good idea):

(Option 4)

Box(
    Modifier.textContextMenuOpenHandler(state, textManager),
    propagateMinConstraints = true
) {
    content()
    CurrentContextMenuRepresentation(state, items) // "just a wrapper" on the same level as `LocalContextMenuRepresentation`
}

@m-sasha
Copy link
Author

m-sasha commented Jul 17, 2024

Maybe we look at it differently. I look that "detector" (how to react) is the same for usual and text menus. It is rather "handler" (what to do) that is different.

That happens to be true for what we're doing in this PR, but that doesn't mean that it's the right way to think about this API. The way I'm looking at it is that there's a more generic ContextMenuArea where you can define the trigger yourself, and a convenience function which bundles the most commonly used trigger.

CoreContextMenuArea looks better ContextMenuArea(Modifier), but I am afraid of creating too much abstraction layers with few differences. Context menu API already suffers from over-abstraction.

This is a super common pattern in Compose, and elsewhere. Again, the problem here is just with being unable to provide the default via a default argument. If it weren't for that, we'd have one function with Modifier.contextMenuOpenDetector as the default argument value and nothing to argue about. Do you not agree with that?

How users should decide what to use just looking at the available names?

Sometimes you have to read the documentation... We could rename modifier to triggerModifier to make it clearer, although I'm not sure it's allowed.

Box + content + LocalContextMenuData + LocalContextMenuRepresentation isn't justified to be extracted from Box + detector + content + LocalContextMenuData + LocalContextMenuRepresentation.

Why is it not justified to be extracted? Even one line of code is justified to be extracted if it shares the same meaning and purpose, and isn't trivial.

P.S. I really think we're arguing about something very inconsequential here, so @igordmn please decide and I will implement it.

@m-sasha m-sasha requested a review from MatkovIvan July 17, 2024 11:25
Copy link
Collaborator

@igordmn igordmn left a comment

Choose a reason for hiding this comment

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

LGTM. Needs a second approval

@@ -251,7 +283,7 @@ class ContextMenuState {
}
}

object Closed : Status()
data object Closed : Status()
Copy link
Member

Choose a reason for hiding this comment

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

Copy link
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 it applies to data objects. A data object only adds equals, hashcode and toString implementations.

Copy link
Member

Choose a reason for hiding this comment

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

Let's just follow the rule to avoid corner cases

Copy link
Author

Choose a reason for hiding this comment

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

But there is no rule about data objects. None of the issues with data classes apply to data objects.

@m-sasha m-sasha requested a review from MatkovIvan July 17, 2024 12:34
@m-sasha
Copy link
Author

m-sasha commented Jul 17, 2024

I plan to wait with this until https://android-review.googlesource.com/c/platform/frameworks/support/+/3176245 is merged.

copybara-service bot pushed a commit to androidx/androidx that referenced this pull request Jul 30, 2024
Improve contextMenuOpenAdjustment in SelectionManager and TextFieldSelectionManager and rename them to selectWordAtPositionIfNotAlreadySelected. The new implementations do not select the word if the clicked position is already selected, and do not crash when an empty SelectionContainer is clicked.

Corresponding CMP PR: JetBrains#1439

Test: Updated implementations for desktop only
Change-Id: I6954cd6a0d3d1febf24a31bc4e228ab445aa54f8
m-sasha added a commit that referenced this pull request Aug 9, 2024
Improve contextMenuOpenAdjustment in SelectionManager and TextFieldSelectionManager and rename them to selectWordAtPositionIfNotAlreadySelected. The new implementations do not select the word if the clicked position is already selected, and do not crash when an empty SelectionContainer is clicked.

Corresponding CMP PR: #1439

Test: Updated implementations for desktop only
Change-Id: I6954cd6a0d3d1febf24a31bc4e228ab445aa54f8
… SelectionContainer and TextField to TextContextMenu.Area
…xtFieldSelectionManager` and `SelectionManager`
… opening the context menu for selectable text.

Also make LocalContextMenuData public.
@m-sasha m-sasha force-pushed the m-sasha/selection-container-right-click2 branch from 30aa85b to 8f41990 Compare August 9, 2024 08:05
@m-sasha m-sasha merged commit 1ec1b58 into jb-main Aug 9, 2024
6 checks passed
@m-sasha m-sasha deleted the m-sasha/selection-container-right-click2 branch August 9, 2024 10:07
mazunin-v-jb pushed a commit that referenced this pull request Aug 14, 2024
Improve contextMenuOpenAdjustment in SelectionManager and TextFieldSelectionManager and rename them to selectWordAtPositionIfNotAlreadySelected. The new implementations do not select the word if the clicked position is already selected, and do not crash when an empty SelectionContainer is clicked.

Corresponding CMP PR: #1439

Test: Updated implementations for desktop only
Change-Id: I6954cd6a0d3d1febf24a31bc4e228ab445aa54f8
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
3 participants