Skip to content

Widgets

Shaun Wild edited this page Sep 16, 2026 · 18 revisions

Widgets

Everything that ships in composegl-ui: the ordinary interface controls. The tier of things only games need — bars, a reticle, a hotbar — is in composegl-game, on Game widgets.

None of it is Material. There is no theme to fight, and every widget takes its look from a skin file rather than from code.


Text

Text("HULL INTEGRITY")
Text("148", colour = Colour.rgb(0xE5484D))
Text(story, softWrap = true, maxLines = 3, ellipsis = "")
Text(name, Modifier.weight(1f), align = HorizontalAlignment.End)
Text(trackName, Modifier.width(160f).marquee())   // too long? it scrolls round

A name too long for a fixed slot can scroll instead of wrapping or ellipsising: see Marquee in Animation.

What was measured is what is drawn — the layout object the font produced at measure time is the object handed to the canvas, so text never wraps differently from the space reserved for it.

style picks a named style from the skin ("label" by default):

Text("GAME OVER", style = "display")

four text styles: a title, the default, a dim one and a wrapped paragraph

Where the text sits

A text node is placed by its line box, whose top is the tallest glyph's ascent. That is right for laying out — a box stopping at the capitals would clip the accent off Á — but it is not what most coordinates mean. If you are porting a layout from an immediate-mode or batch renderer, every y you carry across is a cap top, and handed straight to Text every figure draws ascent - capHeight low:

Text("148", Modifier.offset(y = capTop), anchor = TextAnchor.CapTop)
Text("HP", Modifier.offset(y = baseline), anchor = TextAnchor.Baseline)

Worth doing rather than subtracting yourself, because the mistake is silent — nothing clips and nothing overflows, the text is just low and looks deliberate — and it is proportional to the font size, so a screen with three text sizes is wrong by three different amounts and reads as three separate layout problems.

The anchor moves the node, not the glyphs inside it: same size, same wrapping, and the background, border and clicks move with it. Your own offset still adds on top. If you only want the number, FontMetrics.capInset is the line box top to cap top, and ascent is the baseline.

TextAnchor.Baseline is also how you line a label up with an icon, or two strings at different sizes against each other.

To see where those lines are, turn on TextMetricsOverlay: line box, ascent, cap height, baseline and descent, in five colours, through every label, text field, typewriter, tooltip and prompt glyph. See Debugging.

Styled runs: an underlined term, a struck word, a value in colour

A Text draws one string in one style. When part of a sentence has to look different — a term the reader can tap for an explanation, a word struck through because it no longer applies, a number in another colour — say which characters:

val term = TextRange(4, 12)
Text(
    "The tincture wears off at dawn.",
    runs = listOf(
        TextRun(term, colour = Colour.Orange, decoration = TextDecoration.Underline, tag = "tincture"),
    ),
    onRunHover = { highlight(it?.tag) },
    onRunClick = { explain(it.tag as String) },
)

A TextRun is a range, a colour, a decoration (Underline or Strike) and a tag of your own that comes back when the pointer is over it. Runs may overlap; the later one wins for whichever of colour and decoration it names. A run cannot change the family or the size, on purpose: a run that changes the size changes the line height, and a paragraph whose lines are different heights is a much bigger problem than an underlined term.

That is the whole of what you write. The toolkit keeps ownership of measuring and line breaking, so the sentence stays one node — no splitting it into one node per word, no hit region per word, no line-breaking rules of your own to keep in step with everybody else's.

Two things change underneath, and both are worth knowing:

  • Lines are aligned as well as the block. Ordinary Text hands the string to the backend and never sees where it wrapped, so align can only centre the block and leave the lines ragged inside it. A run-styled Text breaks the lines itself, so it centres them too.
  • It costs more. Text is measured per line and per run boundary rather than once. Everything is cached on the text, the style and the width, so a paragraph standing still measures nothing — but a plain label should stay plain.

Text size, separate from the interface scale

The Viewport scales the whole interface. A player who cannot read the words does not want bigger panels, bigger icons and bigger gaps too — that spends the screen on what was already big enough. So text has its own setting:

ProvideTextScale(settings.textScale) { Game() }

Everything inside draws its text that many times the size its style says, and everything sized by its contents grows to fit: a button round its label, a tooltip, a row, a field's height. Anything you gave a fixed size keeps it, and text in it wraps sooner. Icons, bars and padding do not move.

text at 100%, 125% and 150%: the labels and the button grow, the panel and the icon do not

  • Every text widget follows it: Text, Typewriter, TextField, Tooltip, PromptGlyph, and from Game widgets DamageNumberLayer and MinimapFrame's compass, and so everything built from them, like Button and Stepper. A textStyle you pass by hand is scaled too.
  • The one exception is Subtitles, which uses its own size preset instead. Subtitle size is its own row in its own menu, so a player who set the interface to 150% and subtitles to Medium gets Medium subtitles. See Subtitles and captions.
  • Nested, they multiply. A dense panel that asks for 0.85f inside a player's 1.5f draws at 1.275, so it still honours the setting.
  • Changing it re-measures. It is meant to move when a slider in a settings menu does, not every frame.
  • Sizes are rounded to whole numbers, so 16 at 110% is 18, not 17.6. That is what keeps it sharp: the text is measured and baked at the bigger size rather than measured small and stretched.

The one thing to do in return is register the sizes. A backend bakes fonts at startup and refuses a size it has never seen, so give it every size at every scale your setting offers:

val sizes = scaledTextSizes(listOf(13, 16, 20), listOf(1f, 1.25f, 1.5f))
fonts.registerTrueType("body", Gdx.files.internal("fonts/body.ttf"), sizes)

Forget one and the error names the size it wanted and the sizes it has.

Characters your font does not have

A display font rarely has Chinese, Japanese, Korean or emoji, and player names and chat have all of them. Name the fonts to borrow from, in order:

fonts.registerTrueType("body", Gdx.files.internal("fonts/body.ttf"), sizes)
fonts.registerTrueType("cjk", Gdx.files.internal("fonts/NotoSansCJK.ttf"), sizes, onDemand = true)
fonts.registerPictures("emoji", mapOf("😀" to smiley, "👍" to thumbsUp), sizes)

fonts.fallBackTo(listOf("cjk", "emoji"))

