Terminals and TUI

A terminal block draws a monospace character grid as inline SVG, using a bundled JetBrains Mono Nerd Font so box-drawing characters, powerline separators and Nerd Font icons all land on the grid. It is not a screenshot and it is not a code block. It is a cols × rows array of cells, each holding one character, a foreground colour, a background colour and eight ANSI style bits — and everything in this chapter exists to fill cells.

Three things fill them: child blocks you place at cell coordinates, a text field fed to a real virtual terminal, or an asciinema recording replayed frame by frame. On top of the placement path sits a family of TUI controls — progress bars, buttons, spinners, inputs, dropdowns, checkboxes, radios and two containers — for mocking up a text user interface without recording one.

Every terminal on this page is rendered by the build you are reading. Copy the source beside it and you get the picture above it.

§ 1A terminal is a grid of cells

Write a terminal in a page body and place term_text children inside it. A position is a 1-based (row, col) pair: row = 1 col = 1 is the top-left cell.

gridrow1,col1row3,col8row5,col30
wcl
terminal {
  cols = 44  rows = 5  title = "grid"
  term_text "row 1, col 1" { row = 1  col = 1 }
  term_text "row 3, col 8" { row = 3  col = 8  fg = "cyan" }
  term_text "row 5, col 30" { row = 5  col = 30  fg = "green"  bold = true }
}

cols and rows default to 80 and 24. title and the window chrome are drawn above the grid and cost no cells; set chrome = false to drop the title bar and its close button. The chrome is worth keeping when the terminal stands alone, and worth dropping when you are showing one control in isolation, as most of the small examples below do.

A cell holds exactly one character. The len builtin counts characters too, so when you compute a width in WCL — the padding of a button, the fill of a bar — len and the grid agree. A run of text containing \n starts a new row at the same column, which is how you place a paragraph without one term_text per line.

onetwothree
wcl
terminal {
  cols = 30  rows = 3  chrome = false
  term_text "one\ntwo\nthree" { row = 1  col = 4  fg = "yellow" }
}

Off-grid writes are dropped, not reported

A cell outside cols × rows is discarded silently. term_text "hello" { row = 40 col = 1 } in a 24-row terminal draws nothing, and wcl check says OK — the coordinates are perfectly legal values, and the grid is what refuses them. When a widget you placed does not appear, count rows before you suspect the widget.

§ 2Three ways to fill the grid

The terminal block accepts three sources of content, and it takes exactly one. They are not layered: the block picks a source and ignores the rest.

SourceWritten asFilled byReach for it when
Childrenterm_text / widget blocksYou, at cell coordinatesYou are drawing a picture of an interface
Inline texttext = "…"A virtual terminal, laying the string outYou have output text and want it framed
Replaysource = "rec.cast"An asciinema recording, frame by frameYou want the session as it happened

Precedence runs down that table. A source wins over everything. Failing that, a non-empty text wins over the children. Only when there is neither are the child blocks drawn. This terminal declares both a text and a child, and the child is not there:

alphabeta
wcl
terminal {
  cols = 30  rows = 3  chrome = false
  text = "alpha\nbeta"
  term_text "never drawn" { row = 3  col = 1  fg = "red" }
}

That is a silent override, not an error, so keep one source per terminal.

§ 2.1Inline text

text = "…" is fed to a real virtual terminal and the visible screen is snapshotted into the grid. That means it lays out the way a terminal would: a bare \n is promoted to a carriage return plus a line feed so each authored line starts at column 1, a \t advances to the next tab stop, and a \r returns to the start of the line so anything after it overwrites what was there.

vtcolsize>>>512
wcl
terminal {
  cols = 34  rows = 4  title = "vt"
  text = "col\tsize\nweb\t512\r>>>"
}

You cannot type an escape sequence

