Screen layer
termflow-screen sits one layer above the TTY. It gives you:
- a virtual DOM (
VNodeADT —TextNode,BoxNode,InputNode,RootNode), - a layout DSL for stacking, gaps, fills, and zones,
- an ANSI renderer that diff-paints frames into minimal terminal patches,
- a hit-test cache for routing mouse events to logical zones,
- a theme model with semantic colour slots and box-drawing chars.
libraryDependencies += "org.llm4s" %% "termflow-screen" % "0.4.0"
You reach for the screen layer directly when you want a draw surface and rendering, but not the Elm-style runtime — for example, a one-shot report renderer that emits ANSI to stdout and exits.
VNode — the virtual DOM
enum VNode:
case TextNode(x: XCoord, y: YCoord, txt: List[Text])
case BoxNode(
x: XCoord, y: YCoord, width: Int, height: Int,
children: List[VNode], style: Style, chars: BorderChars
)
case InputNode(
x: XCoord, y: YCoord, prompt: String, style: Style,
cursor: Int = -1, lineWidth: Int = 0, prefixLength: Int = 0
)
Plus the wrapper:
final case class RootNode(
width: Int,
height: Int,
children: List[VNode],
input: Option[InputNode],
overlays: List[Overlay] = Nil,
layout: Option[Layout] = None
)
Every render produces a RootNode. AnsiRenderer.render(prev, next)
diffs the two and emits the ANSI patch.
A few invariants:
- Coordinates are 1-based.
XCoordandYCoordare opaque types overIntso you can't accidentally swap them. Use the.x/.yextension methods inScreenPrelude(2.x,3.y) to construct. BoxNodeis visual-only. It draws a border but does not position its children. If you want layout, useLayout.ColumnorLayout.Rowinstead and attach the box as a sibling.- Only the topmost
InputNodeis live. It carries the hardware cursor position. Nested input nodes are drawn but inert.
File:
modules/termflow-screen/src/main/scala/termflow/tui/vdom.scala.
Layout DSL
Layout describes the stacking shape. Build a tree, then resolve
it at an origin to flatten it into absolute-coordinate VNodes.
enum Layout:
case Elem(vnode: VNode)
case Row(gap: Int, children: List[Layout])
case Column(gap: Int, children: List[Layout])
case Spacer(width: Int, height: Int)
case Fill(content: Layout)
case Zone(id: Any, content: Layout)
case Grid(columns: Int, rowGap: Int, colGap: Int, cells: List[GridCell])
case Border(top: Option[Layout], left: Option[Layout],
center: Option[Layout], right: Option[Layout],
bottom: Option[Layout], gap: Int)
Concrete usage:
import termflow.tui.{Layout, BoxNode, TextNode, Style}
import termflow.tui.TuiPrelude.*
val column = Layout.Column(gap = 1, children = List(
Layout.Elem(TextNode(1.x, 1.y, List("Counter".text(fg = Yellow)))),
Layout.Elem(TextNode(1.x, 1.y, List(s"value: $count".text))),
Layout.Spacer(1, 1),
Layout.Elem(TextNode(1.x, 1.y, List("press + / -".text)))
))
val children: List[VNode] = column.resolve(Coord(2.x, 2.y))
Inside the layout tree the inner (1.x, 1.y) are layout-local —
resolve adds the origin to produce the right absolute coordinates.
The fluent factories Layout.row(gap)(vnode1, vnode2, …) and
Layout.column(gap)(...) skip the Elem wrappers when you have a
flat list of vnodes.
Fill — eat remaining space
Fill(content) consumes whatever's left on the major axis. Useful
for splits and panels:
Layout.Row(gap = 1, children = List(
Layout.Elem(sidebar), // natural width
Layout.Fill(mainPanel) // takes the rest of the row
))
Resolve Fill regions with Layout.resolveTo(layout, at, w, h) — the
form that knows the available width / height. For full-screen apps, the
idiomatic shape is layout.toBudgetedRootNode(width, height), which
puts the layout into RootNode.layout so the renderer applies the
budget at render time and the layout reflows on resize. The eager
layout.toRootNode(width, height) form intentionally does not apply
a budget — see the
full-screen layout cookbook for
when each form is appropriate.
Grid — fixed-column grid with optional span
Cells flow left-to-right, top-to-bottom. Column widths split the
available width evenly (with a budget) or fall back to per-column
natural widths. colSpan / rowSpan reserve a rectangle of slots and
the cursor skips past them.
Layout.grid(columns = 3, rowGap = 1, colGap = 2)(
TextNode(1.x, 1.y, List("a".text)),
TextNode(1.x, 1.y, List("b".text)),
TextNode(1.x, 1.y, List("c".text))
)
// With spans:
Layout.Grid(columns = 2, rowGap = 0, colGap = 0, cells = List(
GridCell(Layout.Elem(headerNode), colSpan = 2),
GridCell(Layout.Elem(leftNode)),
GridCell(Layout.Elem(rightNode))
))
Cells with colSpan > 1 cover the spanned columns inside resolveTo /
resolveTracked; their content can itself be a Zone so mouse clicks
land back on a logical id.
Border — five-zone layout
Layout.border(
top = Layout.Elem(headerNode),
left = Layout.Elem(sidebar),
center = Layout.Elem(mainPanel),
right = Layout.Elem(detailsPanel),
bottom = Layout.Elem(statusBar),
gap = 1
)
Sizing under a budget:
- Top / bottom — natural height, full width.
- Left / right — natural width, middle-band height.
- Center — fills the remainder.
Pass null (or omit the named argument) for any zone you don't need;
its space collapses. Designed for "header / sidebar / main / footer"
shells where you don't want to compute the band heights by hand.
Zone — tag for hit-test
Wrap a sub-layout in Zone(id, content) and Layout.resolveTracked
will register the resulting rectangle in a HitTest[Id] so you can
route mouse clicks back to the same id:
val (vnodes, hits) = Layout.resolveTracked[String](
layout, at = Coord(1.x, 1.y), availableWidth = 80, availableHeight = 24
)
mouseEvent match
case Mouse(MouseEvent.Press(_, col, row, _)) =>
hits.hit(col, row).foreach(handleZone)
File:
modules/termflow-screen/src/main/scala/termflow/tui/Layout.scala.
HitTest
final case class Rect(x: Int, y: Int, width: Int, height: Int):
def right: Int
def bottom: Int
def contains(col: Int, row: Int): Boolean
def at: Coord
final case class HitTest[Id](zones: Vector[(Id, Rect)]):
def add(id: Id, rect: Rect): HitTest[Id]
def ++(other: HitTest[Id]): HitTest[Id]
def hit(col: Int, row: Int): Option[Id]
hit(col, row) returns the topmost zone (last-inserted wins) at the
given coordinate. The cache is built during the layout pass — you
never have to maintain a parallel zone list by hand.
File:
modules/termflow-screen/src/main/scala/termflow/tui/HitTest.scala.
Theme — semantic colour slots
final case class Theme(
primary: Color, secondary: Color,
error: Color, warning: Color,
success: Color, info: Color,
border: Color, background: Color, foreground: Color,
chars: BorderChars = BorderChars.sharp
)
object Theme:
val dark: Theme
val light: Theme
val mono: Theme
val rounded: Theme
A widget that takes (using Theme) can draw against the active
theme without knowing anything about the user's palette. Override by
making your own given Theme = … in scope, or by passing a theme
explicitly to dialog/widget builders.
BorderChars controls box-drawing glyphs:
object BorderChars:
val sharp: BorderChars // ┌─┐│└┘
val rounded: BorderChars // ╭─╮│╰╯
val double: BorderChars // ╔═╗║╚╝
val ascii: BorderChars // +-+|-+ — for terminals without unicode
Capability-aware: when Capabilities.unicode is false, the renderer
substitutes ascii automatically.
Files:
Theme.scala,BorderChars.scala.
RenderFrame and AnsiRenderer
The renderer turns a RootNode into a RenderFrame (a
width × height grid of RenderCell(ch, style, width)), then diffs
against the previous frame and emits ANSI.
final case class RenderCell(ch: Char, style: Style, width: Int = 1)
final case class RenderFrame(
width: Int,
height: Int,
cells: Array[Array[RenderCell]],
cursor: Option[Coord]
)
You normally don't construct a RenderFrame by hand — the runtime
does it for you. Where it shows up is in tests: TuiTestDriver.frame
returns the RenderFrame produced by the last view call, and
GoldenSupport.assertGoldenFrame compares it to a snapshot.
The wide-cell handling is critical:
RenderCell.width = 2 means the next cell is consumed by the same
glyph. This is what makes 好 render correctly.
File:
modules/termflow-screen/src/main/scala/termflow/tui/AnsiRenderer.scala.
ScreenPrelude — sugar
import termflow.tui.TuiPrelude.* // lifts ScreenPrelude transitively
2.x // XCoord
3.y // YCoord
"hello".text // Text(value, Style.empty)
"warning".text(fg = Color.Yellow) // styled text
"OK".text(fg, bg, bold = true) // multiple style args
These are the conversions every example uses. Always import
TuiPrelude.* at the top of view code; you'll thank yourself.
When to climb up
The screen layer plus your own loop is enough for read-only tools (report renderers, status dashboards, pipe viewers). The moment you want input — keystrokes, timers, async work — you want the Application layer. Everything in this layer is reused verbatim by the app layer, so nothing is wasted.
The full per-type API is in the Scaladoc.