a chat panel mixing English, Chinese, Japanese, Korean and emoji in one skin

  • One character at a time. "Ace 玩家 😀" takes its letters from body, its Chinese from cjk and the smiley from emoji. Your font always wins for a character it has; only a character nothing has comes out as a box.
  • It sits on your font's line. Borrowed glyphs are moved onto your font's baseline, and the line height is still yours, so a label does not jump when a name with 한글 in it arrives.
  • onDemand = true makes a character the first time text uses it, instead of baking the whole font at startup — a CJK font has tens of thousands. The atlas takes another page when one fills.
  • Emoji are pictures. Register one per character, from any emoji set. They are drawn in their own colours whatever colour the text is, and an outline rings the letters but not the pictures.
  • Every size, again. A fallback must be registered at every size it is asked for, text scale included, or the error names the size it wanted.
  • fallBackTo("display", listOf(...)) gives one family its own list.

On the raw OpenGL backend, StbFonts has the same fallBackTo and registerPictures (from PNG bytes), but bakes everything up front: register a fallback with codepoints = StbFonts.codepointsOf(textYouExpect) rather than the whole font.

Not yet: emoji made of several characters joined together (families, flags, skin tones), right-to-left scripts, and scripts that need shaping, like Arabic or Devanagari.

Laying out text yourself

If you are doing your own inline layout — an icon in the middle of a sentence, say — ask for the lines rather than approximating them:

val block = fonts.paragraph(story, style, maxWidth = 300f)

block.lines          // where each one breaks, and its baseline
block.words          // the stretches a line may break between
block.boxesOf(term)  // one box per line the term touches
block.indexAt(point) // which character is under the pointer

Greedy, like every interface text layout: a line takes as many words as fit. It breaks after spaces, after hyphens, and between ideographic characters — so Chinese and Japanese wrap without spaces, and a full stop or a closing bracket is never pushed onto a line of its own. A word longer than the whole width overflows rather than being chopped, and TextLine.width says that it did.

Outlined text

A ring round the letters, so a readout stays legible over a moving, colourful background:

Text("148", outline = TextOutline(Colour.Black, width = 2f))

// Or once, for a whole HUD.
ProvideTextOutline(Colour.Black, width = 2f) {
    Text("HULL")
    Text("$ammo")
}

ProvideTextOutline reaches Text, Typewriter, Tooltip, and from Game widgets DamageNumberLayer, CompassBar's names, Subtitles and MinimapFrame's compass letters. Not TextField or PromptGlyph, which have backgrounds of their own; give those an explicit outline if you ever want one.

Three things worth knowing before you use it:

  • It is stamped, not stroked. The canvas draws the run eight times offset and once on top, out of the same bitmap glyphs. Honest up to about a sixth of the text size — two units on sixteen-unit text. Past that the eight copies start showing as eight copies.
  • Use an opaque outline colour. The copies overlap, so a see-through ring reads darker where they stack. A fading label does fade whole — the ring is drawn at the face's alpha, so it goes out with the letters rather than leaving a silhouette — but not evenly: those stacked copies keep the ring reading a shade stronger than the letters all the way down.
  • It does not change layout. The ring is painted outside the text's box and the box does not grow for it, so switching it on moves nothing and rewraps nothing. The price is that a tight clip trims it and a background sized to the text does not cover it — add Modifier.padding of the outline width where that matters.

It costs nine times the glyph quads and no extra nodes, draw calls or measuring. For one hero label where the alpha has to be exactly right, Modifier.outline from composegl-effects composites once instead of stacking; it costs an offscreen picture and a draw call per node, which is why it is the wrong tool for two hundred damage numbers.

Selectable text

A label is scenery: the pointer passes straight through it. For the things a player wants to copy out of a game — a seed, a server address, a lobby code, an error message for a bug report — wrap them:

SelectionContainer { Text("Seed: 8F3A-22C1") }

A seed code with its code dragged over and highlighted

Inside, every Text takes the same gestures a TextField does, from the same code: press and drag to select, double-click a word, triple-click a line, shift-click to extend. Ctrl+C copies (Command+C on a Mac), Ctrl+A selects the whole label, and shift with the arrows, Home and End moves the far end.

  • One selection per container. Pressing a second label moves it there, as on a web page. Pass a SelectionState to read selectedText or clear() it.
  • Pad navigation is unchanged. A click brings the keyboard to the label, so Ctrl+C talks to it rather than to the last button, but a label is never a Tab stop or somewhere the d-pad lands. Modifier.focusableByPointer() is that rule on its own, for widgets of your own.
  • Controls stay controls. The labels on Button, Checkbox, Toggle, RadioButton, Stepper, and from Game widgets a Hotbar slot and a click-to-dismiss notification are not selectable, so pressing them still presses. Anything else you press — a clickable save slot or list row — needs its text wrapped in DisableSelection { }, or the label takes the press first.
  • Focus leaving clears it, and so does the label's text changing.
  • The highlight is the skin's selection style (its background), falling back to field.selection.

One thing changes underneath: a label inside breaks its own lines, as a run-styled Text does, because a selection has to know where each character is. Very occasionally that moves a line break by one word.


Buttons

Button("CONTINUE", onClick = { load(save) })
Button("QUIT", onClick = { exit() }, enabled = false)

// …or with whatever content you like
Button(onClick = { equip(sword) }) {
    Row(horizontalArrangement = Arrangement.spacedBy(6f)) {
        Image(icon)
        Text("EQUIP")
    }
}

IconButton(closeIcon, onClick = { dismiss() })

Every state comes from the skin — no Kotlin here names a colour:

a button at rest resting
a button under the pointer under the pointer
a button being pressed held down
a focused button, ringed in white focused, which is where a pad and the arrow keys are
a greyed-out button disabled

A disabled button still swallows the click, so it cannot fall through to whatever is behind it.

A click also asks for a light tap on a phone or a pad, if the game provided one — see haptics.

Same widget, a different style name, and it is a chip:

four chips, one chosen and one in the danger style


Containers

Panel(Modifier.width(280f)) { … }          // a box with the skin's panel look
Panel(style = "panel.raised") { … }

Dialog(onDismiss = { open = false }) {      // a panel over a dimmed screen
    Text("Abandon the run?")
    Row { Button("YES", onClick = ::abandon); Button("NO", onClick = { open = false }) }
}

Tabs(selected, onSelect = { selected = it }, titles = listOf("GEAR", "SKILLS")) { page ->
    when (page) { 0 -> Gear(); else -> Skills() }
}

