Composables
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:
/** 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
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
| Composable | What 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):
/** 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):
/** 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):
/** 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:
/** 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):
/** 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):
/** 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):
/** 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):
/** 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):
/** 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")
}