UI System

SimVX provides a set of UI controls and layout containers for building game interfaces. All widgets are rendered via the engine’s Vulkan backend: no external GUI toolkit required.

Controls

All controls inherit from Control, which provides rect-based layout, mouse interaction, and focus management.

Label

from simvx.core import Label
from simvx.core.ui import AnchorPreset

label = Label(text="Score: 0", font_size=24)
label.set_anchor_preset(AnchorPreset.TOP_LEFT)
label.margin_left = label.margin_top = 10
root.add_child(label)

Button

from simvx.core import Button

btn = Button(text="Start Game")
btn.pressed.connect(start_game)
root.add_child(btn)

TextEdit

from simvx.core import TextEdit

edit = TextEdit(placeholder="Enter name...")
edit.text_changed.connect(lambda text: print(text))
root.add_child(edit)

Slider

from simvx.core import Slider

slider = Slider(min_value=0, max_value=100, value=50)
slider.value_changed.connect(lambda v: print(f"Volume: {v}"))
root.add_child(slider)

ProgressBar

from simvx.core import ProgressBar

bar = ProgressBar(min_value=0, max_value=100, value=75)
root.add_child(bar)

SplashScreen

The full-screen boot/loading splash. Every windowed game shows the branded face automatically at startup (configure via App(splash=..., splash_min_time=...)); the same control doubles as an in-game loading screen for heavy scene transitions, driven by any 0..1 progress source:

from simvx.core import SplashScreen

batch = assets.load_batch([...])
splash = SplashScreen(progress=batch, min_display_time=1.0, skippable=True)
splash.finished.connect(splash.destroy)
root.add_child(splash)

It fades in from black, holds until both the progress source reports complete and min_display_time (wall-clock, fades included) has elapsed, then fades back to black and reveals the scene beneath. See examples/features/ui/splash_screen.py.

TreeView

TreeView renders a TreeItem hierarchy with indentation, expand/collapse arrows and selection, drawing only the rows the viewport can show.

Invalidation is explicit. The widget flattens the hierarchy into a row cache and reuses it every frame, so an edit to the items is invisible until you say so:

branch.add_child(TreeItem("New"))
branch.expanded = True
tree.refresh()          # now it is on screen

Assigning root refreshes as part of the assignment, and clicking a row’s own arrow refreshes itself. See examples/features/ui/tree.py.

Form controls

Widget

Purpose

Signal

CheckBox

Toggleable on/off

toggled(bool)

SpinBox

Numeric input with step

value_changed(float)

DropDown

Select one of many

item_selected(int)

PopupMenu

Context / right-click menu

item_selected(int)

MenuBar

Top-of-window menu strip

item_selected(int)

Focus and the tab order

focus_mode is the single authority on keyboard focus, on two independent axes:

FocusMode

Can hold focus

In the tab order

NONE (the Control default)

no

no

CLICK (the Button default)

yes

no

ALL (the TextEdit default)

yes

yes

from simvx.core.ui import FocusMode

save.focus_mode = FocusMode.ALL     # opt a button into the tab order

Tab moves focus to the next control in the tab order and Shift+Tab to the previous one, walking the tree in pre-order from the focus owner’s own position and wrapping at the ends. A control that is disabled, or invisible anywhere up its ancestor chain, is skipped. A CLICK control still holds focus when clicked and hands over to its tree neighbour on Tab, so a form’s buttons never divert the keyboard path through the fields.

Set focus_next / focus_previous to override the walk with an explicit chain (a grid menu, say); a link pointing at a control that cannot hold focus falls through to the walk. While a capturing overlay is open, traversal is confined to that overlay’s chain.

A focused widget can claim the key first by setting event.handled in _on_gui_input. The engine’s own text widgets do: MultiLineTextEdit and CodeTextEdit keep Tab for indent and Shift+Tab for dedent, and TerminalEmulator sends both to the shell. A read-only editor does not act on the keys and releases them, so Tab traverses out of it. Keyboard users leave an editable widget the way they do in any editor, with the mouse or a binding of the app’s own. When nothing is focused the UI leaves the key alone, so a game binding its own action to Tab keeps it.

Rebinding the navigation keys

Which key traverses is not baked into the router. Tab, Shift+Tab, Enter and Escape are the defaults of four ordinary input actions – ui_focus_next, ui_focus_prev, ui_accept and ui_cancel – and registering one moves the key:

