0.3.0 · four backends · 32 widgets

Interfaces for the terminal,
down to the serial line.

moonetui is a terminal UI toolkit for MoonBit — a synchronous, portable core with a cell buffer, a differential ANSI renderer, a CSS-like design system, and widgets that run the same on wasm, on Node, in the browser — and on a plain UART.

moonetui — src/examples/gallery, running in this page
Click the terminal, then up and down choose a demo and tab moves into it. It is the same code as moon run --target native src/examples/gallery.
4
backends · wasm, wasm-gc, js, native
420
tests per backend, all passing
32
widgets, from toggles to trees
0
dependencies · a single pure core

Why moonetui

Everything a terminal interface needs, on a core that fits in a microcontroller's flash.

⟳

Synchronous by design

No async, no event loop you can't see. The host drives one step(): poll, parse, dispatch, lay out, compose, diff, write.

▚

A real design system

Selectors, specificity and cascade over name, id and class — the CSS model you already know, applied to the terminal.

⇄

Differential renderer

Two frames become only the spans that changed, then only the escape sequences needed — efficient on any wire.

⌗

Unicode-honest

A generated width table measures every line: CJK double width, emoji, combining marks. Padding is never a guess.

▤

Layout that adds up

Fixed, percentage, auto and fractional sizes along an axis, plus a grid with spans and gutters — integer division that loses no column.

✓

Tested on four backends

The same suite runs on wasm, wasm-gc, js and native, so a regression in one world fails loudly everywhere.

Where it runs

The core is pure and portable; only four operations touch a platform — write, read, raw mode, and the size query. Bring your own terminal: a real one through the C library, a canvas in a browser, a Node pipe — or a serial line with no cursor addressing at all.

TargetDriverWhat you get
Native terminal@driver/nativetermios on Unix, VT mode on Windows — the full modern feature set
Node.js@driver/noderaw mode over stdin/stdout with a queue-fed frame pump
Browser@driver/weba terminal drawn on a canvas, keys encoded, resize in-band
Serial UARTyour DriverECMA-48 / ISO 6429 subset over a byte stream; full repaint degrades gracefully
docs / started

Getting started

One command to add the toolkit, three to see it run.

Install

moonetui is published on mooncakes under the moonbitstack account:

# in your MoonBit project
moon add moonbitstack/moonetui

Run the examples

Every widget and style live in one terminal, a complete application, and the same toolkit on Node, in a browser and on a bare serial line:

# every widget and every style, live; up and down choose, tab moves in
moon run --target native src/examples/gallery

# a complete little application: tabs, timers, a command line
moon run --target native src/examples/monitor

# a styled application in a real terminal, Unix or Windows
moon run --target native src/examples/tui

# under Node
moon run --target js src/examples/hello

# the gallery in a browser: build, serve the repository, open the page
moon build --target js
python -m http.server
# then http://localhost:8000/src/examples/browser/index.html

# whole frames of plain text, as a bare serial line receives them
moon run --target native src/examples/uart

A first application

Widgets are values. They render a line at a time, report what they want through typed messages, and never call back into your code:

let button = @widget.Button::new(" Press me ")

let w : &@widget.Widget = button
let ctx = @widget.Ctx::new(1, @geom.Size::new(20, 3))
w.handle(ctx, @widget.Msg::Key(@event.Key::of(Enter)))
// ctx.asked() == [Pressed(id=1)]

Compose them in a tree, style them with a stylesheet, and drive the frame loop from a driver — the architecture page lays out the pieces.

What the library expects of you

  • A Driver for your terminal — or one of the three shipped.
  • A stylesheet if you want one — plain styles work without.
  • A message handler that answers the widgets' typed messages.
docs / architecture

Architecture

Twelve packages, one direction of flow, four points of contact with the platform.

The frame, step by step

The core is synchronous. wasm-gc has no async, and the core must run there — so the main loop is a step() the host drives: a blocking poll on native, setInterval under Node, requestAnimationFrame in a browser.

poll → parse → dispatch → lay out → compose → diff → write
  • poll — the driver reports what arrived; the app measures the terminal and sends every widget the time that passed.
  • parse — @input turns the byte stream into events: CSI and SS3 sequences, modifiers, SGR mouse, bracketed paste, UTF-8 split across reads.
  • dispatch — the app walks the focus ring and hands each event to a widget; widgets answer with typed messages.
  • lay out — @layout and the design system place boxes along an axis or on the grid.
  • compose — @comp turns placements into composed rows of styled cells.
  • diff — consecutive screens become only the spans that changed.
  • write — @style renders the changed styles as differential SGR sequences, and the driver writes them.

The package map

