"""Widgets — Label, Button, Panel, TextEdit, Slider, ProgressBar.
All widgets draw themselves via the renderer passed to draw().
The renderer is Draw2D (class with classmethods), called directly
without hasattr guards. Colours are float tuples (0-1).
"""
from __future__ import annotations
import logging
from ..descriptors import Property, Signal
from ..math.types import Vec2
from .core import Colour, Control, FocusMode, ThemeColour, ThemeStyleBox
from .theme import StyleBox
log = logging.getLogger(__name__)
__all__ = [
"Label",
"Button",
"Panel",
"TextEdit",
"Slider",
"ProgressBar",
]
# ============================================================================
# Label — Text display widget
# ============================================================================
[docs]
class Label(Control):
"""Text display widget.
Example:
label = Label("Hello World")
label.text_colour = Colour.YELLOW
label.alignment = "center"
"""
_draw_caching = True
text = Property("", hint="Label text")
font_size = Property(14.0, range=(8, 72), hint="Font size")
text_colour = ThemeColour("text")
bg_colour = Property((0.0, 0.0, 0.0, 0.0), hint="Background colour (transparent by default)")
alignment = Property("left", enum=["left", "center", "right"], hint="Text alignment")
def __init__(self, text: str = "", **kwargs):
super().__init__(**kwargs)
self.text = text
# Only auto-size if no explicit size was provided (e.g., from deserialization)
if "size_x" not in kwargs and "size_y" not in kwargs:
self._update_size()
[docs]
def get_minimum_size(self) -> Vec2:
char_width = self.font_size * 0.6
w = max(self.min_size.x, len(self.text) * char_width)
h = max(self.min_size.y, self.font_size * 1.5)
return Vec2(w, h)
def _update_size(self):
"""Auto-size based on text content."""
self.size = self.get_minimum_size()
[docs]
def draw(self, renderer):
if not self.text:
return
x, y, w, h = self.get_global_rect()
# Optional background (transparent by default — no rectangle over content)
if len(self.bg_colour) >= 4 and self.bg_colour[3] > 0:
renderer.draw_filled_rect(x, y, w, h, self.bg_colour)
text_width = renderer.text_width(self.text, self.font_size / 14.0)
if self.alignment == "center":
text_x = x + (w - text_width) / 2
elif self.alignment == "right":
text_x = x + w - text_width
else:
text_x = x
text_y = y + (h - self.font_size) / 2
renderer.draw_text_coloured(self.text, text_x, text_y, self.font_size / 14.0, self.text_colour)
# ============================================================================
# Button — Clickable button
# ============================================================================
# ============================================================================
# Panel — Background panel
# ============================================================================
[docs]
class Panel(Control):
"""Background panel with optional border.
Example:
panel = Panel()
panel.style = StyleBox(bg_colour=Colour.hex("#1A1A2E"))
panel.add_child(VBoxContainer())
"""
_draw_caching = True
style = ThemeStyleBox("panel_style")
def __init__(self, **kwargs):
super().__init__(**kwargs)
# Convenience setters that create StyleBox overrides for bg/border.
@property
def bg_colour(self):
box = self.style
return box.bg_colour if box else (0, 0, 0, 0)
@bg_colour.setter
def bg_colour(self, colour):
box = self.style
if box is not None and self.__dict__.get("_tsb_style") is not None:
box.bg_colour = colour
else:
self.style = StyleBox(bg_colour=colour, border_width=0.0)
self.queue_redraw()
@property
def border_colour(self):
box = self.style
return box.border_colour if box else (0, 0, 0, 0)
@border_colour.setter
def border_colour(self, colour):
box = self.style
if box is not None and self.__dict__.get("_tsb_style") is not None:
box.border_colour = colour
else:
self.style = StyleBox(bg_colour=(0, 0, 0, 0), border_colour=colour)
self.queue_redraw()
@property
def border_width(self):
box = self.style
return box.border_width if box else 0.0
@border_width.setter
def border_width(self, value):
box = self.__dict__.get("_tsb_style")
if box is not None:
box.border_width = value
elif value == 0:
# Common pattern: panel.border_width = 0 — create override with no border
self.style = StyleBox(bg_colour=self.bg_colour, border_width=0.0)
[docs]
def get_minimum_size(self) -> Vec2:
# Panel min = max of children minimums
w, h = self.min_size.x, self.min_size.y
for child in self.children:
if isinstance(child, Control):
ms = child.get_minimum_size()
w = max(w, ms.x)
h = max(h, ms.y)
return Vec2(w, h)
[docs]
def draw(self, renderer):
x, y, w, h = self.get_global_rect()
box = self.style
if box is not None:
box.draw(renderer, x, y, w, h)
else:
if len(self.bg_colour) < 4 or self.bg_colour[3] > 0:
renderer.draw_filled_rect(x, y, w, h, self.bg_colour)
renderer.draw_rect_coloured(x, y, w, h, self.border_colour)
# ============================================================================
# TextEdit — Text input widget
# ============================================================================
[docs]
class TextEdit(Control):
"""Single-line text input.
Example:
edit = TextEdit(placeholder="Enter name...")
edit.text_changed.connect(lambda txt: print(txt))
"""
_draw_caching = True
text = Property("", hint="Current text")
placeholder = Property("", hint="Placeholder text")
max_length = Property(255, range=(0, 10000), hint="Max text length")
text_colour = ThemeColour("text")
placeholder_colour = ThemeColour("placeholder")
style_normal = ThemeStyleBox("input_style_normal")
style_focused = ThemeStyleBox("input_style_focused")
style_disabled = ThemeStyleBox("input_style_disabled")
def __init__(self, text: str = "", placeholder: str = "", **kwargs):
super().__init__(**kwargs)
self.text = text
self.placeholder = placeholder
self.max_length = 255
self.font_size = 14.0
self.cursor_pos = len(text)
self._cursor_blink = 0.0
self.text_changed = Signal()
self.text_submitted = Signal()
self.size = Vec2(200, 30)
[docs]
def get_minimum_size(self) -> Vec2:
w = max(self.min_size.x, 60.0)
h = max(self.min_size.y, self.font_size * 1.5 + 8)
return Vec2(w, h)
def _on_gui_input(self, event):
# Focus on click
if event.button == 1 and event.pressed:
if self.is_point_inside(event.position):
self.set_focus()
if not self.focused:
return
if event.key == "backspace" and not event.pressed:
if self.cursor_pos > 0:
self.text = self.text[: self.cursor_pos - 1] + self.text[self.cursor_pos :]
self.cursor_pos -= 1
self.queue_redraw()
self.text_changed.emit(self.text)
elif event.key == "delete" and not event.pressed:
if self.cursor_pos < len(self.text):
self.text = self.text[: self.cursor_pos] + self.text[self.cursor_pos + 1 :]
self.queue_redraw()
self.text_changed.emit(self.text)
elif event.key == "left" and not event.pressed:
self.cursor_pos = max(0, self.cursor_pos - 1)
self.queue_redraw()
elif event.key == "right" and not event.pressed:
self.cursor_pos = min(len(self.text), self.cursor_pos + 1)
self.queue_redraw()
elif event.key == "home" and not event.pressed:
self.cursor_pos = 0
self.queue_redraw()
elif event.key == "end" and not event.pressed:
self.cursor_pos = len(self.text)
self.queue_redraw()
elif event.key == "enter" and not event.pressed:
self.text_submitted.emit(self.text)
elif event.char and len(event.char) == 1:
if len(self.text) < self.max_length:
self.text = self.text[: self.cursor_pos] + event.char + self.text[self.cursor_pos :]
self.cursor_pos += 1
self.queue_redraw()
self.text_changed.emit(self.text)
[docs]
def process(self, dt: float):
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()
[docs]
def draw(self, renderer):
x, y, w, h = self.get_global_rect()
# StyleBox-based background + border
if self.disabled:
box = self.style_disabled
elif self.focused:
box = self.style_focused
else:
box = self.style_normal
inset = box.inset if box is not None else 5.0
if box is not None:
box.draw(renderer, x, y, w, h)
# Text or placeholder
scale = self.font_size / 14.0
text_to_draw = self.text if self.text else self.placeholder
if text_to_draw:
colour = self.text_colour if self.text else self.placeholder_colour
text_x = x + inset
text_y = y + (h - self.font_size) / 2
renderer.draw_text_coloured(text_to_draw, text_x, text_y, scale, colour)
# Cursor — 2px accent-coloured bar, blinks at 1Hz
if self.focused and self._cursor_blink < 0.5:
cursor_x = x + inset + renderer.text_width(self.text[: self.cursor_pos], scale)
cursor_y = y + inset
cursor_h = h - 2 * inset
accent = self.get_theme().accent
renderer.draw_filled_rect(cursor_x, cursor_y, 2, cursor_h, accent)
# ============================================================================
# Slider — Numeric slider
# ============================================================================
[docs]
class Slider(Control):
"""Horizontal slider for numeric input.
Example:
slider = Slider(0, 100, value=50)
slider.value_changed.connect(lambda v: print(v))
"""
_draw_caching = True
min_value = Property(0.0, hint="Minimum value")
max_value = Property(100.0, hint="Maximum value")
value = Property(50.0, hint="Current value")
bg_colour = ThemeColour("bg_light")
fill_colour = ThemeColour("slider_fill")
handle_colour = ThemeColour("slider_handle")
handle_hover_colour = ThemeColour("text_bright")
def __init__(self, min_val: float = 0, max_val: float = 100, value: float = None, **kwargs):
super().__init__(**kwargs)
if min_val > max_val:
log.warning("Slider created with invalid range: min=%s > max=%s", min_val, max_val)
# Only apply positional defaults if not already set via Property kwargs (deserialization)
if "min_value" not in kwargs:
self.min_value = min_val
if "max_value" not in kwargs:
self.max_value = max_val
if value is not None:
self.value = value
elif "value" not in kwargs:
self.value = (self.min_value + self.max_value) / 2
self.step = 1.0
self._dragging = False
self.value_changed = Signal()
self.size = Vec2(200, 20)
[docs]
def get_minimum_size(self) -> Vec2:
return Vec2(max(self.min_size.x, 60.0), max(self.min_size.y, 20.0))
def _get_value_ratio(self) -> float:
if self.max_value == self.min_value:
return 0.0
return (self.value - self.min_value) / (self.max_value - self.min_value)
def _set_value_from_ratio(self, ratio: float):
ratio = max(0.0, min(1.0, ratio))
new_value = self.min_value + ratio * (self.max_value - self.min_value)
if self.step > 0:
new_value = round(new_value / self.step) * self.step
if new_value != self.value:
self.value = new_value
self.value_changed.emit(self.value)
def _on_gui_input(self, event):
if event.button == 1:
if event.pressed and self.is_point_inside(event.position):
self._dragging = True
self._update_from_mouse(event.position)
elif not event.pressed:
self._dragging = False
if self._dragging and event.position:
self._update_from_mouse(event.position)
def _update_from_mouse(self, mouse_pos):
x, _, w, _ = self.get_global_rect()
px = mouse_pos.x if hasattr(mouse_pos, "x") else mouse_pos[0]
ratio = (px - x) / w
self._set_value_from_ratio(ratio)
[docs]
def draw(self, renderer):
x, y, w, h = self.get_global_rect()
# Track background
renderer.draw_filled_rect(x, y + h / 3, w, h / 3, self.bg_colour)
# Filled portion
ratio = self._get_value_ratio()
fill_w = w * ratio
renderer.draw_filled_rect(x, y + h / 3, fill_w, h / 3, self.fill_colour)
# Handle
handle_x = x + fill_w - 5
handle_colour = self.handle_hover_colour if self.mouse_over else self.handle_colour
renderer.draw_filled_rect(handle_x, y, 10, h, handle_colour)
# ============================================================================
# ProgressBar — Progress display
# ============================================================================
[docs]
class ProgressBar(Control):
"""Progress bar display.
Example:
bar = ProgressBar(0, 100)
bar.value = 75
"""
_draw_caching = True
min_value = Property(0.0, hint="Minimum value")
max_value = Property(100.0, hint="Maximum value")
value = Property(0.0, hint="Current value")
bg_colour = ThemeColour("bg_dark")
fill_colour = ThemeColour("success")
def __init__(self, min_val: float = 0, max_val: float = 100, **kwargs):
super().__init__(**kwargs)
self.min_value = min_val
self.max_value = max_val
self.show_percentage = True
self.font_size = 12.0
self.size = Vec2(200, 20)
[docs]
def get_minimum_size(self) -> Vec2:
return Vec2(max(self.min_size.x, 60.0), max(self.min_size.y, 16.0))
def _get_percentage(self) -> float:
if self.max_value == self.min_value:
return 0.0
ratio = (self.value - self.min_value) / (self.max_value - self.min_value)
return max(0.0, min(100.0, ratio * 100))
[docs]
def draw(self, renderer):
x, y, w, h = self.get_global_rect()
# Background
renderer.draw_filled_rect(x, y, w, h, self.bg_colour)
# Fill
pct = self._get_percentage()
fill_w = w * (pct / 100)
renderer.draw_filled_rect(x, y, fill_w, h, self.fill_colour)
# Percentage text
if self.show_percentage:
text = f"{int(pct)}%"
scale = self.font_size / 14.0
text_w = renderer.text_width(text, scale)
text_x = x + (w - text_w) / 2
text_y = y + (h - self.font_size) / 2
renderer.draw_text_coloured(text, text_x, text_y, scale, Colour.WHITE)