from simvx.core import InputMap

InputMap.add_action("ui_focus_next", ["ctrl+tab"])   # Tab is now the editor's
InputMap.add_action("ui_focus_next")                 # no bindings: nothing traverses forward

So an author has three routes and the engine forbids none: rebind the action, consume the key in the widget, or both. ui_accept activates the focus owner through Control.activate(): a focused Button presses exactly as a click would, a CheckBox toggles, a RadioButton selects. The widget claims the key first – a focused TextEdit keeps Enter for its own submit – and only the focus owner is ever activated (there is no default-button rule), with a held key activating once. See Input for the action table and the defaults’ interaction with InputMap.clear().

Where a mouse event goes

A press, a release and the wheel go to the control under the cursor and then, if that control does not claim it, to its parent, and that parent’s parent, until something claims it or the chain ends. Claiming is one line, and it means the same thing for all three:

class Chart(Control):
    def _on_gui_input(self, event):
        if event.key in ("scroll_up", "scroll_down"):
            self.zoom *= 1.1 if event.key == "scroll_up" else 1 / 1.1
            event.handled = True      # the list around the chart stays put

So a list of buttons scrolls the ScrollContainer it sits in wherever the cursor rests over it, and a caption drawn across a card does not swallow the click meant for the card. A widget that acts on a press claims it – Button, CheckBox, Slider, TextEdit and the rest of the engine’s widgets all do – so the click that presses a button is not also a click on the panel behind it. A widget that ignores the button lets it through.

Only the ancestors are offered the event, never the siblings: a control drawn over another one is not “in front of” it for input purposes, and a click on it does not reach the one it covers.

Where the event goes is decided by the cursor and nothing else. The focus owner has no claim on the wheel: a focused text view does not scroll while the cursor rests over a dialog, a panel or the window background, which is what a pointer-driven wheel means everywhere else. A dialog that must keep the wheel inside it claims it on its own root:

class SettingsDialog(Panel):
    def _on_gui_input(self, event):
        if event.key in ("scroll_up", "scroll_down"):
            event.handled = True      # the view behind the dialog stays put

A dialog registered as a capturing overlay (show_overlay("blocking")) needs no such line: the router scopes the whole chain to that overlay while it is open.

Motion is delivered, not bubbled

Pointer motion is the one mouse event that does not travel the chain. It arrives at the control under the cursor and stops there, through the same _on_gui_input – a move is the event carrying a position with no button and no key – which is how Slider and ColourPicker follow a drag:

class Dial(Control):
    def _on_gui_input(self, event):
        if event.button is None and not event.key and event.position is not None:
            self.aim_at(event.position)   # only this control is offered the move

So a panel never sees the moves passing over the widgets inside it. A control that needs the pointer beyond its own rect – while a drag runs off the edge of it, say – takes the mouse grab with grab_mouse() and gives it back with release_mouse(); the moves and the buttons then route to it alone until it does. SpinBox and SplitContainer hold the grab for exactly the length of a drag, from the press that starts one to the release that ends it.

Hover is a separate matter and is not affected: mouse_over is recomputed for every control the point falls inside, ancestors included, so mouse_entered / mouse_exited still fire on a card whose label the cursor is over.

Scroll chaining

A ScrollContainer claims the wheel only when it actually moves. At its top with the wheel going up, at its bottom with the wheel going down, or with content that fits and nowhere to go at all, it lets the event through to the next scrolling ancestor. VirtualScrollContainer answers the same way. That is the rule Godot’s ScrollContainer follows, and it is why an inner list stops trapping the pointer once the reader has run it to the end.

The widgets that use the wheel, and so claim it, are ScrollContainer and VirtualScrollContainer (both subject to the rule above), TreeView, MultiLineTextEdit (so CodeTextEdit too), RichTextLabel (so OutputPanel and ConsoleWidget, which are built on it), TerminalEmulator, SpinBox, GraphEdit, AutocompletePopup, FileDialog over its card, and FileBrowserPanel over its tree. Button, CheckBox, DropDown, RadioButton, Slider, TextEdit, ToolbarButton, TabContainer, SplitContainer, MenuBar, PopupMenu, ColourPicker and CodeEditorPanel do not, so a row built from them never blocks the list it belongs to.

