Home/GANDER docs/GANDER UI

GANDER UI

Immediate-mode menus, HUD widgets, slot screens, safe areas and mobile orientation.

ui.mdllms-full.txt

Immediate mode. Call widgets every frame from frame(). A button returns true on the frame it is pressed; sliders, toggles and choices write through a reference (vol, on, choice are your var globals). Nothing to create or destroy.

Every widget works with mouse, touch, keyboard and gamepad with no extra code: arrows / WASD / d-pad / left stick move focus to the nearest widget in that direction, confirm (Enter, Space, pad A) presses, left / right adjust a focused slider, segmented, select or tabs, and gander::ui_back() reports back (Esc, Backspace, pad B). A focus ring shows only while a keyboard or pad is in use. On touch screens the whole UI scales up so rows are at least ~48 physical points (gander::ui_scale() tells you the factor).

Ids

A label is the widget's id. Text after ## is part of the id but not shown: "Delete##save2". Two widgets with the same id in one frame both react (the log warns). In loops wrap each item:

for i: i32 in 0..levels.len as i32 {
    gander::ui_push_id(i);
    if gander::ui_button("Play") { start(i); }
    gander::ui_pop_id();
}

Modals get their own id scope automatically.

Layout

Widgets stack top to bottom inside the current panel or area, full width, theme.item_h tall.

Call Does
ui_panel(title, anchor, w, h) ... ui_panel_end() Panel at an anchor, inside the safe area. Sizes <= 1.0 are fractions of the screen. h = 0 fits the content (measured each frame). Empty title: no title row.
ui_panel_at(title, x, y, w, h) Panel at a rect.
ui_area(x, y, w, h) ... ui_panel_end() Same layout, no background (HUD button rows).
ui_row(n) The next n widgets share one row.
ui_space(px), ui_separator() Gaps.
ui_scroll(name, h) ... ui_scroll_end() Clipped list; wheel, touch drag, and focus keeps the focused item visible. h = 0: rest of the panel.
ui_modal(title, w, h) ... ui_modal_end() Dims the screen, takes the focus, blocks everything behind it.
ui_anchor(anchor, w, h, margin) -> float2 Top-left of a rect at an anchor, inside the safe area (for your own drawing).

Anchors: gander::TOP_LEFT, TOP, TOP_RIGHT, LEFT_CENTER, CENTER, RIGHT_CENTER, BOTTOM_LEFT, BOTTOM, BOTTOM_RIGHT.

Widgets