Panel is one widget and two style names here — the art-backed one and a flat one:

two panels side by side, one cut from art and one a flat fill

Dialog puts itself on the back stack, so Escape and the pad's B button close it without you wiring anything up. OnBack { } is how anything else joins that stack.


Dividers

Column {
    Text("AUDIO")
    Divider()                                   // across, 1 thick, skin style "divider"
    Text("VIDEO")
}

Row(Modifier.height(22f)) {
    Text("1920x1080")
    Divider(vertical = true, thickness = 2f)   // down, as tall as the row
    Text("144 Hz")
}

Divider(Modifier.width(80f))                    // a short rule instead of a full one
Divider(style = "divider.strong")               // falls back to "divider"

a panel split by a horizontal divider, with vertical dividers between settings

A divider is as long as the room it is given, and thickness across. The colour is the skin's divider style, so one line in the skin file recolours every divider.

A vertical divider takes all the height its row may have. Give the row a height, or it grows to fill whatever holds it. Row(Modifier.height(IntrinsicSize.Min)) makes the row, and so the line, as tall as the tallest thing in it. Inside a ScrollArea there is no limit, so give the divider a height there. The pad and the mouse pass straight over a divider.


Splitters

Two panes sharing one space, with a divider the player drags to give one of them more — a hierarchy on the left and its properties on the right, a map over a log:

var split by remember { mutableStateOf(0.3f) }

Splitter(
    fraction = split,
    onFractionChange = { split = it },
    modifier = Modifier.fillMaxSize(),
    orientation = Orientation.Horizontal,   // side by side; Vertical stacks them
    minFirst = 120f,
    minSecond = 200f,
    first = { Hierarchy() },
    second = { Properties() },
)

The splitter fills the room it is given. The divider is thickness of it (6 by default), and fraction is how much of the rest the first pane gets. Each pane is clipped to its share, so a squeezed pane cuts its contents off rather than drawing over its neighbour. Like a slider, it reports and the screen holds the answer.

a level editor in nested splitters: a hierarchy beside a map stacked over a log, with the hierarchy's divider held and dragged right, lit blue

  • Mouse or finger. Over the divider the cursor becomes a resize arrow. A press takes the pointer, so the drag carries on outside the splitter and even outside the window.
  • Keys or pad. The divider is focusable. Focused, the arrows or the d-pad across it move it by step (a twentieth of the space by default), the way they point. At a pane's minimum it lets the next press move focus on instead.
  • Double click puts it back at defaultFraction, which is where fraction started unless you pass one. Enter or South twice quickly on the focused divider does the same.

minFirst and minSecond hold whatever fraction says. When there is not room for both, the space is shared in proportion to them. On a right-to-left screen a side-by-side splitter mirrors: the first pane is on the right.

the same editor right to left in the high-contrast skin: the hierarchy on the right, and a yellow focus ring on its divider

The divider's look is the skin's "splitter" style, in its hovered, pressed, focused and disabled states. Pass style = "splitter.thin" to use another.


Collapsing headers

A title bar that folds away the section under it — a long settings page, a debug window full of tweakables, a codex chapter. imgui calls it CollapsingHeader too:

Column(Modifier.width(320f)) {
    CollapsingHeader("Physics", initiallyExpanded = true) {
        Slider(gravity, onValueChange = { gravity = it }, range = 0f..20f)
        Toggle(ragdolls, onCheckedChange = { ragdolls = it }, label = "Ragdolls")
    }
    CollapsingHeader("Audio") {
        Slider(volume, onValueChange = { volume = it })
    }
}

three headers on a settings page: Physics open with a slider and a Ragdolls toggle under it, Audio and Graphics folded with their triangles pointing right

The contents are a column: each thing you put in goes under the one before, so the slider and the toggle above need no Column of their own.

Changed since 0.6.0. In 0.6.0 the contents were a Box, so several things passed straight into a header were all drawn on top of each other at its top-left corner. They now stack one under another. A section that already wraps its rows in a Column looks the same as before. A section that relied on the pile-up to layer things, such as a label over a picture, should wrap those in its own Box.

A click, Enter, Space or the pad's South opens or closes it. The contents grow in and shrink away with animateContentSize, so the sections under it slide instead of jumping. Closed, the contents are not composed at all: the pad, Tab and the mouse go straight past them to the next header.

Audio a moment after it was clicked: its slider is growing in under it and Graphics is sliding down to make room

Whether a section is open is kept with rememberSaveable, so it is still open when the player comes back to the screen (see Saving state). When the game wants to hold the answer — an "expand all" button, or a choice kept in a save file — pass it in:

CollapsingHeader("Graphics", expanded = graphicsOpen, onExpandedChange = { graphicsOpen = it }) {
    GraphicsSettings()
}

Closed like that while focus is inside, focus goes back to the header rather than being lost. spec and clock pick how it moves, as for animateContentSize.

On a right-to-left screen the triangle is at the right, and a closed one points left.

the same headers right to left in the high-contrast skin: triangles at the right, the closed ones pointing left, and a yellow focus ring on Audio

The look is the skin's: "collapsingheader" for the bar and "collapsingheader.open" while it is open; "collapsingheader.glyph", whose text colour is the triangle's; and "collapsingheader.body", whose padding is how far the contents are indented. Pass style = "debugheader" and the same four names hang off that instead.


Fields and settings

var name by remember { mutableStateOf(TextFieldValue("")) }
TextField(name, onValueChange = { name = it }, placeholder = "CALLSIGN")

Checkbox(subtitles, onCheckedChange = { subtitles = it }, label = "Subtitles")
Toggle(vsync, onCheckedChange = { vsync = it }, label = "V-Sync")
RadioButton(quality == High, onSelect = { quality = High }, label = "High")
Slider(volume, onValueChange = { volume = it }, range = 0f..1f, step = 0.05f)

three text fields: one with text, one showing a placeholder, one focused

two toggles and two checkboxes, on and off

two sliders at different values, the second focused

TextField handles selection, the clipboard, and the phone's keyboard, and onSubmit fires on Enter.

Typing with only a pad

A console or a Steam Deck has no keyboard at all. Wrap the screen and every field in it gets one made of buttons:

ProvideGamepadKeyboard {
    NameYourSave()
}

