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.
moon run --target native src/examples/gallery.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.
| Target | Driver | What you get |
|---|---|---|
| Native terminal | @driver/native | termios on Unix, VT mode on Windows — the full modern feature set |
| Node.js | @driver/node | raw mode over stdin/stdout with a queue-fed frame pump |
| Browser | @driver/web | a terminal drawn on a canvas, keys encoded, resize in-band |
| Serial UART | your Driver | ECMA-48 / ISO 6429 subset over a byte stream; full repaint degrades gracefully |
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
Driverfor 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.
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 —
@inputturns 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 —
@layoutand the design system place boxes along an axis or on the grid. - compose —
@compturns placements into composed rows of styled cells. - diff — consecutive screens become only the spans that changed.
- write —
@stylerenders the changed styles as differential SGR sequences, and the driver writes them.
The package map
| Package | Responsibility |
|---|---|
@geom | Integer rectangle algebra — Offset, Size, Region, Spacing — with intersection, union, splitting and clamping. |
@style | Colours (named, indexed, true colour), text attributes, palette quantisation, differential SGR rendering. |
@strip | One rendered line: styled segments measured in terminal columns over a generated Unicode width table. |
@event | Keys, mouse reports, paste, resize and focus as plain values. |
@input | The incremental terminal byte parser. |
@comp | The compositor: placements to rows, rows to diffs, diffs to escape sequences. |
@driver | The seam to a terminal, plus a headless terminal for tests. |
@vt | A virtual terminal: the screen an escape stream draws, read by the browser driver, by tests and by screenshots. |
@layout | Axis placement and the grid — spans, gutters, integer division that loses no column. |
@design | The style system: rules, specificity, cascade, inheritance, chrome. |
@widget | The widget contract and the widget inventory. |
@app | The 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.
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, ) }
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,:hoverand:disabledpseudo-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:disabledand 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)) }
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
| Driver | Environment | Notes |
|---|---|---|
@driver/native | A real terminal | termios on Unix; virtual terminal mode on Windows. |
@driver/node | Node.js | Raw mode, a queue the data handler fills, a frame pump on the clock. |
@driver/web | Browser | A terminal on a canvas — a grid of cells, its own escape handling, key encoding, resize in-band. |
@driver (headless) | Tests | A 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.
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.
Testing
The same suite on four backends — a regression in one world fails loudly everywhere.
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.