# gander UI: menus, HUD, mobile orientation 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: ```goose 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 ```goose 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_*` (https://vbgnt.com/docs/gander/saves.md) | | `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` ```goose 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 https://vbgnt.com/docs/gander/saves.md. 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 ```goose 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 ```goose 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 ```goose 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 ```goose 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.