Grid Inventory

a 4x3 grid of selectable item slots.

▶ Run in browser

Tags: ui inventory game-ui

What it demonstrates

  • A GridContainer laying out item slots in a fixed-column grid.

  • Each slot is a Button carrying an item name and colour swatch.

  • Clicking a slot selects it and updates a detail label below the grid.

  • The grid is wrapped in a centred panel anchored with AnchorPreset.CENTER.

Run: uv run python examples/features/ui/inventory.py Headless self-check: uv run python examples/features/ui/inventory.py –test

Source

  1"""Grid Inventory: a 4x3 grid of selectable item slots.
  2
  3# /// simvx
  4# tags = ["ui", "inventory", "game-ui"]
  5# web = { root = "InventoryDemo", width = 800, height = 600, responsive = true }
  6# ///
  7
  8## What it demonstrates
  9- A `GridContainer` laying out item slots in a fixed-column grid.
 10- Each slot is a `Button` carrying an item name and colour swatch.
 11- Clicking a slot selects it and updates a detail label below the grid.
 12- The grid is wrapped in a centred panel anchored with `AnchorPreset.CENTER`.
 13
 14Run: uv run python examples/features/ui/inventory.py
 15Headless self-check: uv run python examples/features/ui/inventory.py --test
 16"""
 17
 18from simvx.core import AnchorPreset, Colour, Label, Node, Panel, Vec2
 19from simvx.core.ui import Button, GridContainer, VBoxContainer
 20from simvx.graphics import App
 21
 22# (name, swatch colour). Empty slots have a None colour.
 23ITEMS = [
 24    ("Sword", "#C0392B"),
 25    ("Shield", "#2980B9"),
 26    ("Potion", "#27AE60"),
 27    ("Key", "#F1C40F"),
 28    ("Bow", "#8E44AD"),
 29    ("Arrow", "#7F8C8D"),
 30    ("Gem", "#16A085"),
 31    ("Map", "#E67E22"),
 32    ("Bread", "#D35400"),
 33    (None, None),
 34    ("Torch", "#E74C3C"),
 35    (None, None),
 36]
 37COLUMNS = 4
 38
 39
 40class InventoryDemo(Node):
 41    """Root node: a centred grid inventory with click-to-select slots."""
 42
 43    def on_ready(self):
 44        # Centred panel hosting the grid and detail line.
 45        panel = Panel(name="InventoryPanel")
 46        panel.set_anchor_preset(AnchorPreset.CENTER)
 47        panel.margin_left = -240
 48        panel.margin_right = 240
 49        panel.margin_top = -200
 50        panel.margin_bottom = 200
 51        panel.bg_colour = Colour.hex("#1B1B26")
 52        self.add_child(panel)
 53
 54        # FULL_RECT stretches both axes, so all four margins are positive insets.
 55        column = VBoxContainer(name="Column")
 56        column.separation = 16.0
 57        column.set_anchor_preset(AnchorPreset.FULL_RECT)
 58        column.margin_left = 24
 59        column.margin_right = 24
 60        column.margin_top = 20
 61        column.margin_bottom = 20
 62        panel.add_child(column)
 63
 64        title = Label("Inventory")
 65        title.font_size = 22.0
 66        title.alignment = "center"
 67        column.add_child(title)
 68
 69        # The grid: GridContainer sets .position on its children, which is the
 70        # sanctioned container layout policy.
 71        grid = GridContainer(columns=COLUMNS, name="ItemGrid")
 72        grid.separation = 8.0
 73        for i, (name, swatch) in enumerate(ITEMS):
 74            label = name or "(empty)"
 75            slot = Button(label, on_press=self._make_select(i))
 76            slot.name = f"Slot{i}"
 77            slot.size = Vec2(96, 64)
 78            if swatch is not None:
 79                slot.bg_colour = Colour.hex(swatch)
 80            grid.add_child(slot)
 81        column.add_child(grid)
 82
 83        # Detail line: shows the selected item's name.
 84        self._detail = Label("Select an item.")
 85        self._detail.font_size = 15.0
 86        self._detail.text_colour = Colour.LIGHT_GRAY
 87        self._detail.alignment = "center"
 88        column.add_child(self._detail)
 89
 90    def _make_select(self, index: int):
 91        """Return a click handler bound to the slot at index."""
 92
 93        def handler():
 94            name = ITEMS[index][0]
 95            if name is None:
 96                self._detail.text = f"Slot {index + 1} is empty."
 97            else:
 98                self._detail.text = f"Selected: {name} (slot {index + 1})"
 99
100        return handler
101
102
103def _selftest() -> bool:
104    """Headless: click the slots the way a player does and read the panel back.
105
106    ``UITestHarness`` lays the real widgets out at the real screen size and sends
107    real clicks at each slot's own centre, so what is asserted is what a pointer
108    lands on rather than a handler called by name.
109    """
110    from simvx.core.ui.testing import UITestHarness
111
112    harness = UITestHarness(InventoryDemo(name="InventoryDemo"), screen_size=(800, 600))
113    scene = harness.tree.root
114    ok = True
115
116    def check(label: str, passed: bool, detail: str) -> None:
117        nonlocal ok
118        ok = ok and passed
119        print(f"{'ok  ' if passed else 'FAIL'} {label}: {detail}")
120
121    harness.tick()
122    slots = [harness.find_by_name(f"Slot{i}") for i in range(len(ITEMS))]
123
124    # The GridContainer is what places the slots, so the layout is read back off
125    # the widgets: COLUMNS per row, and every row at one y.
126    rows: dict[float, list[float]] = {}
127    for slot in slots:
128        rows.setdefault(round(float(slot.position.y), 1), []).append(float(slot.position.x))
129    widths = sorted({len(xs) for xs in rows.values()})
130    check(
131        f"the grid lays {len(ITEMS)} slots out {COLUMNS} to a row",
132        len(rows) == len(ITEMS) // COLUMNS and widths == [COLUMNS],
133        f"{len(rows)} rows of {widths} slots",
134    )
135
136    # Every slot's label reaches the screen, so a mis-sized panel that clipped
137    # the grid would be caught here rather than looking fine in the node tree.
138    drawn = harness.draw_log.texts()
139    named = [name for name, _ in ITEMS if name]
140    check(
141        "every slot's label is drawn",
142        all(name in drawn for name in named) and "(empty)" in drawn,
143        f"{len(named)} named items plus the empty slots, {len(drawn)} strings drawn",
144    )
145
146    check("nothing is selected to start with", scene._detail.text == "Select an item.", scene._detail.text)
147
148    harness.click(slots[0])
149    harness.tick()
150    first = scene._detail.text
151    check("clicking a slot selects its item", first == "Selected: Sword (slot 1)", first)
152
153    harness.click(slots[9])
154    harness.tick()
155    empty = scene._detail.text
156    check("clicking an empty slot says so", empty == "Slot 10 is empty.", empty)
157
158    # A click on the panel between the slots must not select anything: the
159    # buttons own their own hit areas.
160    harness.click((20.0, 20.0))
161    harness.tick()
162    check("a click off the grid changes nothing", scene._detail.text == empty, scene._detail.text)
163
164    harness.teardown()
165    print("SELFTEST:", "PASS" if ok else "FAIL")
166    return ok
167
168
169if __name__ == "__main__":
170    import sys
171
172    if "--test" in sys.argv:
173        sys.exit(0 if _selftest() else 1)
174    App(title="SimVX Inventory", width=800, height=600).run(InventoryDemo())