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
16.01k stars 1.17k forks source link

Make resize area larger or configurable for undecorated windows #4574

Closed mgroth0 closed 4 weeks ago

mgroth0 commented 6 months ago

When making a window undecorated, the area that you can click in the corners on macOS is sigificantly smaller than when the window is decorated. This leads to frustration as you are tying to get the mouse into the tiny little area that allows you to click and drag to resize an undecorated window.

I would like to make this area larger. I am not sure if this is more on the compose side or the swing side.

Platform: Desktop OS: macos 14.4.1 arm64 Compose Version: 1.6.1 Kotlin version: 2.0.0-Beta5

m-sasha commented 6 months ago

We'll consider it.

In the meanwhile, take a look at UndecoratedWindowResizer.

You could:

  1. Make the (Compose) Window unresizable
  2. Make the (Swing) Window resizable
  3. Copy UndecoratedWindowResizer into your code and add it to the content of the window at the top level.
mgroth0 commented 6 months ago

Thank you for suggesting this workaround.

I was able to get the exact behavior I want. However, I don't love the way I had to do it because I had to resort to reflection and I had make a copy of UndecoratedWindowResizer.

Here is what I did:

  1. Copied UndecoratedWindowResizer to my MyUndecoratedWindowResizer
  2. I wanted to call window.setResizable in such a way to only set the swing part resizable, but not the compose part. This was difficult because ComposeWindow overrides setResizable:
  override fun setResizable(value: Boolean) {
        super.setResizable(value)
        undecoratedWindowResizer.enabled = isUndecorated && isResizable
    }

I wanted the super.setResizable(value) part to run, but not to enable the default undecoratedWindowResizer. The best workaround I could come up with was reflection:

LaunchedEffect(resizable) {
    window.setResizable(resizable)
    ComposeWindow::class
        .declaredMembers
        .single { it.name == "undecoratedWindowResizer" }
        .run {
            isAccessible = true
            val undecoratedWindowResizer = call(window)!!
            val enabled =
                undecoratedWindowResizer::class.declaredMembers.single {
                    it.name == "enabled"
                } as KMutableProperty
            enabled.isAccessible = true
            enabled.setter.call(undecoratedWindowResizer, false)
        }
    myUndecoratedWindowResizer.enabled = resizable
}
  1. I tried to do the same thing that ComposeWindow does in my content:
content()
myUndecoratedWindowResizer.Content(
    modifier = Modifier.layoutId("UndecoratedWindowResizer")
)

The end result is that I was able to customize my resizer just the way I wanted, but the process of doing so required reflection and the code doesn't look very stable or pretty. The main things I dislike about the code are:

I hope knowing my use case is helpful if your team ever decides to redesign some of this API. In the end, I needed to make two adjustments to my custom resizer:

/**
 * See [[androidx.compose.ui.window.UndecoratedWindowResizer]]
 * See https://github.com/JetBrains/compose-multiplatform/issues/4574#issuecomment-2037464649
 * Purposes:
 * I want to control the width of the resize areas
 * I want the resize areas to be different for the title bar
*/
internal class MyUndecoratedWindowResizer(
    private val window: Window,
    private val borderThickness: Dp,
    private val borderThicknessTop: Dp
) {
   // Kept the implementation the same except for only replacing `borderThickness` with `borderThicknessTop` where appropriate
}

If I could suggest some possible goals here, they might be:

Thanks again.

m-sasha commented 6 months ago

I wanted to call window.setResizable in such a way to only set the swing part resizable, but not the compose part. This was difficult because ComposeWindow overrides setResizable:

Yes, that's unfortunate. We could avoid overriding setResizable in ComposeWindow. @MatkovIvan ?

I had to call Modifier.layoutId with the exact right string. Searching the compose code base I realized this string had some affect somewhere, so I had to keep it the same.

You didn't have to do that. Modifier.layoutId is a way to tag children elements so that the MeasurePolicy of the layout can identify them. In this case, the Layout in WindowContentLayout identifies it and has separate code to size and position it. In your case, you aren't putting your resizer in WindowContentLayout, so it meaningless.

I'm not sure we want to expose the resizer itself, but we'll see whether we can provide a smaller API surface to just define the border thickness. Or maybe we just need to select a better default value. Maybe check the sizes of the resizable areas of native windows and use that. @igordmn ?

mgroth0 commented 6 months ago

Are you sure that I am not putting my content into a WindowContentLayout?

I see that WindowContentLayout is used inside of ComposeWindowPanel.setContent, and ComposeWindowPanel is used inside of ComposeWindow.setContent. I am not subclassing ComposeWindow, so it looks to me like my content and also my custom resizer are still inside of a ComposeWindowPanel, and therefore also inside of a WindowContentLayout.

m-sasha commented 6 months ago

If you put your resizer immediately in the window, as the last element, then yes, it looks like WindowContentLayout will find it. But this is not guaranteed (in the sense that we could change this and not consider it a breaking change), and is more a bug than a feature.

I'd recommend not relying on this, and instead laying out your resizer by yourself (just put all your content in a Box and set Modifier.matchParentSize on the resizer).

mgroth0 commented 6 months ago

That sounds like a sufficient workaround for now, thanks. If possible I'd like to keep this issue open, with the hope that maybe some redesigns in the future will make it so we don't need such heavy workarounds to set the resizer widths (and to set them to different widths for different sides).

m-sasha commented 6 months ago

Hmm, now that I think about it - do you actually need to make the AWT window resizable? The resizability there is "by the user". You can just call ComposeWindow.resizable = false and that's it. The resizer should still be able to resize the window.

mgroth0 commented 6 months ago

Just tested your idea, and it seems to work.

Specifically, I remmoved the code that:

And confirmed it seems to behave exactly as before.

One of the concerns I have is that by not making the AWT window resizable, I don't know if this would have unintended side effects, some of which might be platform-specific. I am testing on a Mac. Looking at the implementation o Frame.setResizable, java seems to do multiple things and some of them platform specific. For example, see the implementation of CPlatformWindow.setResizable:

 @Override
  public void setResizable(final boolean resizable) {
      setCanFullscreen(resizable);
      setStyleBits(RESIZABLE, resizable);
      setStyleBits(ZOOMABLE, resizable);
  }

If the recommendation is that users who make a custom override for the undecorated resizer don't call the AWT setResizable, I think the question then is why Compose would ever call the AWT setResizable in the first place. Is it worth considering that Compose just ignore that method entirely? I would just hope that at the end of the day we have consistency - either AWT setResizable is never called anywhere, or we are able to call it everywhere without having to use any reflection hacks.

m-sasha commented 6 months ago

Window.setResizable is useful for decorated windows.

okushnikov commented 1 month ago

Please check the following ticket on YouTrack for follow-ups to this issue. GitHub issues will be closed in the coming weeks.