Call Returns
ui_title(text, align) big heading
ui_label(text) / ui_label(text, align, dim) one line
ui_text(text) / ui_text(text, align, dim) word-wrapped paragraph (outside a UI layout, in a canvas or a HUD box of your own: gander::text_wrap / text_wrap_height in the API sheet's 2D section)
ui_button(label) / ui_button(label, style) bool pressed. Styles BUTTON, BUTTON_PRIMARY, BUTTON_DANGER, BUTTON_GHOST
ui_toggle(label, on: bool&) bool changed (switch)
ui_checkbox(label, on: bool&) bool changed
ui_slider(label, v: f32&, lo, hi, step, decimals) bool changed (step 0: continuous)
ui_segmented(label, "Easy,Normal,Hard", i: i32&) bool changed
ui_select(label, "A,B,C", i: i32&) bool changed. Arrows cycle, the middle opens a list
ui_tabs("Audio,Video", i: i32&) bool changed
ui_text_input(label, placeholder, max_len) 0, TEXT_CHANGED, TEXT_SUBMITTED. Value: ui_text_get(label, out), set with ui_text_set. On phones it is a native field (the OS keyboard appears).
ui_progress(t, label) bar, t 0..1
ui_image(tex, h) a texture, aspect kept
ui_tooltip(text) tooltip for the widget just drawn (hover or focus)
ui_wants_input() true when the pointer is on UI or a modal is open: skip gameplay clicks then
ui_back() back pressed this frame
ui_focus(label), ui_focused(label) move / test focus
ui_device() DEVICE_MOUSE, DEVICE_TOUCH, DEVICE_KEYBOARD, DEVICE_GAMEPAD (last used)

Theme

gander::ui_theme(gander::Theme { accent: gander::hex(0xff7a3d), radius: 6.0, font: my_font, click_sound: click, .. });

Fields: panel, surface, surface_hover, accent, text, text_dim, danger, overlay (colors), radius, padding, spacing, item_h, font_size, title_size, font (a gander::font, Font { 0 } built in), hover_sound, click_sound (a gander::sound, Sound { 0 } none).

Ready-made screens

Call Returns
main_menu(title, subtitle, "Play,Settings,Credits") index pressed or -1; left aligned over your world (draw an attract scene behind)
pause_menu("Resume,Settings,Quit") index or -1; dims the game
game_over(title, subtitle, "Retry,Menu") index or -1
ui_settings(settings: Settings&) true on the frame it closes (Done / back). Tabs Audio / Controls / Graphics, applies live, saves
ui_save_slots(mode, count) slot picked this frame, SLOTS_OPEN (-1) while open, SLOTS_BACK (-2) closed. SLOTS_SAVE / SLOTS_LOAD: slot cards with label and "12 min ago", Delete, confirm before overwriting. Picking only picks: save and load with save_* (GANDER saves)
ui_confirm(title, message, yes, no) -1 open, 1 yes, 0 no / back
ui_loading(title, progress, tip) full-screen loading screen
ui_dialogue(speaker, text, portrait, "Choice A,Choice B", chars_per_sec) -1 while showing; no choices: 0 when the player continues; with choices: the index. A press while typing reveals the rest. dialogue(speaker, text) is the short form
ui_dialogue_progress() 0..1 of the line revealed (drive a face's talking with it)
ui_toast(text, seconds) notification at the top

Menus release the mouse pointer lock; call gander::pointer_lock(true) again when gameplay resumes (it re-locks on the next click).

Settings { master, music, sfx, voice, sensitivity, quality, invert_y, show_fps }: settings_load(s), settings_apply(s) (bus volumes, look sensitivity and invert Y inside look_axis(), quality 0 low / 1 medium / 2 high), settings_save(s).

The whole loop in one call: Flow

var flow = gander::Flow { .. };

fn main() {
    gander::config("Iron Harbor", 1280, 720, 0);
    gander::flow_init(flow);              // loads + applies saved settings
}

export "gander_frame" fn frame(dt: f32) {
    let playing = gander::flow_update(flow, "IRON HARBOR", "Hold the docks until dawn");
    if flow.started { reset_round(); }  // Play or Play again chosen this frame
    if playing { simulate(dt); }        // pause menu is "pause" (Esc / P / Start)
    draw_world();                       // always draw: menus sit on top
    if flow.screen == gander::SCREEN_PLAYING { draw_hud(); }
    if dead() { gander::flow_game_over(flow, "YOU FELL", "The harbor is lost"); }
}

Screens: SCREEN_MAIN, SCREEN_PLAYING, SCREEN_PAUSED, SCREEN_SETTINGS, SCREEN_OVER, SCREEN_SAVE, SCREEN_LOAD.

Save games: gander::Flow { saves: 3, .. } adds Continue / New game / Load game to the main menu (once a save exists) and Save game / Load game to the pause menu. On the frame the player picks a slot, flow.save_to (write it) or flow.load_from (read it; flow.started is true too, so reset first) is that slot, -1 otherwise. The whole pattern is in GANDER saves.

Quit: where gander::can_quit() is true (desktop and Android exports; never the web or iOS) the main, pause and game over menus end with Quit, so web builds look as before. Choosing it sets flow.quitting for that frame, the game's last (gander::quit() ends it after the frame; from the pause menu with saves, flow.save_to is 0 too, so the usual write_save(flow.save_to) writes the autosave). Your own menus: add Quit only when can_quit(). flow_game_over keeps up to 48 bytes of title and 96 of subtitle: keep them short.

HUD

Drawn in screen space; positions are logical px. Keep them inside gander::safe_area() (x left, y top, z right, w bottom) and, with touch controls, above gander::touch_insets() (x left width, y left height, z right width, w right height).

Call Draws
hud_bar(name, x, y, w, h, value, max, style, color) BAR_SMOOTH (with a trailing damage ghost), BAR_SEGMENTED, BAR_THIN; pulses red under 25%
hud_radial(x, y, r, thick, value, max, color) ring meter (cooldown, reload, stamina)
hud_ammo(x, y, mag, mag_size, reserve, align) big count, reserve, round ticks; red when low
hud_crosshair(style, spread, color) CROSS_DOT, CROSS_CROSS, CROSS_CIRCLE, CROSS_CROSS_DOT, CROSS_CHEVRON; spread px for movement / recoil
hud_hitmarker(kill) hit (white) or kill (red) marker, fades by itself
hud_damage(from: float3) red arc pointing at where the hit came from, fades by itself
hud_marker(pos, label, color) -> bool objective diamond + distance; sticks to the screen edge with an arrow when off screen
hud_compass(yaw, y, w) then hud_compass_marker(pos, color) heading strip (yaw 0 = facing -Z = North)
hud_minimap(x, y, size, player, yaw, radius) ... hud_minimap_blip(wx, wz, color, r) ... hud_minimap_end() rotating radar. hud_minimap_begin(..., map_texture, wx0, wz0, wx1, wz1) puts a map image under it
hud_prompt(action, text, x, y) "Press E Open" with the right glyph for keyboard (bound key), gamepad (A/B/X/Y in color) or touch (Tap)
hud_float_text(pos, text, color, size) floating damage number at a world point
hud_counter(name, x, y, value, label, size, align) number that pops when it changes
hud_timer(x, y, seconds, size, align, tenths) m:ss, red under 10 s

Extra 2D primitives: rounded_rect, rounded_rect_outline, shadow (soft), gradient_rect, arc (radians, 0 up, clockwise), triangle, clip ... clip_end.

Mobile: portrait, landscape, safe areas

config() flags, combined with the others:

Flag Logical space Phone held the other way
none / LANDSCAPE height = the config short side (720), width follows the screen LANDSCAPE: "rotate your device" prompt, game letterboxed
PORTRAIT width = the config short side, height follows the screen rotate prompt, letterboxed (desktop windows letterbox too)
ANY_ORIENTATION short side fixed, adapts gander::is_portrait() tells you which layout to draw

gander::set_orientation(flags) switches at runtime (portrait menu, landscape race).

Menus lay out for the orientation (portrait: title high, buttons low where the thumb is). Touch controls size from the short side and stay inside the safe area; in portrait the stick zone is the lower half.

Recipes

FPS HUD

fn draw_hud() {
    let w = gander::width();
    let h = gander::height();
    let sa = gander::safe_area();
    let ins = gander::touch_insets();
    let lb = max(sa.w, ins.y);          // bottom-left clearance
    let rb = max(sa.w, ins.w);          // bottom-right clearance
    gander::hud_crosshair(gander::CROSS_CROSS_DOT, spread, gander::WHITE);
    gander::hud_bar("hp", 32.0 + sa.x, h - 60.0 - lb, 300.0, 16.0, hp, 100.0, gander::BAR_SMOOTH, gander::hex(0x5ad16a));
    gander::hud_ammo(w - 32.0 - sa.z, h - 110.0 - rb, mag, 30, reserve, gander::ALIGN_RIGHT);
    gander::hud_compass(yaw, 14.0, 520.0);
    gander::hud_compass_marker(objective, gander::YELLOW);
    let shown = gander::hud_marker(objective, "Cargo", gander::YELLOW);
    gander::hud_minimap(w - 212.0 - sa.z, 24.0 + sa.y, 188.0, gander::camera_pos(), yaw, 30.0);
    gander::hud_minimap_blip(objective.x, objective.z, gander::YELLOW, 5.0);
    gander::hud_minimap_end();
    if near_crate { gander::hud_prompt("interact", "Open crate", w * 0.5, h * 0.66); }
}

When a bullet hits: gander::hud_hitmarker(killed) and gander::hud_float_text(hit_point, "25", gander::YELLOW, 28.0). When the player is hit: gander::hud_damage(attacker_pos).

Portrait puzzle game

fn main() { gander::config("Gem Drift", 720, 1280, gander::PORTRAIT); }

export "gander_frame" fn frame(dt: f32) {
    let w = gander::width();              // 720
    let h = gander::height();             // taller on long phones: lay out from it
    let sa = gander::safe_area();
    gander::hud_counter("score", 28.0 + sa.x, 24.0 + sa.y, score, "SCORE", 46.0, gander::ALIGN_LEFT);
    // board centered in the middle, buttons in the thumb zone:
    gander::ui_area(24.0 + sa.x, h - 120.0 - sa.w, w - 48.0 - sa.x - sa.z, 100.0);
    gander::ui_row(2);
    if gander::ui_button("Hint") { gander::ui_toast("Try the blue gems", 2.0); }
    if gander::ui_button("Pause", gander::BUTTON_PRIMARY) { paused = true; }
    gander::ui_panel_end();
}

Board taps: gander::touch_at(0) (z = 1 on the first frame of a touch); skip them when gander::ui_wants_input().

Dialogue scene

var line: i32 = 0;
var voice: u32 = 0;

export "gander_frame" fn frame(dt: f32) {
    if line == 0 {
        if gander::ui_dialogue("Mara", "The convoy left at dawn.", mara_portrait, "", 45.0) == 0 { line = 1; }
    } else if line == 1 {
        let k = gander::ui_dialogue("Mara", "Your call, captain.", mara_portrait, "Take the river road,Wait for nightfall", 45.0);
        if k >= 0 { route = k; line = 2; }
    }
}

Start the line's voice clip on the frame the line changes (keep the voice handle; gander::voice_playing(voice) tells you when it ends).

Settings from a pause menu without Flow

var settings = gander::Settings { .. };
var screen: i32 = 0;   // 0 game, 1 paused, 2 settings

fn main() { let ok = gander::settings_load(settings); gander::settings_apply(settings); }

export "gander_frame" fn frame(dt: f32) {
    if screen == 0 && gander::action_pressed("pause") { screen = 1; }
    if screen == 1 {
        let k = gander::pause_menu("Resume,Settings");
        if k == 0 || gander::ui_back() { screen = 0; gander::pointer_lock(true); }
        if k == 1 { screen = 2; }
    } else if screen == 2 {
        if gander::ui_settings(settings) { screen = 1; }
    }
}

Gotchas

  • Float literals are f64 in Goose: i * 0.7 is f64; write (i as f32) * 0.7, or keep values f32 typed.
  • Menus and HUD are drawn every frame you call them; stop calling to hide.
  • ui_panel_end() closes ui_panel, ui_panel_at and ui_area; ui_scroll needs ui_scroll_end(), ui_modal needs ui_modal_end().
  • No emoji in labels (the font has none); use words or draw icons with the primitives.