PackageResponsibility
@geomInteger rectangle algebra — Offset, Size, Region, Spacing — with intersection, union, splitting and clamping.
@styleColours (named, indexed, true colour), text attributes, palette quantisation, differential SGR rendering.
@stripOne rendered line: styled segments measured in terminal columns over a generated Unicode width table.
@eventKeys, mouse reports, paste, resize and focus as plain values.
@inputThe incremental terminal byte parser.
@compThe compositor: placements to rows, rows to diffs, diffs to escape sequences.
@driverThe seam to a terminal, plus a headless terminal for tests.
@vtA virtual terminal: the screen an escape stream draws, read by the browser driver, by tests and by screenshots.
@layoutAxis placement and the grid — spans, gutters, integer division that loses no column.
@designThe style system: rules, specificity, cascade, inheritance, chrome.
@widgetThe widget contract and the widget inventory.
@appThe widget tree, focus ring, scroll containers, docks, grids, screens, timers, and the frame loop.

The widget contract

A widget is a value with four operations. No callbacks, no inheritance, no hidden state:

pub(open) trait Widget {
  fn render_line(Self, Int32, @geom.Size) -> @strip.Strip
  fn hint(Self, @geom.Size) -> @geom.Size
  fn handle(Self, Ctx, Msg) -> Bool
  fn focusable(Self) -> Bool
}

render_line draws one row — the compositor asks only for rows it can see. hint reports the size the content wants. handle takes a message and answers through the Ctx: typed outputs like Pressed, Changed, Selected, Highlighted, Toggled — which a handler can claim before the app acts on them.

docs / widgets

Widgets

Thirty-two widgets, each with its own keyboard contract, message shape and rendering. Every card runs its demo from src/examples/showcase right here: click one and use the keys, and the line under it shows what the widget reports. The code under that is the demo's own source.

moon run --target native src/examples/gallery

Text and display

Static

Text clipped at a character boundary, aligned left, centre or right.

pub fn static_text() -> Demo {
  let left = @widget.Static::new("Text sits on the left by default.")
  let centre = @widget.Static::new("Or in the centre,", align=Centre)
  let right = @widget.Static::new("or on the right.", align=Right)
  let tinted = @widget.label(
    "label() styles it in one call.",
    style=@style.Style::new(fg=@style.cyan, bold=true),
  )
  Demo::new(
    "Static",
    @app.column(0, [
      @app.leaf(1, left, box=line(1)),
      @app.leaf(2, centre, box=line(1)),
      @app.leaf(3, right, box=line(1)),
      @app.leaf(4, tinted, box=line(1)),
    ]),
    group=Text,
    note="Text clipped at a character boundary, aligned left, centre or right.",
    rows=4,
  )
}

fn line(n : Int32) -> @layout.Box {
  @layout.Box::new(extent=Cells(n))
}

Header

The title bar: the title in the centre, a note at the right edge.

pub fn header() -> Demo {
  let bar = @widget.Header::new("moonetui", note="ctrl-c to quit")
  Demo::new(
    "Header",
    @app.column(0, [@app.leaf(1, bar, box=line(1))]),
    group=Text,
    note="The title bar: the title in the centre, a note at the right edge.",
    rows=1,
  )
}

fn line(n : Int32) -> @layout.Box {
  @layout.Box::new(extent=Cells(n))
}

Rule

A horizontal or vertical rule in any of nine styles.

pub fn rule() -> Demo {
  let kinds : Array[(String, @widget.RuleKind)] = [
    ("solid", Solid),
    ("heavy", Heavy),
    ("double", Double),
    ("dashed", Dashed),
    ("thick", Thick),
    ("ascii", Ascii),
  ]
  let rows = kinds.mapi((i, kind) => {
    @app.row(
      i * 10,
      [
        @app.leaf(i * 10 + 1, @widget.Static::new(kind.0), box=line(8)),
        @app.leaf(
          i * 10 + 2,
          @widget.Rule::horizontal(kind=kind.1),
          box=@layout.Box::new(extent=Frac(1)),
        ),
      ],
      box=line(1),
    )
  })
  Demo::new(
    "Rule",
    @app.column(100, rows),
    group=Text,
    note="A horizontal or vertical rule in any of nine styles.",
    rows=6,
  )
}

fn line(n : Int32) -> @layout.Box {
  @layout.Box::new(extent=Cells(n))
}

Digits

Three-row glyphs for clocks and counters, plain or bold.

pub fn digits() -> Demo {
  let clock = @widget.Digits::new(value="12:34")
  let price = @widget.Digits::new(
    value="€9.99",
    style=@style.Style::new(fg=@style.yellow, bold=true),
  )
  Demo::new(
    "Digits",
    @app.row(
      0,
      [@app.leaf(1, clock, box=line(17)), @app.leaf(2, price)],
      box=line(3),
    ),
    group=Text,
    note="Three-row glyphs for clocks and counters, plain or bold.",
    rows=3,
  )
}

