tui4s
Guide

Composables

tui4s-use, plain functions over Kyo signals for the JVM and Scala.js; first, what needs no composable.

You may not need a composable

Kyo does most of the work in one or two lines. A SignalRef holds state, Signal.map derives a value, and Signal.observe runs a setup for each value in a new Scope. These five need no library:

Native.scala
    /** A toggle: `updateAndGet` on a `SignalRef`. */
    def toggle(on: SignalRef[Boolean])(using Frame): Unit < Sync =
      on.updateAndGet(!_).unit

    /** A counter that stops at `max`. */
    def increment(count: SignalRef[Int], max: Int)(using Frame): Unit < Sync =
      count.updateAndGet(n => (n + 1).min(max)).unit

    /** An interval: a fiber that the caller's `Scope` ends. */
    def every(period: Duration)(action: => Any < Async)(using
        Frame,
    ): Unit < (Sync & Scope) =
      Fiber.init(Loop.forever(Async.sleep(period).andThen(action))).unit

    /** A timer that pauses: `Signal.observe` starts the ticks while `paused` is
      * false, and interrupts them when it changes.
      */
    def timer(paused: Signal[Boolean], ticks: SignalRef[Int], period: Duration)(
        using Frame,
    ): Unit < (Sync & Scope) =
      Fiber
        .init(
          paused.observe(p =>
            if p then Kyo.unit
            else every(period)(ticks.updateAndGet(_ + 1)),
          ),
        )
        .unit

    /** A derived value: `Signal.map`. */
    def label(count: Signal[Int])(using Frame): Signal[String] =
      count.map(n => s"$n items")

Add tui4s-use

build.sbt
libraryDependencies += "io.github.yuri4n" %% "tui4s-use" % "<version>"
// a Scala.js or a cross-built project:
libraryDependencies += "io.github.yuri4n" %%% "tui4s-use" % "<version>"

tui4s-use does not depend on tui4s-core. It builds for the JVM and for Scala.js, so a composable works in the terminal and on the web.

The composables

ComposableWhat it gives
useNow(every)The time of Kyo's Clock as a signal.
timeAgo(at, now, format)"5 minutes ago", with a formatter that you can replace.
debounced(s, quiet), throttled(s, every)A signal after a quiet time, or at most once per window.
useDebouncedFn(quiet)(f)A function that runs f after a quiet time.
useAsync(input)(f)The AsyncState of the work for the current input. A new input interrupts the old work.
useStreamFold(s, zero)(f), useStreamText(s)A stream folded into a signal, with the status of the stream.
Keymap.of(bindings*)A key map as data: a handler for onKeyDown and the key hints.
useSelection(items, key)The selected item by its key, with the moves.
useScrollWindow(items, size, selected)The visible slice of a long list.
useDialog[Q, A]A modal flow: open waits for the answer, launch starts it from a key handler.
useHistory(ref, limit)Undo and redo over a SignalRef.
usePersisted(key, initial, storage)A SignalRef kept in a Storage: files on the JVM, localStorage in a browser.
useProcess(cmd)A program that runs (JVM only): its output lines and its exit code.

A composable that starts a fiber needs the Scope of its caller. Call a composable once, where its state lives: at the start of the app, or in the body of a component. Do not call it in a render, when or foreach closure: such a closure runs again on each change.

Recipes

A search as the user types (debounced and useAsync):

Recipes.scala
    /** A search as the user types: `debounced` waits until the query is quiet
      * for 200 ms, and `useAsync` runs `find` for it. A new query interrupts
      * the search for the old one.
      */
    def search(find: String => Chunk[String] < Async)(using
        Frame,
    ): UI < (Sync & Scope) =
      for
          query   <- Signal.initRef("")
          quiet   <- debounced(query, 200.millis)
          results <- useAsync(quiet.map(q => Maybe.when(q.nonEmpty)(q)))(find)
      yield div(
        input.value(query).focusAuto(true),
        results.map {
          case AsyncState.Idle       => span("type to search")
          case AsyncState.Loading    => span("searching...")
          case AsyncState.Done(rows) => span(s"found: ${rows.mkString(", ")}")
          case AsyncState.Failed(e)  => span(s"failed: $e")
        },
      )

The keys of an app as data, and how long ago the last sample came (Keymap.of, useNow, timeAgo):

Dashboard.scala
    /** The keys of the Dashboard as data: the root handler runs them, and the
      * help box lists them.
      */
    def keys(s: State)(using Frame): Keymap =
      Keymap.of(
        Binding.of("pause or resume the sampler", Chord.char('p'))(pause(s)),
        Binding.of("this help (Esc closes it)", Chord.char('?'))(
          s.help.set(true),
        ),
        Binding.of("quit", Chord.char('q'))(Tui.quit),
      )

    /** How long ago the last sample came ("updated 5 seconds ago"), from a
      * clock that ticks each second; the clock stops with the `Scope` of the
      * app.
      */
    def stamp(s: State)(using Frame): UI < (Sync & Scope) =
      for now <- useNow(1.second)
      yield span(timeAgo(s.updated, now).map(t => s"updated $t"))
        .style(_.color(Style.Color.gray))

A long list with a selection and a visible window (useSelection and useScrollWindow):

