"""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).
"""
import logging
from enum import StrEnum
from ..descriptors import Property
from ..signals import Signal
from ..math.types import Vec2
from .core import Colour, Control, FocusMode, ThemeColour, ThemeStyleBox
from .theme import StyleBox
from ..input.enums import MouseButton
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 = Colour((0.0, 0.0, 0.0, 0.0))
alignment = Property("left", enum=["left", "center", "right"], hint="Horizontal text alignment")
vertical_alignment = Property("center", enum=["top", "center", "bottom"], hint="Vertical text alignment")
autoshrink = Property(False, hint="Shrink font_size so text fits the widget width")
min_font_size = Property(8.0, range=(1, 72), hint="Lower bound for autoshrink")
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
lines = self.text.split("\n") if self.text else [""]
longest = max((len(line) for line in lines), default=0)
w = max(self.min_size.x, longest * char_width)
# Single-line keeps the historical 1.5x font-size height; multi-line
# stacks each line on a 1.2x pitch.
n = max(1, len(lines))
if n == 1:
h = max(self.min_size.y, self.font_size * 1.5)
else:
h = max(self.min_size.y, self.font_size * 1.2 * n + self.font_size * 0.3)
return Vec2(w, h)
def _update_size(self):
"""Auto-size based on text content."""
self.size = self.get_minimum_size()
[docs]
def on_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_rect((x, y), (w, h), colour=self.bg_colour, filled=True)
scale = self.font_size / 14.0
lines = self.text.split("\n")
if self.autoshrink:
min_scale = self.min_font_size / 14.0
widest = max(lines, key=lambda s: renderer.text_width(s, scale)) if lines else ""
scale = renderer.fit_scale(widest, w, base_scale=scale, min_scale=min_scale)
glyph_h = 14.0 * scale
line_pitch = glyph_h * 1.2
n = len(lines)
block_h = glyph_h if n == 1 else line_pitch * n
if self.vertical_alignment == "top":
first_y = y
elif self.vertical_alignment == "bottom":
first_y = y + h - block_h
else:
first_y = y + (h - block_h) / 2
for i, line in enumerate(lines):
line_w = renderer.text_width(line, scale)
if self.alignment == "center":
# Centre each line on the rect's centre: when line_w > w the
# overflow is symmetric rather than pushing off the left edge.
line_x = x + (w - line_w) / 2
elif self.alignment == "right":
line_x = x + w - line_w
else:
line_x = x
line_y = first_y + i * line_pitch
renderer.draw_text(line, (line_x, line_y), colour=self.text_colour, scale=scale)
# ============================================================================
# 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)
[docs]
@bg_colour.setter
def bg_colour(self, colour):
colour = Colour.coerce(colour, name="Panel.bg_colour")
if colour is None:
# ``None`` reverts the panel to its theme-default style: mirrors
# :class:`ThemeColour` / :class:`ThemeStyleBox` semantics. Clears
# any per-instance StyleBox override; the getter then reads from
# the theme's ``panel_style`` via :class:`ThemeStyleBox.__get__`.
self.style = None
self.queue_redraw()
return
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)
[docs]
@border_colour.setter
def border_colour(self, colour):
colour = Colour.coerce(colour, name="Panel.border_colour")
if colour is None:
# See ``bg_colour`` setter: ``None`` reverts to the theme default
# by clearing the per-instance StyleBox override.
self.style = None
self.queue_redraw()
return
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
[docs]
@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 on_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_rect((x, y), (w, h), colour=self.bg_colour, filled=True)
renderer.draw_rect((x, y), (w, h), colour=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
# Click position awaiting caret placement. Resolved at draw time because
# mapping a pixel x to a character offset needs renderer.text_width
# (mirrors MultiLineTextEdit._pending_click).
self._pending_click_x: float | None = None
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 + position caret on click
if event.button == MouseButton.LEFT and event.pressed:
if self.is_point_inside(event.position):
self.set_focus()
self._pending_click_x = event.position.x
self.queue_redraw()
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)
def _offset_at_x(self, renderer, click_x: float, text_origin_x: float, scale: float) -> int:
"""Character offset whose caret gap sits nearest ``click_x``.
Walks character boundaries comparing each cumulative text width to the
click's offset from the text origin, returning the closest gap. Clamps
to 0 before the text and to ``len(text)`` past its end.
"""
text = self.text
if not text:
return 0
rel = click_x - text_origin_x
if rel <= 0:
return 0
best_off, best_dist = 0, rel
for i in range(1, len(text) + 1):
dist = abs(renderer.text_width(text[:i], scale) - rel)
if dist < best_dist:
best_dist, best_off = dist, i
return best_off
[docs]
def on_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 on_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
# Resolve a deferred click into a caret offset (needs renderer.text_width).
if self._pending_click_x is not None:
self.cursor_pos = self._offset_at_x(renderer, self._pending_click_x, x + inset, scale)
self._pending_click_x = None
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(text_to_draw, (text_x, text_y), colour=colour, scale=scale)
# 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_rect((cursor_x, cursor_y), (2, cursor_h), colour=accent, filled=True)
# ============================================================================
# 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 == MouseButton.LEFT:
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 on_draw(self, renderer):
x, y, w, h = self.get_global_rect()
# Track background
renderer.draw_rect((x, y + h / 3), (w, h / 3), colour=self.bg_colour, filled=True)
# Filled portion
ratio = self._get_value_ratio()
fill_w = w * ratio
renderer.draw_rect((x, y + h / 3), (fill_w, h / 3), colour=self.fill_colour, filled=True)
# Handle
handle_x = x + fill_w - 5
handle_colour = self.handle_hover_colour if self.mouse_over else self.handle_colour
renderer.draw_rect((handle_x, y), (10, h), colour=handle_colour, filled=True)
# ============================================================================
# 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))
[docs]
@property
def percentage(self) -> float:
"""Progress as a 0.0–1.0 ratio of ``value`` between ``min_value`` and ``max_value``.
Returns 0.0 when ``max_value <= min_value``.
"""
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(1.0, ratio))
[docs]
def on_draw(self, renderer):
x, y, w, h = self.get_global_rect()
# Background
renderer.draw_rect((x, y), (w, h), colour=self.bg_colour, filled=True)
# Fill
pct = self.percentage
fill_w = w * pct
renderer.draw_rect((x, y), (fill_w, h), colour=self.fill_colour, filled=True)
# Percentage text
if self.show_percentage:
text = f"{int(pct * 100)}%"
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(text, (text_x, text_y), colour=Colour.WHITE, scale=scale)