fn line(n : Int32) -> @layout.Box {
  @layout.Box::new(extent=Cells(n))
}

Sparkline

An eight-level bar chart that buckets its data to fit the width.

pub fn sparkline() -> Demo {
  let load = @widget.Sparkline::new(data=[
    3.0, 5.0, 2.0, 8.0, 6.0, 4.0, 7.0, 9.0, 5.0, 3.0, 6.0, 8.0, 4.0, 2.0, 5.0, 7.0,
  ])
  let tall = @widget.Sparkline::new(
    data=[1.0, 2.0, 4.0, 7.0, 9.0, 7.0, 4.0, 2.0, 1.0, 2.0, 4.0, 7.0],
    summary=Mean,
  )
  Demo::new(
    "Sparkline",
    @app.column(0, [
      @app.leaf(1, load, box=line(1)),
      @app.leaf(2, tall, box=line(3)),
    ]),
    group=Text,
    note="An eight-level bar chart that buckets its data to fit the width.",
    rows=4,
  )
}

fn line(n : Int32) -> @layout.Box {
  @layout.Box::new(extent=Cells(n))
}

Progress

A bar against a total, filled in eighths of a column.

pub fn progress() -> Demo {
  let done = @widget.Progress::new(total=100)
  done.set(62)
  let fine = @widget.Progress::new(total=1000)
  fine.set(333)
  Demo::new(
    "Progress",
    @app.column(0, [
      @app.leaf(1, done, box=line(1)),
      @app.leaf(2, fine, box=line(1)),
    ]),
    group=Text,
    note="A bar against a total, filled in eighths of a column.",
    rows=2,
  )
}

fn line(n : Int32) -> @layout.Box {
  @layout.Box::new(extent=Cells(n))
}

LoadingIndicator

Five dots that pulse on every frame, for work with no end in sight.

pub fn loading_indicator() -> Demo {
  Demo::new(
    "LoadingIndicator",
    @app.column(0, [@app.leaf(1, @widget.LoadingIndicator::new())]),
    group=Text,
    note="Five dots that pulse on every frame, for work with no end in sight.",
    rows=3,
  )
}

Placeholder

A coloured stand-in for a widget not written yet; a click cycles what it shows.

pub fn placeholder() -> Demo {
  Demo::new(
    "Placeholder",
    @app.row(0, [
      @app.leaf(
        1,
        @widget.Placeholder::new(label="Sidebar"),
        box=@layout.Box::new(extent=Frac(1)),
      ),
      @app.leaf(
        2,
        @widget.Placeholder::new(label="Content", colour=3),
        box=@layout.Box::new(extent=Frac(2)),
      ),
    ]),
    group=Text,
    note="A coloured stand-in for a widget not written yet; a click cycles what it shows.",
    rows=4,
  )
}

Entry and logs

Input

A single-line field with Textual's keys; a password field shows dots.

pub fn input() -> Demo {
  let name = @widget.Input::new(placeholder="your name")
  let secret = @widget.Input::new(placeholder="password", password=true)
  let code = @widget.Input::new(placeholder="four digits", max_length=4)
  Demo::new(
    "Input",
    @app.column(0, [
      @app.leaf(1, name, box=line(1)),
      @app.leaf(2, secret, box=line(1)),
      @app.leaf(3, code, box=line(1)),
    ]),
    group=Entry,
    note="A single-line field with Textual's keys; a password field shows dots.",
    rows=3,
  )
}

fn line(n : Int32) -> @layout.Box {
  @layout.Box::new(extent=Cells(n))
}

TextArea

A multi-line editor with selection, undo, word moves and a line gutter.

pub fn text_area() -> Demo {
  let area = @widget.TextArea::new(text="fn main {\n  println(\"hello\")\n}\n")
  Demo::new(
    "TextArea",
    @app.column(0, [@app.leaf(1, area)]),
    group=Entry,
    note="A multi-line editor with selection, undo, word moves and a line gutter.",
    rows=5,
  )
}

Log

Lines that follow the tail until the reader scrolls away from it.

pub fn log() -> Demo {
  let lines = @widget.Log::new()
  lines.write_lines([
    "boot ok", "loading modules", "listening on 12000", "ready",
  ])
  Demo::new(
    "Log",
    @app.column(0, [@app.leaf(1, lines)]),
    group=Entry,
    note="Lines that follow the tail until the reader scrolls away from it.",
    rows=4,
  )
}

RichLog

A log of styled lines that can wrap to the width.

