Pantrix UI

The window’s title

Why, in Compose Multiplatform, the window of an app of one window and many screens keeps one name whatever is shown, what the library gives for it, and how it is tested. The problem is not Pantrix's own: it shows the same way in every Compose Multiplatform app that runs on the web and on the desktop.

What is said here of Compose was read in the sources of Compose Multiplatform 1.12.2 (Window.desktop.kt, ComponentUpdater.kt, LocalAwtWindow.kt, ComposeWindowPanel.desktop.kt). What could not be verified is counted in section 7.

1. The problem

Symptom

Cause

A window's title is not in common code. On each platform it is a field of another object:

PlatformThe window's titleFrom common code
Web (js, wasmJs)document.title: the name of the tab and of the historyNone; kotlinx.browser.document is in the web source set alone
Desktop (JVM)java.awt.Frame.title: the title bar, the taskbarNone; the window is reached through LocalAwtWindow, which is @ExperimentalComposeUiApi
AndroidNo window's title a user sees: the recent apps say the app's nameNo need
iOSNo window's title a user sees: the app switcher says the app's nameNo need

Two details make it harder:

  1. More than one screen is there at once. While one page slides over another both are in the composition; in a wide window a list and its detail stand side by side. "Write the title as a screen opens and put the old one back as it closes" is not enough: the second screen opens, the first one puts the very first title back as it closes, and the open screen's name is lost.

  2. On the desktop Window writes a title too. Window(title = …) writes its title again only when its own value changes (ComponentUpdater compares it with the one before); as long as it does not, the title a screen gave stays, and when it does, it is written over it.

2. The solution

A stack of titles is kept for each window. A screen that comes into the composition puts its title at the end of the stack, and one that leaves takes its own title out of it. The window is given the name at the end of the stack; once the stack is empty, the window's own title, read before the first screen came, is put back. Where a screen's title changes, the window changes with it while that screen is at the end of the stack. The app's name is added by a format: a function from the screen's name to the window's, given once at the app's root.

3. With Pantrix

import com.pantrixui.foundation.platform.LocalPantrixWindowTitleFormat
import com.pantrixui.foundation.platform.PantrixWindowTitle
import com.pantrixui.foundation.platform.PantrixWindowTitleFormat

// At the app's root, once: the app's name after each screen's.
CompositionLocalProvider(LocalPantrixWindowTitleFormat provides PantrixWindowTitleFormat { "$it · Admin" }) {
    App()
}

// In a screen: while it is shown, the window is called "Orders · Admin".
@Composable
fun OrdersScreen() {
    PantrixWindowTitle("Orders")
    OrdersList()
}

3.1. Pages inside pages

A page inside another page's content, as a preview of a page or an example of one is, gives the window no name: it leaves the title to the page it stands in. Two pages side by side, a list and its detail, are not one inside the other; both give a name, and the one that came last wins.

4. Without Pantrix

4.1. Common code (commonMain)

/** A window's titles: the last one given first. */
class WindowTitles(private val read: () -> String, private val write: (String) -> Unit) {
    private val given = mutableListOf<Entry>()
    private var own: String? = null

    inner class Entry(title: String) {
        var title: String = title
            set(value) {
                if (field == value) return
                field = value
                if (given.lastOrNull() === this) write(value)
            }

        fun add() {
            if (given.isEmpty()) own = read()
            given.remove(this)
            given += this
            write(title)
        }

        fun remove() {
            val wasLast = given.lastOrNull() === this
            if (!given.remove(this) || !wasLast) return
            val before = given.lastOrNull()
            if (before != null) write(before.title) else own?.let(write).also { own = null }
        }
    }
}

@Composable
expect fun windowTitles(): WindowTitles?

@Composable
fun WindowTitle(title: String) {
    val titles = windowTitles() ?: return
    val entry = remember(titles) { titles.Entry(title) }
    DisposableEffect(entry) {
        entry.add()
        onDispose { entry.remove() }
    }
    SideEffect { entry.title = title }
}

4.2. The platforms' counterparts

// webMain: the page has one title.
private val documentTitles = WindowTitles(read = { document.title }, write = { document.title = it })

@Composable
actual fun windowTitles(): WindowTitles? = documentTitles
// jvmMain: each window's own titles, which go with the window once it is let go.
private val frameTitles = WeakHashMap<Frame, WindowTitles>()

@OptIn(ExperimentalComposeUiApi::class)
@Composable
actual fun windowTitles(): WindowTitles? {
    val frame = LocalAwtWindow.current as? Frame ?: return null
    return remember(frame) {
        synchronized(frameTitles) {
            frameTitles.getOrPut(frame) { WindowTitles(read = { frame.title.orEmpty() }, write = { frame.title = it }) }
        }
    }
}
// androidMain and iosMain: no window's title a user sees.
@Composable
actual fun windowTitles(): WindowTitles? = null

5. Testing

The stack's rules have no platform and are tested on every target: the last name given wins, a name that leaves makes way for the one before it, the window's own title comes back once the last name is gone, and a name that is not at the end changes nothing of the window.

val titles = WindowTitles(read = { window }, write = { window = it })
val orders = titles.Entry("Orders")
val order = titles.Entry("Order 42")
orders.add()
order.add()
assertEquals("Order 42", window)
order.remove()
assertEquals("Orders", window)

On the web document.title is read in a real browser: a screen writes it in its format, changes it as its title changes, and puts the page's own <title> back as it leaves the composition. The desktop's tests open no window (LocalAwtWindow is null in a test): there PantrixWindowTitle does nothing; the page's JVM tests call it so.

6. Pitfalls

7. Verification

Tests

WhatWhereTarget
The stack's rulesui/foundation/src/commonTest/…/platform/WindowTitlesTest.ktJVM, iOS, JS, Wasm
The browser's tabui/foundation/src/webTest/…/platform/WindowTitleOnTheWebTest.ktJS, Wasm
A page naming the tab, and a page inside it notui/components/src/webTest/…/PageScaffoldOnTheWebTest.ktJS, Wasm

Behaviours broken by hand

With the web's counterpart giving null, both browser tests turned red; with a page naming the window whether or not it stands inside another, the page's browser test turned red.

Not verified

8. Where it is in this repository