package dev.darkpyonix.composerust.runtime
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.runtime.snapshots.Snapshot
import androidx.compose.runtime.withFrameNanos
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.exclude
import androidx.compose.foundation.layout.ime
import androidx.compose.foundation.layout.safeDrawing
import dev.darkpyonix.composerust.design.NavigationPresentation
import androidx.compose.foundation.layout.height
import androidx.compose.ui.Alignment
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.input.key.onKeyEvent
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import dev.darkpyonix.composerust.foundation.HostMessages
import dev.darkpyonix.composerust.foundation.systemChrome
import androidx.compose.ui.graphics.Color
import dev.darkpyonix.composerust.protocol.ColorRole
import dev.darkpyonix.composerust.protocol.Modifier as ProtocolModifier
import dev.darkpyonix.composerust.protocol.WindowSizeClass
import dev.darkpyonix.composerust.ui.platform.FrameRequestSource
import dev.darkpyonix.composerust.ui.platform.FrameRequests
import dev.darkpyonix.composerust.ui.platform.LocalFrameRequests
import dev.darkpyonix.composerust.ui.platform.serveFrameRequests
import androidx.compose.ui.platform.LocalWindowInfo
import dev.darkpyonix.composerust.protocol.HostEvent
import dev.darkpyonix.composerust.protocol.Mutation
import androidx.compose.runtime.SideEffect
import dev.darkpyonix.composerust.protocol.DesignSystem
import dev.darkpyonix.composerust.protocol.Paint
import dev.darkpyonix.composerust.protocol.PropertyKind
import dev.darkpyonix.composerust.protocol.PropertyValue
import dev.darkpyonix.composerust.protocol.SlotRole
import dev.darkpyonix.composerust.protocol.WidgetKind
import dev.darkpyonix.composerust.design.CaptionSide
import dev.darkpyonix.composerust.design.LocalDesignTheme
import dev.darkpyonix.composerust.design.LocalReduceTransparency
import dev.darkpyonix.composerust.design.detectHostPlatform
import dev.darkpyonix.composerust.design.ResolvedTheme
import dev.darkpyonix.composerust.design.resolveTheme
import dev.darkpyonix.composerust.protocol.MaterialRole
import dev.darkpyonix.composerust.ui.node.Asset
import dev.darkpyonix.composerust.ui.node.Node
import dev.darkpyonix.composerust.ui.node.NodeTable
import dev.darkpyonix.composerust.ui.node.RenderNode
import dev.darkpyonix.composerust.ui.node.platformWindowMaterial
import dev.darkpyonix.composerust.ui.node.TableError
import java.lang.InterruptedException
import java.lang.System
import androidx.compose.foundation.layout.padding
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.runtime.getValue
import androidx.compose.foundation.isSystemInDarkTheme
import dev.darkpyonix.composerust.design.HostPlatform
/**
* Sends one event to the Host and reports whether the Host consumed it.
*
* Consumption is the same model as the web's `preventDefault()` and Compose's
* `PointerInputChange.consume()`: the handler marks the event, and the boundary returns that
* mark in `MutationBatch.result`. The Renderer treats a non-zero result as "consumed", so a
* Host that always returns 0 simply never consumes. It is what lets Enter in a multiline
* field submit without also inserting a newline.
*/
fun interface EventDispatcher {
fun dispatch(event: HostEvent): Boolean
}
/**
* Owns the interpreted tree and the boundary calls for one Host.
*
* The calls are synchronous and on this thread: there is no queue between the two sides, and
* a batch is consumed inside the call that produced it.
*
* Every batch is applied inside a single `Snapshot.withMutableSnapshot` transaction, so the
* intermediate states of a batch are never drawn.
*/
class ComposeRustHost(private val connection: HostConnection) : EventDispatcher {
val table: NodeTable = NodeTable()
val roots: List<Int> get() = table.roots
/**
* The frame source this Host was started against, and how many requests it had counted
* just before. The frame loop counts from here rather than from when it began listening.
*/
internal var startedWith: FrameRequestSource? = null
private set
internal var requestsBeforeStart: Long = 0L
private set
fun start() = start(FrameRequests.global)
internal fun start(frames: FrameRequestSource) {
startedWith = frames
requestsBeforeStart = frames.counter.value
applyTransaction { apply -> connection.init(apply) }
// After the first batch, so the first thing the Host hears is a press that started
// the application, if one did, and then what the platform says about permission.
table.notifications.attach(this)
}
/** Dispatches synchronously and returns the Host's consumption result. */
override fun dispatch(event: HostEvent): Boolean {
var result = 0L
applyTransaction { apply -> result = connection.dispatchEvent(event, apply) }
return result != 0L
}
/**
* Throws the interpreted tree away and asks the Host for the whole of it again.
*
* For a Renderer that cannot keep its node table across whatever just happened to it.
* It costs the application's state, because the Host keeps no shadow of the tree it
* has already sent and answers by building the application from nothing, so a Renderer
* that can keep its table should keep it instead. The Android host does.
*/
fun resync() {
table.clear()
applyTransaction { apply ->
connection.dispatchEvent(
HostEvent.Resync(nodeId = NodeTable.ROOT_ID, handlerId = 0),
apply,
)
}
}
/** Called once per frame after a Host worker asked for one. */
fun renderFrame(frameTimeNanos: Long) {
// What the notification centre heard since the last frame goes first. A press or an
// answer from the platform asks for a frame for exactly this, because the frame is
// where the UI thread, and so the Host, can be reached from any other thread.
table.notifications.drain()
applyTransaction { apply -> connection.renderFrame(frameTimeNanos, apply) }
}
fun shutdown() {
table.notifications.shutdown()
connection.shutdown()
}
private fun applyTransaction(call: ((Mutation) -> Unit) -> Unit) {
val protocolErrors = mutableListOf<TableError>()
Snapshot.withMutableSnapshot {
try {
call { mutation -> table.apply(mutation) }
} catch (error: Throwable) {
// A malformed batch must not take the process down: it becomes a reported
// protocol error instead.
if (error is InterruptedException) throw error
protocolErrors += TableError(PROTOCOL_DECODE_ERROR, error.describe())
}
protocolErrors += table.drainErrors()
}
// Reported after the transaction so the Host is never re-entered mid-batch.
//
// A Host is free to reject the report itself: nothing promises that reporting an
// error succeeds, and the Rust Host answers a `ProtocolError` event with a
// protocol-error status because no handler owns it. Letting that failure out of here
// would turn a reported error into a crashed composition, which is the outcome the
// report exists to prevent, so the report is best effort and the original error is
// what gets printed.
protocolErrors.forEach { error ->
try {
connection.dispatchEvent(
HostEvent.ProtocolError(
nodeId = NodeTable.ROOT_ID,
handlerId = 0,
code = error.code,
message = error.message,
),
) { mutation -> Snapshot.withMutableSnapshot { table.apply(mutation) } }
} catch (report: Throwable) {
if (report is InterruptedException) throw report
onProtocolError(error)
}
}
}
private companion object {
const val PROTOCOL_DECODE_ERROR = 100
}
}
/**
* Where a protocol error goes when the Host will not take the report.
*
* Tests replace it to assert on what was reported; production leaves it printing.
*/
internal var onProtocolError: (TableError) -> Unit = { error ->
System.err.println("compose-rust protocol error ${error.code}: ${error.message}")
}
/**
* What to say about a failure, all the way down.
*
* The cause as well as the throwable, because some of them say nothing on their own. A
* class whose initialiser failed reports "There was an error during file or class
* initialization" and keeps which class and why in its cause, and a reader given only the
* first line has been told that something went wrong and nothing else. That message was
* printed twice a second by a window that came up black, and finding what it meant took
* a debugger and an address arithmetic.
*/
private fun Throwable.describe(): String {
val parts = mutableListOf<String>()
var current: Throwable? = this
var depth = 0
while (current != null && depth < CAUSE_DEPTH) {
val name = current::class.qualifiedName ?: current::class.simpleName ?: "error"
val said = current.message
parts += if (said.isNullOrBlank()) name else "$name: $said"
current = current.cause
depth++
}
return parts.joinToString(", caused by ")
}
/** How far down a cause chain is worth printing before it is noise. */
private const val CAUSE_DEPTH = 6
/** Creates a Host bound to the composition's lifetime. */
@Composable
fun rememberComposeRustHost(connection: HostConnection): ComposeRustHost {
val host = remember(connection) { ComposeRustHost(connection) }
DisposableEffect(host) {
host.start()
onDispose { host.shutdown() }
}
return host
}
/**
* The same, for a Host that was already started outside composition.
*
* The platform layer starts one before it builds the window, because what the window
* should look like is in the first batch and a window cannot be told after it exists: its
* decoration is fixed when it is created. Starting it again here would be the Host's
* second init, which the boundary answers with an error rather than a tree.
*/
@Composable
fun rememberStartedComposeRustHost(host: ComposeRustHost): ComposeRustHost {
DisposableEffect(host) { onDispose { host.shutdown() } }
return host
}
/**
* Draws the Host's tree and runs the frame loop.
*
* Frame requests coming from Host worker threads coalesce into at most one `render_frame`
* per frame, inside `withFrameNanos`.
*/
@Composable
fun ComposeRustContent(
host: ComposeRustHost,
modifier: Modifier = Modifier,
caption: WindowCaption = WindowCaption.None,
) {
val frames = LocalFrameRequests.current
LaunchedEffect(host, frames) {
// Counted from the Host's start, not from the moment this loop began listening. The
// platform starts the Host before there is a window, and work the Host schedules in
// its first render (an effect, a task spawned at startup) asks for a frame right then.
// Counted from now, that request looked already served, and the work waited for some
// unrelated event to be drawn; in a window where none came, it waited for ever.
serveFrameRequests(
frames.counter,
awaitFrame = { onFrame -> withFrameNanos(onFrame) },
serve = host::renderFrame,
alreadyServed = if (host.startedWith === frames) host.requestsBeforeStart else frames.counter.value,
)
}
// A notification centre answers on a thread of its own, and the way from any thread to
// this one is a frame request. The centre asks through the same source this loop serves.
SideEffect { host.table.notifications.wake = frames::request }
// Whether the window is the one in use decides whether a notification that asked to be
// shown only when it is not is shown at all, and coming back to the window is when a
// permission changed in the system's settings is looked for.
val windowFocused = LocalWindowInfo.current.isWindowFocused
SideEffect { host.table.notifications.windowFocusChanged(windowFocused) }
// The theme is resolved once here, and every node reads it from the CompositionLocal. A `SetTheme` is therefore one record on the wire and one
// invalidation in Compose, not a SetProp per node.
val platform = remember { hostPlatformOverride ?: detectHostPlatform() }
// Compose's isSystemInDarkTheme() reads the setting once on the desktop and never
// notices it change, so a window there keeps its original colours while the rest of
// the screen switches. The desktop installs an observer that does follow the system;
// where nobody installs one, Compose's own answer is correct and is used.
val observedDark = LocalSystemDarkObserver.current?.invoke() ?: isSystemInDarkTheme()
val systemDark = systemDarkOverride ?: observedDark
// The window's size is measured here, where the root content is, and reported to
// the Host only when it crosses a size class boundary. onSizeChanged already fires
// only when the measured size differs, and the reporter drops everything that does
// not change the class, so a drag across one class costs no boundary calls.
val reporter = remember(host) { WindowSizeReporter() }
val density = LocalDensity.current
// The same measurement answers three questions. The Host is told when the class
// changes so a component can choose what to put on the screen; the widgets that
// change shape with the window read it from the CompositionLocal, because they are
// drawn on this side and a round trip to ask would cost a boundary call, a
// VirtualDom pass and a rebuilt subtree for a layout this side can already reach;
// and the design system is resolved against it, because a system is allowed to
// answer differently in a desktop window than on a phone.
var sizeClass by remember(host) { mutableStateOf(WindowSizeClass.Compact) }
val measured = Modifier.onSizeChanged { size ->
with(density) {
val widthDp = size.width.toDp().value
sizeClass = windowSizeClassOf(widthDp)
reporter.report(widthDp, size.height.toDp().value, host)
}
}
// Whether anything in the tree asked to be made of a material, and so whether the
// window is stood up with the platform's own material behind it. The design system is
// told the answer, because chrome that sits straight on the desktop is drawn
// differently from chrome over the application's own page.
val asked = host.table.asksForWindowMaterial(host.roots)
val theme = resolveTheme(
host.table.theme,
platform,
systemDark,
sizeClass,
fontOf = { asset -> (host.table.assets.asset(asset) as? Asset.Font)?.family },
brushOf = { asset -> (host.table.assets.asset(asset) as? Asset.Brush)?.brush },
windowBackdrop = asked && platformBacksWindowWithMaterial(),
)
// The answer goes back to the Host, which asked a question it cannot answer itself:
// an adaptive theme names no system, and the one that ends up running is worked out
// here where the platform is. Only when it changes, the way a size class is reported.
//
// SideEffect rather than LaunchedEffect, and this is not a detail. A boundary call is
// a direct call on the thread the Host was started on, and that thread is the one
// composition is applied on. A coroutine effect runs on the toolkit's event thread
// instead, where the Host has no state at all: the call came back refused, with the
// status that means it was made in the wrong place.
val reported = remember { arrayOfNulls<DesignSystem>(1) }
SideEffect {
if (reported[0] != theme.system) {
reported[0] = theme.system
host.dispatch(
HostEvent.DesignSystemResolved(nodeId = 0, handlerId = 0, system = theme.system),
)
}
}
// A tree that opens with a bar, a picture or a colour of its own makes that the
// window's caption, so the strip the window buttons sit in belongs to it rather than
// to the page underneath it. Otherwise the page keeps it and the content starts below
// the buttons.
// The strips a phone's system draws in. Zero on a desktop, where a title bar is the
// caption above instead. The keyboard is excluded: the renderer moves the field that
// is being typed into itself, and taking the keyboard's height off the whole page as
// well would move everything else twice.
val bars = with(LocalDensity.current) {
val safe = WindowInsets.safeDrawing.exclude(WindowInsets.ime)
SystemBars(top = safe.getTop(this).toDp(), bottom = safe.getBottom(this).toDp())
}
// A rail or a sidebar that runs to the top of the window takes the caption the same
// way a bar does: the window buttons sit on its surface and the page beside it starts
// its own content clear of them. Leaving the page to take the strip instead drew a band
// of page colour across the whole window above the sidebar, which is the leftover
// title bar this exists to avoid.
val navigation = theme.rules.navigation(sizeClass, theme)
val stripTakesTheTop = navigation.carriesCaption &&
navigation.presentation != NavigationPresentation.Bar &&
host.table.opensWithANavigation(host.roots)
val barIsCaption = (caption.height > 0.dp || bars.top > 0.dp) &&
(host.table.opensWithABar(host.roots) || stripTakesTheTop)
// A navigation showing a bar along the bottom grows into the bottom strip, so the page
// must not stop short of it as well. The same question the navigation itself asks,
// asked here because the page is laid out before anything inside it.
val navigationTakesTheBottom = bars.bottom > 0.dp &&
navigation.presentation == NavigationPresentation.Bar &&
host.table.opensWithANavigation(host.roots)
val (pageTop, pageBottom) = pageInsets(caption, bars, barIsCaption, navigationTakesTheBottom)
// The clock and the gesture bar are drawn by the system over what this window drew, so
// they follow the theme that was resolved rather than the device's setting. A light
// application on a device in dark mode otherwise gets white icons on its own white
// bar.
LaunchedEffect(theme.dark) { systemChrome?.setDarkIcons(!theme.dark) }
val captionStyle = theme.rules.caption(theme)
// How much room the buttons take and at which end. The platform's own are at the
// leading edge and the renderer's are wherever this design system puts them, so the
// bar cannot assume either.
// The bar is given the strip above it as well as the caption's own height. The two
// are not the same shape: window buttons sit on the bar's row and a status bar sits
// above it, so they are handed over separately.
val strip = caption.copy(insetTop = bars.top)
val barCaption = when {
!barIsCaption -> WindowCaption.None
LocalWindowActions.current == null -> strip
else -> strip.copy(
buttonsWidth = captionStyle.buttonWidth * 3 +
captionStyle.spacing * 2 +
captionStyle.edgePadding * 2,
buttonsAtStart = captionStyle.side == CaptionSide.Start,
)
}
// The window's collapsible split panes, so the platform's sidebar command reaches one
// wherever focus is. Heard on the way back up from the focused node, so a text field
// that wants the key keeps it.
val sidebarShortcuts = remember(host) { dev.darkpyonix.composerust.foundation.SidebarShortcuts() }
CompositionLocalProvider(
dev.darkpyonix.composerust.foundation.LocalSidebarShortcuts provides sidebarShortcuts,
LocalDesignTheme provides theme,
LocalReduceTransparency provides reduceTransparency,
LocalWindowCaption provides barCaption,
LocalStripTakesTheTop provides (barIsCaption && stripTakesTheTop),
LocalSystemBars provides bars,
) {
// The background fills the whole window and the inset is applied inside it. Putting
// the inset outside instead leaves the window's own background showing through the
// strip the title bar used to occupy, which reads as a leftover title bar rather
// than as content extending underneath one.
// Told once, and again only when the answer changes. A window that never asks is
// never told, and comes up on exactly the path it did before any of this existed.
SideEffect { platformWindowMaterial(asked) }
CompositionLocalProvider(LocalWindowSizeClass provides sizeClass) {
Box(
modifier
.then(measured)
.onKeyEvent { event -> sidebarShortcuts.handle(event, platform) }
.background(host.table.windowFill(host.roots, theme)),
) {
Box(Modifier.padding(top = pageTop, bottom = pageBottom)) {
host.roots.forEach { rootId ->
androidx.compose.runtime.key(rootId) {
RenderNode(rootId, host.table, host)
}
}
}
// The window's own buttons, over everything, because the caption strip is
// the window's and whatever the Host drew runs underneath it. Nothing is
// drawn where the platform draws its own: macOS keeps the system's
// traffic lights, and the platform layer provides no actions there.
if (caption.height > 0.dp) {
WindowButtons(
style = captionStyle,
modifier = Modifier
.align(
if (captionStyle.side == CaptionSide.Start) {
Alignment.TopStart
} else {
Alignment.TopEnd
},
)
.height(caption.height),
)
}
// Over the content rather than in it: a message is not part of the tree,
// and it covers whatever it has to for as long as it is up.
HostMessages(host.table.messages, host.table.insets, host, theme)
}
}
}
}
/**
* Overrides the platform's dark mode reading, for tests and for the dev harness.
*
* `ColorScheme.FollowSystem` reads the platform; nothing else consults this.
*/
var systemDarkOverride: Boolean? = null
/**
* Overrides which platform this is, for tests and for the dev harness.
*
* A design system is allowed to answer differently on two platforms that draw the same
* language, so a test about one of those answers has to be able to say which platform it
* means. Without this it gets the machine the test happens to be running on, which for a
* test about a phone's tab bar is whatever laptop is in front of you.
*/
var hostPlatformOverride: HostPlatform? = null
/**
* How to find out whether the system is in dark mode, when the platform knows better than
* Compose does.
*
* Installed by the platform at startup. The desktop installs a poller, because Compose's
* own reading there is taken once and never revisited. iOS and Android leave it null,
* where Compose observes the change itself and a second mechanism would be noise.
*
* It lives here rather than in the desktop module because this file is compiled for every
* target: the iOS renderer symlinks it, and Skiko, which the desktop observer reads, does
* not exist on Kotlin/Native.
*
* It is a CompositionLocal rather than a global, and that is not a matter of taste. The
* desktop observer polls in a loop that never finishes, which is correct in a window and
* fatal under a test clock: a composition with a coroutine forever waiting on a delay never
* goes idle, so waitForIdle spins until the test times out. As a global, one composition
* installing it silently did that to every composition created afterwards in the same
* process, including tests that had nothing to do with it.
*/
val LocalSystemDarkObserver = staticCompositionLocalOf<(@Composable () -> Boolean)?> { null }
/**
* The strip at the top of a window that belongs to the window rather than to the
* application: on macOS the transparent title bar the close, minimise and zoom buttons
* sit in, and on the platforms where the renderer draws the caption itself, the band it
* draws it in.
*
* Content is allowed to run underneath it, which is the whole point of modern window
* chrome, but a widget placed where the buttons are would leave both unusable. So
* whatever owns the top of the window steps its own content clear of the strip while
* still painting across it.
*
* The Host never sees these numbers and cannot set them. A safe area is a fact about the
* window, not a decision the application makes.
*/
@androidx.compose.runtime.Immutable
data class WindowCaption(
/** How tall the strip is. */
val height: Dp = 0.dp,
/** How much room the window buttons take at the end they sit at. */
val buttonsWidth: Dp = 0.dp,
/**
* Which end that is.
*
* The platform's own buttons are at the leading edge, because the one platform that
* keeps its own puts them there. Buttons the renderer draws sit wherever the running
* design system puts them, which is the trailing edge for five of the seven, and a
* bar that reserved room at the wrong end would push its title away from the buttons
* and then draw them over its other end.
*/
val buttonsAtStart: Boolean = true,
/**
* A strip directly above the bar that the bar's surface covers and its content starts
* below.
*
* A phone's status bar, which is a different shape of problem from [height]: window
* buttons share the bar's row and a clock does not, so a bar that treated the two the
* same would draw its title through the time.
*/
val insetTop: Dp = 0.dp,
) {
companion object {
/** No strip to avoid: a system title bar, or a platform without one. */
val None = WindowCaption()
}
}
/**
* The caption the top app bar has to lay itself out around.
*
* Zero unless the Host's tree opens with a bar. Anything else keeps the caption on the
* page, and a bar buried deeper in the tree is not at the top of the window, so stepping
* its content down by the height of a strip it is nowhere near would only push it out of
* line with everything beside it.
*/
val LocalWindowCaption = staticCompositionLocalOf { WindowCaption.None }
/**
* Whether a rail or a sidebar at the root of the tree runs to the top of the window and
* carries the caption on its own surface.
*
* Separate from [LocalWindowCaption], which a bar opening the tree also receives. A strip
* that stepped its destinations down by the caption whenever one was handed out would
* step them down under a bar that had already taken it, and leave a blank band beside it.
*/
val LocalStripTakesTheTop = staticCompositionLocalOf { false }
/** The child of a frame that fills [slot], if the application filled it. */
private fun NodeTable.slotChild(node: Node, slot: SlotRole): Int? =
node.children.firstOrNull { childId ->
val child = node(childId) ?: return@firstOrNull false
child.widget == WidgetKind.ScaffoldSlot &&
(child.property(PropertyKind.Slot) as? PropertyValue.Integer)?.value?.toInt() ==
slot.ordinal + 1
}
/**
* True when the first thing the tree draws is a top app bar.
*
* Only the leading edge is followed, and only through the layouts that are wrappers
* rather than things on screen: an application writes `Column { TopAppBar { } ... }`, and
* the column is not something the reader sees. A bar reached any other way is not the top
* of the window.
*/
internal fun NodeTable.opensWithABar(roots: List<Int>): Boolean {
var id = roots.firstOrNull() ?: return false
repeat(BAR_SEARCH_DEPTH) {
val node = node(id) ?: return false
when (node.widget) {
WidgetKind.TopAppBar -> return true
// A picture at the top of the window is the same case as a bar: it is what
// the reader sees across the top, and leaving a strip of page colour above it
// reads as a title bar nobody asked for.
WidgetKind.Image -> return true
// A Column stacks its children, so its first child is the top of the window.
// A Box stacks them front to back, and a bar drawn first is chrome the rest
// of the screen scrolls under, which is the same thing here.
//
// A wrapper is passed through whether or not it paints a background. Its
// colour is the page's, and the page is exactly what should start below the
// window buttons rather than run under them; what it holds decides instead.
WidgetKind.Column, WidgetKind.Box -> id = node.children.firstOrNull() ?: return false
// A frame names its parts, so the top of the window is the slot that says it
// is the top bar rather than whichever child happens to come first. Following
// the order instead would hand the caption to the destinations on a screen
// that filled the bottom slot and not the top one.
WidgetKind.Scaffold -> id = slotChild(node, SlotRole.TopBar) ?: return false
WidgetKind.ScaffoldSlot -> id = node.children.firstOrNull() ?: return false
// Nothing else takes it. A shell that paints itself, a Navigation holding a
// whole page for instance, is the page rather than a strip across the top of
// it, and handing it the caption puts its first line of text under the window
// buttons. What its colour should do is fill the window, which is a separate
// question answered by windowFill below.
else -> return false
}
}
return false
}
/**
* True when a navigation holding the whole page is what the tree opens with.
*
* The one that owns the window, rather than one nested inside part of a screen. Only that
* one draws a bar along the bottom edge of the window, and only that bar should grow into
* the strip the system's gesture bar sits in: a nested one has content below it and would
* leave a gap in the middle of the screen.
*/
internal fun NodeTable.opensWithANavigation(roots: List<Int>): Boolean {
var id = roots.firstOrNull() ?: return false
repeat(BAR_SEARCH_DEPTH) {
val node = node(id) ?: return false
when (node.widget) {
WidgetKind.Navigation -> return true
// The same wrappers the search above passes through, for the same reason:
// they are not things the reader sees.
WidgetKind.Column, WidgetKind.Box -> id = node.children.firstOrNull() ?: return false
// A frame's destinations are whatever fills its bottom slot, and the frame
// owns the page around them, which is what this question is really asking.
WidgetKind.Scaffold -> id = slotChild(node, SlotRole.BottomBar) ?: return false
WidgetKind.ScaffoldSlot -> id = node.children.firstOrNull() ?: return false
else -> return false
}
}
return false
}
/**
* The colour the whole window is painted, before the content is laid out inside it.
*
* The root's own fill where it named one, and the theme's background where it did not.
* Painting the theme's background regardless leaves an application that holds its own
* palette with a page in its colour and a strip along the top in the design system's,
* which reads as a leftover title bar. This is the rule a container already follows, that
* the fill the application named wins, applied to the window.
*/
internal fun NodeTable.windowFill(roots: List<Int>, theme: ResolvedTheme): Color {
val named = paintAtTheTopOfTheWindow(roots)
val fill = named?.let(theme::color) ?: theme.color(ColorRole.Background)
return if (asksForWindowMaterial(roots)) fill.letTheWindowShowThrough() else fill
}
/**
* The colour of whatever reaches the top of the window, or null where nothing named one.
*
* The root is asked first and then the leading edge below it, because the thing that fills
* a window is not always the root: an application writes a frame and puts a page in it, so
* what paints is a step or two down and nothing above it names a colour at all. Reading
* only the root left the strip above the page in the design system's background while the
* page under it was the application's own, which is the leftover title bar this section
* exists to avoid.
*/
private fun NodeTable.paintAtTheTopOfTheWindow(roots: List<Int>): Paint? {
var id = roots.firstOrNull() ?: return null
var atTheRoot = true
repeat(BAR_SEARCH_DEPTH) {
val node = node(id) ?: return null
val painted = node.modifiers.firstNotNullOfOrNull {
(it as? ProtocolModifier.Background)?.paint
}
when (node.widget) {
// Chrome, not the window. A bar or a picture takes the caption and paints that
// strip itself, so lending the window its colour would paint every screen that
// opens with one in the chrome's colour.
WidgetKind.TopAppBar, WidgetKind.Image -> return null
// A shell holds the whole page, so the colour it paints is the colour the
// window is.
WidgetKind.Navigation -> return painted
// A frame paints nothing itself and names its parts, so the window's colour is
// the content's rather than the bar's.
WidgetKind.Scaffold -> {
painted?.let { return it }
atTheRoot = false
id = slotChild(node, SlotRole.Content) ?: return null
}
WidgetKind.ScaffoldSlot -> {
painted?.let { return it }
atTheRoot = false
id = node.children.firstOrNull() ?: return null
}
// The same wrappers the caption search passes through, and for the same
// reason: they are not things the reader sees. Their own colour counts only at
// the root, where it is the page's; deeper down a painted Column is a card or
// a bubble, and painting the window in a bubble's colour hides the rounding
// that makes it one.
WidgetKind.Column, WidgetKind.Box -> {
if (atTheRoot && painted != null) return painted
atTheRoot = false
id = node.children.firstOrNull() ?: return null
}
else -> return if (atTheRoot) painted else null
}
}
return null
}
/**
* Whether anything in this tree asked to be made of a material.
*
* Anything, not the page. A window that shows what is behind it is not a window that is
* see-through all over: no application on this platform is. A sidebar is vibrant and the
* page beside it is not, and that is decided where the material is drawn rather than
* here. What is decided here is only whether the window is stood up in a form that has
* anything behind it to show, which is a property of the window and has to be settled
* before it opens.
*
* A page that paints itself still covers what is behind it, exactly as it always did.
*/
internal fun NodeTable.asksForWindowMaterial(roots: List<Int>): Boolean {
// The roots are taken so that the question is asked of a tree rather than of the
// table in the abstract, and so a caller cannot forget to check that one exists.
if (roots.isEmpty()) return false
return asksForMaterial
}
/**
* The page of a window that is backed by a material: none of it.
*
* The material behind the window is the page. Painting one over it is painting over the
* thing that was asked for, and it does not take much: a page at three quarters opacity
* left four percent of the backdrop showing, which measures as a couple of levels and
* reads as a flat dark rectangle.
*
* What the screen declared is still drawn. The root carries the material role, and the
* design system's own glass is drawn for it in the ordinary way, over a window that now
* has something real behind it to be glass over.
*/
private fun Color.letTheWindowShowThrough(): Color =
if (platformBacksWindowWithMaterial()) Color.Transparent else this
/**
* True where the window really has a material behind it for a translucent page to reveal.
*
* A hook with `false` for its default, because only the build that puts the material there
* can answer and the others have to be safe. It used to be read off the operating system's
* name, which is true of every macOS build and describes only one of them: the native
* image, whose C entry puts an `NSVisualEffectView` under the window. The Kotlin/Native
* renderer opens its own window and puts nothing behind it, and it was answering yes.
*
* What a wrong yes costs is the whole look of the window. Chrome becomes the recipe meant
* to sit on the desktop, a tint at 0.18 with a rim, and the page gradient is made
* translucent so the desktop can reach it. With nothing behind either, a window in light
* mode comes up with a grey page and a grey sidebar: nothing transparent, everything drawn
* as though it were.
*
* Everywhere else a page with alpha would show whatever the toolkit leaves behind a
* window, which is nothing to look at and on some systems is black.
*/
var platformBacksWindowWithMaterial: () -> Boolean = { false }
/**
* How many wrappers deep the search for that bar goes.
*
* Deep enough for the layouts an application really writes around a bar, shallow enough
* that a tree which simply does not have one costs a handful of lookups per frame rather
* than a walk.
*/
private const val BAR_SEARCH_DEPTH = 4
/**
* Whether the reader has asked the system for reduced transparency.
*
* Every glass surface draws its opaque fallback instead when this is true, and the blur
* pass that fed it disappears with it, so the setting removes the cost as well as the
* look.
*
* It is read once, from `DXC_REDUCE_TRANSPARENCY` in the environment or the
* `dev.darkpyonix.composerust.reduceTransparency` system property, either of which counts as set when
* it is anything other than "0" or "false". A platform that can query the accessibility
* setting directly assigns to this at startup instead; a platform that cannot leaves the
* reader with a way to say so, which is better than no way at all.
*/
var reduceTransparency: Boolean = run {
val raw = System.getenv("DXC_REDUCE_TRANSPARENCY")
?: System.getProperty("dev.darkpyonix.composerust.reduceTransparency")
?: return@run false
!raw.equals("0", ignoreCase = true) && !raw.equals("false", ignoreCase = true)
}
/**
* The strips of the window the system draws its own things in.
*
* A phone's status bar and its navigation bar, and the home indicator on the platforms
* that have one. Zero on a desktop, where the window is the whole of what the application
* draws in and a title bar is the [WindowCaption] above instead.
*
* This is deliberately not applied as padding around everything. An application that stops
* short of both strips leaves the system's own background showing through them, which is
* the grey band under a phone's gesture bar that no other application on the device has.
* The window's fill is drawn edge to edge and then these are handed to whichever part of
* the tree should grow into them.
*/
data class SystemBars(val top: Dp = 0.dp, val bottom: Dp = 0.dp) {
companion object {
/** A window with nothing over it. */
val None = SystemBars()
}
}
/** What the bottom strip is, for the part of the tree that grows into it. */
val LocalSystemBars = staticCompositionLocalOf { SystemBars.None }
/**
* How far the page has to start and stop short of the edges of the window.
*
* Whatever a strip is given to is not the page's concern, which is what the two booleans
* say. A bar that opens the tree takes the top strip and paints its own surface behind the
* status bar, the way every application on a phone does. A navigation showing a bar along
* the bottom takes the bottom strip for the same reason. What is left over is the page's,
* because content running under a clock or a gesture bar is unreadable.
*/
internal fun pageInsets(
caption: WindowCaption,
bars: SystemBars,
barTakesTheTop: Boolean,
navigationTakesTheBottom: Boolean,
): Pair<Dp, Dp> {
// Never below nothing, whatever a platform measured. This is a padding, and a negative
// one does not draw oddly: it throws, from inside composition, and takes the window
// with it.
val top = if (barTakesTheTop) 0.dp else (caption.height + bars.top).coerceAtLeast(0.dp)
val bottom = if (navigationTakesTheBottom) 0.dp else bars.bottom
return top to bottom
}