pub fn rich_log() -> Demo {
  let lines = @widget.RichLog::new(wrap=true)
  lines.write("deploy passed", style=@style.Style::new(fg=@style.green))
  lines.write("2 warnings", style=@style.Style::new(fg=@style.yellow))
  lines.write("one failure", style=@style.Style::new(fg=@style.red, bold=true))
  lines.write("done")
  Demo::new(
    "RichLog",
    @app.column(0, [@app.leaf(1, lines)]),
    group=Entry,
    note="A log of styled lines that can wrap to the width.",
    rows=4,
  )
}

Choices

Switch

An on-off thumb that slides to the side of its value.

pub fn switch() -> Demo {
  Demo::new(
    "Switch",
    @app.row(0, [
      @app.leaf(1, @widget.Switch::new(), box=line(6)),
      @app.leaf(2, @widget.Switch::new(value=true), box=line(6)),
    ]),
    group=Choice,
    note="An on-off thumb that slides to the side of its value.",
    rows=1,
  )
}

fn line(n : Int32) -> @layout.Box {
  @layout.Box::new(extent=Cells(n))
}

Checkbox

A labelled box that Space, Enter or a click turns on and off.

pub fn checkbox() -> Demo {
  Demo::new(
    "Checkbox",
    @app.column(0, [
      @app.leaf(
        1,
        @widget.Checkbox::new("Enable extras", value=true),
        box=line(1),
      ),
      @app.leaf(2, @widget.Checkbox::new("Send reports"), box=line(1)),
    ]),
    group=Choice,
    note="A labelled box that Space, Enter or a click turns on and off.",
    rows=2,
  )
}

fn line(n : Int32) -> @layout.Box {
  @layout.Box::new(extent=Cells(n))
}

RadioSet

Radio buttons where pressing one releases the rest.

pub fn radio_set() -> Demo {
  let set = @widget.RadioSet::new([
    @widget.RadioButton::new("Fast"),
    @widget.RadioButton::new("Balanced", value=true),
    @widget.RadioButton::new("Quiet"),
  ])
  Demo::new(
    "RadioSet",
    @app.column(0, [@app.leaf(1, set)]),
    group=Choice,
    note="Radio buttons where pressing one releases the rest.",
    rows=3,
  )
}

OptionList

Prompts with separators; the highlight never rests on a disabled one.

pub fn option_list() -> Demo {
  let list = @widget.OptionList::new(options=[
    @widget.Choice::new("Alpha"),
    @widget.Choice::new("Beta"),
    @widget.Choice::new("Retired", disabled=true),
  ])
  list.add_separator()
  list.add_option(@widget.Choice::new("Omega"))
  Demo::new(
    "OptionList",
    @app.column(0, [@app.leaf(1, list)]),
    group=Choice,
    note="Prompts with separators; the highlight never rests on a disabled one.",
    rows=5,
  )
}

SelectionList

A list where every entry is a checkbox of its own.

pub fn selection_list() -> Demo {
  let list = @widget.SelectionList::new(options=[
    @widget.Selection::new("Clean build", "clean", initial_state=true),
    @widget.Selection::new("All targets", "targets", initial_state=true),
    @widget.Selection::new("Sign the tag", "sign"),
  ])
  Demo::new(
    "SelectionList",
    @app.column(0, [@app.leaf(1, list)]),
    group=Choice,
    note="A list where every entry is a checkbox of its own.",
    rows=3,
  )
}

Select

A prompt that opens in place; typing narrows it, Escape folds it away.

pub fn select() -> Demo {
  let pick = @widget.Select::new(
    options=[("Alpha", "a"), ("Beta", "b"), ("Gamma", "g"), ("Delta", "d")],
    prompt="Pick a letter",
  )
  Demo::new(
    "Select",
    @app.column(0, [@app.leaf(1, pick)]),
    group=Choice,
    note="A prompt that opens in place; typing narrows it, Escape folds it away.",
    rows=5,
  )
}

Navigation and structure

Button

Enter, Space or a click presses it; a disabled one takes neither focus nor input.

pub fn button() -> Demo {
  let save = @widget.Button::new(" Save ")
  let cancel = @widget.Button::new(" Cancel ")
  let later = @widget.Button::new(" Later ")
  later.disable(true)
  Demo::new(
    "Button",
    @app.row(
      0,
      [
        @app.leaf(1, save, box=line(10)),
        @app.leaf(2, cancel, box=line(12)),
        @app.leaf(3, later, box=line(11)),
      ],
      gap=2,
    ),
    group=Navigation,
    note="Enter, Space or a click presses it; a disabled one takes neither focus nor input.",
    rows=1,
  )
}

fn line(n : Int32) -> @layout.Box {
  @layout.Box::new(extent=Cells(n))
}

ListView

A vertical list with a moving highlight; wide characters keep their columns.

