compose-rust 0.0.1

Declarative UI in Rust, rendered by an AOT-compiled Compose Multiplatform engine.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
package dev.darkpyonix.composerust.ui.node

import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.BasicText
import androidx.compose.foundation.verticalScroll
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.key
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import dev.darkpyonix.composerust.protocol.PropertyKind
import dev.darkpyonix.composerust.protocol.TypeRole
import dev.darkpyonix.composerust.protocol.WidgetKind
import dev.darkpyonix.composerust.design.ContainerRole
import dev.darkpyonix.composerust.design.ToggleRole
import dev.darkpyonix.composerust.design.LocalDesignTheme
import dev.darkpyonix.composerust.design.LocalGlassDepth
import dev.darkpyonix.composerust.design.SurfaceMaterial
import dev.darkpyonix.composerust.protocol.Modifier as ProtocolModifier
import dev.darkpyonix.composerust.design.ResolvedTheme
import dev.darkpyonix.composerust.foundation.FloatingActionArea
import dev.darkpyonix.composerust.foundation.HostBadge
import dev.darkpyonix.composerust.foundation.HostButton
import dev.darkpyonix.composerust.foundation.HostChip
import dev.darkpyonix.composerust.foundation.HostFloatingAction
import dev.darkpyonix.composerust.foundation.HostLazyGrid
import dev.darkpyonix.composerust.foundation.HostRichText
import dev.darkpyonix.composerust.foundation.HostScaffold
import dev.darkpyonix.composerust.foundation.HostContainerColumn
import dev.darkpyonix.composerust.foundation.HostDialog
import dev.darkpyonix.composerust.foundation.HostDivider
import dev.darkpyonix.composerust.foundation.HostProgressIndicator
import dev.darkpyonix.composerust.foundation.HostSlider
import dev.darkpyonix.composerust.foundation.HostSplitPane
import dev.darkpyonix.composerust.foundation.HostToggle
import dev.darkpyonix.composerust.foundation.HostCanvas
import dev.darkpyonix.composerust.foundation.HostDatePicker
import dev.darkpyonix.composerust.foundation.HostDropdown
import dev.darkpyonix.composerust.foundation.HostIcon
import dev.darkpyonix.composerust.foundation.HostImage
import dev.darkpyonix.composerust.foundation.HostTimePicker
import dev.darkpyonix.composerust.foundation.HostLazyColumn
import dev.darkpyonix.composerust.foundation.HostLazyRow
import dev.darkpyonix.composerust.foundation.HostMenu
import dev.darkpyonix.composerust.foundation.HostNavigation
import dev.darkpyonix.composerust.foundation.HostNavigationItem
import dev.darkpyonix.composerust.foundation.HostSheet
import dev.darkpyonix.composerust.foundation.HostTabs
import dev.darkpyonix.composerust.foundation.HostTooltip
import dev.darkpyonix.composerust.foundation.HostTopAppBar
import dev.darkpyonix.composerust.foundation.HostTextField
import dev.darkpyonix.composerust.foundation.SelectableRegion
import dev.darkpyonix.composerust.foundation.Unselectable
import dev.darkpyonix.composerust.foundation.hostKeyEvents
import dev.darkpyonix.composerust.runtime.EventDispatcher
import dev.darkpyonix.composerust.ui.boxAlignment
import dev.darkpyonix.composerust.ui.horizontalAlignment
import dev.darkpyonix.composerust.ui.horizontalArrangement
import dev.darkpyonix.composerust.ui.maxLines
import dev.darkpyonix.composerust.ui.overflow
import dev.darkpyonix.composerust.ui.textStyle
import dev.darkpyonix.composerust.ui.toComposeModifier
import dev.darkpyonix.composerust.ui.verticalAlignment
import dev.darkpyonix.composerust.ui.verticalArrangement
import dev.darkpyonix.composerust.ui.weightOf

/**
 * Counts recompositions of interpreted nodes, so a test can assert that changing one node
 * does not recompose its siblings. Set only by tests.
 */
internal object RenderNodeObserver {
    var onCompose: ((Int) -> Unit)? = null
}

/** Test tag every interpreted node carries, so tests can address nodes by Host node id. */
fun nodeTestTag(nodeId: Int): String = "compose-node-$nodeId"