a keyboard of buttons along the bottom of the screen, under a name field

  • It opens when a pad moves focus onto a field. A mouse click or Tab never opens it, so desktop players never see it.
  • The d-pad walks the keys, South presses one. The keys type through the field's own editor, so maxLength and the caret work the same as with a real keyboard.
  • Three pages — letters, symbols, a number pad — and the page key cycles them. Shift gives one capital, then lets go.
  • Done, B or Escape close it, and focus goes back to the field. South on the field opens it again.
  • Picking up the mouse, or typing on a real keyboard, closes it. The typed letters still reach the field.

GamepadKeyboard(openOnFocus = false) waits for South instead, for a long form a player walks down. The pad's shortcut buttons — X deletes, Y is a space, the bumpers move the caret, the left stick is Shift, Start is done — need one line in your input sink, because the pad navigator does not use those buttons:

override fun onGamepad(event: GamepadEvent) = keyboard.onGamepad(event) || pad.onGamepad(event)

The keys draw from "button.key" (a lit Shift is "button.key.on"), Done from "button.primary" and the panel from "panel.keyboard". A skin without them falls back to plain buttons and a panel.

Steppers: < Medium >

The console settings control. It needs nothing but left and right, so nothing opens and a player on a stick never leaves the list.

Stepper(options = listOf("Low", "Medium", "High"), selected = quality, onSelect = { quality = it })
NumberStepper(value = volume, range = 0..10, onValueChange = { volume = it })
NumberStepper(fov, range = 60..110, step = 5, format = { "$it°" }, onValueChange = { fov = it })

a quality stepper, a focused volume stepper at 7, and a difficulty stepper at its first option with its left arrow dimmed

  • Left and right change it while it has focus. Arrow keys and the pad both work. Up and down still move focus.
  • Holding repeats. A held stick or d-pad steps once, pauses, then repeats at the pad's rate. A held key repeats at the keyboard's own rate. A mouse held on an arrow does the same on the frame clock, and waits while dragged off it.
  • At an end it lets go. One more press to the right past the last option moves focus to the neighbour instead of doing nothing. That arrow is drawn disabled. Pass wrap = true to go round instead.
  • Enter, South, or a click on the value moves to the next option, and goes round at the end.
  • The arrows stay put. The value is as wide as the widest option. Give the stepper a width and the extra goes to the value.
  • Each step ticks. On a phone or a pad every change asks for a haptic Tick. A press at an end that only moves focus gives nothing.

Styles: stepper behind it, stepper.arrow for the two arrows (pressed while held, disabled at an end), stepper.value for the words.

Dropdowns

One choice out of a list that opens and closes — resolution, language, difficulty, a quality preset:

PopupHost {                           // once, around the screen
    Dropdown(
        options = resolutions,
        selected = current,
        onSelect = { current = it },
        modifier = Modifier.width(200f),
        label = { Text(it.toString()) },
    )
}

a resolution dropdown open over the settings under it, with the chosen option lit

A click, Enter or the pad's South opens the list under the field, over everything else on the screen, with focus on the option that is chosen now. The arrows or the d-pad move, and the same press chooses. While it is open:

  • Focus cannot leave the list. A pad pressing down past the last option stays on it, rather than wandering into the screen behind.
  • Escape, East and Back close it without choosing, before they reach any OnBack behind it. Focus goes back to the field either way.
  • A press outside closes it and does nothing else. It is not also a click on whatever was under the pointer.

The list is as wide as the field, so give the field a width that fits the longest option. It opens upwards when there is no room below, and scrolls when it is taller than maxListHeight or than the room it has.

PopupHost is what draws it on top. Draw order is tree order, so the only place a list can be drawn over its neighbours — and escape a ScrollArea that would clip it — is the end of the screen. The host composes the list there, but as if it were where the dropdown is: it still gets the skin, the fonts and anything else you provided around the dropdown. Forget the host and the screen fails as it is built, saying so.

The field is the "dropdown" style, the list's panel "dropdown.list", and the options are "item" and "item.selected".

Colour pickers

A character's hair, a team colour, a crosshair, a tint you are tweaking in a debug window:

var tint by remember { mutableStateOf(Colour.rgb(0x4CC2FF)) }

ColourPicker(
    colour = tint,
    onColourChange = { tint = it },
    alpha = true,                        // adds the see-through strip
    presets = listOf(Colour.Red, Colour.Blue, Colour.Green),
)

a colour picker: a saturation and brightness square with a ring on it, a rainbow hue strip, an alpha strip over a checkerboard, a hex field reading #C04CC2FF and six team colour swatches

It has a square (strength across, brightness up), a hue strip beside it, an alpha strip when alpha = true, a hex field with a swatch of the colour, and a swatch for each preset. Like a slider, it reports and the screen holds the answer. It keeps the hue itself, so dragging to grey or black and back does not lose it.

  • Mouse or finger. Press or drag on the square or a strip. The drag carries on past the edge and holds the marker there.
  • Arrow keys or d-pad. Move the marker on whichever part has focus, a twentieth of the way per press. At an edge the next press moves focus on.
  • Left stick. On the focused square it moves the marker smoothly, faster the further you push. No virtual cursor needed. A fresh push against the edge the marker is already on moves focus instead, so a stick alone can leave.
  • Shoulder buttons. Turn the hue from anywhere in the picker, and keep turning while held. It wraps past red.
  • Hex field. An ordinary TextField, so a pad player gets the on-screen keyboard — delete with DEL and type the code key by key. It takes #RRGGBB, #RGB, or #AARRGGBB with alpha. The colour changes as soon as the text is one. Half a code is left as typed, and put back to the colour once you leave the field, which the on-screen keyboard being open does not count as.
  • Presets. A click, Enter or South picks one.

an armourer's tint being mixed: the pointer is part way through a drag across the saturation and brightness square, the ring under it, and the plate of armour beside the picker is already the bronze the drag has reached, #C7762B

That picture is taken mid-drag, with the button still down: the ring is where the pointer is and the swatch beside it has already changed.

a squad colour picker with the right shoulder held down: the hue has walked from red round to green, #69F224, the bar is well down the rainbow strip, and the focus ring is still on the square

And that one with nothing but a pad: the right shoulder went down a second before the shutter and is still held, so the hue has turned all the way from red to green while focus stayed put on the square.

On a right-to-left screen the square mirrors: grey is on the right, and the arrows still move the marker the way they point.

a lamp colour picker in the high-contrast skin, right to left: the square is on the right, the hue and alpha strips run down its left, and a drag part way down the alpha strip has left the lamp half see-through at #61FFC53D, its swatch showing the checkerboard through it