pub fn list_view() -> Demo {
  let list = @widget.ListView::new([
    "alpha", "beta", "gamma", "delta", "epsilon", "中文也对齐",
  ])
  Demo::new(
    "ListView",
    @app.column(0, [@app.leaf(1, list)]),
    group=Navigation,
    note="A vertical list with a moving highlight; wide characters keep their columns.",
    rows=4,
  )
}

Tabs

A row of tabs with an underline under the active one; the arrows move it.

pub fn tabs() -> Demo {
  Demo::new(
    "Tabs",
    @app.column(0, [
      @app.leaf(1, @widget.Tabs::new(labels=["Inbox", "Reports", "Settings"])),
    ]),
    group=Navigation,
    note="A row of tabs with an underline under the active one; the arrows move it.",
    rows=2,
  )
}

TabbedContent

Tabs over panes: keys go to the pane first and switch tabs when it passes.

pub fn tabbed_content() -> Demo {
  let jobs = @widget.ListView::new(["build", "test", "deploy"])
  let notes = @widget.Static::new("last run: 42 seconds ago\nall green")
  let panes : Array[&@widget.Widget] = [jobs, notes]
  Demo::new(
    "TabbedContent",
    @app.column(0, [
      @app.leaf(1, @widget.TabbedContent::new(labels=["Jobs", "Notes"], panes~)),
    ]),
    group=Navigation,
    note="Tabs over panes: keys go to the pane first and switch tabs when it passes.",
    rows=5,
  )
}

Collapsible

A titled section that Enter or a click folds and unfolds.

pub fn collapsible() -> Demo {
  let body : &@widget.Widget = @widget.Static::new(
    "Four backends, all passing.\nNo warnings.",
  )
  Demo::new(
    "Collapsible",
    @app.column(0, [
      @app.leaf(
        1,
        @widget.Collapsible::new(
          title="Details",
          contents=body,
          collapsed=false,
        ),
      ),
    ]),
    group=Navigation,
    note="A titled section that Enter or a click folds and unfolds.",
    rows=6,
  )
}

Tree

Nodes with guides; Enter selects, Space folds, Shift and an arrow walk the family.

pub fn tree() -> Demo {
  let tree = @widget.Tree::new(root_label="Dune")
  let root = tree.root_id()
  let arrakis = tree.add(root, "Arrakis", expand=true)
  ignore(tree.add_leaf(arrakis, "Arrakeen"))
  ignore(tree.add_leaf(arrakis, "Carthag"))
  let caladan = tree.add(root, "Caladan")
  ignore(tree.add_leaf(caladan, "Castle Caladan"))
  tree.expand(root)
  Demo::new(
    "Tree",
    @app.column(0, [@app.leaf(1, tree)]),
    group=Navigation,
    note="Nodes with guides; Enter selects, Space folds, Shift and an arrow walk the family.",
    rows=6,
  )
}

DirectoryTree

A tree over a file system that reads each folder when it opens.

pub fn directory_tree() -> Demo {
  let disk = @widget.DirectoryTree::new(path=".", loader=path => {
    if path == "." {
      [
        @widget.DirEntry::new("src", is_dir=true),
        @widget.DirEntry::new("docs", is_dir=true),
        @widget.DirEntry::new("moon.mod", is_dir=false),
        @widget.DirEntry::new("README.md", is_dir=false),
      ]
    } else {
      [
        @widget.DirEntry::new("main.mbt", is_dir=false),
        @widget.DirEntry::new("moon.pkg", is_dir=false),
      ]
    }
  })
  Demo::new(
    "DirectoryTree",
    @app.column(0, [@app.leaf(1, disk)]),
    group=Navigation,
    note="A tree over a file system that reads each folder when it opens.",
    rows=6,
  )
}

Data and notices

DataTable

Columns, a cell cursor, sorting by any column, and stripes if asked.

pub fn data_table() -> Demo {
  let table = @widget.DataTable::new(zebra=true)
  ignore(table.add_column("Target", width=10))
  ignore(table.add_column("Tests", width=6, align=Right))
  ignore(table.add_column("Status"))
  ignore(table.add_row(["wasm", "336", "pass"]))
  ignore(table.add_row(["wasm-gc", "336", "pass"]))
  ignore(table.add_row(["js", "336", "pass"]))
  ignore(table.add_row(["native", "336", "pass"]))
  Demo::new(
    "DataTable",
    @app.column(0, [@app.leaf(1, table)]),
    group=Data,
    note="Columns, a cell cursor, sorting by any column, and stripes if asked.",
    rows=5,
  )
}

Markdown

Headings, emphasis, lists, quotes, code and tables, wrapped to the width.

