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 logging
from enum import StrEnum

from ..descriptors import Property

from ..signals import Signal
from ..math.types import Vec2
from .core import Colour, Control, FocusMode, ThemeColour, ThemeStyleBox
from .theme import StyleBox
from ..input.enums import MouseButton

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 = "center" """ _draw_caching = True text = Property("", hint="Label text") font_size = Property(14.0, range=(8, 72), hint="Font size") text_colour = ThemeColour("text") bg_colour = Colour((0.0, 0.0, 0.0, 0.0)) alignment = Property("left", enum=["left", "center", "right"], hint="Horizontal text alignment") vertical_alignment = Property("center", enum=["top", "center", "bottom"], 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_x" not in kwargs and "size_y" not in kwargs: self._update_size()
[docs] def get_minimum_size(self) -> Vec2: char_width = self.font_size * 0.6 lines = self.text.split("\n") if self.text else [""] longest = max((len(line) for line in lines), default=0) w = max(self.min_size.x, longest * char_width) # 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(self.min_size.y, self.font_size * 1.5) else: h = max(self.min_size.y, self.font_size * 1.2 * n + self.font_size * 0.3) return Vec2(w, h)
def _update_size(self): """Auto-size based on text content.""" self.size = self.get_minimum_size()
[docs] def on_draw(self, renderer): if not self.text: return x, y, w, h = self.get_global_rect() # Optional background (transparent by default: no rectangle over content) if len(self.bg_colour) >= 4 and self.bg_colour[3] > 0: renderer.draw_rect((x, y), (w, h), colour=self.bg_colour, filled=True) scale = self.font_size / 14.0 lines = self.text.split("\n") if self.autoshrink: min_scale = self.min_font_size / 14.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 = 14.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 == "top": first_y = y elif self.vertical_alignment == "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 == "center": # 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 == "right": line_x = x + w - line_w else: line_x = x line_y = first_y + i * line_pitch renderer.draw_text(line, (line_x, line_y), colour=self.text_colour, scale=scale)
# ============================================================================ # Button: Clickable button # ============================================================================
[docs] class Button(Control): """Clickable button with hover/press states. Example: button = Button("Click Me", on_press=my_handler) button.pressed.connect(another_handler) """ _draw_caching = True
[docs] class VisualState(StrEnum): """Canonical Button visual states for theme resolution and overrides.""" NORMAL = "normal" HOVER = "hover" PRESSED = "pressed" DISABLED = "disabled" FOCUSED = "focused"
text = Property("Button", hint="Button text") font_size = Property(14.0, range=(8, 72), hint="Font size") 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") 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") border_colour = ThemeColour("btn_border") def __init__(self, text: str = "Button", on_press=None, **kwargs): super().__init__(**kwargs) self.text = text self._is_pressed = False self._visual_state_override: Button.VisualState | None = None self.focus_mode = FocusMode.CLICK self.pressed = Signal() self.button_down = Signal() self.button_up = Signal() if on_press: self.pressed.connect(on_press) # Auto-size to content if no explicit size was given if "size_x" not in kwargs and "size_y" not in kwargs: self.size = self.get_minimum_size() @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"): 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
[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"), 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: padding_x, padding_y = 16.0, 8.0 char_width = self.font_size * 0.6 text_w = len(self.text) * char_width if self.text else 0 w = max(self.min_size.x, text_w + padding_x * 2) h = max(self.min_size.y, self.font_size + padding_y * 2) return Vec2(w, h)
def _on_gui_input(self, event): if event.button == MouseButton.LEFT: if event.pressed and self.is_point_inside(event.position): self._is_pressed = True self.queue_redraw() self.button_down() elif not event.pressed and self._is_pressed: self._is_pressed = False self.queue_redraw() self.button_up() if self.is_point_inside(event.position): self.pressed() 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.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), } 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 # Apply per-instance ThemeColour overrides on top of the theme StyleBox bg_val = d.get(bg_attr) if bg_attr else None border_val = d.get("_tc_border_colour") if bg_val is not None or border_val is not None: box = StyleBox( bg_colour=bg_val or box.bg_colour, bg_gradient=box.bg_gradient, border_colour=border_val or box.border_colour, border_top=box.border_top, border_bottom=box.border_bottom, border_left=box.border_left, border_right=box.border_right, border_width=box.border_width, content_margin=box.content_margin, ) return box
[docs] def on_draw(self, renderer): x, y, w, h = self.get_global_rect() box = self._get_current_style() if box is not None: box.draw(renderer, x, y, w, h) # Text (centered) if self.text: scale = self.font_size / 14.0 tc = self.text_disabled_colour if self.disabled else self.text_colour text_width = renderer.text_width(self.text, scale) text_x = x + (w - text_width) / 2 text_y = y + (h - self.font_size) / 2 renderer.draw_text(self.text, (text_x, text_y), colour=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.min_size.x, self.min_size.y for child in self.children: if isinstance(child, Control): ms = child.get_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.get_global_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.draw_rect((x, y), (w, h), colour=self.bg_colour, filled=True) renderer.draw_rect((x, y), (w, h), colour=self.border_colour)
# ============================================================================ # TextEdit: Text input widget # ============================================================================
[docs] class TextEdit(Control): """Single-line text input. Example: edit = TextEdit(placeholder="Enter name...") edit.text_changed.connect(lambda txt: print(txt)) """ _draw_caching = True text = Property("", hint="Current text") placeholder = Property("", hint="Placeholder text") max_length = Property(255, range=(0, 10000), hint="Max text length") text_colour = ThemeColour("text") placeholder_colour = ThemeColour("placeholder") 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.max_length = 255 self.font_size = 14.0 self.cursor_pos = len(text) self._cursor_blink = 0.0 # Click position awaiting caret placement. Resolved at draw time because # mapping a pixel x to a character offset needs renderer.text_width # (mirrors MultiLineTextEdit._pending_click). self._pending_click_x: float | None = None self.text_changed = Signal() self.text_submitted = Signal() self.size = Vec2(200, 30)
[docs] def get_minimum_size(self) -> Vec2: w = max(self.min_size.x, 60.0) h = max(self.min_size.y, self.font_size * 1.5 + 8) return Vec2(w, h)
def _on_gui_input(self, event): # Focus + position caret on click if event.button == MouseButton.LEFT and event.pressed: if self.is_point_inside(event.position): self.set_focus() self._pending_click_x = event.position.x self.queue_redraw() if not self.focused: return if event.key == "backspace" and not event.pressed: if self.cursor_pos > 0: self.text = self.text[: self.cursor_pos - 1] + self.text[self.cursor_pos :] self.cursor_pos -= 1 self.queue_redraw() self.text_changed.emit(self.text) elif event.key == "delete" and not event.pressed: if self.cursor_pos < len(self.text): self.text = self.text[: self.cursor_pos] + self.text[self.cursor_pos + 1 :] self.queue_redraw() self.text_changed.emit(self.text) elif event.key == "left" and not event.pressed: self.cursor_pos = max(0, self.cursor_pos - 1) self.queue_redraw() elif event.key == "right" and not event.pressed: self.cursor_pos = min(len(self.text), self.cursor_pos + 1) self.queue_redraw() elif event.key == "home" and not event.pressed: self.cursor_pos = 0 self.queue_redraw() elif event.key == "end" and not event.pressed: self.cursor_pos = len(self.text) self.queue_redraw() elif event.key == "enter" and not event.pressed: self.text_submitted.emit(self.text) elif event.char and len(event.char) == 1: if len(self.text) < self.max_length: self.text = self.text[: self.cursor_pos] + event.char + self.text[self.cursor_pos :] self.cursor_pos += 1 self.queue_redraw() self.text_changed.emit(self.text) def _offset_at_x(self, renderer, click_x: float, text_origin_x: float, scale: float) -> int: """Character offset whose caret gap sits nearest ``click_x``. Walks character boundaries comparing each cumulative text width to the click's offset from the text origin, returning the closest gap. Clamps to 0 before the text and to ``len(text)`` past its end. """ text = self.text if not text: return 0 rel = click_x - text_origin_x if rel <= 0: return 0 best_off, best_dist = 0, rel for i in range(1, len(text) + 1): dist = abs(renderer.text_width(text[:i], scale) - rel) if dist < best_dist: best_dist, best_off = dist, i return best_off
[docs] def on_process(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()
[docs] def on_draw(self, renderer): x, y, w, h = self.get_global_rect() # StyleBox-based background + border if self.disabled: box = self.style_disabled elif self.focused: box = self.style_focused else: box = self.style_normal inset = box.inset if box is not None else 5.0 if box is not None: box.draw(renderer, x, y, w, h) # Text or placeholder scale = self.font_size / 14.0 # Resolve a deferred click into a caret offset (needs renderer.text_width). if self._pending_click_x is not None: self.cursor_pos = self._offset_at_x(renderer, self._pending_click_x, x + inset, scale) self._pending_click_x = None 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_x = x + inset text_y = y + (h - self.font_size) / 2 renderer.draw_text(text_to_draw, (text_x, text_y), colour=colour, scale=scale) # Cursor: 2px accent-coloured bar, blinks at 1Hz if self.focused and self._cursor_blink < 0.5: cursor_x = x + inset + renderer.text_width(self.text[: self.cursor_pos], scale) cursor_y = y + inset cursor_h = h - 2 * inset accent = self.get_theme().accent renderer.draw_rect((cursor_x, cursor_y), (2, cursor_h), colour=accent, filled=True)
# ============================================================================ # 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)) """ _draw_caching = True min_value = Property(0.0, hint="Minimum value") max_value = Property(100.0, hint="Maximum value") value = Property(50.0, hint="Current value") 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 self.value_changed = Signal() self.size = Vec2(200, 20)
[docs] def get_minimum_size(self) -> Vec2: return Vec2(max(self.min_size.x, 60.0), max(self.min_size.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) def _on_gui_input(self, event): if event.button == MouseButton.LEFT: if event.pressed and self.is_point_inside(event.position): self._dragging = True self._update_from_mouse(event.position) elif not event.pressed: self._dragging = False if self._dragging and event.position: self._update_from_mouse(event.position) def _update_from_mouse(self, mouse_pos): x, _, w, _ = self.get_global_rect() px = mouse_pos.x if hasattr(mouse_pos, "x") else mouse_pos[0] ratio = (px - x) / w self._set_value_from_ratio(ratio)
[docs] def on_draw(self, renderer): x, y, w, h = self.get_global_rect() # Track background renderer.draw_rect((x, y + h / 3), (w, h / 3), colour=self.bg_colour, filled=True) # Filled portion ratio = self._get_value_ratio() fill_w = w * ratio renderer.draw_rect((x, y + h / 3), (fill_w, h / 3), colour=self.fill_colour, filled=True) # Handle handle_x = x + fill_w - 5 handle_colour = self.handle_hover_colour if self.mouse_over else self.handle_colour renderer.draw_rect((handle_x, y), (10, h), colour=handle_colour, filled=True)
# ============================================================================ # ProgressBar: Progress display # ============================================================================
[docs] class ProgressBar(Control): """Progress bar display. Example: bar = ProgressBar(0, 100) bar.value = 75 """ _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") bg_colour = ThemeColour("bg_dark") fill_colour = ThemeColour("success") def __init__(self, min_val: float = 0, max_val: float = 100, **kwargs): super().__init__(**kwargs) self.min_value = min_val self.max_value = max_val self.show_percentage = True self.font_size = 12.0 self.size = Vec2(200, 20)
[docs] def get_minimum_size(self) -> Vec2: return Vec2(max(self.min_size.x, 60.0), max(self.min_size.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.get_global_rect() # Background renderer.draw_rect((x, y), (w, h), colour=self.bg_colour, filled=True) # Fill pct = self.percentage fill_w = w * pct renderer.draw_rect((x, y), (fill_w, h), colour=self.fill_colour, filled=True) # Percentage text if self.show_percentage: text = f"{int(pct * 100)}%" scale = self.font_size / 14.0 text_w = renderer.text_width(text, scale) text_x = x + (w - text_w) / 2 text_y = y + (h - self.font_size) / 2 renderer.draw_text(text, (text_x, text_y), colour=Colour.WHITE, scale=scale)