For a settings list, ColourPickerButton is a small swatch that opens the picker under itself. It needs a PopupHost, like a dropdown:

PopupHost {
    ColourPickerButton(colour = crosshair, onColourChange = { crosshair = it }, alpha = true)
}

a Crosshair setting with its green swatch clicked, and the colour picker open under it

Focus goes to the square when it opens. Escape, East, Back, a press outside or another press on the swatch closes it, and focus goes back to the swatch. ColourSwatch(colour = tint, onClick = { ... }) is the plain swatch, if you want to open something of your own. Without onClick it only shows the colour.

The colour maths is public too: Hsv(hue, saturation, value, alpha).toColour(), Hsv.of(colour), colour.toHex() and Colour.fromHex("#FF8000").

Styles: "colourpicker" is the panel, "colourpicker.area" the frame round the square and strips (hovered, focused, disabled), "colourpicker.marker" the ring and bars (text colour for the ring, fill for its outline), "colourpicker.checker" the checkerboard's two greys, "colourswatch" a swatch's frame, and "field" the hex field.


Menus

File, Edit, View along the top of an editor, and a menu that opens on an inventory slot. Both are written in one scope — Item, CheckItem, RadioItem, Submenu and Separator — so a list of items written once works in either. Both drop through a PopupHost, so one has to be round the screen.

Menu bars

PopupHost {
    Column(Modifier.fillMaxSize()) {
        MenuBar(padButton = GamepadButton.Back) {
            Menu("&File") {
                Item("&New", shortcut = Modifiers.Primary + Key.N) { newLevel() }
                Item("&Save", shortcut = Modifiers.Primary + Key.S, enabled = dirty) { save() }
                Submenu("&Recent") {
                    recent.forEach { level -> Item(level.name) { open(level) } }
                }
                Separator()
                Item("&Quit") { quit() }
            }
            Menu("&View") {
                CheckItem("&Grid", checked = showGrid) { showGrid = it }
                Separator()
                RadioItem("&Wireframe", selected = mode == Wire) { mode = Wire }
                RadioItem("S&haded", selected = mode == Shaded) { mode = Shaded }
            }
        }
        LevelEditor()
    }
}

a menu bar with File open: items with Ctrl shortcuts beside them, a greyed-out Save as, a ticked Autosave and lines between the groups

the View menu open with its Render submenu beside it, Shaded chosen with a dot

The bar is as wide as what holds it: the top of the screen, or the top of a window. The menu scope is plain Kotlin, not composables. It runs every time the bar is composed, so enabled = dirty greys Save out the moment nothing needs saving, and a shortcut works while its menu is closed.

  • Mouse. Click a title to open it; click it again to close it. While one is open, move onto another title to switch. Rest on a submenu's row to open it. Moving diagonally from that row towards the submenu does not close it, even across the rows in between.
  • Keyboard. Alt on its own, or F10, puts focus on the bar. The arrows move, and Down, Enter or a title's letter opens a menu. Alt with a letter — Alt+F — opens that menu from anywhere. Alt held for anything else — Alt+Left in a field, Alt+click — does not. A click below the bar while it has focus gives focus to what was clicked. In a menu, Right opens a submenu and Left closes it; Left and Right with nowhere to go move to the next menu along. Escape closes one level at a time, and the last one gives focus back to wherever it was.
  • Shortcuts. Modifiers.Primary + Key.S is Ctrl+S, or Command+S on a Mac, and the menu writes it that way beside the item. It fires from anywhere on the screen while every menu is closed. It does not fire through an open dialogue, or when the focused widget used the key itself — a text field keeps its Ctrl+A.
  • Pad. padButton puts focus on the bar and takes it away. The d-pad moves, South opens and chooses, the shoulders switch menus, and East closes one level through OnBack. Leave padButton null and a pad cannot reach the bar.
  • Letters. & marks the letter: "&File" underlines the F while the keyboard or pad is driving the bar. && is an ampersand. A translated label marks its own letter — "&Fichier".
  • Right to left, the bar reads from the right, menus hang from a title's right edge, submenus open to the left, and Left opens a submenu.

the same menu bar right to left in the high-contrast skin: titles read from the right and the submenu opens to the left

Items can be disabled (enabled = false), which greys them out, skips them on the arrows and stops their shortcut. icon = { Image("icons/save", Modifier.size(16f)) } puts a picture in the column before the label. A submenu with no room on its side opens on the other side.

Styles: "menubar" behind the titles, "menubar.title" for a title and "menubar.title.open" for the one whose menu is open. A menu is "menu", a row "menu.item" ("menu.item.open" while its submenu is open), then "menu.shortcut", "menu.separator", and "menu.check" and "menu.radio" for the tick and the dot.

Context menus

Box(Modifier.size(64f).contextMenu {
    Item("&Use") { use(item) }
    Item("S&plit stack", enabled = item.count > 1) { split(item) }
    Separator()
    Item("&Drop") { drop(item) }
})

a row of inventory slots with a menu opened by right-clicking the Sword slot: Use, a greyed-out Split stack, and Drop

