-
Notifications
You must be signed in to change notification settings - Fork 0
State and signals
glyphora state is intentionally small: Signal[A] stores a mutable value,
Computed[A] derives one, and ReactiveScope records what a view reads. That is
enough to update a terminal UI without reducers, message plumbing, or manual redraw
calls.
private val count = Signal(0)
def view(using ReactiveScope): Element =
text(s"Count: ${count.get}")
// key/mouse/tick handler
count.update(_ + 1)While view runs, count.get subscribes its root reactive scope. update changes
the value, marks that scope stale, and wakes the runtime. On the next pass the view
reads the new value and widgets render a new buffer.
Setting a signal to an equal value (using ==) does not notify dependents.
| Operation | Tracks? | Purpose |
|---|---|---|
signal.get |
yes | read inside view or a Computed and subscribe |
signal.peek |
no | inspect current state in a handler or service without adding a dependency |
signal.set(value) |
— | replace the value and invalidate when it changed |
signal.update(f) |
— | replace the value using its current value |
Use immutable values inside signals so updates are obvious:
private val jobs = Signal(Vector.empty[Job])
def add(job: Job): Unit =
jobs.update(_ :+ job)
def remove(id: JobId): Unit =
jobs.update(_.filterNot(_.id == id))Mutating a collection in place and setting the same reference can bypass equality
change detection. Prefer a new Vector, Map, case class, or other immutable value.
Computed values are lazy and cached. They recompute on the next read after a
dependency changes:
private val query = Signal("")
private val jobs = Signal(Vector.empty[Job])
private val visibleJobs = Computed {
val needle = query.get.trim.toLowerCase
if needle.isEmpty then jobs.get
else jobs.get.filter(_.name.toLowerCase.contains(needle))
}
def view(using ReactiveScope): Element =
column(
text(s"Filter: ${query.get}").dim,
text(s"${visibleJobs.get.size} matching jobs"),
jobTable(visibleJobs.get),
)Long-lived computed values belong beside your signals, not inside view. If you
create a short-lived Computed, call .dispose() when its owner goes away so its
internal subscriptions are detached.
Dependencies are rebuilt every time a computation runs:
private val useRemote = Signal(false)
private val localRows = Signal(Vector.empty[Row])
private val remoteRows = Signal(Vector.empty[Row])
private val activeRows = Computed {
if useRemote.get then remoteRows.get else localRows.get
}When useRemote is false, changes to remoteRows do not invalidate activeRows.
After the branch switches, the old subscription is removed and the remote one is
added. There is no dependency list to maintain.
view may run many times. It should describe current UI, not perform work:
// Good: read state and compose elements.
def view(using ReactiveScope): Element =
report.get match
case Some(value) => reportPanel(value)
case None => text("No report loaded").dim
// Avoid inside view: HTTP calls, file writes, sleeps, starting Futures,
// mutating signals, or constructing long-lived resources.Start side effects from a key/mouse handler, onTick, an app service, or an
Async callback. See Async work & timers.
Once a runner is active, Signal.set and Signal.update must run on its render
thread. This guarantees deterministic ordering between event handling, state
changes, focus, effects, and redraws.
Already safe:
-
.onKey/.onKeyEventhandlers; -
.onMouseEventhandlers; -
KeyBindingsactions; -
onTick(); - completion handlers passed to
Async.runandAsync.runCatching.
Callbacks owned by another thread must hop back:
import io.worxbend.tui.runtime.RenderThread
socket.onMessage { payload =>
RenderThread.runOnRenderThread {
messages.update(_ :+ payload)
}
}The guard is a no-op when no runner is registered. Plain unit tests can construct, read, and update signals without bootstrapping a runtime.
Themes demonstrate the whole model: one signal chooses a theme, view tracks it,
and a key action changes it.
private val themes = Vector(Theme.Dark, Theme.Light, Theme.HighContrast)
private val themeIndex = Signal(0)
override def theme: 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 // tracked read requests a new themed tree
scaffold(statusBar = Some(statusBar(bindings)))(content)theme itself uses .peek because the explicit tracked read in view owns
invalidation. The complete implementation is in the showcase example.
- Put application facts in
Signal; keep derived facts inComputed. - Read with
.getin view code and.peekin handlers when you do not need to create a dependency. - Keep state values immutable and updates small.
- Create long-lived
Computedvalues outsideview. - Model async loading and errors as a single enum.
- Marshal third-party callbacks to the render thread before writing.
Next: use state in Forms & validation, animate it with Motion, or test it through full input cycles in Testing.
Documentation is maintained in website/docs. Read the styled guide · API reference · MIT license