Recipes.scala
    /** A long list that shows 5 rows: `useSelection` keeps the selected row by
      * its key, and `useScrollWindow` gives the rows to show. The arrows move
      * the selection.
      */
    def list(items: Signal[Chunk[String]])(using Frame): UI < (Sync & Scope) =
      for
          selection <- useSelection(items, identity[String])
          window    <- useScrollWindow(items, Signal.initConst(5), selection.index)
          keys       = Keymap.of(
                         Binding.of("up", Chord(Keyboard.ArrowUp))(selection.moveUp),
                         Binding.of("down", Chord(Keyboard.ArrowDown))(
                           selection.moveDown,
                         ),
                       )
      yield div
        .tabIndex(0)
        .focusAuto(true)
        .onKeyDown(keys.handler)(
          window
            .combineLatest(selection.key)
            .map((w, key) =>
              div(
                UI.fragment(
                  w.slice.map(row =>
                    span(if key.contains(row) then s"> $row" else s"  $row"),
                  )*,
                ),
              ),
            ),
        )

A delete that asks first (useDialog). A key handler runs on the runner, one at a time, so it starts the flow with launch, which never waits:

Recipes.scala
    /** A delete that asks first: `d` starts a flow (`launch`) that opens the
      * dialog and waits for its answer; `y` or `n` answers.
      */
    def confirm(items: SignalRef[Chunk[String]])(using
        Frame,
    ): UI < (Sync & Scope) =
      for
          ask   <- useDialog[String, Boolean]
          delete = items.get.map(xs =>
                     if xs.isEmpty then Kyo.unit
                     else
                         ask
                           .open(s"Delete ${xs.head}?")
                           .map(yes =>
                             if yes then items.updateAndGet(_.drop(1)).unit
                             else Kyo.unit,
                           ),
                   )
          keys   = Keymap.of(
                     Binding.of("delete", Chord.char('d'))(ask.launch(delete)),
                     Binding.of("yes", Chord.char('y'))(ask.close(true)),
                     Binding.of("no", Chord.char('n'))(ask.close(false)),
                   )
      yield div
        .tabIndex(0)
        .focusAuto(true)
        .onKeyDown(keys.handler)(
          span(items.map(_.mkString(", "))),
          span(ask.request.map(_.fold("")(q => s"$q (y/n)"))),
        )

An answer that comes in pieces (useStreamText):

Recipes.scala
    /** An answer that comes in pieces (the tokens of a chat model):
      * `useStreamText` joins them, and its status says when the answer is
      * complete.
      */
    def answer(tokens: Stream[String, Async])(using
        Frame,
    ): UI < (Sync & Scope) =
      for reply <- useStreamText(tokens)
      yield div(
        span(reply.value),
        span(reply.status.map {
          case StreamStatus.Running   => "..."
          case StreamStatus.Done      => "(complete)"
          case StreamStatus.Failed(e) => s"(failed: ${e.getMessage})"
        }),
      )

A note that saves itself after the edits stop (useDebouncedFn):

Recipes.scala
    /** A note that saves itself: each edit starts the wait again, and `save`
      * runs 300 ms after the last edit, with the last text.
      */
    def autosave(save: String => Unit < Async)(using
        Frame,
    ): UI < (Sync & Scope) =
      for
          note   <- Signal.initRef("")
          status <- Signal.initRef("saved")
          later  <- useDebouncedFn[String](300.millis)(text =>
                      save(text).andThen(status.set("saved")),
                    )
      yield div(
        input
          .value(note)
          .focusAuto(true)
          .onInput(text => status.set("editing").andThen(later(text))),
        span(status),
      )

A progress that changes faster than the screen needs (throttled):

Recipes.scala
    /** A progress that changes faster than the screen needs: `throttled` shows
      * it at most once per 100 ms, and the last value always shows.
      */
    def progress(done: Signal[Int])(using Frame): UI < (Sync & Scope) =
      for shown <- throttled(done, 100.millis)
      yield span(shown.map(n => s"$n %"))

A setting that the next run keeps (usePersisted):

Recipes.scala
    /** A setting that the next run keeps: `usePersisted` reads it from
      * `storage` and writes each change. `t` changes it. An app gives
      * `Storage.forApp("<app>")`; a test gives `Storage.inMemory`.
      */
    def setting(storage: Storage)(using Frame): UI < (Sync & Scope) =
      for
          dark <- usePersisted("dark", false, storage)
          keys  = Keymap.of(
                    Binding.of("theme", Chord.char('t'))(dark.updateAndGet(!_)),
                  )
      yield div
        .tabIndex(0)
        .focusAuto(true)
        .onKeyDown(keys.handler)(
          span(dark.map(d => if d then "theme: dark" else "theme: light")),
        )

The output and the exit code of a program (useProcess, on the JVM):

Recipes.scala
    /** The output of a program, a line at a time, and its exit code:
      * `useProcess` starts it, and the end of the `Scope` kills it.
      */
    def process(cmd: Chunk[String])(using Frame): UI < (Sync & Scope) =
      Abort.run[ProcessError](useProcess(cmd)).map {
        case Result.Success(run) =>
          useStreamFold(run.lines, Chunk.empty[String])((ls, l) =>
            (ls :+ l).takeRight(5),
          ).map(out =>
            div(
              span(out.value.map(_.mkString(" | "))),
              span(run.exit.map(_.fold("running")(c => s"exit ${c.toInt}"))),
            ),
          )
        case Result.Failure(e)   => span(s"cannot start: $e")
        case Result.Panic(t)     => span(s"cannot start: $t")
      }
Copyright © 2026