Source code for simvx.core.ui.advanced

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

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((box_x, box_y), (box_size, box_size), colour=outline) # Check mark (inner filled rect) if self.checked: pad = 3 renderer.draw_rect( (box_x + pad, box_y + pad), (box_size - pad * 2, box_size - pad * 2), colour=self.check_colour, filled=True, ) # 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(self.text, (text_x, text_y), colour=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_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") arrow_colour = ThemeColour("text_label") focus_colour = ThemeColour("input_focus") # Drag threshold in pixels — movement beyond this starts scrubbing _DRAG_THRESHOLD = 3.0 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._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.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))
# ── 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 with value pre-filled and selected.""" self.set_focus() self._editing = True self._input_text = "" self._cursor_blink = 0.0 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_lost(self): """Exit edit mode when focus moves away.""" if self._editing: self._commit_edit() # ── Tick (cursor blink) ───────────────────────────────────────────
[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()
# ── Input handling ──────────────────────────────────────────────── def _on_gui_input(self, event): x, y, w, h = self.get_global_rect() bw = self._button_width() # --- Mouse wheel (always active) --- 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 # --- Mouse press --- 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] # Left arrow button if px < x + bw: self._set_value(self.value - self.step) return # Right arrow button if px > x + w - bw: self._set_value(self.value + self.step) return # Value area — begin potential drag or click if self._editing: return # already editing, let keyboard handle it self._dragging = True self._drag_started = False self._drag_origin_x = px self._drag_accum = 0.0 self.grab_mouse() return # --- Mouse release --- if event.button == 1 and not event.pressed and self._dragging: self._dragging = False self.release_mouse() if not self._drag_started: # Click without drag → enter edit mode self._enter_edit() return # --- Mouse move while dragging --- if self._dragging and event.position and not event.button: px = event.position.x if hasattr(event.position, "x") else event.position[0] delta = px - self._drag_origin_x if not self._drag_started: if abs(delta) < self._DRAG_THRESHOLD: return 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) # --- Keyboard input while editing --- if not self._editing: return if event.key == "enter" and not event.pressed: self._commit_edit() elif event.key == "escape" and not event.pressed: self._editing = False self._input_text = "" self.release_focus() self.queue_redraw() elif event.key == "backspace" and not event.pressed: self._input_text = self._input_text[:-1] self.queue_redraw() 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.-+e": self._input_text += event.char self.queue_redraw() # ── Drawing ───────────────────────────────────────────────────────
[docs] def draw(self, renderer): x, y, w, h = self.get_global_rect() bw = self._button_width() show_arrows = self.mouse_over or self._dragging # Background renderer.draw_rect((x, y), (w, h), colour=self.bg_colour, filled=True) # Border border = self.focus_colour if (self.focused or self._dragging) else self.border_colour renderer.draw_rect((x, y), (w, h), colour=border) # Arrow buttons (visible on hover) if show_arrows and not self._editing: arrow_colour = self.arrow_colour # Left arrow < renderer.draw_text( "<", (x + bw * 0.25, y + (h - self.font_size) / 2), colour=arrow_colour, scale=self.font_size / 14.0 ) # Right arrow > gt_w = renderer.text_width(">", self.font_size / 14.0) renderer.draw_text( ">", (x + w - bw * 0.75 - gt_w * 0.25, y + (h - self.font_size) / 2), colour=arrow_colour, scale=self.font_size / 14.0, ) # Value / input text scale = self.font_size / 14.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.draw_rect((orig_x - 1, y + 2), (orig_w + 2, h - 4), colour=sel_colour, filled=True) 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.draw_text(display, (text_x, text_y), colour=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.draw_rect((cursor_x + 1, y + 4), (2, h - 8), colour=accent, filled=True)
# ============================================================================ # 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.draw_circle((cx, cy), radius - 3, filled=True) # 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(self.text, (text_x, text_y), colour=self.text_colour, scale=scale)