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

Improved window sizing API (Windows) #5083

Closed iamcalledrob closed 1 month ago

iamcalledrob commented 3 months ago

Currently, window sizing (at least on Windows) is fairly broken for a few reasons:

Swing has some hi-dpi bugs that make window.minimumSize and window.maximumSize not really usable

window.minimumSize takes a density-independent pixel (not dp) value. However, due to a bug, when setting the value the window will get resized to a minimum size with scaling applied.

On a 200% display, window.minimumSize = Dimension(400, 400) will resize the window to 800x800px, then prevent the window from shrinking below 400x400px.

private fun main() = application {
    var minimumSizeDp by remember { mutableStateOf(DpSize(320.dp, 320.dp)) }
    val state = rememberWindowState(size = minimumSizeDp)

    Window(onCloseRequest = ::exitApplication, state = state) {
        val density = LocalDensity.current
        LaunchedEffect(density, minimumSizeDp) {
            window.minimumSize = with(density) { Dimension(minimumSizeDp.width.roundToPx(), minimumSizeDp.height.roundToPx()) }
        }

        var boxSize by remember { mutableStateOf(IntSize.Zero) }
        Box(modifier = Modifier.fillMaxSize().onSizeChanged { boxSize = it }) {
            Button(onClick = { minimumSizeDp = minimumSizeDp.times(1.2f) }) {
                Text("increase min size")
            }

            val boxSizeDp = with (LocalDensity.current) { boxSize.toSize().toDpSize() }
            Text(modifier = Modifier.align(Alignment.BottomEnd), text = "minimumSizeDp: $minimumSizeDp, boxSizeDp: $boxSizeDp")
        }
    }
}

https://github.com/JetBrains/compose-multiplatform/assets/87964/940e433e-ac0d-46ee-b3ca-561682b17d52

The only workaround for this that I've found is to implement a custom WindowProc and implement WM_GETMINMAXINFO manually for the window

WindowState.size is inconsistent, and includes the invisible window frame that surrounds a Windows 10/11 window

You might expect the window size to either be the size of the content below the titlebar, or the size of the visible window.

Repro:

private fun main() = application {
    val initialSize = DpSize(200.dp, 200.dp)

    val state = rememberWindowState(size = initialSize)

    Window(onCloseRequest = ::exitApplication, state = state) {
        Box(modifier = Modifier
            .background(Color.Red)
            .requiredSize(initialSize) // <- Box set to same size as window size
            .padding(10.dp)
            .background(Color.Blue))
    }
}

Screenshot 2024-07-09 160432

This means that sizing logic needs to be platform-specific, and needs to factor in the border thickness for Windows. You have to use something along the lines of:

    // The raw border measurements of the window. Includes a top border which is not applied unless the
    // window is maximised.
    fun borderWidth(hWnd: HWND): Insets {
        val style = User32.INSTANCE.GetWindowLongPtr(hWnd, WinUser.GWL_STYLE).toLong()
        val styleWithoutTitlebar = style and WinUser.WS_CAPTION.inv().toLong()

        // AdjustWindowRectEx modifies "offsets" to become the size of the enclosing frame that
        // would fit "offsets". Since a zeroed rect is passed in, this ends up setting offsets to
        // the additional size that the frame takes up.

        val offsets = WinDef.RECT()
        User32.INSTANCE.AdjustWindowRectEx(
            offsets,
            WinDef.DWORD(styleWithoutTitlebar),
            WinDef.BOOL(false),
            null)

        return Insets(-offsets.top, -offsets.left, offsets.bottom, offsets.right)
    }

The combination of these two issues means that:

  1. It's impossible to adjust a window's minimum size at runtime
  2. It's impossible to (easily) resize a window to fit its content after the window is opened

I'd love to see improvements in this area, since currently a lot of extra work is needed to workaround these issues -- and it's likely that developers primarily using Linux or macOS are not aware of these behavior differences on Windows!

Sanlorng commented 3 months ago

Maybe you can try return the WindowInsets that is provided by compose foundation. like this.

WindowInsets(
    left = if (isMaximized) {
        frameX + padding
    } else {
        edgeX
    },
    right = if (isMaximized) {
        frameX + padding
    } else {
        edgeX
    },
    top = if (isMaximized) {
        frameY + padding
    } else {
        edgeY
    },
    bottom = if (isMaximized) {
        frameY + padding
    } else {
        edgeY
    }
)
iamcalledrob commented 3 months ago

@Sanlorng You could stuff the frame widths into WindowInsets, but since that uses Dp sizing, density would need to be taken into account.

What I think should happen here is that this should be considered a bug, and ComposeWindow should make this adjustment internally when setting the window size.

That way, a 200dp window on Mac, Linux and Win will all open to the same visible size.

The current logic made sense when windows had visible frames (Win8 and earlier) but doesn't make sense today.

Similarly, as a feature request, I think WindowState should have minimumSize and maximumSize fields that bypass the broken AWT implementation.

okushnikov commented 3 months ago

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