Source code for simvx.core.ui.widgets

"""Widgets: Label, Button, Panel, TextEdit, Slider, ProgressBar.

All widgets draw themselves via the renderer passed to draw().
The renderer is Draw2D (class with classmethods), called directly
without hasattr guards. Colours are float tuples (0-1).
"""

import difflib
import logging
from enum import StrEnum

from ..descriptors import Notification, Property
from ..input.actions import ActionKind
from ..input.controls import MODIFIER_SIDES, DeviceType, Key, Pointer
from ..input.enums import CursorShape, PointerPhase
from ..input.events import KeyEvent, PointerEvent, TextEvent
from ..math.types import Vec2
from ..signals import Signal
from ..text.measure import LOGICAL_EM, caret_index_at, text_minimum_width
from .button_group import ButtonGroup
from .core import Colour, Control, FocusMode, ThemeColour, ThemeStyleBox, _cursor_shape
from .enums import HAlign, PointerFilter, VAlign
from .text_selection import _CTRL, _MOTION, _WORD_MOTION, TextSelectionMixin, _word_left, _word_right, _word_span
from .theme import StyleBox

log = logging.getLogger(__name__)

__all__ = [
    "Label",
    "Button",
    "Panel",
    "TextEdit",
    "Slider",
    "ProgressBar",
]

# ============================================================================
# Label: Text display widget
# ============================================================================


