"""Core UI types: Control, Theme, Colour, UIInputEvent, FocusMode, AnchorPreset, SizeFlags, DragData."""
import logging
from ..descriptors import Property
from ..math.types import Vec2
from ..nodes_2d.node2d import Node2D
from ..properties import Colour
from ..signals import Signal
from .enums import AnchorPreset, FocusMode, SizeFlags
from .types import 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_rect",
"draw_line",
"draw_circle",
"draw_text",
"draw_texture",
"draw_nine_patch",
"push_clip",
"pop_clip",
"draw_thick_line",
"draw_lines",
"fill_triangle",
"fill_quad",
"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.
The main render path is the 2D item pipeline: Control renders through the one
``RenderItemCache`` (the collection walk runs each Control's ``on_draw``
through the item builder's ``_OpRecorder``, retains its items, and re-uploads
only what changed) -- this recorder is **not** on that path.
This recorder remains the draw-cache for the ``Draw2D`` ``_draw_recursive``
walk (record/replay so a clean widget skips re-dispatching ``on_draw``), which
the editor's play-mode game viewport and the web runtime still walk. Control
sets the ``Drawable2D`` render bit too (:meth:`Control.queue_redraw`), so the
item cache and this recorder coexist without conflict.
"""
__slots__ = ("_renderer", "commands")
def __init__(self, renderer):
self._renderer = renderer
self.commands: list[tuple[str, tuple, dict]] = []
def __getattr__(self, name):
if name in _DRAW_METHODS:
def _record_and_forward(*args, **kwargs):
self.commands.append((name, args, kwargs))
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", on_change="_on_size_changed")
size_y = Property(30.0, range=(0, 10000), hint="Control height", on_change="_on_size_changed")
# Anchors pin each edge to a fraction of the parent/screen rect
# (0 = left/top, 1 = right/bottom). Use set_anchor_preset() for common presets.
anchor_left = Property(0.0, range=(0.0, 1.0), group="Layout", hint="Left anchor (0=parent left, 1=parent right)")
anchor_top = Property(0.0, range=(0.0, 1.0), group="Layout", hint="Top anchor (0=parent top, 1=parent bottom)")
anchor_right = Property(0.0, range=(0.0, 1.0), group="Layout", hint="Right anchor (0=parent left, 1=parent right)")
anchor_bottom = Property(
0.0, range=(0.0, 1.0), group="Layout", hint="Bottom anchor (0=parent top, 1=parent bottom)"
)
# Margins are pixel offsets from each anchor point.
margin_left = Property(0.0, group="Layout", hint="Pixel offset from left anchor")
margin_top = Property(0.0, group="Layout", hint="Pixel offset from top anchor")
margin_right = Property(0.0, group="Layout", hint="Pixel offset from right anchor")
margin_bottom = Property(0.0, group="Layout", hint="Pixel offset from bottom anchor")
# On-top behaviour is the overlay layer's job: call ``show_overlay(modality)``
# to register this control as an overlay (drawn on top by the single collector,
# input scoped per its capture level) until ``close_overlay()``. The legacy
# ``modal`` / ``pause_tree_when_modal`` / ``top_level`` Properties are retired.
# ``dismiss_on_outside_click`` remains as a widget-set hint.
dismiss_on_outside_click = Property(
True, group="Overlay", hint="Overlay: dismiss when clicking outside the overlay's children"
)
@property
def size(self) -> Vec2:
"""Size backed by size_x/size_y Properties (inspector-synced)."""
return Vec2(self.size_x, self.size_y)
[docs]
@size.setter
def size(self, val):
self.size_x = float(val[0])
self.size_y = float(val[1])
def _on_size_changed(self) -> None:
"""Route a size change into layout invalidation (size Property on_change).
A size change means the owning container must reposition its children,
and (if this control is itself a container) it must re-lay-out its own
children in the new size. ``mark_layout_dirty`` is duck-typed so this base
method needs no ``Container`` import. Containers mid-layout are skipped:
they set child sizes deliberately, so re-marking them would churn.
"""
mark = getattr(self, "mark_layout_dirty", None)
if mark is not None and not self._laying_out:
mark() # self is a container whose own size changed
node = self.parent
while node is not None:
parent_mark = getattr(node, "mark_layout_dirty", None)
if parent_mark is not None:
if not getattr(node, "_laying_out", False):
parent_mark()
break
node = getattr(node, "parent", None)
# 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
# True while this control is registered as an overlay (``show_overlay``). The
# normal draw walk and the item-builder walk prune ``_is_overlay`` subtrees
# (the control is reachable in the tree for hit-testing but drawn exactly once
# by the overlay collector). Class-level default so the prune is a cheap getattr.
_is_overlay: bool = False
# Hit-test clipping: controls that visually push_clip on their children
# (e.g. ScrollContainer) set this so ``UIInputManager._find_control_at_point``
# skips descendants when the cursor is outside the parent's rect. Without
# this, scrolled-out-of-view children still receive clicks.
_clips_input: bool = False
# Per-frame rect cache: auto-expires each frame, no invalidation needed. The
# live epoch is per-tree (``SceneTree._layout_frame``); this class-level
# counter is only the fallback for controls not currently in a tree.
_current_frame: int = 0 # Detached-control fallback; per-tree epoch is authoritative
_rect_frame: int = -1 # Instance default: always misses on first access
_laying_out: bool = False # True while this control's own layout pass runs (Container)
def __init__(self, **kwargs):
super().__init__(**kwargs)
self.min_size = Vec2(0, 0)
self.max_size: Vec2 | None = None
# 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()
# Dismissal signal: emitted by the overlay router on an outside click /
# Escape (and by consumer widgets) so an overlay can unwind its own state.
self.cancel_requested = Signal()
self.confirm_requested = 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
[docs]
@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
[docs]
@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
[docs]
@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
[docs]
@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, _from_parent: bool = False):
"""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(_from_parent=_from_parent)
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.
Extends :meth:`Drawable2D.queue_redraw` additively: it sets the
item-pipeline render-dirty bit (read by the item ``RenderItemCache``, P2)
AND keeps the ``_draw_dirty``/``_DrawRecorder`` cache path (read by the
``Draw2D`` ``_draw_recursive`` walk the editor play-mode game viewport +
web runtime still use).
"""
# Item-pipeline render-dirty (Drawable2D). Always set: the item cache
# owns its own drain, independent of the legacy _draw_dirty short-circuit.
self._render_dirty = True
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()
# Control plugs into the unified ``Node._draw_recursive`` skeleton (design
# §7) via the policy hooks below: it always takes the FAST PATH ((None, None)
# ordering -- children draw in tree order, positioned by their own
# get_rect(), not z-banded), supplies the retained draw-cache self-draw, the
# per-child clip+offset child traversal, and the error-box presentation.
def _ordered_children(self):
"""Control children draw in tree order (positioned by get_rect, no z-band)."""
return None, None
def _draw_self(self, renderer):
"""Self-draw via the retained draw cache (record / replay / passthrough).
Sets ``_skip_children`` when a cached replay already reproduced this
widget's children (a ``_draws_children`` widget recorded its child draws
into the cache), so ``_draw_children`` knows to skip the default walk.
"""
self._skip_children = self._draws_children
# 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 (includes recorded child draws for
# ``_draws_children`` widgets).
if self._draw_cache is not None:
for cmd_name, cmd_args, cmd_kwargs in self._draw_cache:
getattr(renderer, cmd_name)(*cmd_args, **cmd_kwargs)
return
if self._draw_caching:
from .theme import theme_generation
recorder = _DrawRecorder(renderer)
self._draw_dispatch(recorder)
self._draw_cache = recorder.commands
self._draw_dirty = False
self._cache_theme_gen = theme_generation()
else:
self._draw_dispatch(renderer)
def _draw_children(self, renderer):
"""Default child traversal -- skipped for widgets that draw their own."""
if not getattr(self, "_skip_children", False):
self._draw_children_default(renderer)
def _draw_script_error(self, renderer):
"""Paint an error box over this Control's rect, then walk children."""
x, y, w, h = self.get_global_rect()
renderer.draw_rect((x, y), (w, h), colour=(0.8, 0.1, 0.1, 0.3), filled=True)
renderer.draw_rect((x, y), (w, h), colour=(1.0, 0.0, 0.0, 1.0))
renderer.draw_text(f"ERR: {self.name}", (x + 4, y + 4), colour=(1.0, 1.0, 1.0, 1.0), scale=0.8)
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():
# Overlays (``_is_overlay``) are drawn once, on top, by the overlay
# collector (SceneTree.render + the item pipeline). Skip them here so
# they neither draw twice nor draw clipped/under their siblings.
if getattr(child, "_is_overlay", False):
continue
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."""
# Fast path: default anchors (0,0,0,0): most widgets. Skip parent_size fetch
# entirely and avoid Vec2 allocations.
al, at, ar, ab = self.anchor_left, self.anchor_top, self.anchor_right, self.anchor_bottom
if al == 0.0 and at == 0.0 and ar == 0.0 and ab == 0.0:
left = self.margin_left
top = self.margin_top
width = self.size_x
height = self.size_y
else:
parent = self.parent
if parent is not None and isinstance(parent, Control):
_, _, pw, ph = parent.get_rect()
elif self._tree is not None:
ss = self._tree.screen_size
pw = float(ss.x) if hasattr(ss, "x") else float(ss[0])
ph = float(ss.y) if hasattr(ss, "y") else float(ss[1])
else:
pw, ph = 800.0, 600.0
left = pw * al + self.margin_left
top = ph * at + self.margin_top
right = pw * ar - self.margin_right
bottom = ph * ab - self.margin_bottom
# When anchors collapse on an axis, derive size from the matching
# margin pair if either margin is non-zero (BOTTOM_WIDE / TOP_WIDE
# encode height as ``margin_top = -height``). Falls back to
# ``size_x/y`` when both margins are zero so anchored-but-fixed-size
# widgets still honour their declared size.
if al == ar:
margin_w = self.margin_right - self.margin_left
width = margin_w if margin_w else self.size_x
else:
width = right - left
if at == ab:
margin_h = self.margin_bottom - self.margin_top
height = margin_h if margin_h else self.size_y
else:
height = bottom - top
if self.min_size is not None:
width = max(width, self.min_size.x)
height = max(height, self.min_size.y)
if self.max_size is not None:
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.
"""
tree = self._tree
frame = tree._layout_frame if tree is not None else Control._current_frame
if self._rect_frame == frame:
return self._global_rect_cache
x, y, w, h = self.get_rect()
gx = self.position.x + x
gy = self.position.y + y
node = self.parent
while node is not None:
if isinstance(node, Control):
# Control ancestor contributes both its .position and its
# anchor/margin-derived rect offset. get_global_rect() is
# per-frame cached, so this walk is O(depth) once per frame.
nx, ny, _, _ = node.get_rect()
gx += node.position.x + nx
gy += node.position.y + ny
elif hasattr(node, "position") and hasattr(node.position, "x"):
gx += node.position.x
gy += node.position.y
node = node.parent
result = (gx, gy, w, h)
self._global_rect_cache = result
self._rect_frame = 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 place_bottom_strip(self, height: float) -> None:
"""Anchor this control as a full-width horizontal strip at the bottom.
Equivalent to ``set_anchor_preset(AnchorPreset.BOTTOM_WIDE)`` plus
``margin_top = -height``. The negative-margin convention is correct but
surprising; this shortcut makes intent obvious.
"""
self.set_anchor_preset(AnchorPreset.BOTTOM_WIDE)
self.margin_top = -float(height)
self.margin_bottom = 0.0
[docs]
def place_top_strip(self, height: float) -> None:
"""Anchor this control as a full-width horizontal strip at the top."""
self.set_anchor_preset(AnchorPreset.TOP_WIDE)
self.margin_top = 0.0
self.margin_bottom = float(height)
[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 _exit_tree(self):
"""Release any focus / mouse grab owned by this subtree before detaching.
Without this, a focused (or mouse-grabbing) control that is destroyed or
reparented would remain the tree's focus owner and keep receiving
keyboard events: a destroyed button could re-fire on a later key
release. The check runs before ``super()._exit_tree()`` detaches
children, while parent links are still intact, so a focused *descendant*
is correctly recognised as part of the leaving subtree.
"""
if self._tree is not None:
# Centralised on-strand safety: an overlay torn out of the tree pops
# itself from the registry so the layer never holds a detached control.
if self.is_overlay_open:
self._tree.overlays.close(self)
self._tree._notify_ui_subtree_removed(self)
super()._exit_tree()
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
def _first_focusable_descendant(self) -> Control | None:
"""Pre-order walk of self's subtree returning the first FocusMode.ALL Control."""
result: list[Control] = []
Control._walk_focusable(self, result)
# Walk includes self; prefer descendants if any.
for c in result:
if c is not self:
return c
return result[0] if result else None
# ----------------------------------------------------------------- overlay API
[docs]
@property
def is_overlay_open(self) -> bool:
"""True while this control is registered as an open overlay."""
return self._is_overlay
[docs]
def show_overlay(
self,
modality: str = "light",
*,
dim: bool | None = None,
dismiss: bool | None = None,
inert: bool | None = None,
owner: Control | None = None,
initial_focus: Control | None = None,
) -> None:
"""Register this control as an on-top overlay until :meth:`close_overlay`.
``modality`` is one of ``"none"`` / ``"light"`` / ``"blocking"`` (design
§0 preset table). ``dim`` / ``dismiss`` / ``inert`` (``None`` = preset
default) override individual flags. ``owner`` is the logical chain owner
for whole-chain dismissal + input scope (e.g. a ``MenuBar`` for its
dropdown + submenu overlays); defaults to ``self``. ``initial_focus`` is
the descendant to focus when the overlay is capturing (``None`` = the
first focusable descendant). Positioning is the widget's own job: position
first, then call ``show_overlay`` to register.
"""
from .overlay import DEFAULT_SCRIM_COLOUR, OverlayEntry
cap, dim_f, dismiss_f, inert_f = OverlayEntry.expand_preset(modality, dim=dim, dismiss=dismiss, inert=inert)
tree = self._tree
# Resolve the owning render target: nearest enclosing SubViewport, else None.
viewport = None
node = self.parent
while node is not None:
if getattr(node, "_is_subviewport", False):
viewport = node
break
node = getattr(node, "parent", None)
# Resolve the dim colour from the active theme (carried on the entry so the
# collector needs no theme lookup).
try:
dim_colour = self.get_theme().get_colour("overlay_scrim", DEFAULT_SCRIM_COLOUR)
except Exception:
dim_colour = DEFAULT_SCRIM_COLOUR
prev_focus = tree._focused_control if tree is not None else None
entry = OverlayEntry(
control=self,
modality=modality,
capture_input=cap,
dim=dim_f,
inert=inert_f,
dismiss_on_outside_click=dismiss_f,
dim_colour=dim_colour,
owner=owner if owner is not None else self,
viewport=viewport,
prev_focus=prev_focus,
initial_focus=initial_focus,
)
self.visible = True
# Only mark as an overlay when actually registered: a tree-less control
# cannot register, and a stale ``_is_overlay`` flag would make the draw
# walk prune it without the collector ever drawing it (an invisible zombie).
if tree is not None:
self._is_overlay = True
tree.overlays.open(self, entry)
[docs]
def close_overlay(self) -> None:
"""Pop this control (and its open chain) from the overlay registry. Idempotent.
Mirrors :meth:`show_overlay`, which sets ``visible = True``: closing hides the
control again so a persistent (non-freed) overlay -- a reused title/menu
screen -- stops drawing over the scene once dismissed. A consumer that frees
its overlay on close is unaffected (the hide is harmless); one that reuses it
gets ``visible = True`` back on the next ``show_overlay``.
"""
if not self._is_overlay:
return
self._is_overlay = False
self.visible = False
if self._tree is not None:
self._tree.overlays.close(self)