pub fn markdown() -> Demo {
  let doc = @widget.Markdown::new(
    "# Deploy report\n\nAll **four** backends pass, with `--deny-warn`.\n\n- wasm and wasm-gc\n- js and native\n\n> Shipped on a Thursday.",
  )
  Demo::new(
    "Markdown",
    @app.column(0, [@app.leaf(1, doc)]),
    group=Data,
    note="Headings, emphasis, lists, quotes, code and tables, wrapped to the width.",
    rows=9,
  )
}

Tooltip

A padded box of wrapped text, for a hint beside what it explains.

pub fn tooltip() -> Demo {
  let tip = @widget.Tooltip::new(
    "Builds every target and runs the whole suite on each.",
    max_width=34,
    style=@style.Style::new(bg=@style.Colour::rgb(60, 60, 80)),
  )
  Demo::new(
    "Tooltip",
    @app.column(0, [@app.leaf(1, tip)]),
    group=Data,
    note="A padded box of wrapped text, for a hint beside what it explains.",
    rows=4,
  )
}

Toast

A notice with a coloured bar for its level; it expires on its own or on a click.

pub fn toast() -> Demo {
  let done = @widget.Toast::new(
    "All four backends pass.",
    title="Deployed",
    duration=0,
  )
  let warn = @widget.Toast::new(
    "Two tests were skipped.",
    title="Heads up",
    level=Warning,
    duration=0,
  )
  Demo::new(
    "Toast",
    @app.column(0, [@app.leaf(1, done), @app.leaf(2, warn)]),
    group=Data,
    note="A notice with a coloured bar for its level; it expires on its own or on a click.",
    rows=4,
  )
}
docs / design

Design system

The CSS model you already know — selectors, specificity, cascade, inheritance — applied to the terminal.

Rules and cascade

Give a node a name, an id and classes; write rules against them. Rules stack by specificity — ids, then classes and pseudo-classes, then names, compared in that order as CSS does — then by position, and a node inherits the text style of its container:

let sheet = @design.Stylesheet::new()
sheet.add(
  @design.Sel::new(name="Header"),
  @design.Styles::new(text=@style.Style::new(bold=true, fg=@style.cyan)),
)
sheet.add(
  @design.Sel::new(classes=["framed"], focus=true),
  @design.Styles::new(border=Heavy),
)
  • Selectors — name, id, class, and the :focus, :hover and :disabled pseudo-classes.
  • Properties — border, padding, margin, alignment, display, dock, grid spans, offsets, transitions, scroll-bar styles, and the text style itself.
  • Chrome — borders, padding and margins draw behind the children, so a framed container never clips its own frame.
  • States — a node the pointer rests on takes :hover; a disabled one takes :disabled and neither focus nor input.
  • Transitions — an offset moves a node without telling the layout; a transition glides it to wherever the next layout puts it.

Scroll containers and docks

A pane sizes its child to the child's own content, shows a window of it, draws bars that track the window, and moves by wheel, by the arrow, page, home and end keys, and to whatever just took focus. A docked child sits against an edge of its container, over the flow that gives up the space.

Seen live

Borders

Five kinds of frame, drawn by the design system around any node.

pub fn borders() -> Demo {
  let kinds : Array[(String, @design.EdgeKind)] = [
    ("thin", Thin),
    ("round", Round),
    ("heavy", Heavy),
    ("double", Double),
    ("ascii", Ascii),
  ]
  let boxes = kinds.mapi((i, kind) => {
    @app.leaf(
      i + 1,
      @widget.Static::new(kind.0, align=Centre),
      box=@layout.Box::new(extent=Frac(1)),
      styles=@design.Styles::new(border=kind.1),
    )
  })
  Demo::new(
    "Borders",
    @app.row(0, boxes, gap=1, box=line(3)),
    group=Style,
    note="Five kinds of frame, drawn by the design system around any node.",
    rows=3,
  )
}

fn line(n : Int32) -> @layout.Box {
  @layout.Box::new(extent=Cells(n))
}

Colours

The sixteen named colours, the 256-colour cube, and a true-colour sweep cut down to 256 and to 16.

pub fn colours() -> Demo {
  Demo::new(
    "Colours",
    @app.column(0, [@app.leaf(1, Swatches::{ })]),
    group=Style,
    note="The sixteen named colours, the 256-colour cube, and a true-colour sweep cut down to 256 and to 16.",
    rows=5,
  )
}

/// A widget of its own: each row a strip of coloured cells after a label.
priv struct Swatches {}

