"""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
# ============================================================================
[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)
@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_filled_rect(x, y, w, h, bg)
renderer.draw_rect_coloured(x, y, w, h, 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_coloured(text, text_x, text_y, scale, self.text_colour)
# 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_filled_rect(arrow_x, arrow_y, arrow_w, arrow_h, self.arrow_colour)
# ============================================================================
# RadioButton -- Mutually exclusive selection within a group
# ============================================================================