Skip to content

GUI

Build screens and console layouts with the gui_* functions. A GUI task lays out widgets, then suspends on await gui() until the player interacts; on handlers react to changes and clicks.

Every control on this page is also running, in the Control Gallery

The Control Gallery is a mission that shows each widget live with the source that built it sliced out of its own file underneath — plus a Layout category for sizing, and a Traps category for the mistakes that produce a plausible-looking screen. Run it with sbs debug control_gallery --map 0.

The shape of a GUI task

A console or screen is a label that builds a layout and then awaits input:

@console/helm !0 ^5 "Helm"
    gui_console("helm")
    await gui()

await gui() presents the layout and suspends until the player interacts. Each client should have exactly one main GUI task.

Layout

Widgets are placed top to bottom into sections and rows.

gui_section(style="area: 10, 10, 90, 90;")   # a positioned region (percent)
gui_row("row-height: 2em;")                  # a new row
"""Some text"""                              # triple-quoted string = a text label
"""{count} ships"""                          # f-string style interpolation
gui_blank()                                  # a spacer
gui_section(style="area: 10, 10, 90, 90;")
gui_row("row-height: 2em;")
gui_text("Some text")
gui_blank()

Buttons

Inside await gui(), * is a one-shot button (consumed after a click) and + is sticky (stays visible). Or create a button widget with gui_button and react with an on handler.

await gui():
    * "Launch":                 # one-shot
        launch_fighter()
    + "Status" //comms/status   # sticky, navigates a route

# or, as a widget with a handler:
on gui_message(gui_button("Refresh")):
    refresh_panel()

Widgets

Most widgets take a style string and bind to a variable with var=. Handle changes with on gui_message(widget): (fires when the value changes) or on gui_click(widget): (fires on click, for icons and click_tag elements).

cb  = gui_checkbox("text: {label}; state: {enabled}")
dd  = gui_drop_down("text: {menu}; list: arc, line, box", var="menu")
sl  = gui_int_slider("low: 0; high: 10;", var="level")
lb  = gui_list_box(items, "row-height: 1em;", item_template=my_template, select=True)
ib  = gui_icon("icon_index: 137; color: white;", style="click_tag: menu;")
fa  = gui_face(face_string)

on gui_message(dd):
    menu = dd.get_value()
on gui_click(ib):
    jump menu_label

Read a widget's value with widget.get_value() / widget.value; a list box also has get_selected(), get_selected_index(), and set_selected_index(i).

A control can emit a signal

Instead of a handler, a control can emit a signal each time it is used. Pass signal= to gui_button, gui_icon_button, gui_cycle_button, gui_checkbox, gui_drop_down, gui_slider / gui_int_slider or gui_list_box (on a row click), or call gui_signal(widget, name, data) on any control. The signal runs alongside the widget's other handlers, never in place of them.

Route variable What it holds
each key of data whatever you passed
SIGNAL_CLIENT_ID the console that used the control
SIGNAL_ITEM the control
SIGNAL_VALUE its value after the click: checkbox state, dropdown text, slider value, list box selection, cycle button state; None for a plain button
gui_checkbox("Shields", var="shields_on", signal="shields_toggled")

//shared/signal/shields_toggled
    set_shields(SIGNAL_CLIENT_ID, SIGNAL_VALUE)

A click is one console's event, so choose the route type for what it does: state changes go in //shared/signal (runs once, on the server), a repaint of that console goes in //signal.

A list box has two size keys, and they are different things

row-height is the height of ONE item row — a floor, so a two-line item still grows past it — and it is also the box each item is hit-tested in, so it is what decides how much of a row you can click. item-gap is the spacing between items. Declare neither and an item is exactly as tall as its template's rows, flush.

row-height used to mean the gap. If you have a list declaring it and meaning spacing, rename it to item-gap.

Gauges

gui_gauge(value, max, label) draws the engine's status-panel look: a label on the left, the value on the right, and a bar under both colored by how full it is - green, yellow below 50%, red below 25%. Over max the bar is full and turns the engineering console's tuned cyan.

gui_gauge(946, 1000, "Energy")
gui_gauge(45, 120, "FRNT SHLD", show="frac")      # "45 / 120"
gui_gauge(120, 100, "WEAP boost", show="pct")     # "120%", full cyan bar
shields = gui_gauge(120, 120, "REAR SHLD")

on change get_data_set_value(ship_id, "shield_val", 1, default=0):
    shields.value = get_data_set_value(ship_id, "shield_val", 1, default=0)
Option Meaning
show= value (default with a label), frac ("45 / 120"), pct, or none (default without a label - a bare bar)
warn= / crit= where the bar turns yellow / red, as fractions (default 0.5 / 0.25)
color= a fixed bar color, ignoring the thresholds
invert= more is WORSE (wear, heat, damage): green while low, yellow then red as it climbs, and red rather than cyan past max. In markup: gauge://0.7?max=1&invert=1

