"""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
# ============================================================================
[docs]
class DropDown(Control):
"""Drop-down selection from a list of items.
Example:
dd = DropDown(items=["Low", "Medium", "High"], selected=1)
dd.item_selected.connect(lambda idx: print("Selected:", idx))
"""
text_colour = ThemeColour("text")
bg_colour = ThemeColour("bg")
hover_colour = ThemeColour("btn_bg")
border_colour = ThemeColour("border")
item_bg_colour = ThemeColour("bg_darker")
item_hover_colour = ThemeColour("popup_hover")
arrow_colour = ThemeColour("text_label")
def __init__(self, items: list[str] = None, selected: int = 0, **kwargs):
super().__init__(**kwargs)
self.items = list(items) if items else []
self.selected_index = max(0, min(selected, len(self.items) - 1)) if self.items else 0
self.font_size = 14.0
self._open = False
self._hover_index = -1
self.item_selected = Signal()
self.size = Vec2(180, 28)
[docs]
def get_minimum_size(self) -> Vec2:
char_width = self.font_size * 0.6
arrow_space = 24.0
widest = max((len(item) * char_width for item in self.items), default=0)
w = max(self.min_size.x, widest + arrow_space + 12)
h = max(self.min_size.y, 28.0)
return Vec2(w, h)
[docs]
@property
def selected_text(self) -> str:
"""Currently selected item text."""
if 0 <= self.selected_index < len(self.items):
return self.items[self.selected_index]
return ""
def _item_height(self) -> float:
return self.size.y
def _open_list(self):
"""Open the dropdown list as a popup overlay."""
self._open = True
if self._tree:
self._tree.push_popup(self)
def _close_list(self):
"""Close the dropdown list popup."""
self._open = False
if self._tree:
self._tree.pop_popup(self)
def _on_gui_input(self, event):
# Track hover over popup items on mouse move
if self._open and event.position and not getattr(event, "button", 0):
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]
rx, ry, rw, rh = self._popup_rect()
if rx <= px <= rx + rw and ry <= py <= ry + rh:
self._hover_index = int((py - ry) / self._item_height())
else:
self._hover_index = -1
return
if event.button != 1 or not event.pressed:
return
# Click on the button toggles the list
if self.is_point_inside(event.position):
if self._open:
self._close_list()
else:
self._open_list()
# ---- Popup overlay API ----
def _popup_rect(self) -> tuple[float, float, float, float]:
"""Get the rect of the dropdown list area."""
x, y, w, h = self.get_global_rect()
item_h = self._item_height()
return (x, y + h, w, item_h * len(self.items))
[docs]
def draw(self, renderer):
x, y, w, h = self.get_global_rect()
# Main button background
bg = self.hover_colour if self.mouse_over and not self._open else self.bg_colour
renderer.draw_rect((x, y), (w, h), colour=bg, filled=True)
renderer.draw_rect((x, y), (w, h), colour=self.border_colour)
# Selected text
scale = self.font_size / 14.0
text = self.selected_text
if text:
text_x = x + 6
text_y = y + (h - self.font_size) / 2
renderer.draw_text(text, (text_x, text_y), colour=self.text_colour, scale=scale)
# Arrow indicator (right side)
arrow_w, arrow_h = 8, 4
arrow_x = x + w - arrow_w - 8
arrow_y = y + (h - arrow_h) / 2
renderer.draw_rect((arrow_x, arrow_y), (arrow_w, arrow_h), colour=self.arrow_colour, filled=True)
# ============================================================================
# RadioButton -- Mutually exclusive selection within a group
# ============================================================================