Skip to content

Cookbook

w0rxbend edited this page Aug 1, 2026 · 1 revision

Cookbook

These recipes solve common application shapes with the high-level DSL. Unless a snippet shows additional imports, assume:

import io.worxbend.tui.dsl.*

State objects shown as fields belong on the app or owning screen—never recreate them inside view.

Minimal app with concise keys

object Hello extends TuiApp:
  def view(using ReactiveScope): Element =
    centered(36, 7) {
      panel("Hello")(
        text("Welcome to glyphora").bold.color(Color.Cyan),
        text("q quit").dim,
      ).rounded.onKey(Key.char('q')) { quit() }
    }

  def main(args: Array[String]): Unit =
    run().foreach(_ => ())

Use .onKey for exact key/action pairs; use .onKeyEvent only when the handler must inspect modifiers or decide whether to consume.

Fixed sidebar, fluid content

row(
  panel("Projects")(projectList).length(26),
  panel("Workspace")(workspace).fill,
).gap(1)

Inside a row, .length(26) means columns. Inside a column, it means rows. .fill claims what fixed and percentage siblings leave behind.

Weighted dashboard columns

row(
  panel("Queue")(queueGauge).fill(1),
  panel("Throughput")(throughputChart).fill(2),
  panel("Errors")(errorCount).fill(1),
).gap(1).length(12)

The middle panel gets twice the remaining width of either side panel.

Filter a list as the user types

import io.worxbend.tui.widgets.{ListState, TextInputState}

private val query = TextInputState()
private val selection = ListState()
private val projects = Signal(Vector("atlas", "borealis", "glyphora"))

def projectPicker(using ReactiveScope): Element =
  val needle = query.value.trim.toLowerCase
  val visible = projects.get.filter(_.toLowerCase.contains(needle))

  column(
    input(query, placeholder = "filter projects").key("project-query"),
    rule(s"${visible.size} matches"),
    list(visible, selection).key("project-list").fill,
  )

Input's built-in handler requests a redraw after editing, so query.value is fresh on the next view. Keep derived filtering local unless several parts of the app need the result.

Keep commands in one registry

override def bindings = KeyBindings(
  binding("ctrl+r", "refresh data")(reload()),
  binding("ctrl+s", "save changes")(save()),
  binding("?", "show keyboard help") {
    pushScreen(Screen(centered(48, 12)(helpOverlay(bindings))))
  },
  binding("esc", "quit")(quit()),
)

def view(using ReactiveScope): Element =
  scaffold(statusBar = Some(statusBar(bindings)))(content)

The same declarations dispatch commands, render status hints, populate help, and feed the Ctrl+P palette.

Confirm an action in a modal

private def confirmDelete(name: String): Unit =
  pushScreen(Screen {
    centered(44, 8) {
      panel("Delete deployment?")(
        text(name).bold,
        text("This cannot be undone.").color(Color.Red),
        text("Enter confirm · Esc cancel").dim,
      ).rounded
        .onKey(Key.Enter) {
          delete(name)
          popScreen()
          notify("Deployment deleted", ToastLevel.Success)
        }
        .onKey(Key.Escape) { popScreen() }
    }
  })

The modal removes the underlying view from tab order automatically.

Load data without blocking the UI

private enum LoadState[+A]:
  case Idle, Loading
  case Ready(value: A)
  case Failed(message: String)

private val rows = Signal[LoadState[Vector[Row]]](LoadState.Idle)

private def reload(): Unit =
  rows.set(LoadState.Loading)
  Async.runCatching(api.loadRows()) {
    case Right(value) => rows.set(LoadState.Ready(value))
    case Left(error)  => rows.set(LoadState.Failed(error.getMessage))
  }

def rowsView(using ReactiveScope): Element = rows.get match
  case LoadState.Idle            => text("Press r to load.").dim
  case LoadState.Loading         => row(spinner(0), text(" loading…"))
  case LoadState.Ready(value)    => renderRows(value)
  case LoadState.Failed(message) => text(s"Error: $message").color(Color.Red)

Async.runCatching performs work on a daemon worker and delivers the Either on the render thread.

Poll with cancellation

import scala.concurrent.duration.*

private var poller: Cancelable = Cancelable.noop

private def startPolling(): Unit =
  poller.cancel()
  poller = Async.every(15.seconds) { reload() }

