AppTheme presets¶
every built-in theme shown side by side.
▶ Run in browserTags: 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())