-
Notifications
You must be signed in to change notification settings - Fork 0
Getting started
In this guide you will create a small reactive counter, run it in a real terminal, and make two changes that exercise state, layout, styling, and keyboard commands.
You need: JDK 21 or newer, Scala 3, and either Mill or sbt. Run the final app from a terminal—not an IDE output panel—because raw input needs a controlling TTY.
The normal application dependency is tui-dsl. It brings in the core types,
widgets, terminal backend, and runtime transitively.
package build
import mill.*, scalalib.*
object app extends ScalaModule:
def scalaVersion = "3.7.1"
def mvnDeps = Seq(mvn"io.worxbend::tui-dsl:0.10.0")Put application sources under app/src/, then run them with mill app.run (or
./mill app.run when your project checks in the Mill launcher).
scalaVersion := "3.7.1"
libraryDependencies += "io.worxbend" %% "tui-dsl" % "0.10.0"Put application sources under src/main/scala/, then use sbt run.
Why depend on tui-dsl instead of every module?
tui-dsl is the batteries-included application layer. It exports TuiApp, element
factories, style and layout extensions, reactive state, widgets, terminal events,
and runtime effects. Lower-level module dependencies are useful only when you are
embedding glyphora or writing a widget library; see Architecture.
import io.worxbend.tui.dsl.*
object Counter extends TuiApp:
private val count = Signal(0)
override def bindings = KeyBindings(
binding("+", "increment")(count.update(_ + 1)),
binding("-", "decrement")(count.update(_ - 1)),
binding("q", "quit")(quit()),
)
def view(using ReactiveScope): Element =
scaffold(statusBar = Some(statusBar(bindings))) {
centered(34, 7) {
panel("Counter")(
text(s"Count: ${count.get}").bold.color(Color.Cyan),
spacer,
text("Change state; the view follows.").dim,
).rounded
}
}
def main(args: Array[String]): Unit =
run().foreach(_ => ())Run it in a terminal:
# Mill
./mill app.run
# sbt
sbt runPress + and - to change the value; press q to exit. The runner enters raw mode,
uses the alternate screen, hides the cursor, and restores the terminal when it
finishes.
| Code | Responsibility |
|---|---|
extends TuiApp |
owns the event/render lifecycle and terminal-safe cleanup |
Signal(0) |
stores mutable state and invalidates views that read it |
bindings |
declares global commands once for dispatch, help, palette, and status hints |
view(using ReactiveScope) |
tracks signal reads while returning an element tree |
scaffold(...) |
composes optional top bar, sidebar, content, and status bar |
centered(34, 7) |
gives the panel a fixed area centered in available space |
.rounded, .bold, .color(...)
|
type-safe element decoration and style extensions |
run() |
opens the backend and blocks until quit() or an unconsumed Ctrl+C
|
The key detail is count.get. That tracked read connects this view to count.
Calling count.update marks it stale; the runtime schedules a redraw and rebuilds
the view. There is no manual refresh call.
Use Computed for a value that depends on one or more signals:
private val count = Signal(0)
private val parity = Computed(if count.get % 2 == 0 then "even" else "odd")
// inside view
text(s"${count.get} is ${parity.get}")Rows and columns divide their available area using constraints:
row(
panel("Value")(text(count.get.toString)).percent(40),
panel("Parity")(text(parity.get)).fill,
).length(5)Here the row is five cells high. Its first child receives 40% of the width and the second consumes the remainder.
Global commands belong in bindings. Interaction that belongs to one element can
stay beside that element:
panel("Counter")(text(count.get.toString))
.onKey(Key.char('r')) { count.set(0) }Local handlers run before global bindings. A low-level .onKeyEvent handler returns
true to stop bubbling or false to let the parent/global binding see the event.
The process does not have a controlling TTY. Run it from a normal terminal window;
for CI or unit tests, inject HeadlessBackend instead. See Testing.
Key handlers, mouse handlers, and onTick already run on the render thread.
Callbacks from Future, an HTTP client, or another executor must hop back before
changing a signal:
Future(loadData()).foreach { result =>
RenderThread.runOnRenderThread {
data.set(result)
}
}The complete pattern is in Async work & timers.
- Layout & style — rows, columns, constraints, borders, and reusable visual language.
- State & signals — tracked reads, computed values, and render-thread rules.
- Widget catalog — choose the right building blocks.
- The app shell — themes, sidebars, screens, toasts, and the palette.
- Examples — run seven complete applications from the repository.
Documentation is maintained in website/docs. Read the styled guide · API reference · MIT license