"""Core UI types — Control, Theme, Colour, UIInputEvent, FocusMode, AnchorPreset, SizeFlags, DragData."""
from __future__ import annotations
import logging
from ..nodes_2d.node2d import Node2D
from ..descriptors import Property, Signal
from ..math.types import Vec2
from .enums import AnchorPreset, FocusMode, SizeFlags
from .types import Colour, DragData, Theme, ThemeColour, ThemeSize, ThemeStyleBox, UIInputEvent
log = logging.getLogger(__name__)
__all__ = [
"Control",
"Theme",
"ThemeColour",
"ThemeSize",
"ThemeStyleBox",
"Colour",
"UIInputEvent",
"FocusMode",
"AnchorPreset",
"SizeFlags",
"DragData",
]
def _get_default_theme():
from .theme import get_theme as _get
return _get()
# ============================================================================
# _DrawRecorder — Records draw commands for cache replay
# ============================================================================
# Draw methods that produce visual output (record + forward)
_DRAW_METHODS = frozenset({
"draw_filled_rect", "draw_rect", "draw_rect_coloured", "draw_line", "draw_line_coloured",
"draw_circle", "draw_text", "draw_text_coloured", "draw_texture", "draw_nine_patch",
"push_clip", "pop_clip", "fill_rect", "fill_circle", "draw_thick_line",
"draw_lines", "fill_triangle", "set_colour", "new_layer",
"fill_rect_gradient", "draw_gradient_rect",
})
# Query methods that return values (forward only, don't record)
_QUERY_METHODS = frozenset({"text_width"})
class _DrawRecorder:
"""Wraps a renderer, forwarding all draw calls while recording them.
Query methods like text_width() are forwarded without recording.
"""
__slots__ = ("_renderer", "commands")
def __init__(self, renderer):
self._renderer = renderer
self.commands: list[tuple[str, tuple]] = []
def __getattr__(self, name):
if name in _DRAW_METHODS:
def _record_and_forward(*args, **kwargs):
self.commands.append((name, args))
getattr(self._renderer, name)(*args, **kwargs)
return _record_and_forward
# Query methods and anything else: forward directly
return getattr(self._renderer, name)
# ============================================================================
# Control — Base UI element
# ============================================================================
[docs]
class Control(Node2D):
"""Base class for all UI elements.
Supports anchors, margins, sizing, focus, drag-and-drop, and input handling.
Widgets draw themselves using the renderer passed to draw().
Example:
control = Control(name="panel")
control.size = Vec2(200, 100)
control.set_anchor_preset(AnchorPreset.FULL_RECT)
"""
size_x = Property(100.0, range=(0, 10000), hint="Control width")
size_y = Property(30.0, range=(0, 10000), hint="Control height")
@property
def size(self) -> Vec2:
"""Size backed by size_x/size_y Properties (inspector-synced)."""
return Vec2(self.size_x, self.size_y)
@size.setter
def size(self, val):
self.size_x = float(val[0])
self.size_y = float(val[1])
# Touch policy: "mouse" (primary touch emulates mouse, default),
# "multi" (each finger tracked independently), "pass" (ignore touch)
touch_mode: str = "mouse"
# Draw caching — opt-in per class/instance
_draw_caching: bool = False
_draws_children: bool = False
# Per-frame rect cache — auto-expires each frame, no invalidation needed
_current_frame: int = 0 # Class-level counter, incremented by SceneTree.process()
_rect_frame: int = -1 # Instance default — always misses on first access
def __init__(self, **kwargs):
super().__init__(**kwargs)
self.min_size = Vec2(0, 0)
self.max_size: Vec2 | None = None
# Anchors (0 = left/top, 1 = right/bottom of parent)
self.anchor_left = 0.0
self.anchor_top = 0.0
self.anchor_right = 0.0
self.anchor_bottom = 0.0
# Margins
self.margin_left = 0.0
self.margin_top = 0.0
self.margin_right = 0.0
self.margin_bottom = 0.0
# State (backed by properties that auto-invalidate draw cache)
self._mouse_over = False
self._focused = False
self._disabled = False
self.mouse_filter = True
self.z_index = 0
# Focus system
self.focus_mode: FocusMode = FocusMode.NONE
self.focus_next: Control | None = None
self.focus_previous: Control | None = None
# Size flags (for container layout)
self.size_flags_horizontal: SizeFlags = SizeFlags.FILL
self.size_flags_vertical: SizeFlags = SizeFlags.FILL
self.stretch_ratio: float = 1.0
# Signals
self.mouse_entered = Signal()
self.mouse_exited = Signal()
self.focus_entered = Signal()
self.focus_exited = Signal()
self.gui_input = Signal()
# Theme
self._theme: Theme | None = None
# Tooltip
self.tooltip: str = ""
# Draw cache state (per-instance)
self._draw_dirty: bool = True
self._draw_cache: list | None = None
self._draw_cache_pos: tuple[float, float] | None = None
# ------------------------------------------------------------------ theme property
@property
def theme(self) -> Theme | None:
return self._theme
@theme.setter
def theme(self, value: Theme | None):
if value is not self._theme:
self._theme = value
self._invalidate_subtree_draws()
def _invalidate_subtree_draws(self):
"""Mark this control and all descendant controls as needing redraw."""
self.queue_redraw()
for child in self.children:
if isinstance(child, Control):
child._invalidate_subtree_draws()
# ------------------------------------------------------------------ state properties
@property
def mouse_over(self) -> bool:
return self._mouse_over
@mouse_over.setter
def mouse_over(self, value: bool):
if value != self._mouse_over:
self._mouse_over = value
self.queue_redraw()
@property
def focused(self) -> bool:
return self._focused
@focused.setter
def focused(self, value: bool):
if value != self._focused:
self._focused = value
self.queue_redraw()
@property
def disabled(self) -> bool:
return self._disabled
@disabled.setter
def disabled(self, value: bool):
if value != self._disabled:
self._disabled = value
self.queue_redraw()
# ------------------------------------------------------------------ theme
[docs]
def get_theme(self) -> Theme:
"""Get effective theme (own -> parent -> default)."""
if self._theme:
return self._theme
if self.parent and isinstance(self.parent, Control):
return self.parent.get_theme()
return _get_default_theme()
# ------------------------------------------------------------------ draw caching
def _invalidate_transform(self):
"""Override to also invalidate caches on position/transform changes.
Draw caches contain absolute screen coordinates from get_global_rect(),
so any genuine position change must invalidate them. We compare the
current position against the last cached position to detect real changes
(Node2D._transform_dirty is never cleared for Controls, so it can't be
used as a change-detection guard here).
"""
super()._invalidate_transform()
try:
# Always invalidate rect cache — cheap and needed for mid-frame moves
self._rect_frame = -1
# Check if position actually changed since last cache
pos = self._position
last = self._draw_cache_pos
if last is not None and pos.x == last[0] and pos.y == last[1]:
return # Redundant set — skip draw cache invalidation
self._draw_cache_pos = (float(pos.x), float(pos.y))
if self._draw_caching and self._draw_cache is not None:
self._draw_dirty = True
self._draw_cache = None
except AttributeError:
pass
[docs]
def queue_redraw(self):
"""Mark this control as needing a redraw. Propagates upward to parent."""
try:
dirty = self._draw_dirty
except AttributeError:
return # Called before __init__ finished
if dirty:
return
self._draw_dirty = True
if self.parent and isinstance(self.parent, Control) and self.parent._draws_children:
self.parent.queue_redraw()
def _draw_recursive(self, renderer):
if not self.visible:
return
if self._script_error:
x, y, w, h = self.get_global_rect()
renderer.draw_filled_rect(x, y, w, h, (0.8, 0.1, 0.1, 0.3))
renderer.draw_rect_coloured(x, y, w, h, (1.0, 0.0, 0.0, 1.0))
renderer.draw_text_coloured(f"ERR: {self.name}", x + 4, y + 4, 0.8, (1.0, 1.0, 1.0, 1.0))
self._draw_children_default(renderer)
return
# Invalidate draw cache on global theme change
if self._draw_caching and not self._draw_dirty and self._draw_cache is not None:
from .theme import theme_generation
gen = theme_generation()
if getattr(self, "_cache_theme_gen", -1) != gen:
self._draw_dirty = True
self._draw_cache = None
if self._draw_caching and not self._draw_dirty:
# Replay cached commands
if self._draw_cache is not None:
for cmd_name, cmd_args in self._draw_cache:
getattr(renderer, cmd_name)(*cmd_args)
# If this widget draws its own children, skip default child traversal
if self._draws_children:
return
self._draw_children_default(renderer)
return
if self._draw_caching:
# Record new cache
from .theme import theme_generation
recorder = _DrawRecorder(renderer)
self._safe_call(self.draw, recorder)
self._draw_cache = recorder.commands
self._draw_dirty = False
self._cache_theme_gen = theme_generation()
else:
# Normal path (no caching)
self._safe_call(self.draw, renderer)
if not self._draws_children:
self._draw_children_default(renderer)
def _draw_children_default(self, renderer):
"""Draw children, wrapping non-Control 2D nodes in a clip + offset.
Control children position themselves via get_global_rect() and need no
wrapping. Non-Control children (Node2D game nodes like Line2D,
Polygon2D etc.) draw using world_position which may not account for
the parent Control's screen position. We push a translation and clip
so that (0,0) maps to this Control's top-left and content is confined.
Set ``child.draw_overlay = True`` to draw in screen-space instead.
"""
has_xf = hasattr(renderer, "push_transform")
has_clip = hasattr(renderer, "push_clip")
for child in self.children.safe_iter():
if isinstance(child, Control) or getattr(child, "draw_overlay", False):
child._draw_recursive(renderer)
elif has_xf:
x, y, w, h = self.get_global_rect()
renderer.push_transform(1, 0, 0, 1, x, y)
if has_clip:
renderer.push_clip(round(x), round(y), round(w), round(h))
child._draw_recursive(renderer)
if has_clip:
renderer.pop_clip()
renderer.pop_transform()
else:
child._draw_recursive(renderer)
# ------------------------------------------------------------------ sizing
[docs]
def get_minimum_size(self) -> Vec2:
"""Minimum size needed to display content. Subclasses override."""
return Vec2(max(0, self.min_size.x), max(0, self.min_size.y))
# ------------------------------------------------------------------ rect / layout
[docs]
def get_rect(self) -> tuple[float, float, float, float]:
"""Get (x, y, width, height) in parent space."""
parent_size = self._get_parent_size()
left = parent_size.x * self.anchor_left + self.margin_left
top = parent_size.y * self.anchor_top + self.margin_top
right = parent_size.x * self.anchor_right - self.margin_right
bottom = parent_size.y * self.anchor_bottom - self.margin_bottom
width = self.size.x if self.anchor_left == self.anchor_right else right - left
height = self.size.y if self.anchor_top == self.anchor_bottom else bottom - top
if self.min_size:
width = max(width, self.min_size.x)
height = max(height, self.min_size.y)
if self.max_size:
width = min(width, self.max_size.x)
height = min(height, self.max_size.y)
if width < 0 or height < 0:
log.warning("Layout overflow in %s: computed size (%s, %s)", self, width, height)
return (left, top, width, height)
[docs]
def get_global_rect(self) -> tuple[float, float, float, float]:
"""Get (x, y, width, height) in screen space.
Uses axis-aligned position accumulation (no rotation/scale) since
UI controls are always axis-aligned rectangles. Result is cached
per-frame and auto-expires when the frame counter advances.
"""
if self._rect_frame == Control._current_frame:
return self._global_rect_cache
x, y, w, h = self.get_rect()
gx, gy = self.position.x, self.position.y
node = self.parent
while node is not None:
if hasattr(node, "position") and hasattr(node.position, "x"):
gx += node.position.x
gy += node.position.y
node = node.parent
result = (gx + x, gy + y, w, h)
self._global_rect_cache = result
self._rect_frame = Control._current_frame
return result
def _get_parent_size(self) -> Vec2:
"""Get parent control size, or screen size."""
if self.parent and isinstance(self.parent, Control):
_, _, pw, ph = self.parent.get_rect()
return Vec2(pw, ph)
if self._tree:
ss = self._tree.screen_size
return ss if isinstance(ss, Vec2) else Vec2(ss[0], ss[1])
return Vec2(800, 600)
[docs]
def is_point_inside(self, point) -> bool:
"""Check if point (screen coords) is inside this control."""
x, y, w, h = self.get_global_rect()
px = point.x if hasattr(point, "x") else point[0]
py = point.y if hasattr(point, "y") else point[1]
return x <= px < x + w and y <= py < y + h
# ------------------------------------------------------------------ anchor presets
[docs]
def set_anchor_preset(self, preset: AnchorPreset):
"""Set anchors from a preset.
Example:
panel.set_anchor_preset(AnchorPreset.FULL_RECT) # fills parent
label.set_anchor_preset(AnchorPreset.CENTER) # centered
"""
_PRESET_MAP = {
AnchorPreset.TOP_LEFT: (0, 0, 0, 0),
AnchorPreset.TOP_RIGHT: (1, 0, 1, 0),
AnchorPreset.BOTTOM_LEFT: (0, 1, 0, 1),
AnchorPreset.BOTTOM_RIGHT: (1, 1, 1, 1),
AnchorPreset.CENTER_LEFT: (0, 0.5, 0, 0.5),
AnchorPreset.CENTER_RIGHT: (1, 0.5, 1, 0.5),
AnchorPreset.CENTER_TOP: (0.5, 0, 0.5, 0),
AnchorPreset.CENTER_BOTTOM: (0.5, 1, 0.5, 1),
AnchorPreset.CENTER: (0.5, 0.5, 0.5, 0.5),
AnchorPreset.LEFT_WIDE: (0, 0, 0, 1),
AnchorPreset.RIGHT_WIDE: (1, 0, 1, 1),
AnchorPreset.TOP_WIDE: (0, 0, 1, 0),
AnchorPreset.BOTTOM_WIDE: (0, 1, 1, 1),
AnchorPreset.FULL_RECT: (0, 0, 1, 1),
}
anchors = _PRESET_MAP.get(preset, (0, 0, 0, 0))
self.anchor_left, self.anchor_top, self.anchor_right, self.anchor_bottom = anchors
# ------------------------------------------------------------------ input
def _on_gui_input(self, event: UIInputEvent):
"""Override in subclasses to handle input."""
pass
# ------------------------------------------------------------------ focus system
[docs]
def set_focus(self):
"""Request focus for this control."""
log.debug("Focus requested: %s", self)
if self._tree:
self._tree._set_focused_control(self)
[docs]
def grab_focus(self):
"""Claim keyboard focus for this control.
Unfocuses the currently focused control (if any) and sets focus to this one.
Respects focus_mode: NONE-mode controls cannot receive focus.
"""
if self.focus_mode == FocusMode.NONE:
return
self.set_focus()
[docs]
def release_focus(self):
"""Release focus from this control."""
if self._tree and self._tree._focused_control is self:
self._tree._set_focused_control(None)
[docs]
def has_focus(self) -> bool:
"""Return True if this control currently has keyboard focus."""
return self.focused
def _on_focus_gained(self):
"""Override for focus visual feedback."""
pass
def _on_focus_lost(self):
"""Override for removing focus visual."""
pass
[docs]
def focus_next_control(self):
"""Move focus to next control in tab order."""
if self.focus_next and self.focus_next.focus_mode != FocusMode.NONE:
self.focus_next.grab_focus()
return
nxt = self._find_next_focusable()
if nxt:
nxt.grab_focus()
[docs]
def focus_previous_control(self):
"""Move focus to previous control in tab order."""
if self.focus_previous and self.focus_previous.focus_mode != FocusMode.NONE:
self.focus_previous.grab_focus()
return
prev = self._find_previous_focusable()
if prev:
prev.grab_focus()
def _find_next_focusable(self) -> Control | None:
"""Walk tree in pre-order to find next FocusMode.ALL control after self."""
controls = self._collect_focusable_controls()
if not controls:
return None
try:
idx = controls.index(self)
return controls[(idx + 1) % len(controls)]
except ValueError:
return controls[0] if controls else None
def _find_previous_focusable(self) -> Control | None:
"""Walk tree in pre-order to find previous FocusMode.ALL control before self."""
controls = self._collect_focusable_controls()
if not controls:
return None
try:
idx = controls.index(self)
return controls[(idx - 1) % len(controls)]
except ValueError:
return controls[-1] if controls else None
def _collect_focusable_controls(self) -> list[Control]:
"""Collect all FocusMode.ALL controls in the tree in pre-order."""
root = self
while root.parent is not None:
root = root.parent
result: list[Control] = []
self._walk_focusable(root, result)
return result
@staticmethod
def _walk_focusable(node, result: list[Control]):
"""Pre-order walk collecting focusable controls."""
if isinstance(node, Control) and node.focus_mode == FocusMode.ALL:
result.append(node)
for child in node.children:
Control._walk_focusable(child, result)
# ------------------------------------------------------------------ mouse capture
[docs]
def grab_mouse(self):
"""Capture mouse -- all mouse events route to this control until released."""
if self._tree:
self._tree._mouse_grab = self
[docs]
def release_mouse(self):
"""Release mouse capture."""
if self._tree and self._tree._mouse_grab is self:
self._tree._mouse_grab = None
def _update_mouse_over(self, mouse_pos):
"""Update mouse-over state and fire signals."""
was_over = self._mouse_over
self.mouse_over = self.is_point_inside(mouse_pos) # property setter handles queue_redraw
if self._mouse_over != was_over:
if self._mouse_over:
self.mouse_entered()
else:
self.mouse_exited()
def _internal_gui_input(self, event: UIInputEvent):
"""Route input to handler and signal."""
if self.disabled:
return
self._on_gui_input(event)
self.gui_input(event)
# ------------------------------------------------------------------ drag & drop
def _get_drag_data(self, position) -> DragData | None:
"""Override: return DragData if this control supports dragging from this position."""
return None
def _can_drop_data(self, position, data: DragData) -> bool:
"""Override: return True if this control accepts this drag data at the given position."""
return False
def _drop_data(self, position, data: DragData):
"""Override: handle the dropped data."""
pass
[docs]
def set_drag_preview(self, control: Control):
"""Set a visual preview control for the current drag operation."""
if self._tree and hasattr(self._tree, "_drag_preview"):
self._tree._drag_preview = control
# ----------------------------------------------------------------- popup API