Source code for simvx.core.ui.advanced

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


from __future__ import annotations

import logging

from ..descriptors import Property, Signal
from ..math.types import Vec2
from .core import Control, ThemeColour

log = logging.getLogger(__name__)

__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)) """ 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._update_size()
[docs] def get_minimum_size(self) -> Vec2: box_size = 16 gap = 6 char_width = self.font_size * 0.6 text_width = len(self.text) * char_width if self.text else 0 w = max(self.min_size.x, box_size + gap + text_width) h = max(self.min_size.y, 24.0) return Vec2(w, h)
def _update_size(self): """Auto-size based on text content.""" self.size = self.get_minimum_size() def _on_gui_input(self, event): if event.button == 1 and event.pressed: if self.is_point_inside(event.position): self.checked = not self.checked self.toggled.emit(self.checked)
[docs] def draw(self, renderer): x, y, w, h = self.get_global_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.draw_rect_coloured(box_x, box_y, box_size, box_size, outline) # Check mark (inner filled rect) if self.checked: pad = 3 renderer.draw_filled_rect( box_x + pad, box_y + pad, box_size - pad * 2, box_size - pad * 2, self.check_colour, ) # Label if self.text: gap = 6 scale = self.font_size / 14.0 text_x = box_x + box_size + gap text_y = y + (h - self.font_size) / 2 renderer.draw_text_coloured(self.text, text_x, text_y, scale, self.text_colour)
# ============================================================================ # SpinBox -- Numeric input with up/down buttons # ============================================================================
[docs] class SpinBox(Control): """Numeric input with increment/decrement buttons. Example: spin = SpinBox(min_val=0, max_val=100, value=50, step=5) spin.value_changed.connect(lambda v: print("Value:", v)) """ min_value = Property(0.0, hint="Minimum value") max_value = Property(100.0, hint="Maximum value") value = Property(0.0, hint="Current value") text_colour = ThemeColour("text") bg_colour = ThemeColour("bg_input") border_colour = ThemeColour("input_border") button_colour = ThemeColour("btn_bg") button_hover_colour = ThemeColour("btn_hover") arrow_colour = ThemeColour("text_label") focus_colour = ThemeColour("input_focus") def __init__(self, min_val: float = 0, max_val: float = 100, value: float = 0, step: float = 1, **kwargs): super().__init__(**kwargs) self.min_value = min_val self.max_value = max_val self.value = max(min_val, min(max_val, value)) self.step = step self.font_size = 14.0 self._input_text = "" self._editing = False self._cursor_blink = 0.0 self.value_changed = Signal() self.size = Vec2(120, 28)
[docs] def get_minimum_size(self) -> Vec2: return Vec2(max(self.min_size.x, 80.0), max(self.min_size.y, 28.0))
[docs] def process(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()
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) def _button_width(self) -> float: return 20 def _on_gui_input(self, event): bw = self._button_width() x, y, w, h = self.get_global_rect() # Mouse wheel support if event.key == "scroll_up": self._set_value(self.value + self.step) return if event.key == "scroll_down": self._set_value(self.value - self.step) return if event.button == 1 and event.pressed: if not self.is_point_inside(event.position): if self._editing: self._commit_edit() return px = event.position.x if hasattr(event.position, "x") else event.position[0] py = event.position.y if hasattr(event.position, "y") else event.position[1] # Up button (right side, top half) if px >= x + w - bw: if py < y + h / 2: self._set_value(self.value + self.step) else: self._set_value(self.value - self.step) return # Click on text area starts editing — fresh input, type to replace self.set_focus() self._editing = True self._input_text = "" self._cursor_blink = 0.0 # Keyboard input while editing if not self._editing or not self.focused: return if event.key == "enter" and not event.pressed: self._commit_edit() elif event.key == "escape" and not event.pressed: self._editing = False self.release_focus() elif event.key == "backspace" and not event.pressed: self._input_text = self._input_text[:-1] elif event.key == "up" and not event.pressed: self._set_value(self.value + self.step) self._input_text = self._format_value() elif event.key == "down" and not event.pressed: self._set_value(self.value - self.step) self._input_text = self._format_value() elif event.char and event.char in "0123456789.-": self._input_text += event.char def _commit_edit(self): """Apply typed text as new value (no-op if nothing was typed).""" self._editing = False if self._input_text: try: self._set_value(float(self._input_text)) except ValueError: pass self.release_focus() 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}"
[docs] def draw(self, renderer): x, y, w, h = self.get_global_rect() bw = self._button_width() # Text field background renderer.draw_filled_rect(x, y, w - bw, h, self.bg_colour) # Text field border border = self.focus_colour if self.focused else self.border_colour renderer.draw_rect_coloured(x, y, w - bw, h, border) # Value text display = self._input_text if self._editing else self._format_value() scale = self.font_size / 14.0 text_w = renderer.text_width(display, scale) text_x = x + (w - bw - text_w) / 2 text_y = y + (h - self.font_size) / 2 # Selection highlight when editing but nothing typed yet (shows value will be replaced) if self._editing and 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 - bw - orig_w) / 2 renderer.draw_filled_rect(orig_x - 1, text_y, orig_w + 2, self.font_size, sel_colour) renderer.draw_text_coloured(display, text_x, text_y, scale, self.text_colour) # Blinking cursor when editing if self._editing and self._cursor_blink < 0.5: accent = self.get_theme().accent cursor_x = text_x + text_w cursor_y = y + 4 renderer.draw_filled_rect(cursor_x, cursor_y, 2, h - 8, accent) # Button background btn_x = x + w - bw renderer.draw_filled_rect(btn_x, y, bw, h, self.button_colour) renderer.draw_rect_coloured(btn_x, y, bw, h, self.border_colour) # Divider line between up/down mid_y = y + h / 2 renderer.draw_filled_rect(btn_x, mid_y, bw, 1, self.border_colour) # Up arrow (triangle approximated with small filled rect) arrow_w, arrow_h = 8, 4 renderer.draw_filled_rect( btn_x + (bw - arrow_w) / 2, y + h / 4 - arrow_h / 2, arrow_w, arrow_h, self.arrow_colour, ) # Down arrow renderer.draw_filled_rect( btn_x + (bw - arrow_w) / 2, y + 3 * h / 4 - arrow_h / 2, arrow_w, arrow_h, self.arrow_colour, )
# ============================================================================ # DropDown -- Selection from a list of items # ============================================================================ # ============================================================================ # RadioButton -- Mutually exclusive selection within a group # ============================================================================
[docs] class RadioButton(Control): """Mutually exclusive toggle within a named group. Only one RadioButton per group can be selected at a time. Clicking one automatically deselects siblings in the same group. Example: r1 = RadioButton("Option A", group="opts") r2 = RadioButton("Option B", group="opts") r1.selection_changed.connect(lambda on: print("A:", on)) """ text = Property("", hint="Radio button label") selected = Property(False, hint="Whether this radio button is selected") group = Property("", hint="Group name for mutual exclusion") text_colour = ThemeColour("text") circle_colour = ThemeColour("check_box") dot_colour = ThemeColour("check_colour") hover_colour = ThemeColour("btn_hover") def __init__(self, text: str = "", group: str = "", selected: bool = False, **kwargs): super().__init__(**kwargs) self.text = text self.group = group self.selected = selected self.selection_changed = Signal() self._update_size()
[docs] def get_minimum_size(self) -> Vec2: circle_size = 16 gap = 6 char_width = 14.0 * 0.6 text_width = len(self.text) * char_width if self.text else 0 w = max(self.min_size.x, circle_size + gap + text_width) h = max(self.min_size.y, 24.0) return Vec2(w, h)
def _update_size(self): """Auto-size based on text content.""" self.size = self.get_minimum_size() def _deselect_siblings(self): """Deselect other RadioButtons in the same group among siblings.""" if not self.parent: return for child in self.parent.children: if child is self: continue if isinstance(child, RadioButton) and child.group == self.group: if child.selected: child.selected = False child.selection_changed.emit(False) def _on_gui_input(self, event): if event.button == 1 and event.pressed: if self.is_point_inside(event.position): if not self.selected: self._deselect_siblings() self.selected = True self.selection_changed.emit(True)
[docs] def draw(self, renderer): x, y, w, h = self.get_global_rect() radius = 8 cx = x + radius cy = y + h / 2 # Outer circle renderer.draw_circle(cx, cy, radius) # Filled dot when selected if self.selected: renderer.fill_circle(cx, cy, radius - 3) # Label if self.text: gap = 6 scale = 14.0 / 14.0 text_x = x + radius * 2 + gap text_y = y + (h - 14.0) / 2 renderer.draw_text_coloured(self.text, text_x, text_y, scale, self.text_colour)