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…

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.

Note

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).

Note

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:

  • onChangeStart fires once with the value at the press point when the thumb is grabbed.
  • onChangeEnd fires 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),
)
Note

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),
    ),
  ),
)
Note

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)),
      ),
    ),
  ),
)
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.

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"),
)
Note

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,
  ),
)
Note

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)),
  )
}

Search

Esc
to navigate to open Esc to close