Skip to content

Components ​

Since 2.2.44

Every widget registered in LDLib2's ldlib2:ui_element registry has a composable wrapper. The wrappers are thin calls to UIElementNode, which also works for widgets that have no wrapper, including other mods' widgets.

Widget table ​

LDLib2 elementComposable
UIElementBox, Column, Row, Spacer
TextElement, LabelText, Label
ButtonButton
TextField, TextArea, CodeEditorTextField, TextArea, CodeEditor
Switch, Toggle, ToggleGroupElementSwitch, Toggle, ToggleGroup
Slider, ScrollerSlider, SliderVertical, ScrollerHorizontal, ScrollerVertical
Selector, ColorSelector, SearchComponentSelector, ColorSelector, SearchComponent
TagField, StructuredTagEditorTagField, StructuredTagEditor
ItemSlot, FluidSlot, InventorySlotsItemSlot, FluidSlot, InventorySlots
ProgressBarProgressBar
ScrollerViewScrollView
SplitViewSplitViewHorizontal, SplitViewVertical
TabView, TabTabView { tab(...) }
TreeList, Inspector, Scene, GraphViewTreeList, Inspector, Scene, GraphView
UITemplateElementUITemplate
BindableValueBindableValue (mirrors a value only; it cannot carry a synced binding)
VirtualScrollerViewNot wrapped. Use LazyColumn, LazyRow or LazyVerticalGrid.

Dialog is not a registered element but has a wrapper too.

Controlled widgets ​

Widgets that hold a value are controlled: the composition owns the value, and the widget reports edits back.

kotlin
var name by remember { mutableStateOf("") }
TextField(value = name, onValueChange = { name = it })

After each edit, the widget compares itself with the composed value and writes it back if they differ. So:

  • To refuse an edit, do not store it. onValueChange = { if (it.length <= 5) name = it } is a field limited to five characters.
  • To make a widget read-only, ignore the callback.
ComposableValue parameters
TextField, Slider, SliderVertical, ScrollerHorizontal, ScrollerVertical, BindableValuevalue, onValueChange
Switch, Togglechecked, onCheckedChange
Selector, SearchComponentselected, onSelectedChange
TreeListselected: Set<N>, onSelectedChange
ItemSlotitem, onItemChange (defaults to ignoring edits)
FluidSlotfluid, onFluidChange (defaults to ignoring edits)
ColorSelectorcolor, onColorChange
TagField, StructuredTagEditortag: Tag?, onTagChange
TextArea, CodeEditorlines: List<String>, onLinesChange

Details worth knowing:

  • Writes from the composition are not reported as edits. A slider re-clamped by a new range, or a toggle group checking a neighbour after its toggle was removed, does not call onValueChange. If that leaves the widget and the state disagreeing, the widget is set back to the state.
  • Configuration is applied before the value. Slider(50f, onValueChange, range = 0f..100f) starts at 50; it is not first clamped to the default 0f..1f.
  • The caret survives corrections. TextField, TextArea and CodeEditor keep the caret where it was when a value is written back.
  • Mutable values are reported as copies. ItemSlot, FluidSlot, TagField and StructuredTagEditor report a copy, so the next edit cannot change what you stored. ItemStack and FluidStack are compared by content.

TextField has three ways to restrict input:

OptionBehaviour
charFilterRefuses a typed character before it lands. Pasted text containing a refused character is treated as invalid.
validatorJudges the whole text. Invalid text stays in the field, shown as an error, and is not reported.
Not storing in onValueChangeThe field returns to the stored value on the next frame. Use it for corrections, such as clamping a number.

Menu slots and server-side search

A composition only runs on the client, so a widget that has to exist on the server cannot be composed:

  • InventorySlots registers menu slots. The composable version only works in a screen that is not a menu, where it shows the player's inventory read-only. Used in a menu, it logs an error and stays unbound.
  • An ItemSlot that takes part in menu slot sync, or a SearchComponent that searches on the server, has the same problem.

Build such widgets in createUI and add them to the tree there, outside the ComposeElement. Do not use Mount for them either: it also runs on the client only.