private def stopPolling(): Unit =
  poller.cancel()

Make the component or screen that starts work responsible for canceling it.

Sort, filter, and select tabular data

import io.worxbend.tui.widgets.{DataTable, DataTableState}

private val tableState = DataTableState()
private val table = DataTable(
  columns = Seq("Service", "Status", "Replicas"),
  rows = Seq(
    Seq("api", "ready", "3"),
    Seq("worker", "scaling", "12"),
  ),
  widths = Seq(
    Constraint.Fill(2),
    Constraint.Fill(1),
    Constraint.Length(9),
  ),
)

def deployments: Element =
  dataTable(table, tableState).onKey(Key.Enter) {
    tableState.selected
      .flatMap(table.visibleRows(tableState).lift)
      .foreach(openDeployment)
  }

Selection indexes the filtered/sorted visibleRows, which is why the action maps through that method.

Build tabs with reactive selection

private val activeTab = Signal(0)

def workspace(using ReactiveScope): Element =
  tabbedContent(
    "Overview" -> overview,
    "Events"   -> eventLog,
    "Settings" -> settings,
  )(activeTab)

The element tree contains only the selected page. Arrow/click behavior updates the selection signal and rebuilds the correct branch.

Scroll measured Markdown

import io.worxbend.tui.widgets.ScrollViewState

private val scroll = ScrollViewState()

def releaseNotes: Element =
  panel("Release notes")(
    scrollView(markdown(notesSource), scroll)
  ).rounded

markdown reports its height for the current width, so the two-argument scrollView can measure it automatically and show a scrollbar only when needed.

Create and validate a case-class form

import io.worxbend.tui.macros.{deriveForm, Field}

final case class Deploy(name: String, replicas: Int, dryRun: Boolean)

private val form = FormState.of(
  deriveForm[Deploy],
  Field.text("name").mapValidated { value =>
    if value.trim.nonEmpty then Right(value.trim) else Left("required")
  },
  Field.int("replicas").mapValidated { value =>
    if value >= 1 && value <= 20 then Right(value)
    else Left("must be from 1 to 20")
  },
)

def formView(using ReactiveScope): Element =
  Form(form).onKey(Key.CtrlS) { form.submit() }

Read form.result.get to react to valid submission and form.errors.get when you need custom error presentation.

Switch themes at runtime

private val themes = Vector(Theme.Dark, Theme.Light, Theme.HighContrast)
private val themeIndex = Signal(0)

override def theme = themes(themeIndex.peek)

override def bindings = KeyBindings(
  binding("ctrl+t", "switch theme") {
    themeIndex.update(i => (i + 1) % themes.size)
  }
)

def view(using ReactiveScope): Element =
  given Theme = theme
  val _ = themeIndex.get
  scaffold(statusBar = Some(statusBar(bindings)))(content)

The explicit tracked read invalidates the tree; semantic theme roles repaint the chrome.

Add a countdown

import io.worxbend.tui.runtime.{RunnerConfig, Timer}
import scala.concurrent.duration.*

private val timer = Timer(30.seconds)

override def config = RunnerConfig(tickRate = Some(100.millis))

override def onTick(): Unit =
  timer.tick(100.millis)
  if timer.justExpired() then notify("Time is up", ToastLevel.Warning)

def clock: Element = text(timer.formatted).bold

Call timer.start(), stop(), toggle(), or reset() from handlers.

Test an interaction end to end

val backend = HeadlessBackend(Size(50, 10))
val app = TodoApp()
val pilot = Pilot.start(backend) {
  val _ = app.runWith(backend)
}

pilot
  .waitForIdle()
  .typeText("ship docs")
  .pressKey(KeyCode.Enter)
  .waitForIdle()

assert(pilot.screenText.contains("· ship docs"))

Pilot is currently repository test support; the same pattern can be implemented directly over the public HeadlessBackend. See Testing.

Render a custom widget through the DSL

import io.worxbend.tui.core.*
import io.worxbend.tui.widgets.Paragraph

val raw = Paragraph(Text.raw("custom renderer"), wrap = true)

def view(using ReactiveScope): Element =
  panel("Embedded")(widget(raw).fill).rounded

The element wrapper is an escape hatch, not a second rendering system. For a reusable interactive widget, follow the checklist in Contributing.

Clone this wiki locally