impl @widget.Widget for Swatches with fn render_line(_self, row, size) {
  let label = ["16", "256", "24-bit", "→ 256", "→ 16"]
  if row < 0 || row >= label.length() {
    return @strip.Strip::blank(size.width, @style.default)
  }
  let lead = @strip.Strip::of(label[row], @style.Style::new(dim=true)).pad_to(
    8, @style.default,
  )
  let span = @cmp.maximum(size.width - 8, 0)
  let cells = Array::makei(span, x => {
    let colour : @style.Colour = match row {
      0 => Ansi(x * 16 / @cmp.maximum(span, 1))
      1 => Ansi256(16 + x * 216 / @cmp.maximum(span, 1))
      _ => {
        let swept = sweep(x, span)
        match row {
          3 => swept.to_ansi256()
          4 => swept.to_ansi16()
          _ => swept
        }
      }
    }
    @strip.Seg::new(" ", @style.Style::new(bg=colour))
  })
  @strip.Strip::join([lead, @strip.Strip::from_segs(cells)])
}

impl @widget.Widget for Swatches with fn hint(_self, size) {
  @geom.Size::new(size.width, 5)
}

impl @widget.Widget for Swatches with fn handle(_self, _ctx, _msg) {
  false
}

impl @widget.Widget for Swatches with fn focusable(_self) {
  false
}

/// The hue at column `x` of `span`, fully saturated.
fn sweep(x : Int32, span : Int32) -> @style.Colour {
  let h = x.to_double() * 6.0 / @cmp.maximum(span, 1).to_double()
  let f = h - h.floor()
  let up = (f * 255.0).to_int()
  let down = 255 - up
  match h.to_int() {
    0 => @style.Colour::rgb(255, up, 0)
    1 => @style.Colour::rgb(down, 255, 0)
    2 => @style.Colour::rgb(0, 255, up)
    3 => @style.Colour::rgb(0, down, 255)
    4 => @style.Colour::rgb(up, 0, 255)
    _ => @style.Colour::rgb(255, 0, down)
  }
}

Text styles

The SGR attributes every terminal knows, set on any style.

pub fn text_styles() -> Demo {
  let styles : Array[(String, @style.Style)] = [
    ("bold", @style.Style::new(bold=true)),
    ("dim", @style.Style::new(dim=true)),
    ("italic", @style.Style::new(italic=true)),
    ("underline", @style.Style::new(underline=true)),
    ("strike", @style.Style::new(strike=true)),
    ("reverse", @style.Style::new(reverse=true)),
  ]
  let words = styles.mapi((i, pair) => {
    @app.leaf(
      i + 1,
      @widget.label(pair.0, style=pair.1),
      box=line(pair.0.length()),
    )
  })
  Demo::new(
    "Text styles",
    @app.row(0, words, gap=3, box=line(1)),
    group=Style,
    note="The SGR attributes every terminal knows, set on any style.",
    rows=1,
  )
}

fn line(n : Int32) -> @layout.Box {
  @layout.Box::new(extent=Cells(n))
}

Docks and fractions

Edge docks take their side first; the flow divides what is left in cells and shares.

pub fn docks() -> Demo {
  let sheet = @design.Stylesheet::new()
  sheet.add(
    @design.Sel::new(classes=["pane"]),
    @design.Styles::new(border=Round),
  )
  sheet.add(@design.Sel::new(name="Header"), @design.Styles::new(dock=Top))
  sheet.add(@design.Sel::new(name="Footer"), @design.Styles::new(dock=Bottom))
  let pane = (id : Int32, text : String, extent : @layout.Extent) => {
    @app.leaf(
      id,
      @widget.Static::new(text, align=Centre),
      box=@layout.Box::new(extent~),
      classes=["pane"],
    )
  }
  Demo::new(
    "Docks and fractions",
    @app.column(0, [
      @app.leaf(1, @widget.Header::new("docked to the top"), name="Header"),
      @app.row(2, [
        pane(3, "12 columns", Cells(12)),
        pane(4, "two shares", Frac(2)),
        pane(5, "one share", Frac(1)),
      ]),
      @app.leaf(6, @widget.Footer::new([("dock", "bottom")]), name="Footer"),
    ]),
    group=Style,
    note="Edge docks take their side first; the flow divides what is left in cells and shares.",
    rows=7,
    sheet~,
  )
}

Grid

A grid of columns and rows, with spans and a gutter that loses no column.

pub fn grid() -> Demo {
  let sheet = @design.Stylesheet::new()
  sheet.add(
    @design.Sel::new(classes=["cell"]),
    @design.Styles::new(border=Thin),
  )
  let cell = (id : Int32, text : String) => {
    @app.leaf(id, @widget.Static::new(text, align=Centre), classes=["cell"])
  }
  Demo::new(
    "Grid",
    @app.grid(
      0,
      [
        @app.leaf(
          1,
          @widget.Static::new("two columns", align=Centre),
          classes=["cell"],
          styles=@design.Styles::new(column_span=2),
        ),
        @app.leaf(
          2,
          @widget.Static::new("two rows", align=Centre),
          classes=["cell"],
          styles=@design.Styles::new(row_span=2),
        ),
        cell(3, "one"),
        cell(4, "one"),
      ],
      cols=3,
      gutter=1,
    ),
    group=Style,
    note="A grid of columns and rows, with spans and a gutter that loses no column.",
    rows=6,
    sheet~,
  )
}