The composed FluidSlot does not register the click-to-fill RPC, so clicking it with a fluid container does nothing.

Containers ​

ScrollView. layout sizes the scroll view itself. Spacing between children (gap, padding, direction) goes in contentLayout; in layout it would push the scrollbars instead.

kotlin
ScrollView(layout = { height(80.px) }, contentLayout = { gap { all(2.px) } }) {
    rows.forEach { row -> key(row.id) { RowItem(row) } }
}

Dialog. A dialog shown over the whole UI:

kotlin
Dialog(
    visible = confirming,
    onDismiss = { confirming = false },
    title = { Text("Delete it?") },
    buttons = { Button(onClick = { delete(); confirming = false }, text = "Delete") },
) {
    Text("This cannot be undone.")
}
  • A dialog visible on the first composition opens on the first frame after its UI exists.
  • The title, buttons and content slots are only composed while the dialog shows.
  • The player can close it with Escape (dismissOnEscape, default true) or a click outside (dismissOnClickOutside, default false). With either enabled, focus moving outside the dialog also closes it. Each of these calls onDismiss.
  • onDismiss is not called when the dialog closes because visible turned false.

Escape and dialogs

Escape only reaches a dialog when its ModularUI is set to shouldCloseOnEsc(false). Otherwise Escape closes the whole screen first.

TabView.

kotlin
var current: Any by remember { mutableStateOf("general") }
TabView(selectedKey = current, onSelect = { current = it }) {
    tab("general", "General") { GeneralPage() }
    tab("advanced", "Advanced") { AdvancedPage() }
}
  • Tab headers follow the declared order. Reordering the tab(...) calls moves the existing tabs; selection and each page's remembered state are kept.
  • Tab keys must be unique. A repeated key throws IllegalArgumentException.
  • selectedKey = null lets the widget manage its own selection.
  • onSelect is called when the player picks a tab, and once when the selected tab is removed and selectedKey does not name a remaining tab. It is not called for selections the composable makes itself.

SplitViewHorizontal / SplitViewVertical. Two slots (left/right or top/bottom). initialPercentage only sets where the divider starts; the widget reports no drag.

ToggleGroup. At most one of the Toggles placed directly inside it is on. Drive the toggles from one piece of state, as in checked = selected == "a". allowEmpty = true allows unchecking the last one.

Wrapping any element ​

UIElementNode emits any UIElement subclass:

kotlin
UIElementNode(
    factory = { TreeList<TreeNode<String, String>>().setFlattenRoot(true) },
    layout = { size(240.px, 60.px) },
    update = { set(depth) { setRoot(root) } },
)
ParameterUse
factoryBuilds the element. Runs once, when the node is created.
updateRuns on each recomposition. Wrap each property in set(value) { ... } so it only runs when that value changed.
widgetStyleA block whose receiver is the element itself, run after layout and style.
layout, style, classes, id, enabled, visible, focusable, eventsAs for every composable. See Styling and Events.
contentComposable children.

set(value) compares with equals

set(value) only runs when value is not equals to the last one. The example above uses set(depth), not set(root), because TreeNode.equals compares the key and content only. Two different trees with the same root would be "equal", and set(root) would never run. Key on what actually changes, or use reconcile { } to run on every recomposition.

Mount(element) places an element that already exists, built by hand or with the DSL, into the composition. The composition attaches, moves and detaches it but never touches its properties. Pass a stable element (from remember); a new instance on each recomposition replaces the old one each time.

Subcompose(container) { ... } composes content into another element rather than where the call sits. Use it for widgets with named slots, such as the two panes of a SplitView. It is a real subcomposition: CompositionLocals reach it, it recomposes on the same frames, and it is disposed with its parent.

kotlin
val view = remember { SplitView.Horizontal() }
UIElementNode(factory = { view }, layout = { size(200.px, 100.px) })
Subcompose(view.first) { LeftPane() }
Subcompose(view.second) { RightPane() }

LocalUIElement.current returns the ComposeElement hosting the composition, which gives access to its ModularUI and player.

Released under the MIT License.