Source code for simvx.core.ui.advanced

"""Advanced widgets -- CheckBox, SpinBox, DropDown, RadioButton."""

import logging

from ..descriptors import Property
from ..input.controls import Key, Pointer
from ..input.enums import PointerPhase
from ..input.events import KeyEvent, PointerEvent, ScrollEvent, TextEvent
from ..input.state import Input
from ..math.types import Vec2
from ..signals import Signal
from ..text.measure import LOGICAL_EM, text_minimum_width
from .core import Control, FocusMode, ThemeColour
from .enums import PointerFilter
from .widgets import Button

log = logging.getLogger(__name__)

#: What a spin box's edit session accepts as typed text.
_NUMBER_CHARS = "0123456789.-+e"

__all__ = [
    "CheckBox",
    "SpinBox",
    "DropDown",
    "RadioButton",
]

# ============================================================================
# CheckBox -- Toggle control with label
# ============================================================================


[docs] class CheckBox(Control): """Toggle control with a check box and label text. Example: cb = CheckBox("Enable Feature", checked=True) cb.toggled.connect(lambda on: print("Checked:", on)) """ pointer_filter = Property(PointerFilter.STOP, hint="How this control takes part in the pointer hit test") touch_target = True text = Property("", hint="Checkbox label") checked = Property(False, hint="Whether the checkbox is checked") text_colour = ThemeColour("text") check_colour = ThemeColour("check_colour") box_colour = ThemeColour("check_box") hover_colour = ThemeColour("btn_hover") def __init__(self, text: str = "", checked: bool = False, on_toggle=None, **kwargs): super().__init__(**kwargs) self.text = text self.checked = checked self.font_size = 14.0 self.toggled = Signal() if on_toggle: self.toggled.connect(on_toggle) self.autosize_to_content()
[docs] def get_minimum_size(self) -> Vec2: floor_x, floor_y = self.size_floor() box_size = 16 gap = 6 text_width = text_minimum_width(self.text, self.font_size / LOGICAL_EM) w = max(floor_x, box_size + gap + text_width) h = max(floor_y, 24.0) 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 activate(self) -> bool: """Toggle, the way a click does: ``ui_accept`` flips a focused check box. Matches Godot (``CheckBox`` is a button, so ``ui_accept`` toggles it) and Unity (``Toggle`` implements ``ISubmitHandler``). Note the default ``focus_mode`` is ``NONE``, so this only ever runs for a check box an author made focusable. """ self.checked = not self.checked self.toggled.emit(self.checked) return True
[docs] def on_gui_input(self, event): # Claimed when it acts, so a click that toggles this widget is not also # a click on the panel or row it sits in. if not isinstance(event, PointerEvent): return None if event.button in Pointer.PRIMARY and event.phase is PointerPhase.DOWN: if self.is_hit(event): self.activate() return True return None
[docs] def on_draw(self, renderer): x, y, w, h = self._local_rect() box_size = 16 box_x = x box_y = y + (h - box_size) / 2 # Box outline outline = self.hover_colour if self.mouse_over else self.box_colour renderer.rect((box_x, box_y), (box_size, box_size), stroke=outline) # Check mark (inner filled rect) if self.checked: pad = 3 renderer.rect((box_x + pad, box_y + pad), (box_size - pad * 2, box_size - pad * 2), fill=self.check_colour) # Label if self.text: gap = 6 scale = self.font_size / 16.0 text_x = box_x + box_size + gap text_y = y + (h - self.font_size) / 2 renderer.text(self.text, (text_x, text_y), fill=self.text_colour, scale=scale)
# ============================================================================ # SpinBox -- Numeric input with up/down buttons # ============================================================================
[docs] class SpinBox(Control): """Blender-style numeric field: value always visible, arrows on sides, drag to scrub. Interaction modes: - **Display**: Shows formatted value centred. Arrows ``<`` ``>`` on hover. - **Drag**: Mouse-down + horizontal drag scrubs value by ``step`` per pixel. - **Click**: Mouse-down + release without dragging enters edit mode. - **Edit**: Type a new value. Enter commits, Escape cancels. - **Scroll**: Mouse wheel increments/decrements by ``step``. Example:: spin = SpinBox(min_value=0, max_value=100, value=50, step=5) spin.value_changed.connect(lambda v: print("Value:", v)) """ pointer_filter = Property(PointerFilter.STOP, hint="How this control takes part in the pointer hit test") touch_target = True # A click opens an edit session, which needs the keyboard: the spin box # takes focus from a press and from code, and is not a Tab stop. focus_mode = Property(FocusMode.CLICK, hint="Whether this control can take keyboard focus") 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(120.0, range=(0, 10000), hint="Control width", on_change="_on_size_changed") size_y = Property(28.0, range=(0, 10000), hint="Control height", on_change="_on_size_changed") text_colour = ThemeColour("text") bg_colour = ThemeColour("bg_input") border_colour = ThemeColour("input_border") arrow_colour = ThemeColour("text_label") focus_colour = ThemeColour("input_focus") def __init__(self, value: float = 0, step: float = 1, **kwargs): super().__init__(**kwargs) # The bounds arrive through the kwarg gate as ``min_value`` / ``max_value`` # and are already in place here, so the starting value is clamped against # whatever the caller asked for rather than against a parameter default. self.value = max(self.min_value, min(self.max_value, value)) self.step = step self.font_size = 14.0 self._input_text = "" self._editing = False self._cursor_blink = 0.0 self._dragging = False self._drag_origin_x = 0.0 self._drag_started = False # True once drag exceeds threshold self._drag_accum = 0.0 # sub-pixel drag accumulator self.value_changed = Signal() self.focus_exited.connect(self._on_focus_exited)
[docs] @property def wants_text_input(self) -> bool: """Whether the spin box takes typing: only while an edit session is open.""" return self._editing
[docs] def get_minimum_size(self) -> Vec2: floor_x, floor_y = self.size_floor() return Vec2(max(floor_x, 80.0), max(floor_y, 28.0))
# ── Arrow button width (each side) ──────────────────────────────── def _button_width(self) -> float: return 16 # ── Value management ────────────────────────────────────────────── def _set_value(self, new_val: float): """Clamp and set value, emitting signal on change.""" new_val = max(self.min_value, min(self.max_value, new_val)) if new_val != self.value: self.value = new_val self.value_changed.emit(self.value) self.queue_redraw() def _format_value(self) -> str: """Format current value for display.""" if self.step >= 1 and self.value == int(self.value): return str(int(self.value)) return f"{self.value:.2f}" # ── Edit mode ───────────────────────────────────────────────────── def _enter_edit(self): """Switch to text-entry mode, focused, with an empty entry.""" self._editing = True self._input_text = "" self._cursor_blink = 0.0 if self.focused: # Focus came with the press, before the session opened, so the # platform's text input is opened here rather than by the focus change. Input.begin_text_input(self.get_global_rect()) else: self.grab_focus() self.queue_redraw() def _commit_edit(self): """Apply typed text as new value.""" self._editing = False if self._input_text: try: self._set_value(float(self._input_text)) except ValueError: pass self._input_text = "" self.release_focus() self.queue_redraw() def _on_focus_exited(self): """Commit an open edit when focus moves away.""" if self._editing: self._commit_edit() # ── Tick (cursor blink) ───────────────────────────────────────────
[docs] def on_update(self, dt: float): if self._editing: 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()
# ── Input handling ────────────────────────────────────────────────
[docs] def on_gui_input(self, event): x, y, w, h = self._local_rect() bw = self._button_width() # --- Mouse wheel (always active) --- # Claimed, so a spin box inside a scrolling list steps its value # instead of letting the list move under the cursor. if isinstance(event, ScrollEvent): self._set_value(self.value + (self.step if event.scroll[1] > 0 else -self.step)) return True if isinstance(event, (KeyEvent, TextEvent)): return self._on_edit_key(event) if not isinstance(event, PointerEvent): return None # --- Mouse press --- # Every press this widget acts on is claimed: a step, a drag, or an edit # session belongs to the spin box, not to the row it sits in. A press # outside it only commits an open edit, and is left for whoever it hit. if event.button in Pointer.PRIMARY and event.phase is PointerPhase.DOWN: if not self.is_hit(event): if self._editing: self._commit_edit() return None px = self.to_local(event.position).x # From here the press is inside the box, so every exit claims it. # Left arrow button if px < x + bw: self._set_value(self.value - self.step) return True # Right arrow button if px > x + w - bw: self._set_value(self.value + self.step) return True # Value area: begin potential drag or click if self._editing: return True # already editing, let keyboard handle it self._dragging = True self._drag_started = False self._drag_origin_x = px self._drag_accum = 0.0 self.capture_pointer() return True # --- Mouse release, and the cancel that ends the press without one --- if event.button in Pointer.PRIMARY and self._dragging and event.phase in (PointerPhase.UP, PointerPhase.CANCEL): self._dragging = False self.release_pointer() if event.phase is PointerPhase.UP and not self._drag_started: # Click without drag: enter edit mode. A cancel is not a click, # so an alt-tab mid-press must not open the editor. self._enter_edit() return True # --- Mouse move while dragging --- if self._dragging and event.phase is PointerPhase.MOVE: px = self.to_local(event.position).x if not self._drag_started: # A press that has not yet travelled the drag threshold is # still a click that may open the editor. if not event.dragging: return None self._drag_started = True self._drag_accum = 0.0 # Scrub: each pixel of movement = fraction of step self._drag_accum += px - self._drag_origin_x self._drag_origin_x = px # 1 pixel = step * sensitivity (finer for large ranges) rng = self.max_value - self.min_value if rng > 0: sensitivity = max(0.1, rng / 500.0) if self.step < 1 else self.step else: sensitivity = self.step steps_moved = self._drag_accum / max(1.0, 4.0 / sensitivity) if abs(steps_moved) >= 1.0: increment = int(steps_moved) * self.step self._drag_accum -= int(steps_moved) * max(1.0, 4.0 / sensitivity) self._set_value(self.value + increment) return None
def _on_edit_key(self, event: KeyEvent | TextEvent) -> bool | None: """The keyboard half: only live while an edit session is open.""" if not self._editing: return None if isinstance(event, TextEvent): typed = "".join(c for c in event.text if c in _NUMBER_CHARS) if typed and not event.composing: self._input_text += typed self.queue_redraw() return True # A key acts as it goes down and the press is claimed, so its release # comes back here rather than to whatever holds focus by then. if not event.pressed: return None if event.key is Key.ENTER: self._commit_edit() elif event.key is Key.ESCAPE: self._editing = False self._input_text = "" self.release_focus() self.queue_redraw() elif event.key is Key.BACKSPACE: self._input_text = self._input_text[:-1] self.queue_redraw() elif event.key is Key.UP: self._set_value(self.value + self.step) self._input_text = self._format_value() elif event.key is Key.DOWN: self._set_value(self.value - self.step) self._input_text = self._format_value() else: return None return True # ── Drawing ───────────────────────────────────────────────────────
[docs] def on_draw(self, renderer): x, y, w, h = self._local_rect() bw = self._button_width() show_arrows = self.mouse_over or self._dragging # Background renderer.rect((x, y), (w, h), fill=self.bg_colour) # Border border = self.focus_colour if (self.focused or self._dragging) else self.border_colour renderer.rect((x, y), (w, h), stroke=border) # Arrow buttons (visible on hover) if show_arrows and not self._editing: arrow_colour = self.arrow_colour # Left arrow < renderer.text( "<", (x + bw * 0.25, y + (h - self.font_size) / 2), fill=arrow_colour, scale=self.font_size / 16.0 ) # Right arrow > gt_w = renderer.text_width(">", self.font_size / 16.0) renderer.text( ">", (x + w - bw * 0.75 - gt_w * 0.25, y + (h - self.font_size) / 2), fill=arrow_colour, scale=self.font_size / 16.0, ) # Value / input text scale = self.font_size / 16.0 if self._editing: display = self._input_text # Selection highlight when nothing typed yet (value will be replaced) if not self._input_text: sel_colour = self.get_theme().selection orig = self._format_value() orig_w = renderer.text_width(orig, scale) orig_x = x + (w - orig_w) / 2 renderer.rect((orig_x - 1, y + 2), (orig_w + 2, h - 4), fill=sel_colour) display = orig else: display = self._format_value() text_w = renderer.text_width(display, scale) text_x = x + (w - text_w) / 2 text_y = y + (h - self.font_size) / 2 renderer.text(display, (text_x, text_y), fill=self.text_colour, scale=scale) # Blinking cursor when editing if self._editing and self._cursor_blink < 0.5: accent = self.get_theme().accent if self._input_text: cursor_x = text_x + text_w else: cursor_x = text_x + text_w # after the selected-value text renderer.rect((cursor_x + 1, y + 4), (2, h - 8), fill=accent)
# ============================================================================ # DropDown -- Selection from a list of items # ============================================================================ # ============================================================================ # RadioButton -- Mutually exclusive selection within a group # ============================================================================
[docs] class RadioButton(Button): """One choice among several: a ``Button`` drawn as a circle and a label. Exclusivity comes from a :class:`ButtonGroup`, as for any toggle button: give every choice the same group and checking one unchecks the rest. A click or ``ui_accept`` checks a radio button and never unchecks it, so a choice once made stays made whatever the group's ``allow_unchecked``; code can still set ``checked`` to False. ``toggled(checked)`` reports the player's changes, on the button that lost the choice as well as the one that won it. Example: opts = ButtonGroup() r1 = RadioButton("Option A", group=opts, checked=True) r2 = RadioButton("Option B", group=opts) r1.toggled.connect(lambda on: print("A:", on)) """ text = Property("", hint="Radio button label") font_size = Property(14.0, range=(8, 72), hint="Font size") # A radio button is reached by the pointer; an author who wants it in the # Tab order sets ``focus_mode`` on it. focus_mode = Property(FocusMode.NONE, hint="Whether this control can take keyboard focus") toggle_mode = Property(True, hint="A click checks the radio button", on_change="_on_toggle_mode") text_colour = ThemeColour("text") circle_colour = ThemeColour("check_box") dot_colour = ThemeColour("check_colour") def __init__(self, text: str = "", on_press=None, **kwargs): super().__init__(text, on_press, **kwargs)
[docs] def get_minimum_size(self) -> Vec2: floor_x, floor_y = self.size_floor() circle_size = 16 gap = 6 text_width = text_minimum_width(self.text, self.font_size / LOGICAL_EM) w = max(floor_x, circle_size + gap + text_width) h = max(floor_y, 24.0) return Vec2(w, h)
[docs] def activate(self) -> bool: """Check this option, the way a click does; an already-checked one holds. Unchecking is not an option a radio button offers, so the key is consumed either way. Default ``focus_mode`` is ``NONE``: only a radio button an author made focusable is ever activated from the keyboard. """ if not self.checked: self._toggle() self.pressed.emit() return True
[docs] def on_draw(self, renderer): x, y, w, h = self._local_rect() radius = 8 cx = x + radius cy = y + h / 2 # Outer circle renderer.circle((cx, cy), radius, stroke=(1.0, 1.0, 1.0, 1.0)) # Filled dot when checked if self.checked: renderer.circle((cx, cy), radius - 3, fill=(1.0, 1.0, 1.0, 1.0)) # Label if self.text: gap = 6 scale = self.font_size / LOGICAL_EM text_x = x + radius * 2 + gap text_y = y + (h - self.font_size) / 2 renderer.text(self.text, (text_x, text_y), fill=self.text_colour, scale=scale)