The virtual terminal interprets ANSI escapes, but a WCL string literal cannot carry one. The escape table is exactly \", \\, \n, \t, \r — plus \$ inside an interpolating form. There is no \e, no \x1b and no \u, and an unknown escape is a parse error: text = "\e[31mred" fails with invalid escape '\e' before evaluation ever starts. So the text path is for layout, not for colour. Colour authored text with term_text and its fg / bg fields, and colour recorded text with a .cast file, which carries real escape bytes.

§ 2.2Replaying an asciinema recording

Point source at an asciicast v2 file, relative to the document, and the build parses it, replays it through the same virtual terminal, and ships the frames to a small bundled JS player. Record one with asciinema rec demo.cast; the format is a JSON header line followed by one JSON [time, "o", data] array per output event.

wcl
terminal {
  source   = "casts/deploy.cast"
  title    = "deploy"
  autoplay = true
  loop     = true
  speed    = 1.5
}

Four facts about that build are worth knowing before you record:

Replay is the one form a static target cannot reproduce. The Markdown and PDF backends snapshot the recording's last frame as a still image — see Sizing, themes and the other targets.

§ 3term_text, the one primitive

term_text is the only thing the renderer draws. Its label is the content, row and col place it, and ten optional fields carry the full terminal presentation:

FieldTypeDraws
fgutf8?Foreground colour
bgutf8?Background colour
boldbool?Heavier weight — the colour is left alone, so fg = "green" bold = true stays green
dimbool?Foreground blended 45% toward the background
italicbool?Italic face
underlinebool?Underline
strikebool?Line through
blinkbool?A one-second blink animation (HTML only)
inversebool?Foreground and background swapped
concealbool?Foreground forced to the background — invisible, but selectable
stylesbolddimitalicunderlinestrikeinverseonredblinkconcealgreenon236,bold
wcl
terminal {
  cols = 46  rows = 6  title = "styles"
  term_text "bold" { row = 1  col = 2  bold = true }
  term_text "dim" { row = 1  col = 8  dim = true }
  term_text "italic" { row = 1  col = 13  italic = true }
  term_text "underline" { row = 1  col = 21  underline = true }
  term_text "strike" { row = 1  col = 32  strike = true }
  term_text " inverse " { row = 3  col = 2  inverse = true }
  term_text " on red " { row = 3  col = 13  bg = "red"  fg = "bright_white" }
  term_text "blink" { row = 3  col = 24  blink = true  fg = "yellow" }
  term_text "conceal" { row = 3  col = 32  conceal = true }
  term_text "green on 236, bold" { row = 5  col = 2  fg = "green"  bg = "236"  bold = true }
}

§ 3.1How a colour is spelled

fg and bg are strings, and one field accepts four spellings:

SpellingExampleMeans
ANSI name"red", "bright_blue"One of the sixteen themeable slots
256-colour index"208", "236"An index into the full 256-colour space
Hex"#ff5fd2", "#0f0"A literal RGB colour, three or six digits
Default"default", or the field omittedThe terminal's own foreground / background

The eight base names are black, red, green, yellow, blue, magenta (also purple), cyan and white (also grey / gray). Prefix any of them with bright_ — or bright, or bright- — for the top eight slots. Case does not matter. An unrecognised name is not an error: it falls back to the default colour, so a typo like fg = "gren" renders in the default foreground and never complains.

Prefer a name to a hex value. The sixteen named slots resolve through the terminal's palette, so they stay legible when a terminal is switched to palette = :light, and a hex value does not. Reach for hex when you want a specific brand colour that must not move.

§ 4Boxes, fills and glyphs

Three convenience blocks sit above term_text. Each is defined in WCL, and each decomposes into text runs — the renderer never learns about them:

boxesroundeddouble····················term_glyph····················
wcl
terminal {
  cols = 46  rows = 8  title = "boxes"
  term_box { row = 1  col = 1  width = 20  height = 4  border = :rounded  fg = "cyan"  title = " rounded " }
  term_box { row = 1  col = 24  width = 20  height = 4  border = :double  fg = "magenta"  title = " double " }
  term_fill "·" { row = 6  col = 1  width = 20  height = 2  fg = "bright_black" }
  term_glyph "" { row = 6  col = 24  fg = "green"  bold = true }
  term_text "term_glyph" { row = 6  col = 26 }
}

A box's title is drawn over the top edge starting two cells in, so pad it with spaces — " rounded ", not "rounded" — unless you want the border to run straight into the word. Nothing clips the title against the box width; a title longer than the box simply writes past its corner.

§ 5The TUI widget family

Nine tui_* blocks build a text user interface out of the same cells. Seven are leaf controls and two are containers. The leaves are colour-forward and occupy a single row — they lean on background fills and accent colours rather than on box-drawing, because colour reads at a glance and ASCII art does not. Only an open dropdown and the two containers claim more than one row.

BlockDrawsThe fields that matter
tui_progressA two-tone bar, optional label, optional NN%value, max (100), width (24), show_value (true)
tui_buttonA label centred on a solid fillwidth (label + 4), accent (blue), fg (bright_white)
tui_spinnerOne frame of a spinner, optional labelframe (0), kind (braille), accent (cyan)
tui_inputAn accent bar, then a value or a muted placeholdervalue, focused, accent (blue)
tui_dropdownA label and a caret; the option list when openitems, open, selected, width, accent (blue)
tui_checkbox or , then a labelchecked, accent (green), muted (bright_black)
tui_radio or , then a labelselected, accent (green), muted (bright_black)
tui_panelA bordered box holding childrenwidth, height, title, border (:rounded), accent (bright_black)
tui_groupAn optional heading over childrentitle

Every one of them also takes row and col, because every one of them is a TermPrimitive.

§ 5.1The seven leaf controls

controlsimages100%config62%waitingtelemetrydarksearchprojectsApplyCancel
wcl
terminal {
  cols = 52  rows = 9  title = "controls"
  tui_progress "images " { row = 1  col = 1  value = 100  width = 20 }
  tui_progress "config " { row = 2  col = 1  value = 62  width = 20  accent = "cyan" }
  tui_progress { row = 3  col = 9  value = 3  max = 4  width = 20  show_value = false  accent = "yellow" }
  tui_spinner "waiting" { row = 5  col = 1  frame = 3 }
  tui_checkbox "telemetry" { row = 5  col = 14  checked = true }
  tui_radio "dark" { row = 5  col = 30  selected = true }
  tui_input "search projects" { row = 7  col = 1  focused = true }
  tui_button "Apply" { row = 9  col = 1  accent = "green" }
  tui_button "Cancel" { row = 9  col = 11  accent = "red" }
}

Read the three bars together. value is clamped into 0..max, and the readout is an integer percentage of max — the third bar's value = 3 max = 4 would read 75%, but it sets show_value = false and drops the readout entirely. A label is the block's inline argument, and the bar starts one blank cell after it. Nothing aligns columns for you, which is why the two labels above are padded to a common width by hand and the third bar is placed at col = 9 to line up with them.

The spinner is a still picture. A rendered document does not animate, so frame chooses which frame of the cycle to draw, and the index wraps — frame = 13 on the ten-frame braille set is frame = 3. kind = :circle gives ◐◓◑◒ and kind = :line gives |/—\; anything else, including the documented :dots, gives the braille set.

tui_input shows its value when it has one and its inline placeholder, in muted grey, when it does not. focused = true adds a cursor after the text. A dropdown is the one control that grows downward:

ReleaseReleaseDebugReleaseProfile
wcl
terminal {
  cols = 40  rows = 5  chrome = false
  tui_dropdown "Release" { row = 1  col = 1
    items = ["Debug", "Release", "Profile"]
    open  = true
  }
  tui_dropdown "Release" { row = 1  col = 20 }
}

Open, the caret flips to and one filled row per item drops below the field; closed, it is the label and a . The highlighted row defaults to the item equal to the field's text, which is why "Release" is lit above without an explicit selected. Give a selected index to override it, or an index no item has — -1 — to highlight none. width defaults to the longest of the label and the items, plus three cells for padding and the caret.

§ 5.2The two containers

tui_panel and tui_group hold TermPrimitive children, and the children's coordinates are relative to the container. A panel's content origin is one cell inside its border; a group's is the row under its title, or its own row when it has none.

BuildLogTargetsokfetchlinuxokbuildmacos..test40%
wcl
terminal {
  cols = 46  rows = 8  chrome = false
  tui_panel { row = 1  col = 1  width = 26  height = 8  title = " Build "  accent = "cyan"
    tui_group { row = 1  col = 1  title = "Targets"
      tui_checkbox "linux" { row = 1  col = 1  checked = true }
      tui_checkbox "macos" { row = 2  col = 1 }
    }
    tui_progress { row = 5  col = 1  value = 40  width = 14 }
  }
  tui_panel { row = 1  col = 29  width = 18  height = 8  title = " Log "  border = :heavy
    term_text "ok  fetch" { row = 1  col = 1  fg = "green" }
    term_text "ok  build" { row = 2  col = 1  fg = "green" }
    term_text "..  test" { row = 3  col = 1  fg = "yellow" }
  }
}

Follow one checkbox out to the screen. linux sits at row 1 of the group, the group sits at row 1 of the panel's content area, and the panel's content area starts at row 2 of the panel, which is placed at row 1 of the terminal. The offsets add up: it lands on terminal row 3, under the group's Targets heading on row 2. That is the whole layout model — there is no other one.

A container does not clip its children

width and height size the border a panel draws, and nothing more. A child placed past the bottom edge draws over the border and out into the terminal; only the terminal's own cols × rows discards anything. Size a panel to its contents by counting, the way you size everything else here.

§ 6A worked example

Nothing above needed more than one control. Here is a whole screen: a panel framing a group of targets, two labelled bars, a spinner and a pair of buttons. Save it as deploy.wcl and build it with wcl wdoc build deploy.wcl --out out.

deploydeployTargetsstagingproductionimages100%config62%waitingforhealthchecksApplyCancel
deploy.wclwcl
import <wdoc.wcl>

site demo { title = "Demo" }

page deploy {
  title = "Deploy"

  h1 "Deploy"

  terminal {
    cols = 52  rows = 12  title = "deploy"

    tui_panel {
      row = 1  col = 1  width = 52  height = 12
      title = " deploy "  border = :rounded  accent = "cyan"

      tui_group {
        row = 1  col = 2  title = "Targets"
        tui_checkbox "staging" { row = 1  col = 1  checked = true }
        tui_checkbox "production" { row = 2  col = 1 }
      }

      tui_progress "images " { row = 5  col = 2  value = 100  width = 20 }
      tui_progress "config " { row = 6  col = 2  value = 62  width = 20  accent = "cyan" }

      tui_spinner "waiting for health checks" { row = 8  col = 2  frame = 3 }

      tui_button "Apply" { row = 10  col = 2  accent = "green" }
      tui_button "Cancel" { row = 10  col = 12  accent = "red" }
    }
  }
}

Twenty-two lines of layout, and every number in them is a cell count you can verify by reading the picture. Change value = 62 to value = 20 and the bar moves; change frame = 3 and the spinner turns.

§ 7What is native and what lowers

Two kinds in this whole family are implemented in Rust, and it is worth knowing which, because it is the line between what you can extend and what you cannot.

KindImplemented byWhy
terminalRust — @nativeThe cell grid, the palette, the virtual terminal and the asciicast replay are not expressible in WCL
term_textRust — @nativeIt is the drawing operation: a run of characters written into cells
term_box, term_fill, term_glyphWCL — a lower functionBorders and fills are text; the box glyphs are a five-way if
The nine tui_* controlsWCL — a lower functionA bar, a caret, a filled label: all of them are text runs
YoursWCL — a lower functionThe same mechanism, unprivileged

Every wdoc block declares exactly one of the two, and the build refuses anything else. Declare neither and the build says so; claim @native on a block wdoc does not implement and it says that instead:

text
$ wcl wdoc build no-lower.wcl --out out
wdoc::native

  × type 'Spark' declares neither a `lower` nor `@native` — a block is
  │ rendered by a WCL lowering or by wdoc's Rust dispatch, and its type must
  │ say which

$ wcl wdoc build claims-native.wcl --out out
wdoc::native

  × type 'Spark' declares `@native`, but wdoc implements no dispatch for
  │ "spark" — only wdoc's own blocks can be native; a user block is rendered
  │ by its `lower`

There is no third state and no stub. Writing your own blocks covers the rule across all three block families.

What a lower returns here is list<TermFundamental>, and that union has exactly two variants:

wcl
union TermFundamental {
  Text     { content: utf8  row: i64  col: i64  fg: utf8?  bg: utf8?  bold: bool? }
  Children { row: i64  col: i64 }
}

Text is a run of characters. Children is a hole: it marks where the block's own child blocks are drawn, and it is the only reason a container can exist. tui_panel returns its border as Text runs plus a single Children { row: 2, col: 2 }, and that one value is the whole of "children sit inside the border".

A widget never reads its own row and col

A lower lays its output out from its own top-left, (1, 1). The renderer adds the widget's row / col and the origin of every container above it. That is what lets the same tui_progress sit loose in a terminal, inside a panel, or inside a group inside a panel, with no code in the widget for any of the three. Read p.row inside a lower and you will double the offset.

§ 7.1There is no measured layout here

This is the comparison worth drawing, because wdoc has a second widget family that works the other way. The wf_* wireframe widgets are native and measured: the renderer measures a widget's content, derives its size from it, and packs rows, columns and grids from those measurements, which is exactly why they cannot be WCL — a lowering has no way to ask how big its own text turned out.

A terminal widget is never measured. Its size is the number of cells it decides to write, in a font whose every glyph is one cell wide, and you place it by counting. That is the trade: a wireframe lays itself out and you cannot write one in WCL, while a TUI screen you lay out by hand and you can write any control you like. The two families answer different questions — a wireframe asks what a screen should contain, a terminal asks what a screen looks like — and neither is the other's approximation.

§ 8The TermPrimitive interface

One interface stands behind everything placeable in a terminal, and it is three members long:

wcl
interface TermPrimitive {
  row: i64
  col: i64
  lower: fn(&TermPrimitive) -> list<TermFundamental>?
}

A cell position and a way to become text. term_text and the two containers satisfy it, every tui_* control satisfies it, and so does anything you write. lower is declared optional on the interface for the reason the last section gave: a native block would otherwise have to fake one, and the exactly-one-of check does that job better than the type system can.

§ 8.1Placement is the slot's accepts-type

There is no "terminal widget" interface above TermPrimitive. The interface describes output — where a block sits and how it becomes text — in exactly the way ContentBlock describes page output and SvgBlock describes diagram output. What decides where a block may be written is the accepts-type on the slot, and nothing else:

wcl
@block("terminal")
@native
type Terminal extends ContentBlock {
  # ...
  @children(TermPrimitive) children: list<TermPrimitive>
}

@block("tui_panel")
type TuiPanel extends TermPrimitive {
  # ...
  @children(TermPrimitive) children: list<TermPrimitive>
}

terminal extends ContentBlock, so a page accepts it. Its children slot accepts TermPrimitive, so it accepts term_text and every widget. tui_panel extends TermPrimitive — that is what makes it placeable in a terminal — and declares the same slot, so it accepts the same things a terminal does. One interface, read twice, and the direction of the read is the difference.

Cross the slots and wcl check names the kind and the parent:

text
$ wcl check page-with-p-inside-terminal.wcl
wcl::eval::schema_violation

  × block kind 'p' is not allowed inside 'terminal'

$ wcl check page-with-loose-term-text.wcl
wcl::eval::schema_violation

  × block kind 'term_text' is not allowed inside 'page'

§ 9Writing your own control

Declare a @block type that extends TermPrimitive, give it row and col, and give it a lower returning list<TermFundamental>. That is the entire contract. Nothing in the stdlib family is privileged, so a control of your own is built exactly the way tui_progress is.

The stdlib has no sparkline, so here is one. It scales a list of counts onto the eight block characters ▁▂▃▄▅▆▇█ and draws them as a single coloured run:

rpserr
wcl
@block("spark")
type Spark extends TermPrimitive {
  @inline(0) label: utf8?
  values: list<i64>
  accent: utf8?
  row: i64  col: i64
  lower = fn(s: Spark) -> list<TermFundamental> {
    let bars = ["", "", "", "", "", "", "", ""];
    let acc  = if s.accent == none { "cyan" } else { s.accent };
    # `max` answers an f64, so fold the i64 peak by hand.
    let top  = fold(s.values, 1, fn(a: i64, v: i64) -> i64 if v > a { v } else { a });
    let line = join(map(s.values, fn(v: i64) -> utf8 {
      let i = if v < 0 { 0 } else { v * 7 / top };
      at(bars, i)
    }), "");
    let bar_col = if s.label == none { 1 } else { len(s.label) + 2 };
    let label_part = if s.label == none { [] }
      else { [ term_run(s.label, 1, 1, none, none, none) ] };
    flatten([ label_part, [ term_run(line, 1, bar_col, acc, none, none) ] ])
  }
}

terminal {
  cols = 40  rows = 3  chrome = false
  spark "rps " { row = 1  col = 1  values = [3, 7, 4, 9, 12, 8, 15, 14, 6, 2] }
  spark "err " { row = 3  col = 1  accent = "red"
    values = [0, 0, 1, 0, 4, 2, 0, 0, 9, 1]
  }
}

Five things in that declaration are worth naming.

A default supplied as a field default — lower = fn(…) … rather than lower: fn(…) — means an individual instance may override it. That is rarely what you want for a control, but it is the same mechanism the stdlib uses, so nothing about your block is second class.

§ 10Sizing, themes and the other targets

A terminal's pixel size is fixed by the grid, not by the page. font_size defaults to 14 and line_height to 1.2. Each cell is round(font_size × 0.6) wide and round(font_size × line_height) tall. Around the grid sits a round(font_size × 0.45) pad, and above it a round(font_size × 1.7) title bar when chrome is on. A 40 × 6 terminal at the defaults is therefore 332 × 138 pixels. The SVG carries max-width: 100%, so a terminal wider than its column shrinks to fit rather than overflowing. Drop font_size when you would rather it stayed sharp.

Colours come from three layers. The default is a dark terminal on the classic Tango sixteen. palette = :light flips the defaults to the site theme's light foreground and background — the named colours keep working, which is the whole point of naming them. fg and bg override the defaults outright, and a class list themes the wrapping element the way it themes any wdoc block; see Themes and styling. Note that a plain, default-coloured cell deliberately carries no colour of its own in the SVG, so it inherits from the element — which is how a class-themed terminal works at all.

On the static targets a terminal is an image of itself. The PDF backend paints a self-contained SVG with the palette baked in, and the Markdown backend writes the SVG to a file under _wdoc/ and references it — using the terminal's title, then its id, then the word terminal as the alt text, so set a title on any terminal that matters. A replay terminal has no player on either target and is snapshotted at its last frame. If that is not the frame you want a reader of the PDF to see, @except(backends = [:pdf]) the replay and place a still terminal beside it; Visibility and Output targets cover the axis and the four targets.

§ 11Where to go next