Return to Home Page

Window Manager

Jetpack Compose for Desktop handles window management differently than on mobile platforms. The documentation suggests making a window manager class.

E39-Rpi has two main windows, the LoadingWindow and the HmiWindow, as well as some debug windows.

Loading Window

The Loading Window is the main entry point of the application. It shows a menu bar, a picture, and opens the Hmi Window after a delay. The HMI Window can be configured to show up differently, but is on the Rpi shown full screen, without a titlebar.

This picture shows the loading window, with the debug menu bar hidden. Loading Window, hidden menu bar

When I first wrote the loading window, the API for window management on Compose/Desktop was quite different, and there wasn’t a way to selectively have a menu-bar or not at run-time. At the time (May 2021), there wasn’t a way to update the contents of the menu bar at first either. So, to work around it, I made a fake menu bar that looks like a loading bar:

Loading window, hidden menu bar, opened

The Loading... text is actually read from a config file, so it could one day have a script write the version info in there too.

Loading window, quit menu

Loading window, platform menu

Loading window, debug menu

Loading window, configuration menu

HMI Window

The HMI Window holds the Pane Manager and forms the root window for the HMI.

On desktop, it’s opened with a titlebar, and its position is offset to the right of the LoadingWindow.

HMI Window to the right of the Loading Window

WindowManager classes

To make a Compose MPP application with multiple windows, I needed to follow the example in Work with multiple windows and keep track of each Window object in a MutableStateList. Each Window would then be looped over in the main application {}, which would have the effect of opening and closing windows.

The WindowManager uses E39Window, which looks like

interface E39Window {
    val title : String
    val size : DpSize

    val tag : Any

    val defaultPosition : DefaultPosition

    enum class DefaultPosition {
        OVER_MAIN,
        ANYWHERE,
        CENTER
    }

    fun content() : @Composable WindowScope.() -> Unit
}

I’m writing this page in August of 2025, and this code dates from July 2021. For some reason, I had the WindowManager keep track of System Window (Loading, Debug, windows) states separately from the HmiWindow State:

data class HmiWindowState(
        val isHmiWindowOpen : Boolean = false
    )

    data class SystemWindowState(
        val openDebugWindows : SnapshotStateList<E39Window> = mutableStateListOf()
    ) {

        fun findWindowByTag(tag : Any) : E39Window? {
            return openDebugWindows.find { it.tag == tag }
        }

        fun closeWindow(e39Window: E39Window) {
            openDebugWindows.remove(e39Window)
        }

        fun openWindow(e39Window: E39Window) {
            with (openDebugWindows) {
                if (!contains(e39Window)) {
                    add(e39Window)
                }
            }
        }
    }

I kept these as class variables in the WindowManager, and then had to bridge them from the outside-of-compose to inside-of-compose world:

    private val runningApplicationScope = MutableStateFlow<androidx.compose.ui.window.ApplicationScope?>(null)
    private val hmiWindowState = MutableStateFlow(HmiWindowState())
    private val windowManagerState = MutableStateFlow(SystemWindowState())

This shows the rough outline of the rest of the class:

    fun exitApplication() {
        runningApplicationScope.value?.exitApplication()
    }

    //Invoke from Main
    fun runApplication() = application {
        // Currently we use Swing's menu under the hood, so we need to set this property to change the look and feel of the menu on Windows/Linux
        System.setProperty("skiko.rendering.laf.global", "true")

        runningApplicationScope.value = this

        val systemWindowState = produceState(SystemWindowState()) {
            windowManagerState.collect { value = it }
        }

        val hmiWindowState = produceState(HmiWindowState()) {
            hmiWindowState.collect { value = it }
        }


        val isPi = configurationStorage.config[E39Config.CarPlatformConfigSpec._isPi]

        val mainWindowState = rememberWindowState(
            size = DEFAULT_SIZE,
            placement = if (isPi) {
                WindowPlacement.Maximized
            } else {
                WindowPlacement.Floating
            }
        )


        Window(
            title = "BMW E39 Nav Loading",
            state = mainWindowState,
            onCloseRequest = {
                configurablePlatform.stop()
                exitApplication()
            },
            resizable = false,
            visible = true,
            undecorated = isPi
        ) {
            loadingWindow.get().contents()()
        }



        if (hmiWindowState.value.isHmiWindowOpen) {
            Window(
                state = rememberWindowState(
                    position = mainWindowState.position.let {
                        if (configurationStorage.config[E39Config.WindowManagerConfig.hmiShiftRight]) {
                            WindowPosition(it.x + 900.dp, it.y)
                        } else {
                            it
                        }
                    },
                    size = hmiWindow.size,
                    placement = if (isPi) {
                        WindowPlacement.Maximized
                    } else {
                        WindowPlacement.Floating
                    }
                ),
                title = "E39 Menu",
                undecorated = isPi,
                resizable = !isPi,
                alwaysOnTop = true,
                enabled = true,
                onCloseRequest = {
                    closeHmiMainWindow()
                }
            ) {

                //1F means fully bright (because white on black in calibration)
                val tint = configurationStorage.config[E39Config.WindowManagerConfig.brightnessCompensation]

                Box {
                    hmiWindow.content()()
                    Box(Modifier.fillMaxSize().background(
                        color = Color(0F, 0F, 0F, tint)
                    ))
                }
            }
        }

        for (window in systemWindowState.value.openDebugWindows) {
            key(window) {
                Window(
                    title = window.title,
                    state = rememberWindowState(
                        size = window.size
                    ),
                    onCloseRequest = {
                        systemWindowState.value.closeWindow(window)
                    }
                ) {
                    window.content().invoke(this)
                }
            }
        }
    }

    fun openHmiMainWindow() {
        hmiWindowState.value = hmiWindowState.value.copy(isHmiWindowOpen = true)
    }

    fun closeHmiMainWindow() {
        hmiWindowState.value = hmiWindowState.value.copy(isHmiWindowOpen = false)
    }

    fun openDebugWindow(debugWindow: E39Window) {
        windowManagerState.value.openWindow(debugWindow)
    }
}

This sort of setup makes it pretty easy to open windows from other parts of the app:

class DebugLaunchpad @Inject constructor(

    private val windowManager: WindowManager,

    private val mapDebug: MapDebug,
    private val menuDebug: MenuDebug,
    private val keyEventSimulator: KeyEventSimulator,
    ...
    private val picoCommsDebugWindow: PicoCommsDebugWindow,

    private val matrixServiceDebugWindow: MatrixServiceDebugWindow
) : WindowManager.E39Window {

    override val tag: Any
        get() = this

    override val title = "Debug Launchpad"
    override val size = DpSize(300.dp, 800.dp)
    override val defaultPosition: WindowManager.E39Window.DefaultPosition
        get() = WindowManager.E39Window.DefaultPosition.ANYWHERE

    override fun content(): @Composable WindowScope.() -> Unit = {
        Column(Modifier.fillMaxSize(), Arrangement.spacedBy(5.dp)) {
            Button(onClick = { windowManager.openDebugWindow(keyEventSimulator)}) {
                Text("Key Event Simulator")
            }
            Button(onClick = { windowManager.openDebugWindow(mapDebug) }) {
                Text("Map Debug")
            }
            Button(onClick = { windowManager.openDebugWindow(menuDebug)}) {
                Text("Menu Debug")
            }
            ...
            Button(onClick = { windowManager.openDebugWindow(matrixServiceDebugWindow)}) {
                Text("Matrix Chat Debug")
            }
        }
    }
}

Screenshots

Menu bar on device

Return to Top