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 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_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
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)