Widgets
termflow-widgets ships a catalogue of reusable components. Every
widget is built on top of termflow-screen and termflow-app,
follows the same (State, handleKey, view) shape, and takes a
given Theme so it picks up your colour scheme.
libraryDependencies += "org.llm4s" %% "termflow-widgets" % "0.4.0"
The umbrella termflow artefact already depends on this module —
most apps don't need to depend on it directly.
Every widget is exercised by the showcase app. Run
sbt showcaseto see them rendered side-by-side.
The widget protocol
Most widgets follow a three-piece pattern:
State— the widget's data (cursor positions, scroll offsets, selected index). You store this on your model.handleKey(state, key)(...)— pure function from a key to the next state, optionally producing aMsg(e.g. on Enter).view(state, …, focused: Boolean = true)— pure function from state toVNodeorList[VNode]. Thefocusedflag controls which highlight to use.
Stateless widgets (Button, RadioGroup, ProgressBar, Spinner,
StatusBar, Separator) skip step 2 — they only render.
Inputs
TextField
Single-line text input. Handles cursor, paste, grapheme-aware backspace.
import termflow.tui.widgets.TextField
val initial = TextField.State.withPlaceholder("alice@example.com")
val (next, _) = TextField.handleKey[Msg](initial, key)(_ => None)
val node = TextField.view(next, lineWidth = 28, focused = true)
Placeholder text renders dim+italic until the user types.
MultiLineInput
Multi-line editor. Cursor row uses reverse-video (TextField-style) so
it embeds cleanly in any layout. Tab inserts a literal \t.
Grapheme-aware navigation across all four arrows, Backspace, Delete.
val state = MultiLineInput.State(lines = Vector("hello", "world"))
val (next, _) = MultiLineInput.handleKey[Msg](state, key)
val node = MultiLineInput.view(next, width = 60, height = 12, focused = true)
Button
Inline [ Label ]. Focus = primary background bar. Stateless —
Button(label, focused) returns a VNode directly.
widgets.Button(label = "Submit", focused = focusManager.isFocused(SubmitId))
CheckBox / RadioGroup
widgets.CheckBox(label = "Remember me", checked = true, focused = true)
widgets.RadioGroup(
options = Vector("Free", "Pro", "Enterprise"),
selectedIndex = 1,
focusedIndex = 1,
at = Coord(4.x, 8.y)
)
RadioGroup returns List[VNode], one per option. Use Layout.Column
or absolute coordinates if you want different placement.
Capability-aware glyphs: ☐/☒ and ◯/◉ on Unicode terminals,
[ ]/[x] and ( )/(*) on ASCII-only.
Select / Autocomplete
Select is a closed-state dropdown — single click opens, click again
or pick an item closes. Autocomplete is the open-state variant: a
filterable list always visible underneath an input.
val acState = Autocomplete.State.of(Vector("apple", "banana", "cherry"))
val r = Autocomplete.handleKey(acState, key)
val nodes = Autocomplete.view(r.state, width = 16, maxVisible = 6, focused = true)
Both clamp selectedIdx into the visible filtered range. The
viewport scrolls so the cursor row stays visible.
Prompt
Strictly speaking Prompt is in termflow-app, not widgets, but
it's the workhorse for REPL-style apps — see the
Counter tutorial for the full integration.
Data display
ListView
Scrollable, selectable list. ▸ cursor when focused, just colour
when blurred.
val state = ListView.State(items = Vector("apple", "banana", "cherry"))
val (next, _) = ListView.handleKey[Msg](state, key)(_ => None)
val node = ListView.view(next, width = 24, maxVisible = 8, focused = true)
Table
Selectable rows + columns with Align.Left | Right | Center.
val cols = Vector(
Table.Column("Name", width = 20, align = Align.Left),
Table.Column("Score", width = 8, align = Align.Right)
)
val state = Table.State(columns = cols, rows = Vector(Vector("alice","42")))
Tree
Recursive collapsible tree. Stateless — the app owns expanded: Set[Id] and selectedIndex: Int; the renderer takes a typeclass
Children[A, Id] describing how to traverse your data structure.
- Expanded glyph:
[-] - Collapsed glyph:
[+] - Leaf glyph:
(four spaces)
case class Node(name: String, kids: Vector[Node])
given widgets.Tree.Children[Node, String] with
def id(n: Node): String = n.name
def kids(n: Node): Vector[Node] = n.kids
val nodes = widgets.Tree(
roots = Vector(Node("src", Vector(Node("Main.scala", Vector.empty)))),
expanded = Set("src"),
selectedIndex = 0,
render = _.name,
at = Coord(2.x, 2.y)
)
// Mouse: distinguish chevron clicks from label clicks
val rows = widgets.Tree.visibleRows(roots, expanded)
widgets.Tree.hitTest(rows, at = Coord(2.x, 2.y), indentWidth = 2, col, row) match
case Some(widgets.Tree.HitResult.Chevron(idx)) => /* toggle expansion */
case Some(widgets.Tree.HitResult.Label(idx)) => /* select */
case None => /* miss */
LogView
Stateless line-buffer viewer. The app owns the Vector[String]
buffer and the current scrollOffset; LogView wraps and clips
into the requested viewport.
val node = widgets.LogView(
lines = m.logBuffer, // Seq[String]
width = 80,
height = 16,
scrollOffset = m.scrollOffset,
wrap = true
)
Use LogView.maxScroll(lines, width, height, wrap) when the user
scrolls so you clamp the offset correctly.
For a Claude-Code / Cursor-style transcript with a fixed bottom prompt and auto-tail behaviour, see the rolling console recipe.
Layout
Tabs
Stateless tab-header renderer — the app owns the active and focused indices and handles tab-switching keys itself.
val node = widgets.Tabs(
labels = Seq("Inputs", "Data", "Layout"),
activeIndex = m.activeTab,
focusedIndex = if m.headerFocused then m.activeTab else -1,
at = Coord(2.x, 1.y),
separator = " │ "
)
SplitPane
Horizontal/vertical pane divider. Resize via mouse drag (Stage 3 §6.2)
or via keyboard ([ / ]).
val ds = SplitPane.DragState(splitRatio = 0.5, dragging = false)
val ds2 = SplitPane.handleMouse(
state = ds, event = mouseEvent,
direction = SplitPane.Vertical, width = 80, height = 24,
at = Coord(1.x, 1.y), gap = 1
)
Separator
widgets.Separator.horizontal(width = 60, at = Coord(1.x, 5.y), title = Some("Section"))
widgets.Separator.vertical(height = 12, at = Coord(40.x, 1.y))
ScrollBar
Visual thumb track. Use alongside ListView, Table, or
MultiLineInput when content exceeds the visible area.
val sb = ScrollBar.State(offset = 0, visible = 12, total = 60)
if sb.needed then
val node = ScrollBar(sb, at = Coord(80.x, 2.y), height = 12)
Feedback
ProgressBar
widgets.ProgressBar(at = Coord(2.x, 5.y), width = 40, fraction = 0.6)
█ filled, ░ empty on Unicode terminals; # / - on ASCII.
Spinner
val frames = Spinner.Braille // or .Line, .Dots
val frame = Spinner.frame(frames, tickIndex)
Stateless — you advance tickIndex on each Sub.Every tick. See the
async tutorial for the full pattern.
StatusBar
3-column header/footer in inverse video.
widgets.StatusBar(
width = 80,
left = " ▌ TermFlow ",
center = " connected ",
right = " q quit "
)
MenuBar
Top-of-screen menu bar with dropdown items.
val state = widgets.MenuBar.State(
menus = Vector(
widgets.MenuBar.Menu("File", items = Vector("Open…", "Save", "Quit")),
widgets.MenuBar.Menu("Edit", items = Vector("Undo", "Redo"))
)
)
val widgets.MenuBar.KeyResult(next, picked) = widgets.MenuBar.handleKey(state, key)
val node = widgets.MenuBar(next, at = Coord(1.x, 1.y), focused = true)
KeyResult.picked: Option[(menuIdx, itemIdx)] is Some(...) only on
the keystroke that committed a selection — fold it into your domain
Msg and dispatch.
Form
The composite Form.column helper renders multi-row forms with
labels, focus-aware widgets, and inline validation. The
forms tutorial
walks through it end-to-end.
widgets.Form.column(
rows = Vector(
Form.Row(NameId, "Name:", focused => TextField.view(name, lineWidth = 28, focused = focused)),
Form.Row(EmailId, "Email:", focused => TextField.view(email, lineWidth = 28, focused = focused))
),
focusManager = fm,
at = Coord(2.x, 4.y),
labelWidth = 8,
gap = 1,
errors = Map("wiz-email" -> "Email must contain '@'")
)
Coverage
The complete file list is checked by
scripts/check_widget_docs.sh
in CI: any new file under
modules/termflow-widgets/src/main/scala/termflow/tui/widgets/ that
isn't mentioned in this guide will fail the build.
For full per-widget API, see the Scaladoc.