Widgets
A small library of reusable controls composed from the DSL primitives and vdom hooks. A widget is an ordinary vdom component, so its interaction state (hover, pressed) lives in useState…
import io.github.edadma.suit.widgets.*
A small library of reusable controls composed from the DSL primitives and vdom hooks. A
widget is an ordinary vdom component, so its interaction state (hover, pressed) lives in
useState and survives re-renders, and it reconciles in place exactly like an application
component. Each widget is purely declarative output over box / text / row / stack;
the render tree, layout, and input routing underneath give it pixels and behaviour.
A scrolling viewport is the scrollView DSL primitive (its
scroll position lives on the render object, so it is a primitive rather than a composed
widget).
The widgets animate. Hover and press tints fade rather than snap, the checkbox mark
scales and fades as it ticks, and the slider thumb glides toward its value — all driven by
useTransition over the runtime’s frame clock. With motion settled, every
value lands exactly on its target, so the animation is invisible to tests.
Button
val Button: Component2[String, () => Unit]
A push button: a labelled, focusable rectangle that calls onPressed when clicked (a press
and release on the button) or activated from the keyboard (Space or Enter while focused). It
tints on hover and while held.
Button("Increment", () => setCount(count + 1))
Checkbox
val Checkbox: Component2[Boolean, Boolean => Unit]
A small focusable square that toggles, calling onChange with the new state on a click or
on Space while focused. It is controlled — it draws the checked it is given and never
holds the value itself, so the parent owns the state. The check is a filled inner square (no
glyph-font dependency).
val (checked, setChecked, _) = useState(false)
Checkbox(checked, setChecked)
Slider
def Slider(
value: Double,
onChange: Double => Unit,
onChangeStart: (Double => Unit) | Null = null,
onChangeEnd: (Double => Unit) | Null = null,
fill: Boolean = true,
): VNode
A horizontal slider over the range 0..1: a full-width track with a draggable thumb. It is
controlled — it renders the value it is given and reports a new value through
onChange on a press, a drag, or the arrow keys while focused. The new value comes from the
press position in the slider’s own coordinate space (local.x / size.width), which is why
the handlers live on the outer track, not the thumb (see
pointer capture).
val (level, setLevel, _) = useState(0.4)
box(width = 240)(
Slider(level, setLevel),
)
Two optional callbacks bracket a drag, mirroring Flutter’s Slider:
onChangeStartfires once with the value at the press point when the thumb is grabbed.onChangeEndfires once with the final value when it is released.
suit captures the pointer on press, so the release — and any move off the bar in between —
still routes back to the widget. onChange fires on the press and on every drag move as
before. Both bracketing callbacks are pointer-interaction only: the arrow keys report
through onChange alone, having no natural grab/release. Use them to commit an edit or start
a preview only while the user is actively scrubbing:
val (level, setLevel, _) = useState(0.4)
val (scrubbing, setScrubbing, _) = useState(false)
Slider(
level,
setLevel,
onChangeStart = _ => setScrubbing(true),
onChangeEnd = _ => setScrubbing(false),
)
While a drag is active the thumb tracks the cursor exactly (no transition), so scrubbing
feels direct; a keyboard step or any other non-drag change still glides. An accent fill
runs from the start of the track to the thumb for the played-progress look of a scrubber —
pass fill = false for a bare groove.
TextField
val TextField: Component2[String, String => Unit]
A single-line text field — a focusable, bordered box that edits a string. It is
controlled: it renders the value it is given and reports edits through onChange, so
the parent owns the text. While focused it receives typed characters (the runtime opens the
platform’s text-input session for it) and editing keys:
- Backspace / Delete remove before / after the caret (or the selection).
- Left / Right / Home / End move the caret; hold Shift to extend a selection.
- Ctrl+A selects all.
- A click places the caret at the nearest character boundary; a drag selects a range.
- Typing or a deletion replaces the current selection.
Caret and selection geometry come from measuring text prefixes through the installed
TextMeasurer, so positions are exact (and JVM-testable). The content is clipped to the field
and scrolls horizontally to keep the caret in view once the text outgrows it. The caret
blinks while the field is focused and snaps solid for a full interval after any edit or
caret move, so it is steady the instant you type.
val (name, setName, _) = useState("")
col(crossAxisAlignment = CrossAxisAlignment.Stretch)(
TextField(name, setName),
)
Give the field a definite width — put it in a Stretch column or a fixed-width box — so it
fills the row; like the other controlled widgets it does not impose a width of its own.
TextArea
def TextArea(
value: String,
onChange: String => Unit,
caretRequest: Option[CaretRequest] = None,
onCaretAt: (Double, Double) => Unit = (_, _) => (),
): VNode
case class CaretRequest(offset: Int, token: Long)
A multi-line text editor — the TextField counterpart for text that spans many lines. It
is controlled the same way (it renders value and reports edits through onChange), but
the caret moves in two dimensions:
- Enter splits the line; Backspace / Delete remove (joining lines at a boundary).
- Left / Right / Up / Down move the caret — vertical motion keeps the column where a shorter line allows; hold Shift to extend a selection across line breaks.
- Home / End jump to the line’s start / end (Ctrl+Home / Ctrl+End to the document’s); Ctrl+A selects all.
- A click places the caret on the line and column it lands on; a drag selects.
All of the caret arithmetic lives in the pure, JVM-tested EditBuffer model; the widget is the
wiring that holds one in state and paints it. The editor sizes to its content height (one
line’s height per line) rather than scrolling itself, so put it in a scrollView for a
fixed-height editor that scrolls — the click-to-caret math reads the pointer in the editor’s own
coordinates, so it stays correct however far the enclosing viewport is scrolled.
val (sql, setSql, _) = useState("SELECT *\nFROM users;")
box(height = 150, clip = true)(
scrollView(Axis.Vertical)(
col(crossAxisAlignment = CrossAxisAlignment.Stretch)(
TextArea(sql, setSql),
),
),
)
Jumping to a position
caretRequest places the caret from outside — what a “go to the line this error names” command
sends. Pass a fresh token each time so that asking twice for the same offset is honoured twice
rather than collapsing into nothing.
Since the editor sizes to its content, scrolling that caret into view is the enclosing viewport’s
job; only the editor knows which wrapped row an offset falls on, so it reports through
onCaretAt with the top of the caret’s visual row and that row’s height. Pair it with a
scrollArea ref:
val view = useRef[RenderObject | Null](null)
val (jump, setJump, _) = useState(Option.empty[CaretRequest])
val (token, setToken, _) = useState(0L)
def goTo(offset: Int): Unit =
setToken(token + 1)
setJump(Some(CaretRequest(offset, token + 1)))
def reveal(top: Double, h: Double): Unit =
view.current match
case r: RenderScroll =>
if top < r.scrollOffset then r.scrollOffset = top
else if top + h > r.scrollOffset + r.size.height then r.scrollOffset = top + h - r.size.height
case _ => ()
box(height = 150, clip = true)(
scrollArea(Axis.Vertical, ref = view)(
col(crossAxisAlignment = CrossAxisAlignment.Stretch)(
TextArea(sql, setSql, caretRequest = jump, onCaretAt = reveal),
),
),
)
Like TextField, give it a definite width (a Stretch column or a sized box) to fill a pane.
Switch
val Switch: Component2[Boolean, Boolean => Unit]
A pill-shaped toggle with a sliding thumb — the on/off counterpart to a Checkbox. It is
controlled — it renders the on it is given and reports the flip through onChange on a
click or on Space while focused. The thumb glides between the ends and the track fades between
the inactive groove and the accent.
val (live, setLive, _) = useState(true)
row(crossAxisAlignment = CrossAxisAlignment.Center, spacing = 8)(
Switch(live, setLive),
text(if live then "live" else "paused"),
)
RadioGroup
val RadioGroup: Component3[Seq[(String, String)], String, String => Unit]
A single-select column of options, each a (value, label) pair. It is controlled — the
row whose value equals selected shows its dot, and clicking a row (or Space while it is
focused) reports that row’s value through onChange.
val (size, setSize, _) = useState("m")
RadioGroup(Seq("s" -> "Small", "m" -> "Medium", "l" -> "Large"), size, setSize)
Tabs
val Tabs: Component3[Seq[(String, String)], String, String => Unit]
A row of selectable headers, each a (value, label) pair. It is controlled — the tab
whose value equals selected is highlighted, and clicking a tab (or Space / Enter while it is
focused) reports that tab’s value. Pair it with the caller’s own switch on selected to swap
the panel below.
val (tab, setTab, _) = useState("overview")
col(crossAxisAlignment = CrossAxisAlignment.Stretch, spacing = 12)(
Tabs(Seq("overview" -> "Overview", "status" -> "Status"), tab, setTab),
tab match
case "status" => Alert(AlertKind.Success, "All systems nominal.")
case _ => text("An overview."),
)
ProgressBar
val ProgressBar: Component[Double]
A determinate progress bar over 0..1: a rounded track with an accent fill proportional to
its value. The fill animates toward the target, so a jump to a new value glides. It takes its
width from its parent.
ProgressBar(level) // 0.0 .. 1.0
Badge
val Badge: Component[String]
A small rounded pill that labels or counts, painted in the theme’s accent.
Badge(s"$count")
Divider
val Divider: Component[Boolean]
A hairline separator in the theme’s border colour. Divider(false) is a horizontal rule;
Divider(true) is a vertical one. It takes its length from the cross axis of its parent, so
put a horizontal divider in a stretched column and a vertical one in a stretched row.
col(crossAxisAlignment = CrossAxisAlignment.Stretch, spacing = 12)(
text("above"),
Divider(false),
text("below"),
)
Alert
enum AlertKind:
case Info, Success, Warning, Danger
val Alert: Component2[AlertKind, String]
A callout — a tinted, bordered panel that draws attention to a message, coloured by its
AlertKind from the theme’s status roles (info / success / warning / danger).
Alert(AlertKind.Success, "Saved your changes.")
Alert(AlertKind.Danger, "Could not connect.")
Card
val Card: Container
A surface panel — a themed, rounded, shadowed container that groups related content. It is pure chrome (no state, no interaction), so it takes children directly and pads them by the theme’s spacing.
Card(
col(spacing = 8)(
text("Title"),
text("Some grouped content."),
),
)
Dialog
def Dialog(
open: Boolean,
onClose: () => Unit,
maskClosable: Boolean = true,
exitMs: Int = 200,
width: Double = 420,
)(children: VNode*): VNode
A modal dialog — content centred above a dimming scrim that takes over the window until
dismissed. It is controlled: the caller owns open and is told to close through
onClose, which fires on a click on the scrim (when maskClosable), the Escape key, or
anything the caller wires inside the body (a Close button).
The dialog is portaled into the overlay layer, so it escapes any clipping or scrolling of
the place that opened it and always paints on top — the Dialog node can sit anywhere in the
tree. Opening moves focus into the dialog and traps Tab within it; Escape closes it from
anywhere inside; closing restores focus to whatever held it before. The scrim and card
fade in and the dialog stays mounted through its close animation (exitMs) before unmounting,
via usePresence + useTransition.
The content is capped to width pixels — sized to its content but never wider — so body
text has a width to wrap into. Remember text is single-line by default, so pass it maxLines
other than 1 (e.g. maxLines = 0 for unlimited) to wrap. Pass width = Double.NaN to leave the
card uncapped (a long single line then grows it unbounded).
The overlay layer is provided by Suit.run; with none available (outside a running app) the
dialog renders nothing. A headless test wires its own through OverlayContext — see
DialogSpec.
val (open, setOpen, _) = useState(false)
col(spacing = 16)(
Button("Open dialog", () => setOpen(true)),
Dialog(open, () => setOpen(false))(
col(spacing = 16)(
text("A modal dialog", size = 18),
text("It dims the rest and traps focus until dismissed."),
row(mainAxisAlignment = MainAxisAlignment.End)(
Button("Close", () => setOpen(false)),
),
),
),
)
Menu
def Menu(
open: Boolean,
onClose: () => Unit,
anchor: Ref[RenderObject | Null],
exitMs: Int = 150,
width: Double = 180,
placement: Placement = Placement(),
)(items: VNode*): VNode
val MenuItem: Component2[String, () => Unit]
A dropdown menu anchored to a trigger. Like the dialog it is controlled — the caller owns
open and is told to close through onClose — but it is positioned: it portals into the
overlay layer and floats just below the trigger, flipping above it near the bottom edge and
sliding left to stay on-screen.
Give the trigger a ref and hand the same ref to Menu as anchor, so the menu can read the
trigger’s on-screen rectangle:
val (open, setOpen, _) = useState(false)
val anchor = useRef[RenderObject | Null](null)
row(spacing = 16)(
box(ref = anchor)(
Button("Options", () => setOpen(true)),
),
Menu(open, () => setOpen(false), anchor)(
MenuItem("Rename", () => setOpen(false)),
MenuItem("Delete", () => setOpen(false)),
),
)
A click anywhere outside the menu dismisses it (a transparent full-window catcher, not a
dimming scrim), as does Escape; opening traps Tab within the menu and restores focus on close.
Fill it with MenuItems — focusable rows that call their onSelect on a click or on Space /
Enter while focused, and highlight on hover. Wire each onSelect to do the action and close the
menu.
Pass a placement to prefer a different side or add a gap (see Placement below);
the menu still flips and slides to stay on-screen.
Select
def Select(
options: Seq[(String, String)], // value -> label
selected: String,
onChange: String => Unit,
placeholder: String = "Select…",
width: Double = 200,
exitMs: Int = 150,
): VNode
A dropdown select: a field-styled trigger showing the current choice, which opens a menu of
options (each a value -> label pair) on click or from the keyboard (Space / Enter; Escape
closes). It is controlled — the caller owns selected (the chosen value) and is told the new
value through onChange. When selected matches no option, the placeholder shows, muted, as a
prompt.
val (colour, setColour, _) = useState("")
Select(
Seq("red" -> "Red", "green" -> "Green", "blue" -> "Blue"),
colour,
setColour,
placeholder = "Pick a colour",
)
It rides the same anchored-overlay mechanism as Menu: the dropdown portals into the overlay
layer just below the trigger (flipping above near the bottom edge), dismisses on an outside click
or Escape, and traps focus while open. width fixes both the trigger and the dropdown.
Context menu
def contextMenu(
width: Double = 200,
placement: Placement = Placement(),
)(trigger: VNode)(items: (() => Unit) => Seq[VNode]): VNode
A right-click context menu around a trigger. A right-press anywhere on the trigger opens a menu
at the cursor (not beside the trigger); a left-click passes through untouched. The items are
built from a close callback, so a selected item does its work and then closes the menu:
contextMenu()(
box(padding = EdgeInsets.all(24))(text("right-click me")),
) { close =>
Seq(
MenuItem("Cut", () => { cut(); close() }),
MenuItem("Copy", () => { copy(); close() }),
MenuItem("Paste", () => { paste(); close() }),
)
}
Like Menu it portals into the overlay layer, dismisses on an outside click or Escape, and traps
focus while open. See ContextMenuSpec for the headless harness.
Menu bar
case class MenuEntry(label: String, items: (() => Unit) => Seq[VNode])
def menu(label: String)(items: (() => Unit) => Seq[VNode]): MenuEntry
def menuBar(menus: MenuEntry*): VNode // dropdowns 200px wide
def menuBar(width: Double)(menus: MenuEntry*): VNode // a different dropdown width
An application menu bar — the horizontal File / Edit / View … strip across the top of a
window. Each top-level menu carries a label and an item builder (the same close-callback shape
the context menu uses). Clicking a label opens its dropdown below it; with one open, moving the
pointer onto another label slides the open menu to it — the standard menu-bar sweep. A click
outside the open menu, or Escape, closes it; choosing an item runs its action and closes the menu.
menuBar(
menu("File")(close =>
Seq(
MenuItem("New", () => { newDoc(); close() }),
MenuItem("Open…", () => { openDoc(); close() }),
),
),
menu("Edit")(close =>
Seq(
MenuItem("Undo", () => { undo(); close() }),
MenuItem("Redo", () => { redo(); close() }),
),
),
)
It is built entirely from the toolkit’s own widgets and overlay layer — there is no OS menu bar —
so it looks and behaves identically on every platform. See MenuBarSpec for the headless harness.
Placement
case class Placement(
side: PopoverSide = PopoverSide.Below, // Below | Above | Right | Left
gap: Double = 0.0,
align: PopoverAlign = PopoverAlign.Start, // Start | Center | End
)
How a positioned overlay (Menu, Tooltip) places itself against its trigger: the preferred
side, the gap in pixels between trigger and card, and the cross-axis align. It expresses a
preference, not a fixed position — the popover flips to the opposite side when the preferred one
would run off-screen, and slides along the cross axis to stay visible. The default — below, flush,
left-aligned — is the ordinary dropdown placement.
Menu(open, onClose, anchor, placement = Placement(side = PopoverSide.Above, gap = 6))(...)
Tooltip("Saved", placement = Placement(side = PopoverSide.Above))(text("Drafts"))
Tooltip
def Tooltip(
label: String,
delayMs: Int = 400,
exitMs: Int = 120,
placement: Placement = Placement(),
)(trigger: VNode*): VNode
A small label that appears beside its trigger on hover. Wrap the trigger as the child; the
tooltip attaches the hover tracking and an anchor itself, so callers wire nothing. It portals
into the overlay layer and floats just below the trigger (flipping and sliding to stay
on-screen), and is click-through — it never intercepts a click meant for what is underneath.
It shows after a short hover delayMs and fades on both ends. Pass a placement to prefer a
different side — a tooltip often reads better above its trigger.
Tooltip("Saved automatically.")(
text("Drafts"),
)
Both Menu and Tooltip need the overlay layer that Suit.run provides; outside a running app
(or a test that does not wire one) the menu and the floating label render nothing — the tooltip’s
trigger still shows. A headless test wires an overlay through OverlayContext — see MenuSpec /
TooltipSpec.
Data table
def dataTable(
columns: Seq[String],
rows: IndexedSeq[IndexedSeq[String]],
selected: Int = -1,
onSelect: (Int => Unit) | Null = null,
rowHeight: Double = 28.0,
): VNode
A data grid: a header row of columns over a body of string rows (each row a sequence of cell
strings indexed to match the columns). Columns auto-size to their content, the body scrolls
with the wheel and is virtualized — only the rows under the viewport are ever built (see
Virtual list) — so a large result set stays cheap. selected marks a row in the
accent and onSelect reports the clicked row’s index; alternate rows are zebra-striped. Wide
tables scroll horizontally.
Columns are sortable and resizable with no extra wiring: clicking a header sorts the rows by
that column (a caret shows the direction; clicking again reverses it), and dragging the thin handle
on a header’s right edge resizes the column. The sort is a lexicographic string compare, so a
numeric column should be zero-padded or otherwise pre-formatted to sort as expected. selected and
onSelect are always in terms of the original row index, so the caller’s selection stays put
no matter how the view is sorted.
val (sel, setSel, _) = useState(-1)
box(flex = 1)( // a bounded height — the grid's scrolling viewport
dataTable(
columns = Vector("id", "name", "email"),
rows = users.map(u => Vector(u.id.toString, u.name, u.email)),
selected = sel,
onSelect = setSel,
),
)
dataTable (and virtualList) must be given a bounded height — a flex slot, a fixed height,
or a sized box — because that height is the viewport it windows against. In a loose (content-sized)
parent it has no viewport to measure.
Virtual list
def virtualList(itemCount: Int, itemExtent: Double, overscan: Int = 3)(
builder: Int => VNode,
): VNode
A vertically virtualized list: only the items under the viewport (plus a little overscan) are
ever built, so a list of many thousands of fixed-height rows costs the handful on screen rather
than all of them. builder(i) produces item i on demand and itemExtent is each item’s fixed
height, which is what makes the windowing exact. The wheel scrolls it — and a wheel it cannot use
(the list is at an end, or too short to scroll at all) passes out to the view around it, so a
cursor resting on a short list never kills the page scroll under it; see
Chaining. Like dataTable it fills the space its parent gives and
must be given a bounded height — that height is the viewport.
box(flex = 1)(
virtualList(itemCount = 10000, itemExtent = 24) { i =>
box(padding = EdgeInsets.symmetric(horizontal = 8, vertical = 0))(text(s"Row $i"))
},
)
Splitter
def splitter(
axis: Axis = Axis.Horizontal,
initial: Double = 0.5,
min: Double = 0.1,
max: Double = 0.9,
gutter: Double = 6.0,
onResize: (Double => Unit) | Null = null,
)(first: VNode, second: VNode): VNode
A resizable split of two panes with a draggable gutter between them — the layout a
sidebar-plus-content or an editor-plus-preview window is built from. axis chooses the
arrangement: Axis.Horizontal (the default) sets the panes side by side with a vertical gutter;
Axis.Vertical stacks them with a horizontal one.
It is uncontrolled — it owns the split position, starting at initial (the first pane’s
fraction of the area, 0..1) and clamped between min and max so neither pane can be dragged
shut. Drag the gutter to resize, or focus it and use the arrow keys (Left/Right, or Up/Down on a
vertical split); pass onResize to observe the fraction (e.g. to persist it). The panes resize
through the flex layout, so the split holds its proportion when the window resizes, and each pane is
clipped to its share, so content that outgrows it is cut rather than spilling across the gutter.
Give the splitter a bounded size (a flex slot, a fixed height, or a sized box): it fills the area it is given and divides that.
box(flex = 1)(
splitter(initial = 0.25, min = 0.15, max = 0.5)(
sidebarContent, // the first (left) pane
mainContent, // the second (right) pane fills the rest
),
)
Scroll area
def scrollArea(
axis: Axis = Axis.Vertical,
both: Boolean = false,
thickness: Double = 8.0,
ref: Ref[RenderObject | Null] | Null = null,
onScroll: (Offset => Unit) | Null = null,
)(children: VNode*): VNode
A scrolling viewport with a visible, draggable scrollbar — the themed counterpart to the bare
scrollView DSL primitive, which is wheel-only. The bar rides the
trailing edge (the right edge for a vertical area, the bottom for a horizontal one), appears only
when the content overflows, and can be dragged to scroll as well as turned by the wheel; its
colours come from the active theme.
Like scrollView it must be given a bounded size along the scroll axis — that extent is the
viewport it scrolls within — and takes a single content node (wrap several in a col/row).
Pass both = true for a viewport that scrolls on both axes at once: the content keeps its
natural size in either direction and a bar appears on each axis that overflows. Useful for content
with a fixed intrinsic size larger than the viewport — a document page, an image, a wide table —
that should neither wrap nor shrink to fit.
Driving it from code. ref reaches the viewport’s RenderScroll, whose scrollOffset,
maxScroll, scrollBy (and the per-axis offsetX / offsetY, scrollByX / scrollByY for a
biaxial viewport) move it. onScroll reports the offset whenever the view moves — by the wheel, by
a drag of the bar, or by a caller setting it — so a position indicator cannot drift out of step with
what is on screen:
val view = useRef[RenderObject | Null](null)
val (atTop, setAtTop, _) = useState(true)
def toTop(): Unit =
view.current match
case r: RenderScroll => r.scrollOffset = 0
case _ => ()
scrollArea(ref = view, onScroll = off => setAtTop(off.y <= 0))(content)
sizedBox(height = 240)(
scrollArea()(
col(mainAxisSize = MainAxisSize.Min)(
rows.map(r => box(padding = EdgeInsets.all(8))(text(r)))*,
),
),
)
Theme
case class Theme(
primary, primaryHover, primaryActive, onPrimary: Color,
background, surface, surfaceText, border, accent, track: Color,
info, success, warning, danger: Color,
radius, spacing, textSize: Double,
isDark: Boolean,
)
object Theme:
val dark: Theme // stock dark-blue (the context default)
val light: Theme // stock light-blue — the light counterpart to dark
val violetDark: Theme // violet accent, dark scheme
val violetLight: Theme // violet accent, light scheme
val default: Theme // == dark
val builtIns: List[Theme] // [dark, light, violetDark, violetLight] — for a theme picker
def ThemeProvider(theme: Theme)(children: VNode*): VNode
def useTheme()(using Hooks): Theme
Styling is a general system, not baked into the widgets. A Theme is a record of palette
(background is the app body behind everything, surface an elevated panel on top of it;
the info / success / warning / danger status roles are what an Alert paints from) and
metric tokens, plus an isDark flag recording the colour scheme. The built-in widgets read it
through useTheme() and paint from whatever the nearest enclosing ThemeProvider supplies (or
Theme.default if there is none). Swap one record at the top of the tree and every control
below restyles — no widget code is touched.
There are four built-in themes — a blue and a violet accent, each in a light and a dark scheme. Switching light/dark is just choosing which record to provide, so an app holds the choice in state and a toggle flips it:
val (dark, setDark, _) = useState(true)
val theme = if dark then Theme.dark else Theme.light
ThemeProvider(theme)(
box(bg = theme.background)(
col(spacing = 16)(
Switch(dark, setDark), // flips the whole tree light/dark
Button("Save", onSave),
Checkbox(on, setOn),
),
),
)
A custom theme is a copy of any built-in — override the brand colours and keep the rest:
val brand = Theme.dark.copy(
primary = Color.rgb(0x9775fa),
accent = Color.rgb(0x9775fa),
radius = 10.0,
)
A controlled-widget example
Because Checkbox and Slider are controlled, the pattern is always the same: hold the
value in useState, render the widget with it, and pass the setter as onChange.
val App = view {
val (on, setOn, _) = useState(true)
val (level, setLevel, _) = useState(0.5)
col(spacing = 16)(
row(crossAxisAlignment = CrossAxisAlignment.Center, spacing = 8)(
Checkbox(on, setOn),
text(if on then "on" else "off", color = Color.white),
),
text(s"${(level * 100).toInt}%", color = Color.white),
box(width = 240)(Slider(level, setLevel)),
)
}