Skip to content

UI Components

magmacrunchmedia edited this page Aug 24, 2026 · 4 revisions

UI Components

All three widgets draw into the game's viewport, each into its own named group, so they compose over a renderer that clears the whole surface every frame. None of them handle input — you drive them from your own key handling, which is what lets you use the same code for keyboard and controller.

from texastoast.ui import DialogueBox, Menu, HUD

Constructing them

Since 0.4.0 the widgets take the renderer and inherit its dimensions:

renderer = CanvasRenderer(game.canvas, 400, 300)

dialogue = DialogueBox(renderer)
menu = Menu(renderer)
hud = HUD(renderer)

The pre-0.4 form — a bare canvas plus explicit width and height — still works unchanged:

dialogue = DialogueBox(game.canvas, 400, 300)

Passing the renderer is preferred because the size is then stated once. When a widget and the renderer disagree about the viewport, the widget draws its box against the wrong edges.

Every widget is frame-driven

This is the contract, and it changed in 0.3.0. Call render() on all three from your render(), and update(dt) on the dialogue from your update():

def update(dt):
    dialogue.update(dt)        # advances the typewriter
    ...

def render():
    renderer.clear()
    ...                        # world
    hud.render()               # then overlays, in the order you want them stacked
    dialogue.render()
    menu.render()

render() is safe to call every frame whether or not the widget is showing — it is a no-op when inactive, so there is no need to branch.

Before 0.3.0 DialogueBox drew once from show() and drove its typewriter from its own canvas.after() timer. Any renderer that cleared the canvas wiped the box off screen while it still reported active, so input stayed captured and the game looked frozen behind an invisible modal. Two consequences of the fix are worth knowing: the typewriter advances by dt rather than wall clock, and it no longer runs while the game loop is stopped.

DialogueBox

A bottom-of-screen text box with typewriter output and an optional speaker name.

dialogue = DialogueBox(renderer, box_height=100, speed=0.03)

dialogue.show("Welcome, traveler!", speaker="Old Wizard",
              on_complete=lambda: print("done"))

dialogue.update(dt)   # from update() — advances the typewriter
dialogue.render()     # from render() — safe when inactive

dialogue.active    # a box is on screen
dialogue.waiting   # all text is revealed, waiting for dismissal
dialogue.displayed # the text revealed so far

speed is seconds per character; speed=0 reveals everything at once.

dismiss() does the two-stage thing players expect:

  • while text is still typing, it completes the text immediately
  • once fully revealed, it closes the box and fires on_complete

So a single key can drive the whole interaction:

def on_key(event):
    if dialogue.active and event.keysym in ("z", "Return", "space"):
        dialogue.dismiss()
        return
    ...

Typing is driven by your game loop, through update(dt) — so a paused loop is a paused typewriter. If you want dialogue to keep animating while the rest of the game is frozen, keep calling dialogue.update(dt) and skip the rest of your update instead:

def update(dt):
    dialogue.update(dt)        # always
    if dialogue.active or paused:
        return                 # world is frozen, dialogue is not
    ...

That is the pattern examples/game_template.py uses.

Menu

A centered list with keyboard-driven selection.

menu = Menu(renderer)

menu.show(
    ["Resume", "Settings", "Quit"],
    on_select=lambda index, label: handle(label),
    on_cancel=lambda: menu.hide(),
    title="PAUSED",
    selected=0,
)

menu.move_up()
menu.move_down()
menu.confirm()          # hides, then calls on_select
menu.cancel()           # hides, then calls on_cancel
menu.hide()             # hides, calls nothing
menu.render()           # from render() — safe when inactive

menu.active
menu.selected_index

Menu.render() was called Menu._draw() before 0.3.0, and the menu drew itself from show(). It is now yours to call every frame.

Items can be greyed out, and the selection skips them:

menu.set_enabled(1, False)   # "Settings" is now unselectable

Two behaviors to be aware of:

  • show([]) does nothing. An empty item list returns early and leaves the menu inactive, so check before calling if the list is built dynamically.
  • confirm() hides the menu before calling on_select. If your handler tracks a paused flag, remember to clear it — otherwise an unhandled option leaves the game paused with no menu on screen. Handle every label, including the ones that do nothing yet.
def on_select(index, label):
    global paused
    paused = False            # always clear it
    if label == "Quit":
        game.quit()

HUD

Screen-space stat bars and text that ignore the camera.

hud = HUD(renderer)

hud.add_stat("hp", "HP", value=100, max_value=100, color="#e94560")
hud.set_stat("hp", 75)        # clamped to 0..max_value

hud.add_text("score", "Score: 0", x=300, y=8, fill="#fdd835")
hud.set_text("score", "Score: 120")
hud.remove_text("score")

hud.clear()                   # drop every stat and text
hud.render()                  # draw — call once per frame

Stats are laid out top-left in insertion order. Extra keyword arguments to add_text go to tkinter's create_text.

set_stat and set_text silently do nothing if the key does not exist, so a typo shows up as a value that never changes rather than an error. Register with add_* first.

For text that appears and disappears — a "Press Z to talk" hint — add_text and remove_text are the pair to use; calling add_text every frame works but rebuilds the entry each time.

Drawing groups

Each widget owns one named group — "dialogue", "menu", "hud" — and clears only that group when it redraws. This is what lets three widgets share a surface that the renderer wipes every frame without any of them erasing the others.

Within a frame, draw order is z-order: later calls paint on top. That is the whole layering model, which is why the call order in your render() decides what a dialogue box covers.

The surface itself is the UISurface protocol (Rendering and Camera), so a widget does not know it is drawing on tkinter. A practical benefit: you can test widget logic with no display at all by passing any object with the protocol's methods.

class FakeSurface:
    width, height = 640, 480
    def __init__(self):
        self.groups = {}
    def begin_group(self, group):
        self.groups[group] = []
    def clear_group(self, group):
        self.groups.pop(group, None)
    def ui_rect(self, x, y, w, h, *, fill, outline="", outline_width=0, group=""):
        self.groups.setdefault(group, []).append(("rect", fill))
    def ui_text(self, x, y, text, *, fill, font=None, anchor="nw",
                width=None, group=""):
        self.groups.setdefault(group, []).append(("text", text))

surface = FakeSurface()
menu = Menu(surface)
menu.show(["Resume", "Quit"])
menu.render()
assert ("text", "> Resume") in surface.groups["menu"]

That is exactly how tests/test_ui_surface.py covers these widgets, which is why most of the UI suite no longer needs a display.

Clone this wiki locally