Source code for simvx.core.ui.core

"""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)