/**
 * Interprets one node of the table into real Compose widgets.
 *
 * Only the node's own state is read here, so a `SetProp` on a sibling cannot invalidate this
 * composable.
 *
 * `parentModifier` carries what only the parent layout can express, which today is the
 * `Weight` modifier: weight is parent data of a `Column` or `Row` scope and cannot be
 * produced by a modifier chain built outside that scope.
 */
@Composable
fun RenderNode(
    nodeId: Int,
    table: NodeTable,
    dispatcher: EventDispatcher,
    parentModifier: Modifier = Modifier,
) {
    val node = table.node(nodeId) ?: return
    // Observation point for the recomposition test; null in production.
    RenderNodeObserver.onCompose?.let { observer -> SideEffect { observer(nodeId) } }
    val theme = LocalDesignTheme.current
    val chain = parentModifier
        .then(node.modifiers.toComposeModifier(nodeId, dispatcher, theme))
        .testTag(nodeTestTag(nodeId))
    // The TextField wires its own key handling, because it has an editor to intercept and a
    // composition to protect; everything else routes keys here.
    val keyDownHandler = node.handler(PropertyKind.OnKeyDown)
    val withKeys = if (keyDownHandler == null || node.widget == WidgetKind.TextField) {
        chain
    } else {
        chain.hostKeyEvents(nodeId, keyDownHandler, dispatcher)
    }
    // Only where the node said it is a place files may be dropped. Everywhere else this
    // adds nothing to the chain and the platform shows no drop cursor.
    val modifier = platformFileDrop(withKeys, node, dispatcher)
    // A layout made of glass tells what is inside it so. A field in a glass bar is part of
    // the bar in the systems that draw glass, and it can only take that shape if it knows
    // what it is sitting on.
    val glass = node.isMadeOfGlass(theme)
    when (node.widget) {
        WidgetKind.Column -> Column(
            modifier = modifier,
            verticalArrangement = node.verticalArrangement(theme),
            horizontalAlignment = node.horizontalAlignment(),
        ) { OnGlass(glass) { Children(node, table, dispatcher) } }

        WidgetKind.Row -> Row(
            modifier = modifier,
            horizontalArrangement = node.horizontalArrangement(theme),
            verticalAlignment = node.verticalAlignment(),
        ) { OnGlass(glass) { Children(node, table, dispatcher) } }

        // A place files may be dropped is laid out as a plain box. Being this widget is
        // the whole of the difference: the drop target is set up for it and for nothing
        // else in the tree.
        WidgetKind.Box, WidgetKind.FileDropTarget ->
            Box(modifier, contentAlignment = node.boxAlignment()) {
            OnGlass(glass) { Children(node, table, dispatcher) }
        }

        // One widget whether or not it carries runs. A paragraph with a bold phrase in
        // it is one piece of text, and three widgets would wrap at the seams.
        WidgetKind.Text -> HostRichText(node, modifier, dispatcher, theme)

        WidgetKind.Spacer -> Spacer(modifier)
        // The controls are drawn through `Unselectable`, which leaves them out of a
        // selectable region around them: a label is pressed, not copied. Outside a region
        // it adds nothing.
        WidgetKind.Button -> Unselectable { HostButton(node, modifier, dispatcher, theme) }
        WidgetKind.TextField -> Unselectable { HostTextField(node, modifier, dispatcher) }
        WidgetKind.LazyColumn -> HostLazyColumn(node, modifier, table, dispatcher)

        // The same window as a list, rounded to whole rows because a row is what a grid
        // lays out. How many items a row holds is the grid's own answer where the screen
        // gave a minimum width instead of a count.
        WidgetKind.LazyGrid -> HostLazyGrid(node, modifier, table, dispatcher)
        // Drawing commands rather than children: the size comes from the modifier chain
        // and the commands are read from the node's one property.
        WidgetKind.Canvas -> HostCanvas(node, modifier, theme)
        // ScrollColumn is a column that scrolls without the Host windowing it, so every
        // child is materialised. Use LazyColumn when the list is long.
        //
        // LinearProgressIndicator is declared by an extension package rather than the core
        // schema, and is drawn like any built-in widget.
        WidgetKind.LinearProgressIndicator -> {
            val progress = node.number(PropertyKind.Progress)
            if (progress == null) {
                LinearProgressIndicator(modifier = modifier)
            } else {
                LinearProgressIndicator(progress = { progress }, modifier = modifier)
            }
        }
        WidgetKind.ScrollColumn -> Column(
            modifier = modifier.verticalScroll(rememberScrollState()),
            verticalArrangement = node.verticalArrangement(theme),
            horizontalAlignment = node.horizontalAlignment(),
        ) { Children(node, table, dispatcher) }

        // The same thing on its side: a row that scrolls without the Host windowing it,
        // so every child is materialised. Use LazyRow when the strip is long. The scroll
        // state is remembered here, under the node's own key, so a recomposition of the
        // row or of any child leaves the position where the user put it.
        WidgetKind.ScrollRow -> Row(
            modifier = modifier.horizontalScroll(rememberScrollState()),
            horizontalArrangement = node.horizontalArrangement(theme),
            verticalAlignment = node.verticalAlignment(),
        ) { Children(node, table, dispatcher) }

        // The containers, the overlays and the tab strip carry no appearance of their own:
        // each one names the kind of container it is and the design system decides what
        // that looks like.
        WidgetKind.Card ->
            HostContainerColumn(ContainerRole.Card, node, modifier, table, dispatcher, theme)

        WidgetKind.Surface ->
            HostContainerColumn(ContainerRole.Surface, node, modifier, table, dispatcher, theme)

        WidgetKind.TopAppBar -> HostTopAppBar(node, modifier, table, dispatcher, theme)
        WidgetKind.Dialog -> HostDialog(node, modifier, table, dispatcher, theme)
        WidgetKind.Menu -> HostMenu(node, modifier, table, dispatcher, theme)
        WidgetKind.Tabs -> Unselectable { HostTabs(node, modifier, table, dispatcher, theme) }
        WidgetKind.Tooltip -> HostTooltip(node, modifier, table, dispatcher, theme)
        WidgetKind.LazyRow -> HostLazyRow(node, modifier, table, dispatcher)

        // The selection controls and the indicators. Each one is drawn from the state it
        // was sent: the tick, the track, the thumb, the sweep and the weight of a rule are
        // the design system's, so the same declaration is a Material control here and a
        // Cupertino one there.
        WidgetKind.Checkbox -> Unselectable {
            HostToggle(ToggleRole.Checkbox, node, modifier, dispatcher, theme)
        }

        WidgetKind.RadioButton -> Unselectable {
            HostToggle(ToggleRole.RadioButton, node, modifier, dispatcher, theme)
        }

        WidgetKind.Switch -> Unselectable {
            HostToggle(ToggleRole.Switch, node, modifier, dispatcher, theme)
        }

        WidgetKind.Slider -> Unselectable { HostSlider(node, modifier, dispatcher, theme) }
        WidgetKind.ProgressIndicator -> HostProgressIndicator(node, modifier, theme)
        WidgetKind.Divider -> HostDivider(node, modifier, theme)

        // A picture is one registered id. The bytes were read when the Host registered
        // them, so what a frame carries is the id and a lookup.
        WidgetKind.Image -> HostImage(node, modifier, table.assets, dispatcher)
        WidgetKind.Icon -> HostIcon(node, modifier, table.assets, dispatcher, theme)

        // The pickers carry a value, a range and a change handler. Which way of picking the
        // user gets, a calendar grid, a wheel, a dial or a flyout, is the design system's
        // decision, and there is no property that could ask for one of them.
        WidgetKind.DatePicker -> Unselectable { HostDatePicker(node, modifier, dispatcher, theme) }
        WidgetKind.TimePicker -> Unselectable { HostTimePicker(node, modifier, dispatcher, theme) }
        WidgetKind.Dropdown ->
            Unselectable { HostDropdown(node, modifier, table, dispatcher, theme) }

        // One declaration, three presentations. Which one this is comes from the design
        // system, asked about the size class this window is in, so the Host that declared
        // it never learned how wide the window was.
        WidgetKind.Navigation -> HostNavigation(node, modifier, table, dispatcher, theme)

        // Normally drawn by the Navigation it belongs to, which knows whether it is the
        // selected one. On its own it is a destination nobody has chosen.
        WidgetKind.NavigationItem -> Unselectable { HostNavigationItem(node, modifier, theme) }

        // A temporary surface from an edge of the window. Which edge is this side's
        // decision, and so is everything about the drag that closes it.
        WidgetKind.Sheet -> HostSheet(node, modifier, table, dispatcher, theme)

        // The screen's frame. The application filled slots; what each one becomes here
        // comes from the design system and the width this window was measured at.
        WidgetKind.Scaffold -> HostScaffold(node, modifier, table, dispatcher, theme)

        // Drawn by the frame it belongs to, which is the only thing that knows which slot
        // this is. On its own it is a tree with a label nobody read.
        WidgetKind.ScaffoldSlot -> Column(modifier = modifier) {
            Children(node, table, dispatcher)
        }

        // A label, perhaps a glyph, and the chosen state exactly as the Host sent it. The
        // shape of the token is the design system's.
        WidgetKind.Chip -> HostChip(node, modifier, dispatcher, theme)

        // The one action a screen is about, in the form its design system gives it. Where
        // it goes is decided by the frame it is in, which asks the same rule.
        WidgetKind.FloatingAction -> HostFloatingAction(node, modifier, dispatcher, theme)

        // A count, a word or a dot, on its child or on its own. Where it sits and how a
        // large count is written are the design system's.
        WidgetKind.Badge -> HostBadge(node, modifier, table, dispatcher, theme)

        // Every Text inside, however deep, is one selection. The modifier goes on the
        // region rather than the column inside it, so a weight the parent gave it still
        // reaches the parent's scope.
        WidgetKind.SelectionContainer -> SelectableRegion(modifier) {
            Column { Children(node, table, dispatcher) }
        }

        // A side pane and a body. Whether they are side by side, laid over one another or
        // shown one at a time is the design system's answer for the split pane's own width,
        // and the drag on the divider never leaves this side until it is let go.
        WidgetKind.SplitPane -> HostSplitPane(node, modifier, table, dispatcher, theme)
    }
}