States

Rules for :focus, :hover and :disabled stack over the plain rule by weight.

pub fn states() -> Demo {
  let sheet = @design.Stylesheet::new()
  sheet.add(
    @design.Sel::new(classes=["key"]),
    @design.Styles::new(border=Round),
  )
  sheet.add(
    @design.Sel::new(classes=["key"], hover=true),
    @design.Styles::new(text=@style.Style::new(fg=@style.cyan)),
  )
  sheet.add(
    @design.Sel::new(classes=["key"], focus=true),
    @design.Styles::new(border=Heavy, text=@style.Style::new(bold=true)),
  )
  sheet.add(
    @design.Sel::new(classes=["key"], disabled=true),
    @design.Styles::new(text=@style.Style::new(dim=true)),
  )
  let key = (id : Int32, text : String) => {
    @app.leaf(
      id,
      @widget.Button::new(text, focus_style=@style.default),
      box=line(14),
      classes=["key"],
    )
  }
  Demo::new(
    "States",
    @app.row(
      0,
      [key(1, "focus me"), key(2, "hover me"), key(3, "disabled")],
      gap=1,
      box=line(3),
    ),
    group=Style,
    note="Rules for :focus, :hover and :disabled stack over the plain rule by weight.",
    rows=3,
    sheet~,
    disabled=[3],
  )
}

fn line(n : Int32) -> @layout.Box {
  @layout.Box::new(extent=Cells(n))
}
docs / drivers

Drivers & terminals

Four operations touch a platform: write, read what is available, set raw mode, and ask for the terminal size. Everything else is pure.

Shipped drivers

DriverEnvironmentNotes
@driver/nativeA real terminaltermios on Unix; virtual terminal mode on Windows.
@driver/nodeNode.jsRaw mode, a queue the data handler fills, a frame pump on the clock.
@driver/webBrowserA terminal on a canvas — a grid of cells, its own escape handling, key encoding, resize in-band.
@driver (headless)TestsA terminal that understands the sequences — the whole suite runs against it.

The three terminal worlds

▮

Bare serial UART

A microcontroller and a wire. No cursor addressing, no colour: pass plain=true when the app is built and the renderer sends whole frames instead of diffs — the same cell buffer, written line by line, nothing but text and newlines. Bring a Driver that writes bytes; nothing else changes.

▤

Serial-era terminals

Anything speaking the classic control set: cursor movement, erase, and the eight-colour palette. moonetui emits only the ECMA-48 / ISO 6429 subset these terminals understand — SGR (CSI … m) for style runs, CUP (CSI H) for addressing — and quantises true colour down to the palette the far end actually has.

_rgba

Modern terminals

The full feature set: true colour, box-drawing glyphs, double-width CJK and emoji, bracketed paste, SGR mouse reports, focus events. The differential renderer keeps the wire quiet between frames.

Standards

  • Unicode — every line is measured in terminal columns over a generated width table: wide CJK characters, double-width emoji, combining marks. Alignment and padding are never guesses.
  • ECMA-48 / ISO 6429 — the control sequences moonetui emits follow the international standard for control functions for coded character sets: SGR for character attributes, CUP and friends for cursor addressing, ED for erase. A terminal built to the standard renders moonetui output without negotiation.
  • UTF-8 — the input side decodes UTF-8 incrementally, so a code point split across two reads arrives whole.
docs / testing

Testing

The same suite on four backends — a regression in one world fails loudly everywhere.

420
wasm
420
wasm-gc
420
js
420
native

Run the suite

moon check --target all --deny-warn
moon build --target all
moon test  --target all
moon fmt && moon info --target all   # the diff must stay clean

What the suite covers

  • Geometry and strips — rectangle algebra, clipping at character boundaries, Unicode width.
  • The parser — CSI and SS3 sequences, modifiers, mouse, paste, split UTF-8, and sequences it should pass over whole.
  • The virtual terminal — cursor movement, erasing, private modes, string sequences and scrolling, so output can be checked cell by cell.
  • The compositor — rows from placements, diffs from consecutive frames, spans to escape sequences.
  • Layout and design — axis and grid placement, the cascade, inheritance, chrome behind children.
  • Every widget — each one's keyboard contract, message shape and rendered rows asserted against expected text.
  • Every demo — each one in the gallery starts in a headless application and draws, so the examples on this site cannot rot.

CI runs the whole gate on Ubuntu, macOS and Windows on every pull request and every push to master, and a tag publishes the module to mooncakes.