Skip to content

Conversation

@rock3r
Copy link

@rock3r rock3r commented Nov 26, 2025

This fixes two issues with text context menus:

1. The menu should not show if it's empty

If the context menu has no items, it should not show. This is easily achieved by only popping up the menu if the items list is not empty.

2. The menu should not pop up "after the fact"

Once 1 is fixed, there is another issue that crops up: the menu which was not popped up initially, becomes visible as soon as there would be items in it. For example, if no menu pops up in a SelectionContainer if the user right-clicks an empty spot, but then does
a selection. Same for BTF, if the user types any text in an initially-empty BTF after right clicking it.

This is done by explicitly closing the menu session if it contains no items.

Note: JPopupContextMenuRepresentation works differently and this only explicitly fixes #1 for it. Due to it being a completely different implementation, this does not really suffer from #2

Fixes CMP-9342

Important

This requires CMP-9229 (#2595) to be merged first

Testing

fun main() = singleWindowApplication {
    Column(Modifier.padding(16.dp)) {
        SelectionContainer {
            Column {
                Text("Hello hello")
                Spacer(Modifier.height(20.dp))
                Text("I am selectable, too")
            }
        }
        Spacer(Modifier.height(20.dp))
        BasicTextField(
            rememberTextFieldState(),
            Modifier.background(Color.White).border(1.dp, Color.Gray).padding(4.dp)
        )
    }
}
Screen.Recording.2025-11-26.at.16.02.33.mov

This should be tested by QA

Release Notes

Fixes - Desktop

  • Fixed empty context menu showing up when right clicking an empty text selection, or an empty BTF when there is no text in the clipboard

This changes the `SelectionManager.textManager` implementation for
`SelectionContainer`s in `ContextMenu.desktop.kt`, so that the copy action is
only available when the selected text is not empty.

This lets implementors of `TextContextMenu` decide whether they want to show
a disabled copy action in the menu, or not.
This fixes two issues with text context menus:

### 1. The menu should not show if it's empty
If the context menu has no items, it should not show.
This is easily achieved by only popping up the menu if
the items list is not empty.

### 2. The menu should not pop up "after the fact"
Once 1 is fixed, there is another issue that crops up:
the menu which was not popped up initially, becomes
visible as soon as there would be items in it. For
example, if no menu pops up in a `SelectionContainer`
if the user right-clicks an empty spot, but then does
a selection. Same for BTF, if the user types any text
in an initially-empty BTF after right clicking it.

This is done by explicitly closing the menu session if
it contains no items.

Note: JPopupContextMenuRepresentation works differently
and this only explicitly fixes JetBrains#1 for it. Due to it
being a completely different implementation, this does
not really suffer from JetBrains#2
@m-sasha
Copy link

m-sasha commented Dec 2, 2025

I don't think this is the right solution, although we may adopt it regardless.

What happens here is that the context menu is opened and then immediately closed, which is already not ideal.
But also, if the menu is really opened and then the state changes such that all the items are gone, the menu will get auto-closed.

This is all very strange behavior.

I see two better, but more complex, solutions:

  1. When the context-menu-opening gesture happens, all state relevant to the context menu is remembered and frozen. Changes to the state don't affect the menu. If there are no items, the menu is not opened. This is not ideal, because eventually (when the user selects a menu item) the context menu will have to meet the real state and make a decision how to resolve conflicts (maybe the selection is now collapsed).
  2. All relevant menu items should be always displayed (for SelectionContainer only "Copy" and "Select All"), and only their enabled state should change based on the underlying selection state. I think this is the better solution, but it will require some work, as we don't have "enabled" state in the ContextMenuItem API.

This is also being made more complicated because of uncertainty w.r.t. new context menu API from AOSP.

@igordmn

@m-sasha
Copy link

m-sasha commented Dec 2, 2025

What happens here is that the context menu is opened and then immediately closed, which is already not ideal.

Correction: this doesn't actually happen.

But the menu will get automatically closed if the state changes to "no items". It's also just bad practice to have session-closing logic in the composition.

@igordmn
Copy link
Collaborator

igordmn commented Dec 2, 2025

I agree with @m-sasha that opening and closing menu isn't the right approach. The code that does the opening should check first and don't open the menu. The menu itself should not change state, only react on it (it can do that in event handlers, but not in recomposition/layout/draw)

Something like the first approach from m-sasha would work:

fun TextContextMenuArea(
    textManager: TextManager,
    items: () -> List<ContextMenuItem>,
    state: ContextMenuState,
    content: @Composable () -> Unit
) {
    val contextMenuItems: List<ContextMenuItem>? by remember { mutableStateOf() }
    ContextMenuArea(
        items = { contextMenuItems?.invoke() ?: emptyList() },
        state = state,
        modifier = Modifier.contextMenuOpenDetector(
            key = Pair(textManager, state)
        ) { pointerPosition ->
            ...
            val currentItems = items()
            if (currentItems.isNotEmpty()) {
                contextMenuItems = currentItems
                state.status = ContextMenuState.Status.Open(Rect(pointerPosition, 0f))
            }
        },
        content = content
    )
}

We froze the items, but not the operations, but I think it is fine.

@m-sasha
Copy link

m-sasha commented Dec 2, 2025

@igordmn

We froze the items, but not the operations, but I think it is fine.

Do you think it's ok that we will try to execute operations that may be invalid at the time when they're executed...?
I agree that it sounds like a strange case, but potentially bad things could happen there (maybe crashes).

  • Cutting from or pasting into non-editable text field.
  • Copying text from password text fields.

@rock3r
Copy link
Author

rock3r commented Dec 2, 2025

@m-sasha afaik Swing also freezes the items when the menu opens. It's probably fine in 99% of cases; actions that know they're doing something potentially risky can just throw a try/catch just in case

@igordmn
Copy link
Collaborator

igordmn commented Dec 2, 2025

Do you think it's ok that we will try to execute operations that may be invalid at the time when they're executed...?

We should additionally check the validity during the operation (perform the same check, as for showing menu and the item), but it is fine to perform the same operation on a different state if it is still valid (copy/cut/paste another text).

Also, it can be out of scope of this PR, because as far as I understand, the current code already froze the items, we just explicitly do this earlier for showing.

@m-sasha
Copy link

m-sasha commented Dec 2, 2025

I don't think we're freezing them. They are retrieved in derivedStateOf in DefaultContextMenuRepresentation.Representation:

            val components by remember {
                derivedStateOf {
                    items().map {
                        TextContextMenuItemWithComposableLeadingIcon(
                            key = it,
                            label = it.label,
                            enabled = true,
                            onClick = {
                                session.close()
                                it.onClick()
                            }
                        )
                    }
                }
            }

@igordmn
Copy link
Collaborator

igordmn commented Dec 2, 2025

I don't think we're freezing them

items and ContextMenuItem fields are not mutable states, thus derivedStateOf is only initialized once.

Also I see that in the previous state it was without derivedStateOf, but it still should be frozen (because parent composition doesn't recompose).

When you changed it in that PR, did you think about this case? If not, we can consider it undefined. If yes, but it is in fact frozen, it is a bug. But we can convert it to a feature ).

@m-sasha
Copy link

m-sasha commented Dec 2, 2025

items and ContextMenuItem fields are not mutable states, thus derivedStateOf is only initialized once.

I'm not sure I understand what you mean.
items is a function which returns the set of context menu items based on the current state of the text component, so when the state changes, it is called again, and the value of the derived state changes.

Here's an example:

        var text1 by remember { mutableStateOf("") }
        var readOnly by remember { mutableStateOf(false) }
        TextField(
            value = text1,
            onValueChange = { text1 = it },
            readOnly = readOnly
        )
        LaunchedEffect(Unit) {
            while(true) {
                delay(2000)
                readOnly = !readOnly
            }
        }
Screen.Recording.2025-12-02.at.16.33.35.mp4

@igordmn
Copy link
Collaborator

igordmn commented Dec 2, 2025

Thanks. So it works. I missed the code inside TextManager that captures the value as mutableStateOf.

In the end, which option we choose, should depend on UX we need:

  1. Freeze items
    a. Freeze values
    b. Don't freeze values, don't perform invalid operations
  2. Show everything, disable unavailable items
  3. Don't show menu with no items, hide menu if all items gone

1a.

This is probably not UX friendly (copied something that is not seen by user), and difficult to implement

2

From native OS perspective it is a better UX, but from IDEA perspective it should hide the items (I just checked). So, it should be at most configurable (via custom Representation in the legacy context menu), at least we should keep the current "hide" behavior.

1b

It looks fine to me from UX perspective.

implemented here (it should already not perform invalid operations)

3

Also looks fine to me. Maybe even the best from the UX perspective when we hide the items?

Implemented in the PR, but we need:

  • move closing to an effect, and capture state via snapshotFlow()
  • not sure, but probably still check it on opening, to prevent "opened -> closed" state change in a single recomposition. Something else can also subscribe to it and do something (Blur background? Show additional info-popup? Trigger tada animation?)

@m-sasha
Copy link

m-sasha commented Dec 2, 2025

From native OS perspective it is a better UX, but from IDEA perspective it should hides the items

I don't think we should force the IDEA/Swing behavior. We should allow it, yes, (via a custom TextContextMenu).

@m-sasha
Copy link

m-sasha commented Dec 8, 2025

With #2617 merged, I think we can close this.

@rock3r rock3r closed this Dec 8, 2025
@rock3r rock3r deleted the sebp/CMP-9342_fix-text-context-menus branch December 8, 2025 10:51
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants