"""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 ..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 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 = 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.autosize_to_content()
[docs]
def get_minimum_size(self) -> Vec2:
lines = self.text.split("\n") if self.text else [""]
w = max(self.min_size_x, text_minimum_width(self.text, self.font_size / LOGICAL_EM))
# 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)
[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 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 / 16.0
lines = self.text.split("\n")
if self.autoshrink:
min_scale = self.min_font_size / 16.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 = 16.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._combined_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")
# A text field is a first-class focus target: Tab-reachable and eligible for
# an overlay's auto-election. Redeclared rather than assigned in
# ``__init__``, so a caller's ``focus_mode=`` survives construction.
focus_mode = Property(FocusMode.ALL, hint="Whether this control can take keyboard focus")
size_x = Property(200.0, range=(0, 10000), hint="Control width", on_change="_on_size_changed")
size_y = Property(30.0, range=(0, 10000), hint="Control height", on_change="_on_size_changed")
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.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()
# Fired when an edit session ends with a changed value: on Enter, on Tab,
# or on focus loss (Qt ``editingFinished`` parity). Distinct from
# ``text_submitted`` (Enter-only) so blur-commit consumers (an inspector
# field) and explicit-submit consumers (a REPL/search box) never conflate.
self.editing_finished = Signal()
# Value at the start of the current edit session; the commit baseline and
# the Esc-revert target. Refreshed on focus-in.
self._committed_text = text
self.focus_entered.connect(self._snapshot_committed)
self.focus_exited.connect(self._commit)
def _snapshot_committed(self) -> None:
"""Start an edit session: baseline the committed value (focus-in)."""
self._committed_text = self.text
def _has_pending_edit(self) -> bool:
"""True while the buffer differs from the last committed value."""
return self.text != self._committed_text
def _commit(self) -> None:
"""Commit the current buffer if it changed (idempotent).
Emits ``editing_finished`` exactly once per real change and re-baselines,
so repeated calls (Enter then blur, or a pre-teardown flush) are safe.
"""
if self.text != self._committed_text:
self._committed_text = self.text
self.editing_finished.emit(self.text)
[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. Claimed: the click placed a caret in
# this field and belongs to it.
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()
event.handled = True
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:
# Enter is an explicit submit AND ends the edit session. text_submitted
# stays Enter-only; _commit fires editing_finished iff the value changed.
self.text_submitted.emit(self.text)
self._commit()
elif event.key == "escape" and not event.pressed:
# Revert an in-progress edit and consume the key; if nothing changed,
# leave it unhandled so it bubbles to overlay-chain dismissal.
if self.text != self._committed_text:
self.text = self._committed_text
self.cursor_pos = len(self.text)
self.queue_redraw()
self.text_changed.emit(self.text)
event.handled = True
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_update(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 / 16.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")
size_x = Property(200.0, range=(0, 10000), hint="Control width", on_change="_on_size_changed")
size_y = Property(20.0, range=(0, 10000), hint="Control height", on_change="_on_size_changed")
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()
[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):
# A press on the track sets the value and is claimed; so is the release
# that ends the drag it started.
if event.button == MouseButton.LEFT:
if event.pressed and self.is_point_inside(event.position):
self._dragging = True
self._update_from_mouse(event.position)
event.handled = True
elif not event.pressed:
if self._dragging:
event.handled = True
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(max_value=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")
size_x = Property(200.0, range=(0, 10000), hint="Control width", on_change="_on_size_changed")
size_y = Property(20.0, range=(0, 10000), hint="Control height", on_change="_on_size_changed")
bg_colour = ThemeColour("bg_dark")
fill_colour = ThemeColour("success")
def __init__(self, **kwargs):
super().__init__(**kwargs)
self.show_percentage = True
self.font_size = 12.0
[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 / 16.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)