Text Input¶
single-line TextEdit fields with focus routing, submit and activation.
â–¶ Run in browserTags: ui text input focus scrolling
Two TextEdit fields with placeholder text and live keyboard editing, plus a
Send button in the same tab order. Click a field to focus it or Tab between them;
Enter on the message field fires text_submitted, and Enter on the focused Send
button presses it, because the field claims the key for itself and the button
does not. F2 rebinds traversal onto Ctrl+Tab, F3 rebinds activation onto
Ctrl+Enter. A ScrollContainer underneath clips a list that overflows its box.
What it demonstrates¶
TextEdit(placeholder=...)for single-line entry, withtext_submittedwired to a label.Focus routing: click-to-focus, with
focus_mode = FocusMode.ALLputting each field AND the button in the tab order, so Tab and Shift+Tab walk between them.ui_acceptactivating the focus owner: the focusedButtonfirespressedas a click would, while the focusedTextEditkeeps the key for its own submit. Only the focus owner is ever activated – there is no default button.Rebinding both: the keys are the
ui_focus_next/ui_acceptinput actions, soInputMap.add_actionmoves them off Tab and Enter.Per-field focus styling via the
style_normal/style_focusedStyleBoxes (a text field has no flat background colour: its states are style boxes).A
ScrollContainerclipping overflowing rows, scrolled by the mouse wheel or by dragging its scrollbar.
Run: uv run python examples/features/ui/text_input.py Headless self-check: uv run python examples/features/ui/text_input.py –test
Source¶
1"""Text Input: single-line TextEdit fields with focus routing, submit and activation.
2
3Two `TextEdit` fields with placeholder text and live keyboard editing, plus a
4Send button in the same tab order. Click a field to focus it or Tab between them;
5Enter on the message field fires `text_submitted`, and Enter on the focused Send
6button presses it, because the field claims the key for itself and the button
7does not. F2 rebinds traversal onto Ctrl+Tab, F3 rebinds activation onto
8Ctrl+Enter. A `ScrollContainer` underneath clips a list that overflows its box.
9
10# /// simvx
11# tags = ["ui", "text", "input", "focus", "scrolling"]
12# web = { root = "TextInputDemo", width = 800, height = 600, responsive = true }
13# ///
14
15## What it demonstrates
16- `TextEdit(placeholder=...)` for single-line entry, with `text_submitted`
17 wired to a label.
18- Focus routing: click-to-focus, with `focus_mode = FocusMode.ALL` putting each
19 field AND the button in the tab order, so Tab and Shift+Tab walk between them.
20- `ui_accept` activating the focus owner: the focused `Button` fires `pressed`
21 as a click would, while the focused `TextEdit` keeps the key for its own
22 submit. Only the focus owner is ever activated -- there is no default button.
23- Rebinding both: the keys are the `ui_focus_next` / `ui_accept` input actions,
24 so `InputMap.add_action` moves them off Tab and Enter.
25- Per-field focus styling via the `style_normal` / `style_focused` StyleBoxes
26 (a text field has no flat background colour: its states are style boxes).
27- A `ScrollContainer` clipping overflowing rows, scrolled by the mouse wheel or
28 by dragging its scrollbar.
29
30Run: uv run python examples/features/ui/text_input.py
31Headless self-check: uv run python examples/features/ui/text_input.py --test
32"""
33
34from simvx.core import (
35 AnchorPreset,
36 Button,
37 Colour,
38 FocusMode,
39 InputMap,
40 Key,
41 Label,
42 Node,
43 Panel,
44 ScrollContainer,
45 TextEdit,
46 VBoxContainer,
47 Vec2,
48 on_input,
49)
50from simvx.graphics import App
51
52
53class TextInputDemo(Node):
54 """Root node for text input demo."""
55
56 def on_ready(self):
57 panel = Panel(name="MainPanel")
58 # Fill the viewport with a 30px gutter so the panel grows with the window.
59 panel.set_anchor_preset(AnchorPreset.FULL_RECT)
60 panel.margin_left = 30
61 panel.margin_top = 30
62 panel.margin_right = 30
63 panel.margin_bottom = 30
64 panel.bg_colour = Colour.hex("#12121A")
65 self.add_child(panel)
66
67 vbox = VBoxContainer(name="Layout")
68 vbox.set_anchor_preset(AnchorPreset.FULL_RECT)
69 vbox.margin_left = 20
70 vbox.margin_top = 20
71 vbox.margin_right = 20
72 vbox.margin_bottom = 20
73 vbox.separation = 15
74 panel.add_child(vbox)
75
76 # Title: both lines centre together across the label's width.
77 title = Label("Text Input\n& Clipping Demo")
78 title.text_colour = Colour.hex("#4FC3F7")
79 title.font_size = 16.0
80 title.size = Vec2(460, 50)
81 title.alignment = "center"
82 vbox.add_child(title)
83
84 # Field 1
85 label1 = Label("Username:")
86 label1.size = Vec2(460, 18)
87 vbox.add_child(label1)
88
89 # A field's look comes from its per-state StyleBoxes, not a flat colour:
90 # copy the theme box and override just the colours this demo wants.
91 self._username = edit1 = TextEdit(placeholder="Enter username...")
92 edit1.size = Vec2(460, 30)
93 edit1.style_normal = edit1.style_normal.replace(bg_colour=Colour.hex("#1A1A2E"))
94 edit1.style_focused = edit1.style_focused.replace(
95 bg_colour=Colour.hex("#1A1A2E"), border_colour=Colour.hex("#4FC3F7")
96 )
97 vbox.add_child(edit1)
98
99 # Field 2
100 label2 = Label("Message:")
101 label2.size = Vec2(460, 18)
102 vbox.add_child(label2)
103
104 self._message = edit2 = TextEdit(placeholder="Type a message...")
105 edit2.size = Vec2(460, 30)
106 edit2.style_normal = edit2.style_normal.replace(bg_colour=Colour.hex("#1A1A2E"))
107 edit2.style_focused = edit2.style_focused.replace(
108 bg_colour=Colour.hex("#1A1A2E"), border_colour=Colour.hex("#E94560")
109 )
110 vbox.add_child(edit2)
111
112 # Echo
113 self._echo = echo = Label("")
114 echo.text_colour = Colour.GREEN
115 echo.size = Vec2(460, 20)
116
117 def on_submit(txt):
118 echo.text = f"Sent: {txt}"
119
120 edit2.text_submitted.connect(on_submit)
121 vbox.add_child(echo)
122
123 # A Button defaults to click-only focus (the web's tabindex="-1"), so it
124 # joins the tab order explicitly. Once focused, the ui_accept key fires
125 # ``pressed`` exactly as a click does -- while the focused field above
126 # keeps that same key for its own submit, because a widget claims a key
127 # before the router acts on it.
128 self._send = send = Button("Send", name="SendButton")
129 send.focus_mode = FocusMode.ALL
130 send.size = Vec2(460, 30)
131 send.pressed.connect(lambda: on_submit(edit2.text))
132 vbox.add_child(send)
133
134 # Clipped scroll area: overflowing content exercises the scrollbar.
135 clip_label = Label("Scrollable area (mouse wheel, or drag the bar):")
136 clip_label.size = Vec2(460, 18)
137 vbox.add_child(clip_label)
138
139 self._scroll = scroll_area = ScrollContainer(name="ScrollArea")
140 scroll_area.size = Vec2(460, 160)
141 scroll_area.separation = 4
142 vbox.add_child(scroll_area)
143
144 for i in range(1, 11):
145 line = Label(f"Line {i}: scrollable content row {i}")
146 line.size = Vec2(440, 18)
147 line.text_colour = Colour.WHITE
148 scroll_area.add_child(line)
149
150 # Instructions
151 help_text = Label("Tab walks field -> field -> Send / Enter submits or presses / wheel scrolls")
152 help_text.text_colour = Colour.GRAY
153 help_text.size = Vec2(460, 18)
154 help_text.alignment = "center"
155 vbox.add_child(help_text)
156
157 # Which key traverses focus, and which activates the focus owner, are
158 # input actions like any other, so either can be moved out of the way of
159 # a widget that wants the key for itself.
160 self._binding_hint = Label("")
161 self._binding_hint.text_colour = Colour.hex("#FFB74D")
162 self._binding_hint.size = Vec2(460, 18)
163 self._binding_hint.alignment = "center"
164 vbox.add_child(self._binding_hint)
165 self._traverse_key = "tab"
166 self._accept_key = "enter"
167 self._pending = {}
168 self._show_bindings()
169
170 @on_input(key=Key.F2)
171 def toggle_traversal_key(self, event):
172 """Ask for a swap of focus traversal between Tab and Ctrl+Tab."""
173 self._pending["ui_focus_next"] = "ctrl+tab" if self._traverse_key == "tab" else "tab"
174 return True
175
176 @on_input(key=Key.F3)
177 def toggle_accept_key(self, event):
178 """Ask for a swap of activation between Enter and Ctrl+Enter."""
179 self._pending["ui_accept"] = "ctrl+enter" if self._accept_key == "enter" else "enter"
180 return True
181
182 def on_update(self, dt):
183 """Apply a requested rebind from the update hook rather than the handler.
184
185 ``InputMap`` warns about actions registered after ticking begins from
186 outside the tree's processing span, and input handlers run outside that
187 span on desktop. ``on_update`` runs inside it, so rebinding here keeps
188 the example warning-free.
189 """
190 if not self._pending:
191 return
192 for action, combo in self._pending.items():
193 InputMap.remove_action(action)
194 InputMap.add_action(action, [combo])
195 if action == "ui_focus_next":
196 self._traverse_key = combo
197 else:
198 self._accept_key = combo
199 self._pending = {}
200 self._show_bindings()
201
202 def _show_bindings(self):
203 traverse = "Ctrl+Tab" if self._traverse_key == "ctrl+tab" else "Tab"
204 accept = "Ctrl+Enter" if self._accept_key == "ctrl+enter" else "Enter"
205 self._binding_hint.text = f"F2 moves traversal (on {traverse}) / F3 moves activation (on {accept})"
206
207
208def _selftest() -> bool:
209 """Headless: type into the fields, walk the tab order, and move both bindings.
210
211 Focus is only ever elected the way a user elects it -- a click on a field, or a
212 traversal key -- and text arrives as character events, so the routing is what
213 is being measured rather than the widgets' setters. The rebinding checks are
214 two-sided: after F2 the old key must stop traversing as well as the new one
215 starting, or "rebound" would be indistinguishable from "bound twice".
216 """
217 from simvx.core.testing import InputSimulator
218 from simvx.core.ui.testing import UITestHarness
219
220 harness = UITestHarness(TextInputDemo(name="TextInputDemo"), screen_size=(800, 600))
221 scene = harness.tree.root
222 sim = InputSimulator(tree=harness.tree)
223 ok = True
224
225 def check(label: str, passed: bool, detail: str) -> None:
226 nonlocal ok
227 ok = ok and passed
228 print(f"{'ok ' if passed else 'FAIL'} {label}: {detail}")
229
230 def focused():
231 """Whichever of the demo's three focusable controls holds the keyboard."""
232 return next((c for c in (scene._username, scene._message, scene._send) if c.has_focus()), None)
233
234 harness.tick()
235
236 harness.click(scene._username)
237 harness.type_text("ada")
238 harness.tick()
239 check(
240 "clicking a field focuses it and the characters land in that field",
241 focused() is scene._username and scene._username.text == "ada" and scene._message.text == "",
242 f"username = {scene._username.text!r}, message = {scene._message.text!r}",
243 )
244
245 order = [focused()]
246 for _ in range(2):
247 harness.press_key("tab")
248 order.append(focused())
249 back = []
250 for _ in range(2):
251 harness.press_key("shift+tab")
252 back.append(focused())
253 check(
254 "Tab walks username -> message -> Send, and Shift+Tab walks back",
255 order == [scene._username, scene._message, scene._send] and back == [scene._message, scene._username],
256 " -> ".join(type(c).__name__ for c in order),
257 )
258
259 # Enter means two different things depending on who holds focus: the field
260 # claims it for submit, the button lets the router press it.
261 harness.click(scene._message)
262 harness.type_text("hello")
263 harness.press_key("enter")
264 harness.tick()
265 submitted = scene._echo.text
266 check(
267 "Enter on the message field submits it rather than pressing Send",
268 submitted == "Sent: hello" and focused() is scene._message,
269 f"{submitted!r} with focus still on the field",
270 )
271
272 scene._echo.text = ""
273 harness.press_key("tab")
274 harness.press_key("enter")
275 harness.tick()
276 check(
277 "and Enter on the focused Send button presses it",
278 focused() is scene._send and scene._echo.text == "Sent: hello",
279 f"{scene._echo.text!r} from the button",
280 )
281
282 # F2 moves traversal off Tab. The hint label is the demo's own readout, so it
283 # has to agree with what the keys actually do.
284 sim.press_key(Key.F2)
285 harness.tick()
286 sim.release_key(Key.F2)
287 harness.click(scene._username)
288 harness.press_key("tab")
289 after_plain_tab = focused()
290 harness.press_key("ctrl+tab")
291 after_ctrl_tab = focused()
292 check(
293 "F2 moves traversal onto Ctrl+Tab, and plain Tab stops traversing",
294 after_plain_tab is scene._username
295 and after_ctrl_tab is scene._message
296 and "on Ctrl+Tab" in scene._binding_hint.text,
297 scene._binding_hint.text,
298 )
299
300 # F3 does the same for activation.
301 sim.press_key(Key.F3)
302 harness.tick()
303 sim.release_key(Key.F3)
304 harness.click(scene._send)
305 scene._echo.text = ""
306 harness.press_key("enter")
307 after_plain_enter = scene._echo.text
308 harness.press_key("ctrl+enter")
309 check(
310 "F3 moves activation onto Ctrl+Enter, and plain Enter stops activating",
311 after_plain_enter == ""
312 and scene._echo.text.startswith("Sent:")
313 and "on Ctrl+Enter" in scene._binding_hint.text,
314 scene._binding_hint.text,
315 )
316
317 # The scroll area holds more rows than it can show, so it clips and scrolls.
318 scroll = scene._scroll
319 _, _, _, view_h = scroll.get_global_rect()
320 harness.scroll(scroll, "down", amount=4)
321 harness.tick()
322 check(
323 "the row list overflows its box and the wheel scrolls it",
324 scroll.content_size.y > view_h and scroll.scroll_y > 0.0,
325 f"{scroll.content_size.y:.0f}px of rows in a {view_h:.0f}px box, scrolled to {scroll.scroll_y:.0f}",
326 )
327
328 harness.teardown()
329 for action in ("ui_focus_next", "ui_accept"):
330 InputMap.remove_action(action) # leave the map as this run found it
331 print("SELFTEST:", "PASS" if ok else "FAIL")
332 return ok
333
334
335if __name__ == "__main__":
336 import sys
337
338 if "--test" in sys.argv:
339 sys.exit(0 if _selftest() else 1)
340 app = App(title="SimVX Text Input Demo", width=800, height=600)
341 app.run(TextInputDemo())