Declarative UI: Building Terminal Screens with Widgets in Phosphor
July 31, 2026
Most BBS systems render screens the hard way: imperative code that clears the terminal, prints line by line, and redraws on every update. It works, but it’s verbose and fragile. One missed redraw and the screen is stale.
Phosphor takes a different approach. You describe the screen as a tree of widgets. The runtime handles rendering, re-rendering on data change, and input routing. You only write callbacks for interactive behavior.
The Imperative Way
This is how screens used to work:
screen "BoardList" {
on enter {
clear()
print "Message Boards"
print "============="
boards = sql("SELECT * FROM boards ORDER BY name")
for b in boards {
print "${b.name} (${b.unread} unread)"
}
print ""
print "[Q] Quit"
}
on update {
clear()
print "Message Boards"
print "============="
boards = sql("SELECT * FROM boards ORDER BY name")
for b in boards {
print "${b.name} (${b.unread} unread)"
}
print ""
print "[Q] Quit"
}
}
Every update is a full redraw. Every screen is copy-pasted logic. Add a filter, a selection cursor, or pagination and the code balloons. The structure and the data are tangled together.
The Declarative Way
Here’s the same screen with declarative widgets:
screen "BoardList" {
title = "Message Boards"
bind boards = sql("SELECT * FROM boards ORDER BY name")
bind selected = 0
layout {
banner { text = "Message Boards" }
spacer 1
list {
data = boards
format = { item -> "${item.name} (${item.unread} unread)" }
selected = selected
on_select { item -> selected = item.index }
on_activate { item -> navigate "board" with {name: item.name} }
}
spacer 1
statusbar {
left = "${len(boards)} boards"
right = "[Q] Quit"
}
}
on key 'Q' { back() }
}
No clear(). No print. No manual redraw. The layout block declares the widget tree. The runtime renders it on screen enter and re-renders automatically when boards changes — because boards is a bind variable.
How Reactivity Works
Every widget that references a bind variable is reactive. When the data source changes, the widget re-renders itself. No on update handler needed for layout updates:
screen "LiveDashboard" {
bind online = session_count()
bind channels = chat_channels()
bind mail = mail_unread_count(user.id)
layout {
banner { text = "Dashboard" }
spacer 1
label "Online: ${online}" // updates when session_count changes
label "Mail: ${mail} unread" // updates when mail arrives
spacer 1
table {
data = channels // updates when channels change
columns = ["Channel", "Users", "Topic"]
}
statusbar { right = "[Q] Quit" }
}
// No on update needed — the layout handles it.
// Only write on update if you need custom side effects:
on update {
if mail > prev_mail {
flash("New mail!")
}
prev_mail = mail
}
on key 'Q' { back() }
}
The binding system is the engine behind this. bind declarations register with a BindingContext that tracks dependencies. When a binding’s source changes (a database row updated, a session count incremented), the context fires the binding’s update callback, which re-renders any widget referencing that binding.
The Widget Catalog
Phosphor ships with 14 built-in widgets:
Text widgets:
label— single line of text, supports interpolationbanner— prominent heading, text or ANSI artseparator— horizontal linespacer— vertical whitespace
Data widgets:
table— data table with columns, row coloring, selection, paginationlist— selectable list with custom formatting and filtering
Input widgets:
input— single-line text field, bidirectional bindingtextarea— multi-line text input
Container widgets:
panel— bordered container with optional titletabs— tabbed interface, lazy rendering per tabregion— empty container filled dynamically
Indicator widgets:
progress— progress bar with custom characterschart— ASCII line/bar/sparkline chartstatusbar— fixed bottom bar with left/center/right slotsmenu_bar— horizontal top menu with hotkeys
The Table Widget
The table is the most powerful widget. It binds to a reactive data source, renders columns, supports row coloring, selection, pagination, and activation:
screen "Portfolio" {
title = "Portfolio"
bind positions = sql("SELECT symbol, shares, market_value, gain_loss
FROM positions WHERE user_id = ?", user.id)
bind total = sql_one("SELECT SUM(market_value) FROM positions WHERE user_id = ?", user.id)
bind selected = 0
layout {
banner {
text = "Portfolio — ${user.username}"
color = accent
align = center
}
spacer 1
table {
data = positions
columns = ["Symbol", "Shares", "Value", "P&L"]
row_color = { row ->
if row.gain_loss > 0 then bright_green else bright_red
}
selected = selected // highlights the selected row
on_select { row ->
selected = row.index
}
on_activate { row ->
navigate "position_detail" with {symbol: row.symbol}
}
page_size = 20
empty_message = "No positions"
}
spacer 1
panel {
border = true
content {
label "Total Value: $${total[0]}"
label "Positions: ${len(positions)}"
}
}
statusbar {
left = "Total: $${total[0]}"
right = "[R] Refresh [D] Detail [Q] Quit"
}
}
on key 'R' { refresh() }
on key 'D' {
if len(positions) > 0 {
navigate "position_detail" with {symbol: positions[selected].symbol}
}
}
on key 'Q' { back() }
}
The row_color callback is a Phosphor function that receives each row and returns a color. Gains show in bright green, losses in bright red. The table re-renders when positions changes — no manual redraw.
The Tabs Widget
Tabs let you switch between views instantly. The runtime renders only the active tab but keeps all tab states alive:
tabs {
tab "Inbox" {
list {
data = mail_inbox(user.id, limit: 20)
format = { m -> "${m.unread ? '●' : ' '} ${m.from} — ${m.subject}" }
on_activate { m -> navigate "mail_view" with {id: m.id} }
}
}
tab "Sent" {
list {
data = mail_sent(user.id, limit: 20)
format = { m -> "${m.to} — ${m.subject}" }
on_activate { m -> navigate "mail_view" with {id: m.id} }
}
}
tab "Drafts" {
list {
data = mail_drafts(user.id)
on_activate { m -> navigate "mail_edit" with {id: m.id} }
}
}
selected = 0
on_change { idx -> selected = idx }
}
The Chart Widget
ASCII charts render directly in the terminal. Bind to a SQL query and the chart updates live:
chart {
type = "line" // "line", "bar", "sparkline"
data = sql("SELECT date, value FROM portfolio_history WHERE user_id = ?", user.id)
x = "date"
y = "value"
color = bright_green
height = 15 // rows
title = "Portfolio Value"
}
The AST: Widgets as Records
Every widget is a Java record implementing a sealed Widget interface. This gives exhaustive pattern matching in the runtime — the compiler verifies every widget type is handled:
public sealed interface Widget extends AstNode {
int line();
int column();
Optional<String> id();
record LabelWidget(
Optional<String> id,
Expr text,
List<Map.Entry<String, Expr>> properties,
int line, int column
) implements Widget {}
record TableWidget(
Optional<String> id,
List<Map.Entry<String, Expr>> properties,
Optional<Stmt> onSelect,
Optional<Stmt> onActivate,
int line, int column
) implements Widget {}
record PanelWidget(
Optional<String> id,
List<Map.Entry<String, Expr>> properties,
List<Widget> children,
int line, int column
) implements Widget {}
record TabsWidget(
Optional<String> id,
List<TabDef> tabs,
List<Map.Entry<String, Expr>> properties,
Optional<Stmt> onChange,
int line, int column
) implements Widget {}
// ... 14 widget types total
}
Every widget carries an optional id for targeted updates. When a binding fires, the runtime can find the widget by ID and re-render just that widget instead of the entire screen. A dashboard with 20 widgets doesn’t re-render all 20 when one counter changes — it updates only the widget with id = "mail_count".
Custom Widgets from Java
Plugins can register custom widgets via the @PhosphorWidget annotation:
@PhosphorWidget(name = "ticker", description = "Live stock ticker tape")
public class TickerWidget implements PhosphorWidget {
@Override
public void render(WidgetContext ctx) {
// Render an animated ticker tape using jterm primitives
// ctx.data() = the bound data
// ctx.term() = the terminal
// ctx.bounds() = the allocated rectangle
}
}
Then use it in any .phos file:
screen "TradingFloor" {
bind prices = stock_prices(["AAPL", "GOOG", "MSFT", "TSLA"])
layout {
banner { text = "Trading Floor" }
ticker {
data = prices
speed = 200
}
}
}
The plugin registers the widget at startup. The parser recognizes it as a custom widget. The runtime delegates rendering to the Java implementation. The BBS sysop writes one line of Phosphor; the plugin author writes the rendering logic in Java. Both sides stay clean.
Mixing Declarative and Imperative
Declarative UI and imperative code can be mixed freely. Use declarative for layout, imperative for complex logic:
screen "StockScreener" {
title = "Stock Screener"
bind results = sql("SELECT * FROM stocks WHERE pe_ratio < ? ORDER BY pe_ratio", max_pe)
bind max_pe = 15
bind filter = "value"
bind selected_idx = 0
layout {
banner { text = "Stock Screener" }
spacer 1
panel {
title = "Filters"
content {
input {
label = "Max P/E: "
value = max_pe
on_submit { val -> max_pe = to_int(val) }
}
}
}
spacer 1
table {
data = results
columns = ["Symbol", "Name", "P/E", "Dividend", "Sector"]
selected = selected_idx
on_activate { row -> navigate "stock_detail" with {symbol: row.symbol} }
}
statusbar {
left = "${len(results)} results"
right = "[Q] Quit"
}
}
// Imperative code for complex logic
on key 'S' {
sort_col = input("Sort by: ")
results = sql("SELECT * FROM stocks WHERE pe_ratio < ? ORDER BY ${sort_col}", max_pe)
}
on key 'Q' { back() }
}
The layout block handles rendering. The on key handlers handle complex interactions. When results changes (either from the input’s on_submit or the on key 'S' handler), the table re-renders automatically. No coordination code needed.
Why This Matters
The declarative UI is what makes Phosphor practical for sysops who aren’t programmers. You don’t need to understand terminal control codes, screen buffers, or redraw logic. You describe what the screen looks like, bind it to data, and the runtime does the rest.
A screen that used to be 60 lines of imperative rendering code is now 20 lines of declarative layout. A screen that needed a manual clear() and redraw on every update now updates itself. And the widget tree is just data — the runtime can diff it, optimize re-renders, and target individual widgets by ID.
That’s the difference between a config file and a programming language. Phosphor is both.