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).
"""


from __future__ import annotations

import logging

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 = Property((0.0, 0.0, 0.0, 0.0), hint="Background colour (transparent by default)") 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_filled_rect(x, y, w, h, self.bg_colour) 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_coloured(self.text, text_x, text_y, self.font_size / 14.0, self.text_colour)
# ============================================================================ # 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 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") def __init__(self, text: str = "Button", on_press=None, **kwargs): super().__init__(**kwargs) self.text = text self._is_pressed = False 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() # Convenience colour setters — create flat StyleBox overrides so existing # code that sets e.g. ``btn.bg_colour = (r,g,b,a)`` still works. @property def bg_colour(self): box = self.style_normal return box.bg_colour if box else (0, 0, 0, 0) @bg_colour.setter def bg_colour(self, colour): self.style_normal = StyleBox(bg_colour=colour, border_width=0.0) @property def hover_colour(self): box = self.style_hover return box.bg_colour if box else (0, 0, 0, 0) @hover_colour.setter def hover_colour(self, colour): self.style_hover = StyleBox(bg_colour=colour, border_width=0.0) @property def pressed_colour(self): box = self.style_pressed return box.bg_colour if box else (0, 0, 0, 0) @pressed_colour.setter def pressed_colour(self, colour): self.style_pressed = StyleBox(bg_colour=colour, border_width=0.0) @property def border_colour(self): box = self.style_normal return box.border_colour if box else (0, 0, 0, 0) @border_colour.setter def border_colour(self, colour): box = self.style_normal if box is not None: box.border_colour = colour @property def border_width(self): box = self.style_normal return box.border_width if box else 0.0 @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] 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 _get_current_style(self): """Select the appropriate StyleBox for the current state.""" if self.disabled: return self.style_disabled if self._is_pressed: return self.style_pressed if self.mouse_over: return self.style_hover if self.focused: return self.style_focused return self.style_normal
[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_coloured(self.text, text_x, text_y, scale, tc)
# ============================================================================ # 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) @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) @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 @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_filled_rect(x, y, w, h, self.bg_colour) renderer.draw_rect_coloured(x, y, w, h, 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_coloured(text_to_draw, text_x, text_y, scale, colour) # 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_filled_rect(cursor_x, cursor_y, 2, cursor_h, 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)) """ _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_filled_rect(x, y + h / 3, w, h / 3, self.bg_colour) # Filled portion ratio = self._get_value_ratio() fill_w = w * ratio renderer.draw_filled_rect(x, y + h / 3, fill_w, h / 3, 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.draw_filled_rect(handle_x, y, 10, h, handle_colour)
# ============================================================================ # 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))
def _get_percentage(self) -> float: 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(100.0, ratio * 100))
[docs] def draw(self, renderer): x, y, w, h = self.get_global_rect() # Background renderer.draw_filled_rect(x, y, w, h, self.bg_colour) # Fill pct = self._get_percentage() fill_w = w * (pct / 100) renderer.draw_filled_rect(x, y, fill_w, h, self.fill_colour) # Percentage text if self.show_percentage: text = f"{int(pct)}%" 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_coloured(text, text_x, text_y, scale, Colour.WHITE)