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)
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¶
Focus and the tab order¶
focus_mode is the single authority on keyboard focus, on two independent axes:
|
Can hold focus |
In the tab order |
|---|---|---|
|
no |
no |
|
yes |
no |
|
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.
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.