-
Notifications
You must be signed in to change notification settings - Fork 0
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
import io.worxbend.tui.widgets.ScrollViewState
private val scroll = ScrollViewState()
def releaseNotes: Element =
panel("Release notes")(
scrollView(markdown(notesSource), scroll)
).roundedmarkdown reports its height for the current width, so the two-argument
scrollView can measure it automatically and show a scrollbar only when needed.
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.
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.
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).boldCall timer.start(), stop(), toggle(), or reset() from handlers.
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.
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).roundedThe element wrapper is an escape hatch, not a second rendering system. For a reusable interactive widget, follow the checklist in Contributing.
Documentation is maintained in website/docs. Read the styled guide · API reference · MIT license