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 Anchor, Label
label = Label(text="Score: 0", font_size=24, anchor=Anchor.TOP_LEFT, inset=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)
A focused text field claims the keys that type (any printable key without Ctrl, Alt or Meta) and
every character event, so a game action bound to W stays shut while the player types a w. Any
control does the same by setting wants_text_input, a class attribute or a property where the answer
depends on its state. Typing, a paste and an IME commit all go through edit.insert(text), which
turns line breaks into spaces (the field holds one line), clips to max_length and emits
text_changed once. MultiLineTextEdit.insert(text) is the same path for the multi-line editors,
where a line break starts a new line.
CommandLine is a TextEdit for a console’s input line: Enter submits through text_submitted,
records the line in history and clears the field, and Up and Down walk the history.
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 |
|---|---|---|
Toggleable on/off |
|
|
One choice of a |
|
|
Numeric input with step |
|
|
Select one of many |
|
|
Context / right-click menu |
|
|
Top-of-window menu strip |
|
Focus and the tab order¶
Keyboard focus belongs to Node: any node can hold it, a board piece a pad cursor lands on as much
as a button. focus_mode is the single authority on it, and each mode includes the ones before it:
|
Can hold focus |
In the tab order |
Takes focus on hover |
|---|---|---|---|
|
no |
no |
no |
|
yes |
no |
no |
|
yes |
yes |
no |
|
yes |
yes |
yes |
from simvx.core.ui import FocusMode
save.focus_mode = FocusMode.ALL # opt a button into the tab order ("all" works too)
save.grab_focus() # take focus from code; release_focus() gives it up
save.focused # read-only: grab_focus and release_focus move it
tree.focus_owner # read-only: the node holding focus, or None
grab_focus() raises on a node whose focus_mode is NONE: a widget that focuses itself declares
the mode it takes focus in.
Every node has focus_entered and focus_exited signals and a focus_rect, where it is on screen
for directional focus (a control’s rect, None when it is off screen, and None on a node with no
extent unless it overrides it). tree.focus_reason says why focus last moved (FocusReason: a
click, Tab, a direction, hover, an overlay, code, or the owner leaving), so a focus ring drawn only
for the keyboard reads it rather than guessing. The tree keeps an index of the nodes whose
focus_mode takes focus, so Tab and a D-pad search those and never walk the rest.
Hover focus (FocusMode.HOVER) is for a menu the mouse should drive the way a pad does, where
the item under the cursor is the one Enter presses. The mouse or a pen coming to rest over the
topmost HOVER node gives it focus, and the rules keep it from surprising anyone:
Only a change of that node counts. Resting on it, or moving within it, is not a new request, so Tab or a D-pad can move focus away while the cursor sits still; leaving it gives nothing back.
It never moves focus while a text session is open, while a key or a button is held, or while a pointer is captured. The node waits and takes focus when that ends.
It never takes focus from inside its own subtree: hovering the card whose text field has focus leaves the field focused.
A finger never focuses by hovering, and only the window with the operating system’s keyboard focus moves focus by hover (the router’s
window_focused, which the exported web page keeps from its own focus and blur).A key that arrives before the frame has caught up with the pointer brings the hover up to date first, so the key goes to the node under the cursor.
A tree with no HOVER node does none of this.
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 returning True from on_gui_input, which is the same
answer every input handler gives. 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 isinstance(event, ScrollEvent):
self.zoom *= 1.1 if event.scroll[1] > 0 else 1 / 1.1
return True # the list around the chart stays put
A handler tells the four event types apart by type: a KeyEvent for a key press or release, a
TextEvent for a typed character, a PointerEvent for a press, release or move, and a
ScrollEvent for a wheel notch. Put the isinstance test in the branch that reads the field,
never above several branches: a positional guard is only correct while every branch over it
returns, and the next edit moves one.
@on_gui_input does the telling apart in the decorator, with @on_input’s filters, and gives
each input a method of its own. The same chart:
class Chart(Control):
@on_gui_input(ScrollEvent)
def zoomed(self, event):
self.zoom *= 1.1 if event.scroll[1] > 0 else 1 / 1.1
return True
@on_gui_input(Mouse.RIGHT)
def open_menu(self, event):
...
Only the handlers whose filter matches run, in declaration order, until one claims; an
on_gui_input override is the catch-all after them. Bare @on_gui_input hears all four event
types. An action name is refused: actions resolve after the UI has had the event. The filters are
listed under Input: handlers.
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.
What is under the pointer is decided by pointer_filter. PASS, the default, is hit and lets the
pointer reach what is behind it, so a panel or a container over a game never eats a click it does
nothing with: the control behind, and in the end the game’s own handlers, hear it next. IGNORE
is not hit at all, which is what Label and ProgressBar declare. STOP is hit and blocks
everything behind it, which is what every widget the player operates declares: buttons, sliders,
text fields, lists, dialogs. A game’s own control that must block redeclares it:
class Card(Control):
pointer_filter = Property(PointerFilter.STOP)
The operating system’s pointer takes the cursor_shape of the control under it (a CursorShape,
or None to show the parent’s): TextEdit shows IBEAM, a SplitContainer shows resize arrows
over its divider, and a control holding the pointer keeps its shape for the length of its drag.
Input.cursor_shape reads the shape in force.
Whether a press has become a drag is answered once, on the event, as PointerEvent.dragging. The
distance it takes is per pointer type, tree.drag_threshold by default, and any node can ask more
or less of its own subtree with node.drag_threshold = {PointerType.TOUCH: 20}; the router reads
it from the node that took the press.
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 isinstance(event, ScrollEvent):
return 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 a PointerEvent
whose phase is MOVE), which is how Slider and ColourPicker follow a drag:
class Dial(Control):
def on_gui_input(self, event):
if isinstance(event, PointerEvent) and event.phase is PointerPhase.MOVE:
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) captures it with
capture_pointer() and gives it back with release_pointer(); the moves and the buttons then
route to it alone until it does. SpinBox and SplitContainer hold the capture for exactly the
length of a drag, from the press that starts one to the release that ends it.
A press belongs to what took it¶
Every press in progress has one owner, in one table: the control that claimed it by returning
True. What follows the press goes to that owner and to nothing else. A key’s release reaches the
control that took the key even after focus has moved on, and a control that gained focus in between
never hears a release it did not see pressed. A pointer’s release reaches the control that took its
press wherever the pointer ended up, and a pointer capture is an entry in the same table. So a widget
acts on a key as it goes down and claims it: the release then comes back to it, and a game action
bound to the same key does not fire while the player types.
An owner that hides, is disabled, is paused or stops processing while the press is held is
cancelled, never suspended: it hears a PointerPhase.CANCEL, or a key release with cancelled
True, and loses the press, so the release the player makes later reaches nobody and nothing
resumes when the control comes back. The audit that does this runs once a frame and costs one test
while no press is held.
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, so an inner list stops trapping the wheel once the reader has scrolled 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,
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 places itself in its parent’s rect. Setting position directly on a top-level
control is an anti-pattern: it won’t follow the viewport. Place it in one call with anchor,
inset and size:
from simvx.core import Anchor, Label, Panel
hint = Label("Hint", anchor=Anchor.BOTTOM_LEFT, inset=16)
card = Panel(anchor=Anchor.TOP_RIGHT, size=(220, 120), inset=10)
bar = Panel(anchor=Anchor.BOTTOM_WIDE, size=(0, 48)) # full-width strip, 48 tall
hud = Panel(anchor=Anchor.FULL_RECT) # fills the parent
title = Panel(anchor=Anchor.CENTER, size=(400, 200)) # centred box
anchor is an Anchor (or its string, "top_right"). Each member fixes both axes, and on each axis
it is one of three kinds:
Axis kind |
Members (horizontal / vertical) |
Placement |
|---|---|---|
Side |
|
Held |
Centre |
|
Centred between the two insets, |
Wide |
|
Stretched from one inset to the other; |
inset is the distance in from the parent’s edges, as in CSS: one number for all four, (x, y)
for the sides and the top and bottom, or (left, top, right, bottom). An inset on a side the anchor
does not use has no effect, and on a centre axis unequal insets move the centre
(inset=(0, 80, 0, 0) on CENTER sits the box 40 pixels below the middle).
The engine derives the four anchor_* fractions and the signed margin_* offsets from these and
derives them again whenever anchor, inset or the size changes, so a label whose text grows
stays against its right edge. A scene file saves anchor, inset and size, never the derived
numbers. For an unusual layout (a fractional anchor such as 0.25, say), leave anchor as None and
set anchor_left / _top / _right / _bottom and margin_left / _top / _right / _bottom directly: a
margin is the pixel offset from its anchor.
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 from 0.0 to 1.0.
from simvx.core.ui import AppTheme, set_theme
theme = AppTheme.dark()
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
set_theme(theme)
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.