Focus follows the click’s owner

A left press gives focus to the control that acted on it – the one that claimed the event – and, when nothing claimed it, to the control under the cursor. A click on a label inside a panel that takes clicks therefore focuses the panel, not the label. Click focus does not consult focus_mode: the owner of the press takes it whatever its mode, so a widget that claims a press it does not want the keyboard for – a text view’s scrollbar, say – becomes the focus owner until the next click.

Clipping children

clip_contents confines a control’s children to the control’s own rect, on both draw pipelines:

card = Panel()
card.size = Vec2(160, 48)
card.clip_contents = True          # a long project name stops at the card's edge

It is an ordinary Property, so the inspector shows it and a scene file round-trips it. It is off by default, on for ScrollContainer and VirtualScrollContainer (a viewport is a clip by definition), and those two narrow the window they clip to so it stops short of the scrollbar gutter while a scrollbar is showing. Turning it off on either turns their clipping off with it.

Clipping is about drawing. Hit-testing is separate: ScrollContainer also hides children outside its rect from the cursor, so a row scrolled out of view cannot be clicked.

Anchors & Margins

Every Control positions itself via anchors (fractional 0–1 coordinates on its parent) and margins (pixel offsets from the anchor). Setting position directly on a control is an anti-pattern: it won’t scale with the viewport.

from simvx.core.ui import AnchorPreset

ok.set_anchor_preset(AnchorPreset.BOTTOM_RIGHT)
ok.margin_right = ok.margin_bottom = -20   # 20px inset from corner

hud.set_anchor_preset(AnchorPreset.FULL_RECT)   # fill parent
title.set_anchor_preset(AnchorPreset.CENTER)    # centred box

AnchorPreset covers the common cases: TOP_LEFT, TOP_RIGHT, BOTTOM_LEFT, BOTTOM_RIGHT, CENTER_*, *_WIDE, FULL_RECT. For custom layouts set anchor_left / _top / _right / _bottom individually.

A minimum size

min_size is the floor under what a control measures itself at, and so under the rect it is given:

name = Label("Player")
name.min_size = Vec2(90, 0)        # the column stays 90px wide however short the name is

It reads and writes as a Vec2 over the min_size_x / min_size_y Properties behind it, the way size does, so the inspector shows it and a scene file round-trips it. Assign a whole vector to change it: editing the vector it returns changes nothing. Raising it re-measures the containers above, so a list whose rows demand more room this way gains its scrollbar straight away.

Containers

Containers automatically arrange their children.

HBoxContainer / VBoxContainer

from simvx.core import HBoxContainer, VBoxContainer, Button

menu = VBoxContainer(separation=10)
menu.add_child(Button(text="New Game"))
menu.add_child(Button(text="Load Game"))
menu.add_child(Button(text="Settings"))
root.add_child(menu)

GridContainer

from simvx.core import GridContainer

grid = GridContainer(columns=3, h_separation=5, v_separation=5)
for i in range(9):
    grid.add_child(Button(text=str(i + 1)))

MarginContainer

from simvx.core import MarginContainer

margin = MarginContainer(
    margin_left=20, margin_right=20,
    margin_top=10, margin_bottom=10,
)
margin.add_child(Label(text="Padded content"))

Theming

Theme controls colours, fonts, and sizing for all widgets. Colours are (r, g, b, a) floats in 0.0–1.0 range.

from simvx.core.ui import get_theme

theme = get_theme()
theme.colours["accent"] = (0.3, 0.6, 1.0, 1.0)
theme.colours["bg"] = (0.15, 0.15, 0.15, 1.0)
theme.sizes["font_size"] = 18

Theme sets the size text is drawn at; the typeface itself is a project setting. See Fonts and Languages for choosing one and for the fallback faces that draw scripts it does not cover.

Individual widgets can override theme colours via ThemeColour descriptors:

btn = Button(text="Delete")
btn.bg_colour = (0.8, 0.2, 0.2, 1.0)  # Override theme
btn.bg_colour = None                    # Revert to theme default

Default colour keys include: bg, bg_light, bg_dark, text, text_disabled, accent, accent_hover, accent_pressed, border, focus, btn_bg, btn_hover, btn_pressed, error, warning, success.

API Reference

See simvx.core.ui for the complete UI API.