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.
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:
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.
Menu Structure
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.
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")
}
}
}
}