JetBrains / compose-multiplatform

Compose Multiplatform, a modern UI framework for Kotlin that makes building performant and beautiful user interfaces easy and enjoyable.
https://jetbrains.com/lp/compose-multiplatform
Apache License 2.0
15.27k stars 1.11k forks source link

Erratic window focus behavior #4803

Closed mgroth0 closed 1 month ago

mgroth0 commented 1 month ago

Describe the bug

A window will only focus properly if certain conditions are met.

While researching this issue, I came accross the WindowAdapter workaround in https://github.com/JetBrains/compose-multiplatform/issues/4231 by @m-sasha. This is one of the requirements that must be met, but it alone is not enough.

In the scenario we have two windows. Window 1 has a text field, and the window 2 has a button. The button in window 2 should focus window 1 and its text field so that the user can start typing.

The expectation is that it will just work without complication.

What actually happens is that numerous strange requirements exist. It can work, but only if all of the following are true:

  1. We must use the WindowAdapter workaround, but the simple version in which focus.requestFocus() is called right away only works about 50% of the time. In order for it to work 100% of the time, we need to run it after a short delay.
  2. The compose content of the window that is being focused must be clicked at least once. The button will not work at all until it is clicked. Clicking the empty space in the window is enough, but clicking the decoration bar is not.
  3. Both the ComposeWindow and the FocusRequester must request focus (if there are multiple components in the window with the text field)

Requirement 3 is not a bug, but 1 and 2 are definitely bugs. The reason I include 3 here is to emphasize that even without 1 and 2, this operation is already quite complex and easy to mess up, which increases the cognitive burden caused by 1 and 2

Affected platforms

Versions

To Reproduce

Part 1

  1. Run the code, do not touch the window titled "Window 1"
  2. Click the "Focus TextField" button
  3. Observe that Window 1 lights up as if it is focused, but typing does not work
  4. Mash the "Focus TextField" out of frustration (still without clicking "Window 1")
  5. Mash some keys
  6. Observe still no text in text field
  7. Carefully click the title bar of "Window 1". Drag it around a bit.
  8. Click "Focus TextField"
  9. Try to type
  10. Observe still no typed text on screen
  11. Finally, click the empty space below the text field in "Window 1" once
  12. Click "Focus TextField"
  13. Type something, and observe it works
  14. At this point, it will fully work for the remainder of the process. You can select any window from any application, do whatever, and observe the "Focus TextField" button now works 100% of the time. It is unbreakable. All it needed was for some empty space in "Window 1" to be clicked once.

Part 2

  1. Edit the code to remove this part:
scope.launch {
    delay(100)
    javax.swing.SwingUtilities.invokeLater {
        focus.requestFocus()
    }
}
  1. Repeat the steps from Part 1.
  2. Observe that the button works 50% of the time. It seems rhythmic, too. Like once it works, then once it doesn't work, then once it works, and so on.
  3. Observe that even if you click random windows in between button presses, that rhythm still exists.

Context

In the end, it seems that the second issue does have an ok workaround. It adds complexity to the code and the variable delay also adds room for error, but its ok.

But the first issue is worse, in my opinion, because the only workaround I have found so far is that the user has to literally click the window, if that could even be considered a workaround. Maybe java.awt.Robot could click the window, but I don't think I would want to go there so I didn't try it.

import androidx.compose.material3.Button
import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.awt.ComposeWindow
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Window
import androidx.compose.ui.window.WindowPosition
import androidx.compose.ui.window.WindowState
import androidx.compose.ui.window.application
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import java.awt.event.WindowAdapter
import java.awt.event.WindowEvent

fun main() {
    val state1 = WindowState()
    val state2 = WindowState()
    // position the two windows next to eachother. 
    // They are negative for my external monitor, but this can be changed
    state1.position = WindowPosition(-1300.dp, -600.dp)
    state1.size = DpSize(300.dp, 300.dp)
    state2.position = WindowPosition(-900.dp, -600.dp)
    state2.size = DpSize(300.dp, 300.dp)
    var window1: ComposeWindow? = null
    val focus = FocusRequester()
    application {
        Window(
            state = state1,
            onCloseRequest = ::exitApplication,
            title = "Window 1"
        ) {
            window1 = window
            val s = remember { mutableStateOf("") }
            TextField(
                s.value,
                onValueChange = {
                    s.value = it
                },
                modifier = Modifier.focusRequester(focus)
            )
            val scope = rememberCoroutineScope()
            DisposableEffect(window) {
                val listener =
                    object : WindowAdapter() {
                        override fun windowActivated(e: WindowEvent) {
                            javax.swing.SwingUtilities.invokeLater {
                                focus.requestFocus()
                            }
                            scope.launch {
                                delay(100)
                                javax.swing.SwingUtilities.invokeLater {
                                    focus.requestFocus()
                                }
                            }
                        }
                    }
                window.addWindowListener(listener)
                onDispose {
                    window.removeWindowListener(listener)
                }
            }
        }
        Window(
            state = state2,
            onCloseRequest = ::exitApplication,
            title = "Window 2"
        ) {
            Button(
                onClick = {
                    window1!!.requestFocus()
                    focus.requestFocus()
                }
            ) {
                Text("Focus TextField")
            }
        }
    }
}
eymar commented 1 month ago

Reproduced with 1.6.10-rc01 too.

m-sasha commented 1 month ago

The problem here is that you're calling Window.requestFocus, which transfers focus to the window component itself. Compose, then, doesn't receive the key events, because they go to the window, rather than to the component on which Compose listens to key events.

You simply need to do window.toFront() instead.

P.S. All the workarounds in https://github.com/JetBrains/compose-multiplatform/issues/4231 are needed because in that ticket the window receiving focus does not exist yet when the request is being made (and the app is not even in the foreground), which is not the case here.