Text Input¶

single-line TextEdit fields with focus routing, submit and activation.

â–¶ Run in browser

Tags: 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, with text_submitted wired to a label.

  • Focus routing: click-to-focus, with focus_mode = FocusMode.ALL putting each field AND the button in the tab order, so Tab and Shift+Tab walk between them.

  • ui_accept activating the focus owner: the focused Button fires pressed as a click would, while the focused TextEdit keeps 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_accept input actions, so InputMap.add_action moves them off Tab and Enter.

  • Per-field focus styling via the style_normal / style_focused StyleBoxes (a text field has no flat background colour: its states are style boxes).

  • A ScrollContainer clipping 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())