AppTheme presets

every built-in theme shown side by side.

▶ Run in browser

Tags: ui

Each column assigns one AppTheme preset to its own widget subtree (a single self.theme = theme), so the panels, buttons, inputs, sliders, checkboxes and tabs below it all render in that theme’s colours. Every button row uses Button.set_visual_state_override to pin one button per visual state: normal, hover, pressed, disabled, focused.

The columns live in a ScrollContainer: the mouse wheel scrolls vertically and, while the scroll area holds keyboard focus, the arrow keys scroll horizontally. At narrower window widths, scroll right to reach the last columns and down to reach each column’s Tabs row.

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

Source

  1#!/usr/bin/env python3
  2"""AppTheme presets: every built-in theme shown side by side.
  3
  4Each column assigns one AppTheme preset to its own widget subtree (a single
  5``self.theme = theme``), so the panels, buttons, inputs, sliders, checkboxes
  6and tabs below it all render in that theme's colours. Every button row uses
  7``Button.set_visual_state_override`` to pin one button per visual state:
  8normal, hover, pressed, disabled, focused.
  9
 10The columns live in a ScrollContainer: the mouse wheel scrolls vertically and,
 11while the scroll area holds keyboard focus, the arrow keys scroll horizontally.
 12At narrower window widths, scroll right to reach the last columns and down to
 13reach each column's Tabs row.
 14
 15Run: uv run python examples/features/ui/theme.py
 16Headless self-check: uv run python examples/features/ui/theme.py --test
 17"""
 18
 19from simvx.core.ui import (
 20    AnchorPreset,
 21    AppTheme,
 22    Button,
 23    CheckBox,
 24    Control,
 25    FocusMode,
 26    HBoxContainer,
 27    Label,
 28    Panel,
 29    ProgressBar,
 30    ScrollContainer,
 31    Slider,
 32    TabContainer,
 33    TextEdit,
 34    VBoxContainer,
 35)
 36from simvx.graphics import App
 37
 38# All theme presets to show
 39THEMES: list[tuple[str, AppTheme]] = [
 40    ("Dark", AppTheme.dark()),
 41    ("Abyss", AppTheme.abyss()),
 42    ("Midnight", AppTheme.midnight()),
 43    ("Light", AppTheme.light()),
 44    ("Monokai", AppTheme.monokai()),
 45    ("Solarised", AppTheme.solarised_dark()),
 46    ("Nord", AppTheme.nord()),
 47]
 48
 49COL_W = 210.0
 50COL_H = 780.0
 51PAD = 8.0
 52
 53
 54class ThemeColumn(VBoxContainer):
 55    """A column showing widgets rendered with a specific theme."""
 56
 57    def __init__(self, theme_name: str, theme: AppTheme, **kwargs):
 58        super().__init__(**kwargs)
 59        self.separation = 4.0
 60        # Assign theme to this subtree so all children inherit it
 61        self.theme = theme
 62
 63        # Title
 64        title = Label(f" {theme_name} ")
 65        title.font_size = 15.0
 66        title.alignment = "center"
 67        title.size_x = COL_W
 68        self.add_child(title)
 69
 70        # --- Buttons ---
 71        self._section("Buttons")
 72        for label, state in [
 73            ("Normal", Button.VisualState.NORMAL),
 74            ("Hover", Button.VisualState.HOVER),
 75            ("Pressed", Button.VisualState.PRESSED),
 76            ("Disabled", Button.VisualState.DISABLED),
 77            ("Focused", Button.VisualState.FOCUSED),
 78        ]:
 79            btn = Button(label)
 80            btn.size_x = COL_W - 2 * PAD
 81            btn.size_y = 26
 82            btn.set_visual_state_override(state)
 83            self.add_child(btn)
 84
 85        # --- Panel ---
 86        self._section("Panel")
 87        panel = Panel()
 88        panel.size_x = COL_W - 2 * PAD
 89        panel.size_y = 36
 90        pl = Label("Panel content")
 91        pl.font_size = 12.0
 92        pl.set_anchor_preset(AnchorPreset.TOP_LEFT)
 93        pl.margin_left = 6
 94        pl.margin_top = 8
 95        panel.add_child(pl)
 96        self.add_child(panel)
 97
 98        # --- TextEdit ---
 99        self._section("TextEdit")