It opens four ways:

  • Right-click — at the pointer. It wins over a button inside that only clicks. A widget that takes the right button itself with onPointer keeps it, and so does a button that is not inside the menu's widget, like a HUD button drawn over a map.
  • Long press — at the finger, after the same hold onLongPress uses. Like a right-click it reaches past a button inside that only clicks, so on a touch screen a slot built as a button still opens its menu. The release is not a click. A widget's own onLongPress wins. Only a pointer or a finger holds it open: a held Enter or South is still a click. A press that moves — a slider's thumb, a list scrolling — is not a hold. A quick tap still reaches a clickable around the menu's widget.
  • Shift+F10 — the menu of the focused widget, or of the nearest one around it with a menu, opens under the focused widget itself.
  • padButton (the pad's North unless you say) — the same, from a pad.

None of these reach past a dialog: a Dialog open over the menu's widget keeps Shift+F10, the pad button and right-clicks on its own buttons to itself.

It hangs down and towards the end of where it opened, and flips back when it would go off the screen. While it is open, focus is trapped in it. Escape, Back, East and a click outside close it, and so does the widget it opened on leaving the screen. While it is open it shows the items as the widget last composed them. Its items' shortcuts are shown but do not fire; put the same item on a MenuBar for that.

To share items, write them as an extension and call it in both:

fun MenuScope.editItems() {
    Item("Cu&t", shortcut = Modifiers.Primary + Key.X) { cut() }
    Item("&Copy", shortcut = Modifiers.Primary + Key.C) { copy() }
}

MenuBar { Menu("&Edit") { editItems() } }
TextField(notes, { notes = it }, Modifier.contextMenu { editItems() })

Lists and scrolling

LazyColumn(count = saves.size, spacing = 6f) { index -> SaveRow(saves[index]) }
LazyRow(count = 9, spacing = 4f) { slot -> HotbarSlot(slot) }
LazyVerticalGrid(count = shop.size, columns = GridCells.Adaptive(64f)) { index -> ShopTile(shop[index]) }

ScrollArea(Modifier.fillMaxSize()) { LongPatchNotes() }

a panel of save slots with a scrollbar down the side

LazyColumn and LazyVerticalGrid measure only what is on screen; Layout covers grids, and lists in sections whose headers stay at the top (LazyColumn { stickyHeader { … }; items(…) { … } }). ScrollArea is for content that is one piece and simply too tall.

Both remember how far they were scrolled when their screen is left and come back to, as long as a SaveableStateHolder is above them — see Saving state.

The bar sits over the contents rather than beside them, on the edge the lines end at: the right of an ordinary screen, and the left of a right-to-left one, where the text begins on the right. See Localisation.


Panning and zooming

A plane of interface the player drags around and zooms into: a world map, a skill tree, a tile board, a node editor, a diagram bigger than the screen.

val camera = rememberPanZoomState(
    zoom = 1f, minZoom = 0.25f, maxZoom = 3f,
    bounds = Rect(0f, 0f, 4000f, 3000f),      // world units; panning stops at the edges
)

PanZoomCanvas(
    state = camera,
    modifier = Modifier.fillMaxSize(),
    // World units, under the children. Remembered on what it draws, like any other draw here:
    // the canvas redraws when the lambda changes, so a fresh one each time asks for one each time.
    background = remember(links) { { visible -> drawLanes(links, visible) } },
) {
    skills.forEach { skill ->
        key(skill.id) {
            TalentIcon(skill, Modifier.worldPosition(skill.x, skill.y, anchor = Alignment.Centre))
        }
    }
}

camera.animateTo(centre = Offset(skill.x, skill.y), zoom = 1.5f)
val world = camera.screenToWorld(pointer)

A skill tree built on this — node states worked out from the graph, lines tinted by what they join, the pad walking the branches — comes ready made as SkillTree in composegl-game.

a world map zoomed in on Thornfell, Redhollow and Castle Vey, with roads between the pins and a "you are here" marker

Three notches of the wheel and a drag, on the real thing. The names and the pin edges are drawn at the size they appear, so they are as sharp here as at their own size.

The children are laid out once, in world units, at their natural size, where Modifier.worldPosition puts them. Pan and zoom are a transform the canvas draws them through and the pointer finds them through, so moving the camera measures and composes nothing — and a node at three times its size is drawn at three times its size rather than stretched from a picture, so its edges and its letters stay sharp. Clicks, hover, drags, tooltips, focus rings and boundsInRoot all work inside the plane with nothing written for them.

This is what Modifier.scale cannot do: that is a captured picture magnified, soft past about 1.15× and with a ceiling at 4096 pixels. A camera has neither.

  • Drag empty space to pan, and a fast one flings on. A drag that starts on a button pans once it has moved further than a click would, and the button is not clicked; a slider inside keeps its own drag. Past an edge the world gives a little and springs back.
  • The wheel zooms about the pointer, so what is under it stays under it. A sideways wheel pans.
  • Pinch with two fingers zooms about their middle and pans with it.
  • Double click does reset: PanZoomReset.Initial goes back to where the camera started, PanZoomReset.Fit fits the whole world in view.
  • The pad, while focus is on the canvas or inside it: the left stick pans, the right trigger zooms in and the left one out, and resetButton (R3 by default) resets. The d-pad walks focus from node to node — the toolkit's ordinary directional focus, so a neighbour up and to the right is reached the same way it would be anywhere else — and the camera eases to keep the focused node in view. A direction with nothing that way leaves focus exactly where it is, and focus never falls onto the canvas itself. With the canvas itself focused, a direction goes to the nearest node that way from the middle of the view, scored the same way, and pans a step when there is none.
  • Keys, the same way: = zooms in, - zooms out, 0 resets, and the arrows move focus as the d-pad does.

the same map zoomed out, the whole coast in the window, with the "you are here" marker still full size

The same map, five notches of the wheel the other way: the whole coast at half size. The red marker is the one thing that did not shrink, because it is placed with scaleWithZoom = false.

Modifier.worldPosition(x, y, anchor, scaleWithZoom) is parent data, like layoutId: anchor says which point of the child sits on the world point — Alignment.TopStart for a tile board, Alignment.Centre for a node in a tree. With scaleWithZoom = false the child follows the camera but keeps its own size on screen, which is what a label, a pin or a player marker wants. A world is a picture rather than a line of text, so it is not mirrored on a right-to-left screen.

The camera is plain state and can be driven from anywhere: screenToWorld and worldToScreen, zoomAbout(point, zoom), panBy, snapTo, animateTo, fit(), reset(), and visibleWorld for what is in view now. rememberPanZoomState keeps it where the player left it when a screen comes back, under a SaveableStateHolder — see Saving state.

A child entirely outside the view is neither drawn nor hit-tested, so a board of thousands draws the few dozen on screen. For a world too big to compose at all, the lazy form composes only what is near the view:

LazyPanZoomCanvas(
    items = tiles,                                                 // 40,000 of them
    area = { Rect.of(it.column * 64f, it.row * 64f, 64f, 64f) },
    state = camera,
    key = { it.id },
) { tile -> Tile(tile) }

Items are sorted into a grid of cells once per list, and the set composed changes only when the view crosses into different cells — so a pan inside one cell composes nothing.

Text is a picture made at one pixel size, so zoomed text is made again at the nearest of a few steps (0.5, 0.75, 1, 1.5, 2, 3) once a gesture settles, and stretched between steps while the camera is moving. The canvas's own look is the skin's "panzoom" style: the backdrop behind the world, and a ring when the pad has focus on the canvas itself.

a crafting graph in the high-contrast skin, right to left, with a yellow focus ring on the Rod node

A crafting graph in the high-contrast skin on a right-to-left screen, on a pad: down then right walked focus from Ore to Ingot to Rod, and the camera came along to keep the ringed node in view, so Ore has gone off the left edge. The panel reads right to left; the graph does not, because a diagram is a picture rather than a line of text.


Tables

Rows with columns that line up, sort and resize: a scoreboard, a server browser, a list of items and their stats.

var picked by remember { mutableStateOf<Player?>(null) }

Table(rows = players, key = { it.id }, selected = picked, onSelect = { picked = it }) {
    column("Name", weight = 1f) { Text(it.name) }
    column("Kills", width = 64f, sortBy = { it.kills }) { Text("${it.kills}") }
    column("Ping", width = 64f, sortBy = { it.ping }, align = HorizontalAlignment.End) { Text("${it.ping}") }
}

a deathmatch scoreboard sorted by kills, highest first: the Kills title is lit with a down arrow beside it, and Mirela's row is the selected one, in blue

A column is either width wide or shares out what the fixed ones leave by weight (1 when it says neither), and never goes under minWidth. The header stays put while the rows scroll under it, and the body is a LazyColumn, so only the rows on screen are built.

a server browser scrolled halfway down its list with the titles still at the top, Server widened into Map and the divider between them held, lit blue

  • Sorting. Click a title with a sortBy to sort by it, lowest first; click again to turn it round. Enter or the pad's South on a focused title does the same, and Up from the first row reaches the titles. sortButton — the pad's North unless you say — cycles through the sortable columns from anywhere in the table. It is an InputBinding, so a controls screen can rebind it to a key (InputBinding.Keyboard(Key.S)) or a mouse button. The sort is stable, and the rows slide to their new places, which needs a key. A value read from state — a live distance — re-sorts the table as it changes.
  • Resizing. Drag the divider between two columns and the edge follows the pointer: the column before it grows as the one after it shrinks, so the last column is resized from the divider at its start. A double click puts both back to their declared widths. resizable = false on a column takes away the dividers on either side of it.
  • Selection. Rows take focus, so the arrows and the d-pad walk them in the order they are shown. A click, Enter or South on one calls onSelect; the screen keeps the answer and hands it back as selected.
  • Right to left, the first column is on the right and a divider drags left to widen.

a backpack right to left in the high-contrast skin: Item on the right, sorted by Value lowest first with an up arrow, and a yellow focus ring on the Rope row where the pad stepped down to

The sort, the dragged widths and the scroll live in a TableState. The one rememberTableState() makes is kept by a SaveableStateHolder like a list's scroll (see Saving state), and it can be read and set from outside:

val scores = rememberTableState(sortColumn = 1, descending = true)
Table(rows = players, state = scores) { … }

scores.sortBy(2)                          // by ping
scores.setColumnWidth(0, 180f)            // as if it had been dragged
settings.widths = scores.columnWidths     // to keep a layout between runs

empty = { Text("No servers found") } is what the body shows with no rows.

Every piece of it is the skin's: table round it, table.header and table.header.cell for the titles (table.header.cell.sorted for the one sorted by), table.divider for the handles — its padding is how far in the line is drawn — table.row, table.row.alt for every other row and table.row.selected, table.cell for the padding round each cell, and table.empty. Pass style = "scores" to use scores.row and so on instead.


Trees

Nested rows that open and close: a scene hierarchy, a quest log, a codex, a file picker.

var selection by remember { mutableStateOf<SceneNode?>(null) }

TreeView(
    roots = scene.roots,
    children = { it.children },
    key = { it.id },
    modifier = Modifier.fillMaxSize(),
    selected = selection,
    onSelect = { selection = it },
) { node, expanded ->
    Text(node.name)
}

a scene tree with World and Player open, indent guides beside the children, and Weapon chosen

It is a LazyColumn underneath, so only the rows on screen are built, and a tree of 10,000 nodes scrolls like a screenful. A node is asked for its children only when it is open. To draw the arrow, the tree calls hasChildren only for the rows it builds (and for the focused row when Left or Right is pressed), so the default, which asks children, costs a screenful of calls, not the whole tree. Pass something cheaper when finding children is slow, like a folder on disk:

TreeView(
    roots = listOf(saveFolder),
    children = { folder -> folder.listFiles().orEmpty().sortedBy { it.name } },
    hasChildren = { it.isDirectory },
    key = { it.path },
    onActivate = { file -> load(file) },
) { file, _ -> Text(file.name) }
  • Mouse — a click selects a row. A click on its arrow opens or closes it. A double click opens it too, or calls onActivate if you gave one.
  • Keyboard and pad — Up and Down move row by row, and past either end they leave the tree. Right opens a closed row, or goes into an open row's first child. Left closes an open row, or goes up to the parent. Enter or South selects.
  • Right to left — the indent comes in from the right, a closed row's arrow points left, and Left and Right swap.

a save picker where a click on Chapter 2's arrow has opened it, showing its three saves

a quest log on a pad: A Crown of Thorns chosen with South, then focus moved down to Side quests and opened with Right, while Done stays shut

a right-to-left bestiary in the high-contrast skin, the indent coming in from the right, shut rows' arrows pointing left, focus on Wraith and Lich chosen

What is open is kept by key in a TreeState, so a row stays open when the list is sorted. The default rememberTreeState() is saved like any rememberSaveable (see Saving state). Make your own to open rows from code:

val tree = rememberTreeState("world")          // starts with "world" open

Button("Find the player", onClick = {
    tree.expandAll(listOf("world", "actors"))
    tree.scrollTo("player")
})
TreeView(scene.roots, { it.children }, { it.id }, state = tree) { node, _ -> Text(node.name) }

scrollTo waits one frame, until the rows have been worked out again, so it can follow expandAll or collapse even when rows above the one you want have moved; if the row still is not there, the scroll is dropped. collapse, toggle, collapseAll and isExpanded are there too. If a row closes while focus is on something under it, focus moves up to the nearest row still showing. Keys must be unique across the whole tree; a repeated key fails with the key in the message.

For something on the whole row, like a right-click menu, use rowModifier:

TreeView(
    roots, { it.children }, { it.id },
    rowModifier = { node -> Modifier.contextMenu { Item("&Delete") { delete(node) } } },
) { node, _ -> Text(node.name) }

Styles: "tree.row" for a row in each state and "tree.row.selected" for the chosen one. "tree.toggle" and "tree.toggle.open" are the arrow: a triangle in the style's text colour, or its picture if the style's background is one. In a right-to-left screen a closed arrow's picture is turned to point left; a nine-patch is flipped, which needs a canvas that can mirror layers. "tree.guide" is the indent lines; give it "background": "none" to hide them. style = "codex" reads "codex.row" and so on instead. indent, glyphSize and spacing set the sizes.

NodeTree, in composegl-debug, is one of these over the interface itself: see Debugging.


Pictures

Image("portraits/sniper")                       // by name, out of the skin's atlas
Image(texture, Modifier.size(64f), tint = Colour.rgb(0x808080))
Image(icon, fit = ImageFit.Cover, alignment = Alignment.TopStart)

A name that is not in the atlas stops there and says so, rather than drawing nothing — because nothing looks exactly like a widget somebody has not written yet.

Your own 3D scene in a panel

SceneView(state, modifier) { ... } lays out like a picture and shows your renderer's scene inside it, redrawn only when you call state.invalidate(). Give it onPointer, onKey or onPad to orbit, drag or pick: positions arrive in the picture's own pixels, with (0, 0) at its corner. See Scene view.

Sprite-sheet animation

Moved to Animation.


Spinners and indeterminate progress

For "working, nobody knows how long": saving, loading, connecting, finding a match.

if (saving) Spinner(Modifier.size(24f))          // the turning arc in the corner
IndeterminateBar(Modifier.fillMaxWidth())        // a block sliding along a track

Spinner(Modifier.size(32f), clock = Clock.World) // stops when the game is paused
Spinner(rememberSpriteAnimation("spinner_", fps = 12f), Modifier.size(24f))  // frames from the atlas

spinners and bars moving: a bar sliding under a LOADING WORLD header, a small spinner inside a greyed-out Connecting... button and beside Saving, spinners at 16, 24 and 40 with a thin one and a bar standing up, and a yellow spinner and bar in the high-contrast skin

A Spinner is an arc that chases its tail round a circle, once every revolutionMillis (1000 by default). It is 24 across unless you size it, and thickness sets how wide the arc is. An IndeterminateBar is length long (160) and thickness across (6) unless you size it; its block crosses the track every sweepMillis and is cut off at the ends. orientation = Orientation.Vertical runs it up from the bottom, and on a right-to-left screen a flat one runs right to left.

  • A named clock. Both run on Clock.Ui by default, so they keep moving over a paused world. Pass clock = Clock.World for one that should stop with the game.
  • Cheap. Only the drawing moves. Nothing recomposes and nothing is laid out again; each frame the clock moves, the widget asks for a redraw and reads the time as it draws. On a stopped clock it asks for nothing. A test's settle() does not wait for one.
  • Scenery. Neither takes focus or clicks, so the pad and the mouse pass over them.

Their look is the skin's. The spinner's arc is the "spinner" style's text colour, with that style's background behind it, and "spinner.track"'s text colour is a ring under the arc when the skin names it. The bar draws "indeterminatebar.track" under the whole bar (its padding insets the block) and "indeterminatebar.fill" as the block. Pass style = to use other names. For a picture instead, such as a turning disc or an hourglass, hand Spinner a looping SpriteAnimation; it plays it as an AnimatedImage.

the same screen held still part way through a turn: the arcs stretched most of the way round their faint rings, and each bar's block near the start of its track


Tooltips and prompts

TooltipHost {                       // once, around the screen
    Tooltip("Reloads faster when crouched") {
        IconButton(reloadIcon, onClick = { })
    }
}

PromptGlyph(Action.Interact)        // draws E or Ⓐ, depending on what the player is using

a tooltip under a button, explaining what it costs

PromptGlyph changes the moment somebody picks up a pad, without anything being reloaded.


Showing and hiding: AnimatedVisibility

Moved to Animation.


Rebinding controls

KeyBindButton(
    binding = binds[Jump],                    // a key, a mouse button or a pad button; null draws a dash
    onBind = { input ->
        binds[Jump] = input
        prompts.bind(Jump, input)             // every PromptGlyph(Jump) follows
    },
    cancelKey = Key.Escape,                   // and GamepadButton.Back on a pad
)

a controls list with one binding waiting for a press

Click it, press Enter on it or press South on it, and it says PRESS A KEY. The next press of anything is the answer, and nothing else hears it: an arrow or the d-pad is bound instead of moving focus, East is bound instead of going back. The release of that press is swallowed too, so binding Enter or South does not start it listening again. Escape or the pad's Back gives up, and so does focus leaving it.

A mouse button counts when it is pressed on the button, which is where the cursor already is. Sticks and triggers are not bindings. accepts = { it !is InputBinding.Mouse } refuses a kind of press and keeps listening.

It never decides what a clash means. onBind gets the press either way, and clashesWith says who else has it:

onBind = { input ->
    val clash = binds.clashesWith(input, ignoring = Jump)
    if (clash.isEmpty()) binds[Jump] = input else warning = "Already used by ${clash.first()}"
}

Pass a KeyBindState to read isListening from outside — a "press Esc to cancel" line under the list — or to call listen() yourself.


Switching screens: Crossfade

Moved to Animation.


Sliding between pages: AnimatedContent

Moved to Animation.


Several values off one state: updateTransition

Moved to Animation.


Typewriter

val line = rememberTypewriter(dialogue[at])
Typewriter(line, charactersPerSecond = 40f, onFinished = { showChoices = true })

Text that arrives a letter at a time, skipping to the end on a press. It measures the whole string up front, so the box does not grow as the words appear. Sending the same line back to the beginning with line.restart() types it out again, even when the words have not changed.

For a whole conversation — a speaker, a portrait, answers, skip, auto and a scrollable log — see the dialogue box, which is built on this.


Game widgets

Bars, the reticle, damage numbers, world markers, cooldowns, the hotbar, the minimap frame, the compass bar, the dialogue box, notifications, subtitles and particles live in their own module, composegl-game. They have their own page.


What next

  • Game widgets — bars, reticle, damage numbers, world markers, cooldowns, hotbar, minimap, compass bar, dialogue, subtitles, particles
  • Skins — how all of these get their look
  • Input — focus, pads, and keyboard
  • Shaders — blurring, outlining or dissolving any of the above
  • Animation — clocks, animated values, and panels that come and go

Clone this wiki locally