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, Signal
from ..math.types import Vec2
from .core import Colour, Control, FocusMode, ThemeColour, ThemeStyleBox
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 = "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="Text alignment") 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 w = max(self.min_size.x, len(self.text) * char_width) h = max(self.min_size.y, self.font_size * 1.5) return Vec2(w, h)
def _update_size(self): """Auto-size based on text content.""" self.size = self.get_minimum_size()
[docs] def 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) text_width = renderer.text_width(self.text, self.font_size / 14.0) if self.alignment == "center": text_x = x + (w - text_width) / 2 elif self.alignment == "right": text_x = x + w - text_width else: text_x = x text_y = y + (h - self.font_size) / 2 renderer.draw_text(self.text, (text_x, text_y), colour=self.text_colour, scale=self.font_size / 14.0)
# ============================================================================ # 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 == 1: 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 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): 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): 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 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 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 on click if event.button == 1 and event.pressed: if self.is_point_inside(event.position): self.set_focus() 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)
[docs] def 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 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 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 == 1: 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 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 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)