100        edit = TextEdit("Editable text")
101        edit.size_x = COL_W - 2 * PAD
102        edit.size_y = 26
103        self.add_child(edit)
104
105        edit_f = TextEdit("Focused input")
106        edit_f.size_x = COL_W - 2 * PAD
107        edit_f.size_y = 26
108        edit_f.focused = True
109        self.add_child(edit_f)
110
111        # --- Slider ---
112        self._section("Slider")
113        sl = Slider(0, 100, value=65)
114        sl.size_x = COL_W - 2 * PAD
115        sl.size_y = 18
116        self.add_child(sl)
117
118        # --- ProgressBar ---
119        self._section("Progress")
120        pb = ProgressBar(min_value=0, max_value=100)
121        pb.value = 72
122        pb.size_x = COL_W - 2 * PAD
123        pb.size_y = 18
124        self.add_child(pb)
125
126        # --- CheckBox ---
127        self._section("CheckBox")
128        cb_on = CheckBox("Enabled", checked=True)
129        self.add_child(cb_on)
130        cb_off = CheckBox("Disabled feature")
131        self.add_child(cb_off)
132
133        # --- Tabs ---
134        self._section("Tabs")
135        tabs = TabContainer()
136        tabs.size_x = COL_W - 2 * PAD
137        tabs.size_y = 60
138        for name in ("Scene", "Debug", "Output"):
139            tabs.add_child(Control(name=name))
140        self.add_child(tabs)
141
142    def _section(self, name: str):
143        lbl = Label(f"  {name}")
144        lbl.font_size = 10.0
145        self.add_child(lbl)
146
147    def on_draw(self, renderer):
148        """Draw themed column background before children."""
149        x, y, w, h = self.get_global_rect()
150        t = self.theme
151        renderer.draw_rect((x, y), (w, h), colour=t.bg, filled=True)
152        # Subtle top accent bar
153        renderer.draw_rect((x, y), (w, 2), colour=t.accent, filled=True)
154
155
156class ThemeDemoRoot(Control):
157    """Root: a scrollable horizontal row of themed columns."""
158
159    def __init__(self):
160        super().__init__()
161        self._scroll = ScrollContainer()
162        # Arrow-key scrolling only reaches a focused control, so the scroll area
163        # takes focus itself.
164        self._scroll.focus_mode = FocusMode.ALL
165        self.add_child(self._scroll)
166
167        self._hbox = HBoxContainer()
168        self._hbox.separation = 4.0
169        self._scroll.add_child(self._hbox)
170
171        for name, theme in THEMES:
172            col = ThemeColumn(name, theme)
173            col.size_x = COL_W
174            col.size_y = COL_H
175            self._hbox.add_child(col)
176
177    def on_ready(self):
178        self.set_anchor_preset(AnchorPreset.FULL_RECT)
179        self.margin_left = 6
180        self.margin_top = 6
181        self._scroll.set_anchor_preset(AnchorPreset.FULL_RECT)
182        self._hbox.size_x = len(THEMES) * (COL_W + 4)
183        self._hbox.size_y = COL_H
184        self._scroll.grab_focus()
185
186
187def _selftest() -> bool:
188    """Headless: check each column really wears its own preset, and that it scrolls.
189
190    Theme inheritance is read back off the widgets themselves -- a button several
191    levels down a column resolves to that column's preset, not to the app default
192    -- which is the claim a single ``self.theme = theme`` makes. The scroll area is
193    driven only by wheel and arrow key through the harness, at a window narrower
194    than the content, which is the case the docstring describes.
195    """
196    from simvx.core.ui.testing import UITestHarness
197
198    # Narrower than the seven columns, so both axes have somewhere to scroll.
199    harness = UITestHarness(ThemeDemoRoot(), screen_size=(640, 480))
200    root = harness.tree.root
201    ok = True
202
203    def check(label: str, passed: bool, detail: str) -> None:
204        nonlocal ok
205        ok = ok and passed
206        print(f"{'ok  ' if passed else 'FAIL'} {label}: {detail}")
207
208    harness.tick()
209
210    columns = [c for c in root._hbox.children if isinstance(c, ThemeColumn)]
211    titles = [next(w for w in col.children if isinstance(w, Label)).text for col in columns]
212    check(
213        "every preset gets a column, titled and in order",
214        titles == [f" {name} " for name, _ in THEMES],
215        ", ".join(t.strip() for t in titles),
216    )
217
218    # One assignment per column, and every widget under it inherits: a Button is
219    # nested a level down, so resolving to the column's preset is the whole claim.
220    buttons = [next(w for w in col.children if isinstance(w, Button)) for col in columns]
221    inherited = [btn.get_theme() is theme for btn, (_, theme) in zip(buttons, THEMES, strict=True)]
222    check(
223        "a widget inside a column resolves that column's theme, not the default",
224        all(inherited),
225        f"{sum(inherited)}/{len(inherited)} columns",
226    )
227    backgrounds = {tuple(theme.bg) for _, theme in THEMES}
228    check(
229        "and the presets really are different colours",
230        len(backgrounds) == len(THEMES),
231        f"{len(backgrounds)} distinct backgrounds across {len(THEMES)} presets",
232    )
233
234    # The button row exists to show all five visual states at once, so each column
235    # must hold one button pinned to each of them.
236    states = [[b.visual_state_override for b in col.children if isinstance(b, Button)] for col in columns]
237    wanted = [
238        Button.VisualState.NORMAL,
239        Button.VisualState.HOVER,
240        Button.VisualState.PRESSED,
241        Button.VisualState.DISABLED,
242        Button.VisualState.FOCUSED,
243    ]
244    check(
245        "each column pins one button per visual state",
246        all(row == wanted for row in states),
247        ", ".join(s.value for s in states[0]),
248    )
249
250    # At this size the strip overflows both ways, so the viewport clips and the
251    # scrollbar appears -- the chrome that says there is more to reach.
252    scroll = root._scroll
253    content, (_, _, view_w, view_h) = scroll.content_size, scroll.get_global_rect()
254    check(
255        "the strip is wider and taller than the window, so it clips and gains a scrollbar",
256        content.x > view_w and content.y > view_h and len(harness.draw_log.calls_of_type("push_clip")) == 1,
257        f"{content.x:.0f}x{content.y:.0f} of content in a {view_w:.0f}x{view_h:.0f} viewport",
258    )
259
260    # Wheel over the scroll area moves it down; the arrow keys move it sideways
261    # because the area holds focus (on_ready grabs it).
262    harness.scroll(scroll, "down", amount=3)
263    harness.tick()
264    down = scroll.scroll_y
265    harness.scroll(scroll, "up", amount=3)
266    harness.tick()
267    check(
268        "the mouse wheel scrolls the column strip vertically, and back",
269        down > 0.0 and scroll.scroll_y == 0.0,
270        f"0 -> {down:.0f} -> {scroll.scroll_y:.0f}",
271    )
272
273    check("the scroll area holds keyboard focus", scroll.focused, f"focused = {scroll.focused}")
274    for _ in range(3):
275        harness.press_key("right")
276    harness.tick()
277    right = scroll.scroll_x
278    for _ in range(3):
279        harness.press_key("left")
280    harness.tick()
281    check(
282        "and the arrow keys scroll it horizontally, to the far columns and back",
283        right > 0.0 and scroll.scroll_x == 0.0,
284        f"0 -> {right:.0f} -> {scroll.scroll_x:.0f}",
285    )
286
287    harness.teardown()
288    print("SELFTEST:", "PASS" if ok else "FAIL")
289    return ok
290
291
292if __name__ == "__main__":
293    import sys
294
295    if "--test" in sys.argv:
296        sys.exit(0 if _selftest() else 1)
297    total_w = int(len(THEMES) * (COL_W + 4) + 16)
298    # bg_colour omitted → clear colour auto-syncs from the active theme's bg_black
299    app = App(width=total_w, height=800, title="SimVX Theme Presets")
300    app.run(ThemeDemoRoot())