"""Advanced widgets -- CheckBox, SpinBox, DropDown, RadioButton."""
import logging
from ..descriptors import Property
from ..input.enums import MouseButton
from ..math.types import Vec2
from ..signals import Signal
from ..text.measure import LOGICAL_EM, text_minimum_width
from .core import Control, ThemeColour
log = logging.getLogger(__name__)
#: Font size RadioButton draws and measures its label at. It has no
#: ``font_size`` of its own, so the two must read the same constant.
_RADIO_FONT_SIZE = 14.0
__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.autosize_to_content()
[docs]
def get_minimum_size(self) -> Vec2:
box_size = 16
gap = 6
text_width = text_minimum_width(self.text, self.font_size / LOGICAL_EM)
w = max(self.min_size_x, box_size + gap + text_width)
h = max(self.min_size_y, 24.0)
return Vec2(w, h)
[docs]
def content_signature(self) -> object:
return (self.text, self.font_size)
[docs]
def content_uses(self, chars: str) -> bool:
return any(char in self.text for char in chars)
[docs]
def activate(self) -> bool:
"""Toggle, the way a click does: ``ui_accept`` flips a focused check box.
Matches Godot (``CheckBox`` is a button, so ``ui_accept`` toggles it) and
Unity (``Toggle`` implements ``ISubmitHandler``). Note the default
``focus_mode`` is ``NONE``, so this only ever runs for a check box an
author made focusable.
"""
self.checked = not self.checked
self.toggled.emit(self.checked)
return True
def _on_gui_input(self, event):
# Claimed when it acts, so a click that toggles this widget is not also
# a click on the panel or row it sits in.
if event.button == MouseButton.LEFT and event.pressed:
if self.is_point_inside(event.position):
self.activate()
event.handled = True
[docs]
def on_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 / 16.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_value=0, max_value=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")
size_x = Property(120.0, range=(0, 10000), hint="Control width", on_change="_on_size_changed")
size_y = Property(28.0, range=(0, 10000), hint="Control height", on_change="_on_size_changed")
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, value: float = 0, step: float = 1, **kwargs):
super().__init__(**kwargs)
# The bounds arrive through the kwarg gate as ``min_value`` / ``max_value``
# and are already in place here, so the starting value is clamped against
# whatever the caller asked for rather than against a parameter default.
self.value = max(self.min_value, min(self.max_value, 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()
[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 on_update(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) ---
# Claimed, so a spin box inside a scrolling list steps its value
# instead of letting the list move under the cursor.
if event.key == "scroll_up":
self._set_value(self.value + self.step)
event.handled = True
return
if event.key == "scroll_down":
self._set_value(self.value - self.step)
event.handled = True
return
# --- Mouse press ---
# Every press this widget acts on is claimed: a step, a drag, or an edit
# session belongs to the spin box, not to the row it sits in. A press
# outside it only commits an open edit, and is left for whoever it hit.
if event.button == MouseButton.LEFT and event.pressed:
if not self.is_point_inside(event.position):
if self._editing:
self._commit_edit()
return
px = event.position.x
event.handled = True
# 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 == MouseButton.LEFT 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()
event.handled = True
return
# --- Mouse move while dragging ---
if self._dragging and event.position and event.button is None:
px = event.position.x
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 on_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 / 16.0
)
# Right arrow >
gt_w = renderer.text_width(">", self.font_size / 16.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 / 16.0,
)
# Value / input text
scale = self.font_size / 16.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_index=1)
dd.item_selected.connect(lambda idx: print("Selected:", idx))
"""
items = Property(default_factory=list, hint="Item labels, in the order they are listed")
selected_index = Property(0, range=(0, 10000), hint="Index of the item shown on the button")
font_size = Property(14.0, range=(8, 72), hint="Font size")
size_x = Property(180.0, range=(0, 10000), hint="Control width", on_change="_on_size_changed")
size_y = Property(28.0, range=(0, 10000), hint="Control height", on_change="_on_size_changed")
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, **kwargs):
super().__init__(**kwargs)
self._open = False
self._hover_index = -1
self.item_selected = Signal()
[docs]
def get_minimum_size(self) -> Vec2:
scale = self.font_size / LOGICAL_EM
arrow_space = 24.0
widest = max((text_minimum_width(item, scale) for item in self.items), default=0.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."""
items: list[str] = self.items
index: int = self.selected_index
if 0 <= index < len(items):
return items[index]
return ""
def _item_height(self) -> float:
return self.size.y
def _open_list(self):
"""Open the dropdown list as a light overlay (only while open).
``show_overlay("light")`` scopes input to this control and dismisses on an
outside click via the router. The control is its own overlay: ``on_draw``
paints the open list (camera-immune, via ``get_global_rect``), so we only
register WHILE OPEN -- the closed button must paint inline, not on top.
"""
self._open = True
self.show_overlay("light")
def _close_list(self):
"""Close the dropdown list popup."""
if not self._open:
return
self._open = False
self.close_overlay()
[docs]
def is_point_inside(self, point) -> bool:
"""The DropDown's input scope covers the button AND, when open, the
item-list rect below it. The router's hit-test consults this."""
if super().is_point_inside(point):
return True
if self._open:
rx, ry, rw, rh = self._popup_rect()
px = point.x if hasattr(point, "x") else point[0]
py = point.y if hasattr(point, "y") else point[1]
return rx <= px <= rx + rw and ry <= py <= ry + rh
return False
def _popup_rect(self) -> tuple[float, float, float, float]:
"""Rect of the open dropdown list area (below the button)."""
x, y, w, h = self.get_global_rect()
item_h = self._item_height()
return (x, y + h, w, item_h * len(self.items))
def _on_gui_input(self, event):
if event.position is not None and getattr(event, "button", None) is None:
# Track hover over list items on mouse-move while open.
if self._open:
px = event.position.x
py = event.position.y
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 != MouseButton.LEFT or not event.pressed:
return
px = event.position.x
py = event.position.y
x, y, w, h = self.get_global_rect()
# Click on the button toggles the list. Claimed, so the row around it
# does not also act on the click that opened the list.
if x <= px <= x + w and y <= py <= y + h:
if self._open:
self._close_list()
else:
self._open_list()
event.handled = True
return
# While open, a click on an item selects + closes.
if self._open:
rx, ry, rw, rh = self._popup_rect()
if rx <= px <= rx + rw and ry <= py <= ry + rh:
idx = int((py - ry) / self._item_height())
if 0 <= idx < len(self.items):
self.selected_index = idx
self.item_selected.emit(idx)
self._close_list()
event.handled = True
[docs]
def on_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 / 16.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)
# Open dropdown list: drawn on top by the overlay collector while this
# control is registered (``show_overlay`` in ``_open_list``).
if not self._open or not self.items:
return
item_h = self._item_height()
list_y = y + h
list_h = item_h * len(self.items)
renderer.draw_rect((x - 1, list_y - 1), (w + 2, list_h + 2), colour=self.border_colour, filled=True)
renderer.draw_rect((x, list_y), (w, list_h), colour=self.item_bg_colour, filled=True)
renderer.push_clip(int(x), int(list_y), int(w), int(list_h))
for i, item in enumerate(self.items):
iy = list_y + i * item_h
if i == self._hover_index:
renderer.draw_rect((x + 1, iy), (w - 2, item_h), colour=self.item_hover_colour, filled=True)
elif i == self.selected_index:
renderer.draw_rect((x + 1, iy), (w - 2, item_h), colour=(0.18, 0.38, 0.68, 1.0), filled=True)
renderer.draw_text(item, (x + 6, iy + (item_h - self.font_size) / 2), colour=self.text_colour, scale=scale)
renderer.pop_clip()
# ============================================================================
# RadioButton -- Mutually exclusive selection within a group
# ============================================================================