/**
 * How a node that takes files gets its drop target, filled in by the platform that has
 * one.
 *
 * A hook rather than a call, because setting up a drop target means reading the toolkit's
 * clipboard flavours and only the desktop has them. A phone has no notion of letting a
 * file go over a window, so it leaves this alone and the modifier chain is unchanged,
 * which is the same rule pointer hover follows.
 */
var platformFileDrop: @Composable (Modifier, Node, EventDispatcher) -> Modifier =
    { modifier, _, _ -> modifier }

/**
 * Whether the system has been asked to reduce motion, answered by the platform that can
 * be asked.
 *
 * A hook for the same reason as the one above: the setting lives in a different place on
 * each desktop and nowhere at all on a target that has no desktop. The Host is not
 * consulted and never learns the answer, because this is the user's setting about their
 * own machine rather than anything the application declared.
 *
 * Read only by a node that declared a motion role, so a screen that declares none never
 * asks the question.
 */
var platformReducedMotion: () -> Boolean = { false }

/**
 * Whether the person at this machine has asked the platform for high contrast colours:
 * Windows' contrast themes, macOS's increased contrast, GNOME's high contrast setting.
 *
 * A hook for the same reason as the one above. When it says yes, an application's palette
 * is set aside and the design system's own colours are drawn: those colours were chosen
 * by the person so they could see the screen, and a brand must not paint over them.
 *
 * Read when the theme is resolved. The desktop answers once and keeps the answer; a
 * target that does not install anything answers no.
 */