A value outside 0..max clamps the bar, never the number: -45 / 8 shows an empty bar and says -45. Keep the handle and set .value - only the gauge repaints. The same drawing is available inside a text area as [Energy](gauge://946?max=1000) (below).

Rich text areas

gui_text is a single styled line. For a multi-line, formatted block — help, briefings, a log, a comms transcript — use gui_text_area, which parses a small markdown-like language and auto-scrolls when its content overflows.

brief = "$t Mission Briefing^^Reach the beacon and hold the line.^^- Jump to the Kessel system.^- Defend the relay for five minutes.^- Do not let the convoy through."
gui_text_area(brief)
  • $t a title; #/##/### headings (plain, like markdown; $nh1..$nh3 are the auto-numbered forms); - bullets; 1. ordered lists; a blank line resets; ^ is a newline; {var} interpolates.
  • Inline objects by namespace: ![](image://key?scale=0.5), [](ship://hull?...), [](face://...), [](style://font:gui-4;color:#8cf).
  • Pipe tables — | Ship | Hull | rows with a |:--|--:| alignment row — render as a grid, columns sized to fit. (For an interactive table with controls, use gui_table instead.) A table whose first row is empty (| | |) has no header - that is how you write a grid of cells.
  • Gauges — [Energy](gauge://946?max=1000) on its own line, or as a table cell, draws a gauge (see Gauges; the options are URL parameters: gauge://45?max=120&show=frac). A grid of bare gauges is the engine's ENGN / WEAP / SHLD / SENS block:

    | | |
    |:--:|:--:|
    | [ENGN](gauge://1?max=1&show=none) | [WEAP](gauge://0.4?max=1&show=none) |
    | [SHLD](gauge://0.2?max=1&show=none) | [SENS](gauge://1?max=1&show=none) |
    
  • Icons — ![](icon://wanted?color=#f66) Bounty posted at the start of a line draws an icon one line tall with the text beside it. The name is an icon name or a sheet index (icon://137). At the start of a list item the icon is the bullet: - ![](icon://check.on) Hails answered. A table cell can start with an icon too. To give a whole list one icon, declare it once on the line above - [](bullet://check.on?color=#8f8) - and every - item below uses it until a blank line (bullet://none stops it early; numbered lists keep their numbers).

  • Faces, images and ships lead a line too — - ![](face://{face}) Lt Marek puts a portrait beside the name, which makes a crew roster a few lines of markdown. A face is two text lines tall, a ship four (the engine frames a 3D ship small), an icon or image one; size=N sets it. The list-wide form takes the same kinds: [](bullet://image://arrow). A picture with no text after it is still drawn full size, as before.
  • Collapsible sections — write a heading as ##+ Weapons (starts closed) or ##- Hull (starts open). A click on the heading folds or unfolds everything down to the next heading at the same or a higher level - tables, lists and sub-headings included - and the heading stays in view. What the reader opened stays open when the area's text is updated. The marker touches the hashes, so ## - x is still an ordinary heading.
  • Hyperlinks — a [Torgoth](ref://torgoth) line, or a table cell, is a clickable link. Give the area link_resolver= (a function key -> text, or a {key: text} dict) and it navigates within the same document — a Kralien entry can link straight to the Torgoth one (a codex). on_link=fn(key, widget) hears every click. <hr> draws a horizontal rule.
  • Choices — a [Kneel by the body](signal://lp_pick?pick=kneel) line is a button. Lines of them next to each other are one group, drawn as flat buttons that share a row while they fit and wrap onto the next row when they do not. Clicking one replaces the group with the choice made, so the area reads as the story so far, and emits the signal with the query as variables (strings), plus SIGNAL_CHOICE (the words), SIGNAL_CLIENT_ID and SIGNAL_ITEM (the area). Continue the story from the route with gui_text_area_append(SIGNAL_ITEM, text); the new text can end in more choices. A [](choices://?layout=stack&fill=#234) line restyles the groups after it (fill, text, chosen_fill, chosen_text, font, pad_x, pad_y, gap, layout = flow or stack). A boarding scene can be read this way with boarding_reader(area, client_id).

    //shared/signal/lp_pick
        gui_text_area_append(SIGNAL_ITEM, lp_scene_text(pick))
    
    - A single unformatted line just renders as plain text; a parse slip shows Document syntax issue line number N — so a blank/garbled area is usually a syntax slip on that line. Engine text is ASCII-only.

Reacting while the GUI is up

on handlers belong to the task that built the widget, and live until the next GUI build replaces them:

on change red_alert:                       # a variable changed
    repaint_alert()

on change get_data_set_value(ship_id, "red_alert", 0):   # any expression
    update_banner()

on gui_message(gui_button("Dock")):        # a button was pressed
    request_dock()

on signal "wave_cleared":                  # a signal fired
    show_bonus()

Which task a handler runs on decides how it must END, and whether it can repaint the screen. See Handler lifetime for the full table, including on_press=, gui_message_callback and the trap where ->END in a handler kills the console.

Updating without a rebuild

Set a widget's value and the dirty system re-renders it automatically — no need to rebuild the whole page:

the_face.value = new_face_string
on_screen.update(f"image:{get_mission_dir_filename('RedAlert')}")

Inside a region, the OWNER repaints

A widget inside a sub-region - an overlay slot, a tab of a tabbed panel, a listbox row - cannot repaint itself: the engine draws the new text over the old one instead of replacing it. There, change what the region's owner builds from and let the owner redraw - for an overlay, overlay_patch. gui_gauge and gui_cycle_button already follow this rule.

gui_represent() is deprecated

Widgets mark themselves dirty when their value changes and re-render on their own. Calling gui_represent() is harmless but redundant.

Consoles

Route a client to a standard console, or build one widget by widget:

gui_console("helm")                  # a standard console
gui_activate_console("cockpit")      # switch this client to a console
gui_layout_widget("2dview")          # size a gameplay view (2dview / 3dview)

Full rebuild / reroute

To rebuild a page, jump back to its label; to move clients between pages:

gui_reroute_server(server_status)          # redirect the server task
gui_reroute_clients(mission_end_screen)    # redirect every client task

See the gui API for the full list of widgets and options, and the GUI tutorial for a worked example.