"""Advanced widgets -- CheckBox, SpinBox, DropDown, RadioButton."""
import logging
from ..descriptors import Property
from ..input.controls import Key, Pointer
from ..input.enums import PointerPhase
from ..input.events import KeyEvent, PointerEvent, ScrollEvent, TextEvent
from ..input.state import Input
from ..math.types import Vec2
from ..signals import Signal
from ..text.measure import LOGICAL_EM, text_minimum_width
from .core import Control, FocusMode, ThemeColour
from .enums import PointerFilter
from .widgets import Button
log = logging.getLogger(__name__)
#: What a spin box's edit session accepts as typed text.
_NUMBER_CHARS = "0123456789.-+e"
__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))
"""
pointer_filter = Property(PointerFilter.STOP, hint="How this control takes part in the pointer hit test")
touch_target = True
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:
floor_x, floor_y = self.size_floor()
box_size = 16
gap = 6
text_width = text_minimum_width(self.text, self.font_size / LOGICAL_EM)
w = max(floor_x, box_size + gap + text_width)
h = max(floor_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
[docs]
def on_draw(self, renderer):
x, y, w, h = self._local_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.rect((box_x, box_y), (box_size, box_size), stroke=outline)
# Check mark (inner filled rect)
if self.checked:
pad = 3
renderer.rect((box_x + pad, box_y + pad), (box_size - pad * 2, box_size - pad * 2), fill=self.check_colour)
# 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.text(self.text, (text_x, text_y), fill=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))
"""
pointer_filter = Property(PointerFilter.STOP, hint="How this control takes part in the pointer hit test")
touch_target = True
# A click opens an edit session, which needs the keyboard: the spin box
# takes focus from a press and from code, and is not a Tab stop.
focus_mode = Property(FocusMode.CLICK, hint="Whether this control can take keyboard focus")
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")
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()
self.focus_exited.connect(self._on_focus_exited)
[docs]
@property
def wants_text_input(self) -> bool:
"""Whether the spin box takes typing: only while an edit session is open."""
return self._editing
[docs]
def get_minimum_size(self) -> Vec2:
floor_x, floor_y = self.size_floor()
return Vec2(max(floor_x, 80.0), max(floor_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, focused, with an empty entry."""
self._editing = True
self._input_text = ""
self._cursor_blink = 0.0
if self.focused:
# Focus came with the press, before the session opened, so the
# platform's text input is opened here rather than by the focus change.
Input.begin_text_input(self.get_global_rect())
else:
self.grab_focus()
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_exited(self):
"""Commit an open edit 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_edit_key(self, event: KeyEvent | TextEvent) -> bool | None:
"""The keyboard half: only live while an edit session is open."""
if not self._editing:
return None
if isinstance(event, TextEvent):
typed = "".join(c for c in event.text if c in _NUMBER_CHARS)
if typed and not event.composing:
self._input_text += typed
self.queue_redraw()
return True
# A key acts as it goes down and the press is claimed, so its release
# comes back here rather than to whatever holds focus by then.
if not event.pressed:
return None
if event.key is Key.ENTER:
self._commit_edit()
elif event.key is Key.ESCAPE:
self._editing = False
self._input_text = ""
self.release_focus()
self.queue_redraw()
elif event.key is Key.BACKSPACE:
self._input_text = self._input_text[:-1]
self.queue_redraw()
elif event.key is Key.UP:
self._set_value(self.value + self.step)
self._input_text = self._format_value()
elif event.key is Key.DOWN:
self._set_value(self.value - self.step)
self._input_text = self._format_value()
else:
return None
return True
# ── Drawing ───────────────────────────────────────────────────────
[docs]
def on_draw(self, renderer):
x, y, w, h = self._local_rect()
bw = self._button_width()
show_arrows = self.mouse_over or self._dragging
# Background
renderer.rect((x, y), (w, h), fill=self.bg_colour)
# Border
border = self.focus_colour if (self.focused or self._dragging) else self.border_colour
renderer.rect((x, y), (w, h), stroke=border)
# Arrow buttons (visible on hover)
if show_arrows and not self._editing:
arrow_colour = self.arrow_colour
# Left arrow <
renderer.text(
"<", (x + bw * 0.25, y + (h - self.font_size) / 2), fill=arrow_colour, scale=self.font_size / 16.0
)
# Right arrow >
gt_w = renderer.text_width(">", self.font_size / 16.0)
renderer.text(
">",
(x + w - bw * 0.75 - gt_w * 0.25, y + (h - self.font_size) / 2),
fill=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.rect((orig_x - 1, y + 2), (orig_w + 2, h - 4), fill=sel_colour)
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.text(display, (text_x, text_y), fill=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.rect((cursor_x + 1, y + 4), (2, h - 8), fill=accent)
# ============================================================================
# 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))
"""
pointer_filter = Property(PointerFilter.STOP, hint="How this control takes part in the pointer hit test")
touch_target = True
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:
floor_x, floor_y = self.size_floor()
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(floor_x, widest + arrow_space + 12)
h = max(floor_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, in its own local space), 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()
try:
px, py = self.to_local(point)
except ValueError:
return False # scaled to zero: nothing to hit
return bool(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), in local space."""
x, y, w, h = self._local_rect()
item_h = self._item_height()
return (x, y + h, w, item_h * len(self.items))
[docs]
def on_draw(self, renderer):
x, y, w, h = self._local_rect()
# Main button background
bg = self.hover_colour if self.mouse_over and not self._open else self.bg_colour
renderer.rect((x, y), (w, h), fill=bg)
renderer.rect((x, y), (w, h), stroke=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.text(text, (text_x, text_y), fill=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.rect((arrow_x, arrow_y), (arrow_w, arrow_h), fill=self.arrow_colour)
# 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.rect((x - 1, list_y - 1), (w + 2, list_h + 2), fill=self.border_colour)
renderer.rect((x, list_y), (w, list_h), fill=self.item_bg_colour)
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.rect((x + 1, iy), (w - 2, item_h), fill=self.item_hover_colour)
elif i == self.selected_index:
renderer.rect((x + 1, iy), (w - 2, item_h), fill=(0.18, 0.38, 0.68, 1.0))
renderer.text(item, (x + 6, iy + (item_h - self.font_size) / 2), fill=self.text_colour, scale=scale)
renderer.pop_clip()
# ============================================================================
# RadioButton -- Mutually exclusive selection within a group
# ============================================================================