var platformHighContrast: () -> Boolean = { false }

/**
 * Tells the platform whether this window wants a material behind it.
 *
 * A hook because only one build can answer: the effect view is put behind the window by
 * the shared library's own C entry, which exists in the native image and not in the
 * development shell, and not at all on a platform whose windows are not ours. Whoever
 * cannot do it leaves this alone, and the window comes up the way it always did.
 */
var platformWindowMaterial: (Boolean) -> Unit = { }

/**
 * The axis a widget stacks its children along, which is the axis a child's `Weight` is a
 * share of: a weight under a Column is a share of the height, a weight under a Row a share
 * of the width.
 */
internal enum class StackingAxis { Vertical, Horizontal, None }

/**
 * How the node that hosts a child lays its children out, so a child can tell whether a
 * `Weight` of its own has already decided one of its sizes.
 *
 * Only the layouts that really apply a weight are named here. Anything else, a Box or a
 * widget that draws its children without a weight scope, leaves a weighted child both of
 * its axes free.
 */
internal fun NodeTable.stackingAxis(nodeId: Int): StackingAxis = when (node(nodeId)?.widget) {
    WidgetKind.Column,
    WidgetKind.ScrollColumn,
    WidgetKind.Card,
    WidgetKind.Surface,
    WidgetKind.SelectionContainer,
    -> StackingAxis.Vertical

    WidgetKind.Row,
    WidgetKind.ScrollRow,
    WidgetKind.TopAppBar,
    -> StackingAxis.Horizontal

    else -> StackingAxis.None
}