[docs] class Label(Control): """Text display widget. Example: label = Label("Hello World") label.text_colour = Colour.YELLOW label.alignment = HAlign.CENTRE """ pointer_filter = Property(PointerFilter.IGNORE, hint="How this control takes part in the pointer hit test") _draw_caching = True # A Label that measured its own size re-measures when either changes; one # given a size, or laid out by a container, keeps it. text = Property("", hint="Label text", on_change="_content_changed") font_size = Property(14.0, range=(8, 72), hint="Font size", on_change="_content_changed") text_colour = ThemeColour("text") bg_colour = Colour((0.0, 0.0, 0.0, 0.0)) alignment = Property(HAlign.LEFT, hint="Horizontal text alignment") vertical_alignment = Property(VAlign.CENTRE, hint="Vertical text alignment") autoshrink = Property(False, hint="Shrink font_size so text fits the widget width") min_font_size = Property(8.0, range=(1, 72), hint="Lower bound for autoshrink") def __init__(self, text: str = "", **kwargs): super().__init__(**kwargs) self.text = text # Only auto-size if no explicit size was provided (e.g., from deserialization) if "size" not in kwargs and "size_x" not in kwargs and "size_y" not in kwargs: self.autosize_to_content()
[docs] def get_minimum_size(self) -> Vec2: floor_x, floor_y = self.size_floor() lines = self.text.split("\n") if self.text else [""] w = max(floor_x, text_minimum_width(self.text, self.font_size / LOGICAL_EM)) # Single-line keeps the historical 1.5x font-size height; multi-line # stacks each line on a 1.2x pitch. n = max(1, len(lines)) if n == 1: h = max(floor_y, self.font_size * 1.5) else: h = max(floor_y, self.font_size * 1.2 * n + self.font_size * 0.3) return Vec2(w, h)
[docs] def content_signature(self) -> object: return (self.text, self.font_size)
[docs] def content_uses(self, chars: str) -> bool: return any(char in self.text for char in chars)
[docs] def on_draw(self, renderer): if not self.text: return x, y, w, h = self._local_rect() # Optional background (transparent by default: no rectangle over content) if len(self.bg_colour) >= 4 and self.bg_colour[3] > 0: renderer.rect((x, y), (w, h), fill=self.bg_colour) scale = self.font_size / 16.0 lines = self.text.split("\n") if self.autoshrink: min_scale = self.min_font_size / 16.0 widest = max(lines, key=lambda s: renderer.text_width(s, scale)) if lines else "" scale = renderer.fit_scale(widest, w, base_scale=scale, min_scale=min_scale) glyph_h = 16.0 * scale line_pitch = glyph_h * 1.2 n = len(lines) block_h = glyph_h if n == 1 else line_pitch * n if self.vertical_alignment == VAlign.TOP: first_y = y elif self.vertical_alignment == VAlign.BOTTOM: first_y = y + h - block_h else: first_y = y + (h - block_h) / 2 for i, line in enumerate(lines): line_w = renderer.text_width(line, scale) if self.alignment == HAlign.CENTRE: # Centre each line on the rect's centre: when line_w > w the # overflow is symmetric rather than pushing off the left edge. line_x = x + (w - line_w) / 2 elif self.alignment == HAlign.RIGHT: line_x = x + w - line_w else: line_x = x line_y = first_y + i * line_pitch renderer.text(line, (line_x, line_y), fill=self.text_colour, scale=scale)
# ============================================================================ # Button: Clickable button # ============================================================================ #: The backend id of the keyboard an action button presses on. Far above any a #: backend hands out, so a binding pinned to a real keyboard never hears it. _ONSCREEN_KEYBOARD = 1000 #: Each modifier flag a key event states, and the group that sets it. _MODIFIER_FLAGS = {"ctrl": Key.CTRL, "shift": Key.SHIFT, "alt": Key.ALT, "meta": Key.META}
[docs] class Button(Control): """Clickable button with hover/press states. Example: button = Button("Click Me", on_press=my_handler) button.pressed.connect(another_handler) **An action button** is the pressable form of an on-screen hint. ``Button(action="jump")`` shows the action's prompt for the device the player is using ("Space", or "A" on a pad) and follows it when the player changes device or rebinds; with ``text`` as well it reads "Jump (Space)". Holding the button holds the action's key: it publishes the press of the first key the action is bound to through an input router of its own, the way ``TouchController`` publishes its pad, so every ``@on_input`` handler and every poll of the action sees an ordinary press, and a rebind moves the key it presses. The key comes from an on-screen keyboard unit, so a binding pinned to one real keyboard does not hear it, and it counts as the keyboard for ``Input.input_method``. The action is checked when it is assigned, or when the button enters a tree if it has none yet: a name no context declares raises naming the near miss, as ``Input`` does, and so does an action that is not a button action or has no key to press (a pad-only action belongs on a ``TouchController``). An action button takes no keyboard focus, since a focused one would hear the very key it names. **A toggle button** stays down. With ``toggle_mode=True`` a click (or ``ui_accept``) flips ``checked`` and fires ``toggled(checked)`` before ``pressed``; the button draws its CHECKED state while ``checked`` holds. Setting ``checked`` from code changes the state and redraws but fires nothing, as ``CheckBox`` and ``Slider`` do, so a handler never has to tell its own write from the player's. ``checked`` is drawn whatever ``toggle_mode`` says, which lets code show a push button as engaged (a Play button while the game runs) without the click flipping it. For a set of mutually exclusive choices put the buttons in a :class:`ButtonGroup`: ``Button("Brush", group=tools)``. Joining a group sets ``toggle_mode``, and the group unchecks the others whenever one is checked, by click or by code. """ pointer_filter = Property(PointerFilter.STOP, hint="How this control takes part in the pointer hit test") _draw_caching = True touch_target = True
[docs] class VisualState(StrEnum): """Canonical Button visual states for theme resolution and overrides.""" NORMAL = "normal" HOVER = "hover" PRESSED = "pressed" DISABLED = "disabled" FOCUSED = "focused" CHECKED = "checked"
text = Property("Button", hint="Button text") action = Property( "", hint="Action this button prompts for and presses; empty for a plain button", on_change="_on_action_changed", ) font_size = Property(14.0, range=(8, 72), hint="Font size") # A button takes focus when clicked, so the focus ring follows the pointer # and Enter re-fires the button the user just used. Redeclared rather than # assigned in ``__init__``: an assignment there would discard a caller's # ``focus_mode=``. focus_mode = Property(FocusMode.CLICK, hint="Whether this control can take keyboard focus") toggle_mode = Property(False, hint="A click flips checked instead of springing back", on_change="_on_toggle_mode") checked = Property(False, hint="Whether the button is checked (held down)", on_change="_on_checked_changed") style_normal = ThemeStyleBox("btn_style_normal") style_hover = ThemeStyleBox("btn_style_hover") style_pressed = ThemeStyleBox("btn_style_pressed") style_disabled = ThemeStyleBox("btn_style_disabled") style_focused = ThemeStyleBox("btn_style_focused") style_checked = ThemeStyleBox("btn_style_checked") text_colour = ThemeColour("text_bright") text_disabled_colour = ThemeColour("text_dim") bg_colour = ThemeColour("btn_bg") hover_colour = ThemeColour("btn_hover") pressed_colour = ThemeColour("btn_pressed") checked_colour = ThemeColour("btn_pressed") border_colour = ThemeColour("btn_border") # Class-level defaults, because the action's change hook can run from # inside ``Control.__init__``, before this class's own ``__init__`` body. #: What the action's prompt reads, and the signals that say it may have changed. _prompt = "" _prompt_listeners: tuple = () #: The key and the keys held with it while the button holds its action. _held_keys: tuple = () #: The modifier flags each of those key events states, since the events are #: queued behind the pointer's and the held-key record has not seen them yet. _held_flags: dict = {} _action_router = None _pointer_activation = False _group: ButtonGroup | None = None def __init__(self, text: str | None = None, on_press=None, *, group: ButtonGroup | None = None, **kwargs): super().__init__(**kwargs) # An action button with no text of its own shows its prompt alone. self.text = text if text is not None else ("" if self.action else "Button") self._is_pressed = False self._visual_state_override: Button.VisualState | None = None self.pressed = Signal() self.button_down = Signal() self.button_up = Signal() self.toggled = Signal() if on_press: self.pressed.connect(on_press) if group is not None: self.group = group # Auto-size to content if no explicit size was given if "size" not in kwargs and "size_x" not in kwargs and "size_y" not in kwargs: self.autosize_to_content() @property def border_width(self): box = self.style_normal return box.border_width if box else 0.0
[docs] @border_width.setter def border_width(self, value): for attr in ( "_tsb_style_normal", "_tsb_style_hover", "_tsb_style_pressed", "_tsb_style_disabled", "_tsb_style_focused", "_tsb_style_checked", ): box = self.__dict__.get(attr) if box is not None: box.border_width = value
[docs] @property def is_pressed(self) -> bool: """Whether the button is currently held down (mouse or touch).""" return self._is_pressed
@property def group(self) -> ButtonGroup | None: """The :class:`ButtonGroup` this button is exclusive within, or None.""" return self._group
[docs] @group.setter def group(self, group: ButtonGroup | None) -> None: if group is not None and not isinstance(group, ButtonGroup): raise TypeError(f"Button.group takes a ButtonGroup or None, got {type(group).__name__}") if group is self._group: return if self._group is not None: self._group._remove(self) self._group = group if group is not None: self.toggle_mode = True group._add(self)
def _on_toggle_mode(self) -> None: if not self.toggle_mode and self._group is not None: raise ValueError("A button in a ButtonGroup is a toggle button; set its group to None first") def _on_checked_changed(self) -> None: if self.checked and self._group is not None: self._group._uncheck_others(self) self.queue_redraw() def _toggle(self) -> None: """Flip ``checked`` as a click does and report it, the group's other member first.""" group = self._group if self.checked and group is not None and not group.allow_unchecked: return previous = group.checked_button if group is not None else None self.checked = not self.checked if previous is not None and previous is not self: previous.toggled.emit(False) self.toggled.emit(self.checked) if group is not None: group.checked_changed.emit(group.checked_button)
[docs] @property def visual_state_override(self) -> Button.VisualState | None: """The forced visual state, or None if the button reflects its actual state.""" return self._visual_state_override
[docs] def set_visual_state_override(self, state: Button.VisualState | str | None) -> None: """Force a specific visual state for theme/screenshot purposes. Accepts a ``VisualState`` enum, the matching string ("normal", "hover", "pressed", "disabled", "focused", "checked"), or ``None`` to clear the override and revert to the actual interaction state. """ if state is None: self._visual_state_override = None elif isinstance(state, Button.VisualState): self._visual_state_override = state elif isinstance(state, str): try: self._visual_state_override = Button.VisualState(state) except ValueError: valid = ", ".join(s.value for s in Button.VisualState) raise ValueError(f"Invalid visual state {state!r}. Expected one of: {valid}") from None else: raise TypeError(f"Expected Button.VisualState, str, or None; got {type(state).__name__}") self.queue_redraw()
[docs] def get_minimum_size(self) -> Vec2: floor_x, floor_y = self.size_floor() padding_x, padding_y = 16.0, 8.0 text_w = text_minimum_width(self._shown_text(), self.font_size / LOGICAL_EM) w = max(floor_x, text_w + padding_x * 2) h = max(floor_y, self.font_size + padding_y * 2) return Vec2(w, h)
[docs] def content_signature(self) -> object: return (self._shown_text(), self.font_size)
[docs] def content_uses(self, chars: str) -> bool: shown = self._shown_text() return any(char in shown for char in chars)
def _shown_text(self) -> str: """What the button draws: its text, its action's prompt, or both as "Jump (Space)".""" text = str(self.text) if not self.action or not self._prompt: return text return f"{text} ({self._prompt})" if text else self._prompt # -- The action ---------------------------------------------------------- def _on_action_changed(self) -> None: self._release_action() if self.action: self.focus_mode = FocusMode.NONE if self._tree is not None: self._check_action() self._listen_for_prompt() self._refresh_prompt() else: self._stop_listening() if self._prompt: self._prompt = "" self._refit() def _notification(self, what: Notification) -> None: super()._notification(what) if what is Notification.ENTER_TREE and self.action: self._check_action() self._listen_for_prompt() self._refresh_prompt() elif what is Notification.EXIT_TREE: self._release_action() self._stop_listening() self._action_router = None def _listen_for_prompt(self) -> None: """Re-read the prompt when the bindings, the device in use or the pads change.""" if self._prompt_listeners: return tree = self._tree assert tree is not None, "a prompt is listened for once the button is in a tree" inp, refresh = tree.input, self._refresh_prompt self._prompt_listeners = tuple( signal.connect(lambda *_: refresh()) for signal in ( tree.input_map.bindings_changed, inp.input_method_changed, inp.controller_connected, inp.controller_disconnected, ) ) def _stop_listening(self) -> None: listeners, self._prompt_listeners = self._prompt_listeners, () for connection in listeners: connection.disconnect() def _check_action(self) -> None: """Raise unless the action is one this button can press, naming what to do instead.""" name = self.action tree = self._tree assert tree is not None, "an action is checked once the button is in a tree" imap = tree.input_map if not imap.declares(name): near = difflib.get_close_matches(name, sorted(imap._known), n=3, cutoff=0.6) hint = f" Did you mean {' or '.join(repr(n) for n in near)}?" if near else "" raise ValueError( f"Button(action={name!r}): no input context declares an action called {name!r}.{hint} " "Declare it on a node's input_context before the button enters the tree." ) kind = imap.kind(name) if kind is not ActionKind.BUTTON: raise ValueError( f"Button(action={name!r}): {name!r} is a {kind.value} action and a button presses a " "button action; give each direction an action of its own, or use a TouchController" ) if imap.get_bindings(name) and self._key_binding() is None: raise ValueError( f"Button(action={name!r}): {name!r} has no key binding for the button to press. Bind a " "key as well, or put a TouchController on screen for an action played on a pad" ) def _key_binding(self): """The first binding of the action a key press satisfies, or None.""" tree = self._tree if tree is None: return None for binding in tree.input_map.get_bindings(self.action): control = binding.control if control is None or control.device is not DeviceType.KEYBOARD or control.unit is not None: continue if all(h.device is DeviceType.KEYBOARD and h.unit is None for h in binding.held): return binding return None def _press_action(self) -> None: """Hold the action's key, and the keys its binding holds with it.""" if self._held_keys or self._tree is None: return binding = self._key_binding() if binding is None: return if self._action_router is None: from ..input.router import InputRouter # The button is an input source of its own, as the on-screen pad is. self._action_router = InputRouter(self._tree) # A modifier group names either side; the left one stands in for it. held = tuple(min(MODIFIER_SIDES[h], key=repr) if h in MODIFIER_SIDES else h for h in binding.held) self._held_keys = (*held, binding.control) mods = binding.modifiers self._held_flags = {flag: group in mods for flag, group in _MODIFIER_FLAGS.items()} for key in self._held_keys: self._action_router.key(key, True, device_id=_ONSCREEN_KEYBOARD, **self._held_flags) def _release_action(self) -> None: """Let go of whatever :meth:`_press_action` holds, the key first.""" keys, self._held_keys = self._held_keys, () router = self._action_router if router is None or self._tree is None: return for key in reversed(keys): router.key(key, False, device_id=_ONSCREEN_KEYBOARD, **self._held_flags) def _refresh_prompt(self) -> None: """Read the action's prompt again, refitting the button if it changed.""" if self._tree is None or not self.action: return prompt = self._tree.input.prompt(self.action) if prompt != self._prompt: self._prompt = prompt self._refit() def _refit(self) -> None: """Keep a button that sized itself fitted to what it now shows.""" if self._content_size is not None and (self.size_x, self.size_y) == self._content_size: self.autosize_to_content() else: self._on_size_changed() self.queue_redraw()
[docs] def activate(self) -> bool: """Fire ``pressed``, the way a completed click does. The click path ends here too, so a keyboard activation and a mouse release inside the button are the same event to every listener. ``button_down`` / ``button_up`` are NOT synthesised: they report a physical press being held, and a keyboard activation holds nothing. An action button activated other than by the pointer taps its action's key. A toggle button flips ``checked`` first, so a ``pressed`` handler reads the new state. """ if self.action and not self._pointer_activation: self._press_action() self._release_action() if self.toggle_mode: self._toggle() self.pressed.emit() return True
[docs] def on_gui_input(self, event): # A press the button acts on is claimed, so a click on a button inside a # panel that also takes clicks presses the button and stops there. if not isinstance(event, PointerEvent): return None if event.phase is PointerPhase.CANCEL and self._is_pressed: self._is_pressed = False self.queue_redraw() self.button_up.emit() self._release_action() return True if event.button in Pointer.PRIMARY: if event.phase is PointerPhase.DOWN and self.is_hit(event): self._is_pressed = True self.queue_redraw() self.button_down.emit() if self.action: self._press_action() return True if event.phase is PointerPhase.UP and self._is_pressed: self._is_pressed = False self.queue_redraw() self.button_up.emit() self._release_action() if self.is_hit(event): self._pointer_activation = True try: self.activate() finally: self._pointer_activation = False return True return None
def _compute_visual_state(self) -> Button.VisualState: """Resolve the visual state honouring ``_visual_state_override``.""" if self._visual_state_override is not None: return self._visual_state_override if self.disabled: return Button.VisualState.DISABLED if self._is_pressed: return Button.VisualState.PRESSED if self.checked: return Button.VisualState.CHECKED if self.mouse_over: return Button.VisualState.HOVER if self.focused: return Button.VisualState.FOCUSED return Button.VisualState.NORMAL _STATE_STYLE_MAP: dict = { VisualState.NORMAL: ("style_normal", "_tc_bg_colour"), VisualState.HOVER: ("style_hover", "_tc_hover_colour"), VisualState.PRESSED: ("style_pressed", "_tc_pressed_colour"), VisualState.DISABLED: ("style_disabled", None), VisualState.FOCUSED: ("style_focused", None), VisualState.CHECKED: ("style_checked", "_tc_checked_colour"), } def _get_current_style(self): """Select the appropriate StyleBox for the current state, applying ThemeColour overrides.""" d = self.__dict__ style_attr, bg_attr = self._STATE_STYLE_MAP[self._compute_visual_state()] box = getattr(self, style_attr) if box is None: return None # Layer per-instance ThemeColour overrides on top of the theme StyleBox bg_val = d.get(bg_attr) if bg_attr else None return box.with_overrides(bg_colour=bg_val, border_colour=d.get("_tc_border_colour"))
[docs] def on_draw(self, renderer): x, y, w, h = self._local_rect() box = self._get_current_style() if box is not None: box.draw(renderer, x, y, w, h) # Text (centered) shown = self._shown_text() if shown: scale = self.font_size / 16.0 tc = self.text_disabled_colour if self.disabled else self.text_colour text_width = renderer.text_width(shown, scale) text_x = x + (w - text_width) / 2 text_y = y + (h - self.font_size) / 2 renderer.text(shown, (text_x, text_y), fill=tc, scale=scale)
# ============================================================================ # Panel: Background panel # ============================================================================
[docs] class Panel(Control): """Background panel with optional border. Example: panel = Panel() panel.style = StyleBox(bg_colour=Colour.hex("#1A1A2E")) panel.add_child(VBoxContainer()) """ _draw_caching = True style = ThemeStyleBox("panel_style") def __init__(self, **kwargs): super().__init__(**kwargs) # Convenience setters that create StyleBox overrides for bg/border. @property def bg_colour(self): box = self.style return box.bg_colour if box else (0, 0, 0, 0)
[docs] @bg_colour.setter def bg_colour(self, colour): colour = Colour.coerce(colour, name="Panel.bg_colour") if colour is None: # ``None`` reverts the panel to its theme-default style: mirrors # :class:`ThemeColour` / :class:`ThemeStyleBox` semantics. Clears # any per-instance StyleBox override; the getter then reads from # the theme's ``panel_style`` via :class:`ThemeStyleBox.__get__`. self.style = None self.queue_redraw() return box = self.style if box is not None and self.__dict__.get("_tsb_style") is not None: box.bg_colour = colour else: self.style = StyleBox(bg_colour=colour, border_width=0.0) self.queue_redraw()
@property def border_colour(self): box = self.style return box.border_colour if box else (0, 0, 0, 0)
[docs] @border_colour.setter def border_colour(self, colour): colour = Colour.coerce(colour, name="Panel.border_colour") if colour is None: # See ``bg_colour`` setter: ``None`` reverts to the theme default # by clearing the per-instance StyleBox override. self.style = None self.queue_redraw() return box = self.style if box is not None and self.__dict__.get("_tsb_style") is not None: box.border_colour = colour else: self.style = StyleBox(bg_colour=(0, 0, 0, 0), border_colour=colour) self.queue_redraw()
@property def border_width(self): box = self.style return box.border_width if box else 0.0
[docs] @border_width.setter def border_width(self, value): box = self.__dict__.get("_tsb_style") if box is not None: box.border_width = value elif value == 0: # Common pattern: panel.border_width = 0: create override with no border self.style = StyleBox(bg_colour=self.bg_colour, border_width=0.0)
[docs] def get_minimum_size(self) -> Vec2: # Panel min = max of children minimums w, h = self.size_floor() for child in self.children: if isinstance(child, Control): ms = child._combined_minimum_size() w = max(w, ms.x) h = max(h, ms.y) return Vec2(w, h)
[docs] def on_draw(self, renderer): x, y, w, h = self._local_rect() box = self.style if box is not None: box.draw(renderer, x, y, w, h) else: if len(self.bg_colour) < 4 or self.bg_colour[3] > 0: renderer.rect((x, y), (w, h), fill=self.bg_colour) renderer.rect((x, y), (w, h), stroke=self.border_colour)
# ============================================================================ # TextEdit: Text input widget # ============================================================================
[docs] class TextEdit(TextSelectionMixin, Control): """Single-line text input with selection and clipboard. Shift with an arrow, Home or End extends a selection, as do a mouse drag and a double click (which takes the word under it); Ctrl+A selects everything. Ctrl+C, Ctrl+X and Ctrl+V copy, cut and paste through the system clipboard, and typing or a paste replaces the selection. Example: edit = TextEdit(placeholder="Enter name...") edit.text_changed.connect(lambda txt: print(txt)) """ pointer_filter = Property(PointerFilter.STOP, hint="How this control takes part in the pointer hit test") cursor_shape = Property(CursorShape.IBEAM, coerce=_cursor_shape, hint="The pointer's shape over this control") _draw_caching = True touch_target = True
[docs] @property def wants_text_input(self) -> bool: return True
text = Property("", hint="Current text", on_change="_clear_selection") placeholder = Property("", hint="Placeholder text") max_length = Property(255, range=(0, 10000), hint="Max text length") # A text field is a first-class focus target: Tab-reachable and eligible for # an overlay's auto-election. Redeclared rather than assigned in # ``__init__``, so a caller's ``focus_mode=`` survives construction. focus_mode = Property(FocusMode.ALL, hint="Whether this control can take keyboard focus") size_x = Property(200.0, range=(0, 10000), hint="Control width", on_change="_on_size_changed") size_y = Property(30.0, range=(0, 10000), hint="Control height", on_change="_on_size_changed") text_colour = ThemeColour("text") placeholder_colour = ThemeColour("placeholder") selection_colour = ThemeColour("selection") style_normal = ThemeStyleBox("input_style_normal") style_focused = ThemeStyleBox("input_style_focused") style_disabled = ThemeStyleBox("input_style_disabled") def __init__(self, text: str = "", placeholder: str = "", **kwargs): super().__init__(**kwargs) self.text = text self.placeholder = placeholder self.font_size = 14.0 self.cursor_pos = len(text) self._cursor_blink = 0.0 # True from a press in the field until its release: moves extend the selection. self._dragging_text = False self.text_changed = Signal() self.text_submitted = Signal() # Fired when an edit session ends with a changed value: on Enter, on Tab, # or on focus loss (Qt ``editingFinished`` parity). Distinct from # ``text_submitted`` (Enter-only) so blur-commit consumers (an inspector # field) and explicit-submit consumers (a REPL/search box) never conflate. self.editing_finished = Signal() # Value at the start of the current edit session; the commit baseline and # the Esc-revert target. Refreshed on focus-in. self._committed_text = text self.focus_entered.connect(self._snapshot_committed) self.focus_exited.connect(self._commit) def _snapshot_committed(self) -> None: """Start an edit session: baseline the committed value (focus-in).""" self._committed_text = self.text def _has_pending_edit(self) -> bool: """True while the buffer differs from the last committed value.""" return self.text != self._committed_text def _commit(self) -> None: """Commit the current buffer if it changed (idempotent). Emits ``editing_finished`` exactly once per real change and re-baselines, so repeated calls (Enter then blur, or a pre-teardown flush) are safe. """ if self.text != self._committed_text: self._committed_text = self.text self.editing_finished.emit(self.text)
[docs] def get_minimum_size(self) -> Vec2: floor_x, floor_y = self.size_floor() w = max(floor_x, 60.0) h = max(floor_y, self.font_size * 1.5 + 8) return Vec2(w, h)
# ---------------------------------------------------------------- # Selection: the shared methods over a caret at a character offset # ---------------------------------------------------------------- @property def _caret(self) -> int: """The caret's character offset, the position the shared selection methods order.""" return self.cursor_pos @_caret.setter def _caret(self, pos: int) -> None: self.cursor_pos = pos def _text_between(self, start: int, end: int) -> str: """The text from offset *start* to *end*.""" text: str = self.text return text[start:end] def _remove_between(self, start: int, end: int) -> None: """Remove the text from offset *start* to *end*.""" self.text = self.text[:start] + self.text[end:]
[docs] def select_all(self) -> None: """Select the whole text, with the caret at its end.""" self._select_start = 0 self._select_end = self.cursor_pos = len(self.text) self.queue_redraw()
[docs] def insert(self, text: str) -> None: """Insert *text* at the caret, replacing the selection, and move the caret past it. Typing, a paste and an IME commit all arrive here, whatever their length. The field holds one line, so each line break in *text* (``\\n``, ``\\r\\n`` or ``\\r``) becomes a space, as a browser's single-line input does with a pasted paragraph. The insertion is clipped to ``max_length``. ``text_changed`` fires once for the whole insertion, and not at all when nothing fits. """ text = text.replace("\r\n", " ").replace("\r", " ").replace("\n", " ") start, end = self._ordered_selection() text = text[: max(0, self.max_length - len(self.text) + end - start)] if not text: return self.text = self.text[:start] + text + self.text[end:] self.cursor_pos = start + len(text) self._cursor_blink = 0.0 self.queue_redraw() self.text_changed.emit(self.text)
def _text_origin_x(self) -> float: """Local x where the first character is drawn.""" box = self._style_box() return self._local_rect()[0] + (box.inset if box is not None else 5.0) def _offset_at(self, position) -> int: """Character offset whose caret gap sits nearest the screen *position*.""" local_x = self.to_local(position)[0] return caret_index_at(self.text, local_x - self._text_origin_x(), self.font_size / LOGICAL_EM)
[docs] def on_gui_input(self, event): if isinstance(event, PointerEvent): return self._on_pointer(event) if not self.focused: return None if isinstance(event, TextEvent): if not event.composing: self.insert(event.text) return True # A key acts as it goes down, auto-repeat included, and the press is # claimed: the field owns it, so its release comes back here and no # game action bound to the same key fires while the player types. if not isinstance(event, KeyEvent): return None if not event.pressed: return None claimed = self._handle_key(event) if claimed: self._cursor_blink = 0.0 self.queue_redraw() return claimed
def _on_pointer(self, event: PointerEvent) -> bool | None: """A press places the caret (two select a word) and a drag from it selects. Shift on the press extends the selection to it instead, from the anchor it already has or from the caret. Claimed: the press placed a caret in this field and belongs to it. """ if event.button in Pointer.PRIMARY and event.phase is PointerPhase.DOWN: if not self.is_hit(event): return None self.grab_focus() if event.click_count >= 2 and not event.shift: self._clear_selection() self.cursor_pos = self._offset_at(event.position) span = _word_span(self.text, self.cursor_pos) if span is not None: self._select_start, self._select_end = span self.cursor_pos = span[1] else: with self._cursor_motion(event.shift): self.cursor_pos = self._offset_at(event.position) self._start_selection() self._dragging_text = True self.capture_pointer() self._cursor_blink = 0.0 self.queue_redraw() return True if not self._dragging_text: return None if event.phase is PointerPhase.MOVE: self.cursor_pos = self._offset_at(event.position) self._update_selection_end() self.queue_redraw() return True if event.phase in (PointerPhase.UP, PointerPhase.CANCEL) and event.button in Pointer.PRIMARY: self._dragging_text = False self.release_pointer() return True return None def _handle_key(self, event: KeyEvent) -> bool | None: """Act on a key press; True when the field used it. Every binding names the modifiers it wants and no others, as MultiLineTextEdit's do, except Shift on a motion, which decides whether that motion extends the selection. """ key = event.key mods = (event.ctrl, event.shift, event.alt) shortcut = {Key.A: self.select_all, Key.C: self.copy, Key.X: self.cut, Key.V: self.paste}.get(key) if mods == _CTRL and shortcut is not None: shortcut() return True if mods in _WORD_MOTION and key in (Key.LEFT, Key.RIGHT): with self._cursor_motion(event.shift): step = _word_left if key is Key.LEFT else _word_right self.cursor_pos = step(self.text, self.cursor_pos) return True # Ctrl+Home and Ctrl+End reach the ends of the text, which on one line are Home and End. if (mods in _MOTION and key in (Key.LEFT, Key.RIGHT, Key.HOME, Key.END)) or ( mods in _WORD_MOTION and key in (Key.HOME, Key.END) ): # Without Shift, Left and Right collapse a selection onto its edge. start, end = self._ordered_selection() with self._cursor_motion(event.shift): if key is Key.HOME: self.cursor_pos = 0 elif key is Key.END: self.cursor_pos = len(self.text) elif start != end and not event.shift: self.cursor_pos = start if key is Key.LEFT else end elif key is Key.LEFT: self.cursor_pos = max(0, self.cursor_pos - 1) else: self.cursor_pos = min(len(self.text), self.cursor_pos + 1) return True if key in (Key.BACKSPACE, Key.DELETE) and mods in _MOTION: if self._has_selection(): self._delete_selection() elif key is Key.BACKSPACE and self.cursor_pos > 0: self.text = self.text[: self.cursor_pos - 1] + self.text[self.cursor_pos :] self.cursor_pos -= 1 elif key is Key.DELETE and self.cursor_pos < len(self.text): self.text = self.text[: self.cursor_pos] + self.text[self.cursor_pos + 1 :] else: return True self.text_changed.emit(self.text) return True if key is Key.ENTER: # Enter is an explicit submit AND ends the edit session. text_submitted # stays Enter-only; _commit fires editing_finished iff the value changed. self.text_submitted.emit(self.text) self._commit() return True if key is Key.ESCAPE: # Revert an in-progress edit. Outside a dialog the revert is the whole # of Escape and the key is claimed; inside one it also dismisses, so # the key is left for the dialog, and an unchanged field leaves it # for dismissal either way. if self.text != self._committed_text: self.text = self._committed_text self.cursor_pos = len(self.text) self.queue_redraw() self.text_changed.emit(self.text) return None if self.tree._ui._overlay_scope() is not None else True return None return None
[docs] def on_update(self, dt: float): old_visible = self._cursor_blink < 0.5 self._cursor_blink += dt if self._cursor_blink > 1.0: self._cursor_blink = 0.0 if old_visible != (self._cursor_blink < 0.5): self.queue_redraw()
def _style_box(self): """The box the field draws in its current state, or None for a bare field.""" if self.disabled: return self.style_disabled return self.style_focused if self.focused else self.style_normal
[docs] def on_draw(self, renderer): x, y, w, h = self._local_rect() box = self._style_box() inset = box.inset if box is not None else 5.0 if box is not None: box.draw(renderer, x, y, w, h) scale = self.font_size / LOGICAL_EM text_x = x + inset if self._has_selection(): start, end = self._ordered_selection() sel_x = text_x + renderer.text_width(self.text[:start], scale) sel_w = renderer.text_width(self.text[start:end], scale) renderer.rect((sel_x, y + inset), (sel_w, h - 2 * inset), fill=self.selection_colour) # Text or placeholder text_to_draw = self.text if self.text else self.placeholder if text_to_draw: colour = self.text_colour if self.text else self.placeholder_colour text_y = y + (h - self.font_size) / 2 renderer.text(text_to_draw, (text_x, text_y), fill=colour, scale=scale) # Cursor: 2px accent-coloured bar, blinks at 1Hz if self.focused and self._cursor_blink < 0.5: cursor_x = text_x + renderer.text_width(self.text[: self.cursor_pos], scale) accent = self.get_theme().accent renderer.rect((cursor_x, y + inset), (2, h - 2 * inset), fill=accent)
# ============================================================================ # Slider: Numeric slider # ============================================================================
[docs] class Slider(Control): """Horizontal slider for numeric input. Example: slider = Slider(0, 100, value=50) slider.value_changed.connect(lambda v: print(v)) """ pointer_filter = Property(PointerFilter.STOP, hint="How this control takes part in the pointer hit test") _draw_caching = True touch_target = True min_value = Property(0.0, hint="Minimum value") max_value = Property(100.0, hint="Maximum value") value = Property(50.0, hint="Current value") size_x = Property(200.0, range=(0, 10000), hint="Control width", on_change="_on_size_changed") size_y = Property(20.0, range=(0, 10000), hint="Control height", on_change="_on_size_changed") bg_colour = ThemeColour("bg_light") fill_colour = ThemeColour("slider_fill") handle_colour = ThemeColour("slider_handle") handle_hover_colour = ThemeColour("text_bright") def __init__(self, min_val: float = 0, max_val: float = 100, value: float = None, **kwargs): super().__init__(**kwargs) if min_val > max_val: log.warning("Slider created with invalid range: min=%s > max=%s", min_val, max_val) # Only apply positional defaults if not already set via Property kwargs (deserialization) if "min_value" not in kwargs: self.min_value = min_val if "max_value" not in kwargs: self.max_value = max_val if value is not None: self.value = value elif "value" not in kwargs: self.value = (self.min_value + self.max_value) / 2 self.step = 1.0 self._dragging = False # The placement and width a drag is measured against: see _update_from_mouse. self._drag_frame: tuple[tuple[float, float, float, float, float, float], float] | None = None self.value_changed = Signal()
[docs] def get_minimum_size(self) -> Vec2: floor_x, floor_y = self.size_floor() return Vec2(max(floor_x, 60.0), max(floor_y, 20.0))
def _get_value_ratio(self) -> float: if self.max_value == self.min_value: return 0.0 return (self.value - self.min_value) / (self.max_value - self.min_value) def _set_value_from_ratio(self, ratio: float): ratio = max(0.0, min(1.0, ratio)) new_value = self.min_value + ratio * (self.max_value - self.min_value) if self.step > 0: new_value = round(new_value / self.step) * self.step if new_value != self.value: self.value = new_value self.value_changed.emit(self.value)
[docs] def on_gui_input(self, event): # A press on the track sets the value and is claimed; so is the release # that ends the drag it started. # Carried rather than returned: a press starts the drag AND is its first # motion, so the tracking below runs on the same event. if not isinstance(event, PointerEvent): return None claimed = False if event.button in Pointer.PRIMARY: if event.phase is PointerPhase.DOWN and self.is_hit(event): self._dragging = True self._drag_frame = (self._draw_affine(), self._local_rect()[2]) # The drag outlives the track: a thumb the pointer has run off # keeps following the pointer. self.capture_pointer() claimed = True elif event.phase in (PointerPhase.UP, PointerPhase.CANCEL): claimed = self._dragging if self._dragging: self.release_pointer() self._dragging = False self._drag_frame = None if self._dragging: self._update_from_mouse(event.position) return claimed
def _update_from_mouse(self, mouse_pos): """Set the value from where the pointer is along the track, as the track stood at the press. A drag is measured in the frame it started in, so a slider whose value moves it, one bound to the tree's ``ui_scale`` above all, maps a still pointer to a still value instead of chasing its own track as it rescales. A slider that does not move reads exactly as it would live. """ if self._drag_frame is None: _x, _y, w, _h = self._local_rect() local_x = float(self.to_local(mouse_pos).x) else: (a, b, c, d, tx, ty), w = self._drag_frame det = a * d - b * c if det == 0.0: return local_x = (d * (float(mouse_pos[0]) - tx) - b * (float(mouse_pos[1]) - ty)) / det self._set_value_from_ratio(local_x / w)
[docs] def on_draw(self, renderer): x, y, w, h = self._local_rect() # Track background renderer.rect((x, y + h / 3), (w, h / 3), fill=self.bg_colour) # Filled portion ratio = self._get_value_ratio() fill_w = w * ratio renderer.rect((x, y + h / 3), (fill_w, h / 3), fill=self.fill_colour) # Handle handle_x = x + fill_w - 5 handle_colour = self.handle_hover_colour if self.mouse_over else self.handle_colour renderer.rect((handle_x, y), (10, h), fill=handle_colour)
# ============================================================================ # ProgressBar: Progress display # ============================================================================
[docs] class ProgressBar(Control): """Progress bar display. Example: bar = ProgressBar(max_value=100) bar.value = 75 """ pointer_filter = Property(PointerFilter.IGNORE, hint="How this control takes part in the pointer hit test") _draw_caching = True min_value = Property(0.0, hint="Minimum value") max_value = Property(100.0, hint="Maximum value") value = Property(0.0, hint="Current value") size_x = Property(200.0, range=(0, 10000), hint="Control width", on_change="_on_size_changed") size_y = Property(20.0, range=(0, 10000), hint="Control height", on_change="_on_size_changed") bg_colour = ThemeColour("bg_dark") fill_colour = ThemeColour("success") def __init__(self, **kwargs): super().__init__(**kwargs) self.show_percentage = True self.font_size = 12.0
[docs] def get_minimum_size(self) -> Vec2: floor_x, floor_y = self.size_floor() return Vec2(max(floor_x, 60.0), max(floor_y, 16.0))
[docs] @property def percentage(self) -> float: """Progress as a 0.0–1.0 ratio of ``value`` between ``min_value`` and ``max_value``. Returns 0.0 when ``max_value <= min_value``. """ if self.max_value <= self.min_value: return 0.0 ratio = (self.value - self.min_value) / (self.max_value - self.min_value) return max(0.0, min(1.0, ratio))
[docs] def on_draw(self, renderer): x, y, w, h = self._local_rect() # Background renderer.rect((x, y), (w, h), fill=self.bg_colour) # Fill pct = self.percentage fill_w = w * pct renderer.rect((x, y), (fill_w, h), fill=self.fill_colour) # Percentage text if self.show_percentage: text = f"{int(pct * 100)}%" scale = self.font_size / 16.0 text_w = renderer.text_width(text, scale) text_x = x + (w - text_w) / 2 text_y = y + (h - self.font_size) / 2 renderer.text(text, (text_x, text_y), fill=Colour.WHITE, scale=scale)