/** Whether this node asked for a material that the running design system draws as glass. */
private fun Node.isMadeOfGlass(theme: ResolvedTheme): Boolean = modifiers.any { value ->
    value is ProtocolModifier.Material &&
        theme.rules.material(value.role, theme) is SurfaceMaterial.Glass
}

/**
 * Runs [content] one glass layer deeper where [glass] is true, and unchanged otherwise.
 *
 * Unchanged is the ordinary case and costs nothing: no provider is composed for a node
 * that is not made of glass.
 */
@Composable
internal fun OnGlass(glass: Boolean, content: @Composable () -> Unit) {
    if (!glass) {
        content()
        return
    }
    CompositionLocalProvider(LocalGlassDepth provides LocalGlassDepth.current + 1, content = content)
}

/**
 * Children of a Column: weight is applied here because `Modifier.weight` exists only inside
 * `ColumnScope`. Every layout that stacks its children vertically draws them through this,
 * so a weighted child is given its share wherever it is hosted and not only under a Column.
 */
@Composable
internal fun ColumnScope.Children(node: Node, table: NodeTable, dispatcher: EventDispatcher) {
    // `key` keeps each child's state attached to its Host node id across Move mutations.
    node.children.forEach { childId -> key(childId) { WeightedChild(childId, table, dispatcher) } }
}

@Composable
private fun ColumnScope.WeightedChild(childId: Int, table: NodeTable, dispatcher: EventDispatcher) {
    // Its own composable, so reading the child's modifier list subscribes this scope alone
    // and a `SetModifier` on one child cannot invalidate its siblings.
    val weight = table.node(childId)?.modifiers?.weightOf()
    RenderNode(
        childId,
        table,
        dispatcher,
        if (weight == null) Modifier else Modifier.weight(weight),
    )
}

@Composable
private fun RowScope.Children(node: Node, table: NodeTable, dispatcher: EventDispatcher) {
    node.children.forEach { childId -> key(childId) { WeightedChild(childId, table, dispatcher) } }
}

@Composable
private fun RowScope.WeightedChild(childId: Int, table: NodeTable, dispatcher: EventDispatcher) {
    val weight = table.node(childId)?.modifiers?.weightOf()
    RenderNode(
        childId,
        table,
        dispatcher,
        if (weight == null) Modifier else Modifier.weight(weight),
    )
}

/**
 * A Box has no weight axis, so its children are drawn as they are.
 *
 * Except the one action a screen is about. A Box is how a screen holds its page, so an
 * action declared in one belongs to that page and is laid over it, at the corner the design
 * system names, without taking any of the room the page is laid out in.
 */
@Composable
private fun BoxScope.Children(node: Node, table: NodeTable, dispatcher: EventDispatcher) {
    node.children.forEach { childId ->
        key(childId) {
            if (table.node(childId)?.widget == WidgetKind.FloatingAction) {
                FloatingActionArea(LocalDesignTheme.current) { RenderNode(childId, table, dispatcher) }
            } else {
                RenderNode(childId, table, dispatcher)
            }
        }
    }
}

/** The label of a Button follows the design system's button type role unless overridden. */
internal fun Node.buttonTextStyle(theme: ResolvedTheme, role: TypeRole) = textStyle(theme, role)