Source code for simvx.core.ui.core

"""Core UI types: Control, Theme, Colour, UIInputEvent, FocusMode, AnchorPreset, SizeFlags, DragData."""

import logging
import weakref
from typing import Any

from ..descriptors import Property
from ..math.types import Vec2
from ..node import T
from ..nodes_2d.node2d import Node2D
from ..properties import Colour
from ..signals import Signal
from ..text.measure import add_metrics_listener
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") # The floor under this control's measured minimum, and so under the size a # container gives it. Declared like the size pair -- scalars behind a Vec2 # view -- rather than kept as a plain attribute, so that raising it retires # the remembered measurements that were taken under the old floor. The range # is an inspector hint only (``clamp=False``): a floor is not a limit. # # They route into ``_on_size_changed`` for the same reason ``size_x`` does: # raising a floor widens the rect this control is drawn at, which moves its # siblings in a container and invalidates the geometry the retained pipeline # holds for it. Forgetting the measurements is only the first line of that # method, so it is a strict superset of what these used to do. min_size_x = Property( 0.0, range=(0, 10000), clamp=False, group="Layout", hint="Minimum width", on_change="_on_size_changed" ) min_size_y = Property( 0.0, range=(0, 10000), clamp=False, group="Layout", hint="Minimum 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. # A change moves this control's rect, and the whole subtree with it (children # are laid out from the rect origin), so on_change marks the moved set for # the retained item pipeline. anchor_left = Property( 0.0, range=(0.0, 1.0), group="Layout", hint="Left anchor (0=parent left, 1=parent right)", on_change="_on_rect_changed", ) anchor_top = Property( 0.0, range=(0.0, 1.0), group="Layout", hint="Top anchor (0=parent top, 1=parent bottom)", on_change="_on_rect_changed", ) anchor_right = Property( 0.0, range=(0.0, 1.0), group="Layout", hint="Right anchor (0=parent left, 1=parent right)", on_change="_on_rect_changed", ) anchor_bottom = Property( 0.0, range=(0.0, 1.0), group="Layout", hint="Bottom anchor (0=parent top, 1=parent bottom)", on_change="_on_rect_changed", ) # Margins are pixel offsets from each anchor point. Like the anchors, a # change can move or resize the rect, and everything laid out inside it # goes with it, so on_change marks the subtree for the item pipeline (a # conservative mark: margins an anchor preset leaves unused still fire). margin_left = Property(0.0, group="Layout", hint="Pixel offset from left anchor", on_change="_on_rect_changed") margin_top = Property(0.0, group="Layout", hint="Pixel offset from top anchor", on_change="_on_rect_changed") margin_right = Property(0.0, group="Layout", hint="Pixel offset from right anchor", on_change="_on_rect_changed") margin_bottom = Property(0.0, group="Layout", hint="Pixel offset from bottom anchor", on_change="_on_rect_changed") # 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" ) # Whether children are drawn through this control's own rect. Godot spells # the same switch ``Control.clip_contents``; the rect it clips to is derived # from the control (:meth:`_child_clip_rect`), not declared beside it. clip_contents = Property(False, group="Layout", hint="Clip children's drawing to this control's rect") # Whether this control can take keyboard focus, and so whether Tab reaches # it and an overlay may elect it. Declared rather than assigned in # ``__init__``: a subclass that wants a different default redeclares this # Property, and an ``__init__`` assignment on the base class would overwrite # every one of those on construction. # # The value is a ``FocusMode`` member or the integer it wraps # (``Control(focus_mode=2)``). The name form does not work: ``FocusMode`` is # an ``IntEnum``, so the implied coercion is ``FocusMode(value)`` and # ``FocusMode("all")`` has no member to find. focus_mode = Property(FocusMode.NONE, hint="Whether this control can take keyboard focus") # Hover text shown by the tooltip layer after the cursor rests on this # control. Empty means no tooltip. tooltip = Property("", hint="Hover text (empty = no tooltip)") # Whether this control is hit-testable at all. A falsy value makes clicks # pass straight through to whatever sits behind it, which is what a # decorative panel over a game viewport wants. Read for truthiness, never # compared against ``True``, so a caller may store a richer marker. mouse_filter = Property(True, hint="Take part in hit-testing (False = clicks pass through)") # How a container treats this control on each axis, and its share of the # room left over once every child has its minimum. These are how container # layout is authored, so they belong beside the anchors rather than in a # constructor body. :class:`SizeFlags` says what each flag means. size_flags_horizontal = Property(SizeFlags.FILL, group="Layout", hint="Container sizing behaviour, horizontal") size_flags_vertical = Property(SizeFlags.FILL, group="Layout", hint="Container sizing behaviour, vertical") stretch_ratio = Property( 1.0, range=(0.0, 100.0), clamp=False, group="Layout", hint="Share of a container's leftover room, relative to its EXPAND siblings", ) # Ceiling on the rect this control is given, or ``None`` for no ceiling. # A ``Vec2``; unlike ``min_size`` it is one value rather than a pair of # scalars, because nothing derives a partial maximum from one axis. Lowering # it shrinks the rect, so it takes the same route into layout and the # retained pipeline as the size and minimum-size properties. max_size = Property( None, group="Layout", hint="Maximum size as a Vec2, or None for unbounded", on_change="_on_size_changed" ) @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])
@property def min_size(self) -> Vec2: """Minimum size, backed by min_size_x/min_size_y Properties (inspector-synced). A view, like :attr:`size`: assign a whole vector to change it. Editing the returned vector in place changes nothing. """ return Vec2(self.min_size_x, self.min_size_y)
[docs] @min_size.setter def min_size(self, val): self.min_size_x = float(val[0]) self.min_size_y = float(val[1])
# Size this control last gave itself from its content, so that a later # re-fit can tell "still as measured" from "the game resized me". _content_size: tuple[float, float] | None = None # Content that size was measured from, so a later re-fit can tell "the same # content measures differently now" from "the content itself changed". _content_key: object = None
[docs] def content_signature(self) -> object: """What :meth:`get_minimum_size` measures, beyond the font metrics. Assigning new content to a control does not resize it; that is the caller's to do. A re-fit therefore has to distinguish content it may re-measure from content that has changed underneath it since, because re-measuring the latter would apply a resize nobody asked for, at whatever moment the metrics happened to move. Subclasses whose minimum size depends on their own state return it here. The default says "nothing that can change", which is the safe answer: it makes every re-fit re-measure, as it would without this at all. """ return None
[docs] def content_uses(self, chars: str) -> bool: """Whether new metrics for *chars* can change this control's minimum size. A control answering ``False`` is left alone when only those characters moved, which is what keeps a browser rasterising glyphs a few at a time from re-fitting an entire UI on every frame it does so. Subclasses that size themselves to a known string answer from it. The default says any character might matter, which is the safe answer. """ return True
[docs] def autosize_to_content(self) -> None: """Size this control to fit its content, and keep it fitted. How wide a string is depends on the font it is drawn with, and a renderer can resolve that font after the scene has been built. Controls sized this way are measured again when that happens, so a button never ends up narrower than the label inside it. A size set from anywhere else wins: a container that lays this control out, or a game that assigns ``size``, keeps its size, and only the layout is told to run again. """ self.size = self.get_minimum_size() # Read back rather than reuse the minimum, so the comparison in # ``_refit_to_content`` is against exactly what the size properties hold. self._content_size = (self.size_x, self.size_y) self._content_key = self.content_signature() # A size the control measured for itself is not one the author typed, so # a save must not write it into their constructor call: a `Label` that # loaded back with its measured width would be a sized control, and # would never re-fit when the font behind it resolves. self._record_derived("size_x", self.size_x) self._record_derived("size_y", self.size_y) _content_sized.add(self)
def _refit_to_content(self, chars: str = "") -> None: """Re-measure after the metrics for *chars* moved (all of them if empty). Left alone unless this control still holds the size it measured for itself, out of the content it measured then: a container or a game may own the size now, and the content may have moved on. Either way only the minimum changed, and whoever owns the layout runs again with the new one. """ if chars and not self.content_uses(chars): return owns_size = self._content_size is not None and (self.size_x, self.size_y) == self._content_size if owns_size and self._content_key == self.content_signature(): self.autosize_to_content() else: self._on_size_changed() self.queue_redraw() 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. The remembered minimum sizes go too, up the whole chain: a container measures the room its children occupy, and one of them just resized. """ self._invalidate_minimum_size() 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) # A size change moves every anchored child (their rect derives from this # control's size) and, with each one, its whole subtree. Mark the moved # set for the retained item pipeline, which otherwise replays their # absolute coordinates as captured. Zero-anchor children keep their # offset from this rect's unchanged origin, so they stay retained. for child in self.children: if isinstance(child, Control) and ( child.anchor_left or child.anchor_top or child.anchor_right or child.anchor_bottom ): child._mark_transform_render_dirty() self._mark_layout_rect_dirty() def _mark_layout_rect_dirty(self) -> None: """Force a re-collect when this control's rect moved under a world child. The retained pipeline bakes the clip rectangle a world-space child is drawn through at collection time, and the patch path rewrites only the appearance columns. So a rect change would move the child's geometry while its scissor stayed where the panel used to be, and the child would be clipped away by a rectangle it no longer sits in. This bit is the same bridge a dirty ``CanvasLayer`` uses, and for the same reason: the stale quantity belongs to a whole subtree, which an in-place per-node re-capture has no handle on. Gated on actually hosting a world-space child. A container animating a child control's margins must not force a scene re-collect every frame, and a rect change on a control with only ``Control`` children moves no baked clip. """ for child in self.children: if isinstance(child, Node2D) and not isinstance(child, Control): self._layout_rect_dirty = True return def _on_rect_changed(self) -> None: """Anchor/margin on_change: this control's rect may have moved in its parent. Children are laid out relative to the rect's origin, so when the rect moves the whole subtree draws at new absolute coordinates. Mark the transform bits (self plus every 2D descendant) so the retained item pipeline re-captures the moved geometry; this control's own render-dirty bit was already raised by the blanket Property hook on the same write. The :meth:`_invalidate_transform` call is a PROMPTNESS optimisation, not the correctness mechanism: it makes a mid-frame anchor write visible on the same frame. Correctness is the layout epoch in :attr:`_transform_dirty`, which picks the change up on the next tick whether or not anything hooked it. Removing this line costs a frame of latency; removing the epoch loses the answer. """ # ``_invalidate_transform`` raises the render bit on self plus every 2D # descendant before it touches the transform flag, so the mark is covered. self._invalidate_transform() self._mark_layout_rect_dirty() # 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 # Remembered combined minimum size (:meth:`_combined_minimum_size`) and the # text-metrics epoch it was measured under. Class-level defaults, so a # control that is never measured pays nothing for the cache. _min_size_cache: tuple[float, float] | None = None _min_size_epoch: int = -1 # 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) # The two halves of :attr:`_transform_dirty`, which this class re-declares as a # descriptor over them. Class-level so they read safely during # ``Node2D.__init__``, which assigns ``self._transform_dirty = True`` through # the descriptor before any instance attribute exists. Kept separate from # ``_rect_frame``: the two are stamped at different moments, and one field # would let a fresh transform certify a stale ``(width, height)``. _transform_flag: bool = True _transform_epoch: int = -1 # Raised when this control's rect moved AND it hosts a world-space child whose # clip the retained pipeline baked at collection. See :meth:`_on_rect_changed`. _layout_rect_dirty: bool = False def __init__(self, **kwargs): super().__init__(**kwargs) # State (backed by properties that auto-invalidate draw cache) self._mouse_over = False self._focused = False self._disabled = False # Explicit tab-order links. Node references, so there is no source form # a scene file could carry: these stay plain attributes. self.focus_next: Control | None = None self.focus_previous: Control | None = None # 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 # 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 # Screen-space origin this control had when the cache was recorded. A # replay reproduces absolute coordinates, so it is only valid while the # control still sits there. self._draw_cache_origin: 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()
# ------------------------------------------------------------------ layout in the transform chain _has_layout_offset = True def _layout_offset(self) -> Vec2: """This control's anchor/margin rect origin, in its parent's space. The hook that puts UI layout into the 2D transform chain: a control's local translation is ``position`` plus this, so :attr:`world_position` reports where the control is drawn, and so does that of any ``Node2D`` parented into it. :meth:`get_rect` reads anchors, margins, sizes, the parent control's rect and the screen size, and reads no world transform and not ``position``, so there is no cycle. """ x, y, _w, _h = self.get_rect() return Vec2(x, y) def _layout_epoch(self) -> int: """The layout frame this control's cached rect and transform belong to. Per-tree (``SceneTree._layout_frame``, bumped every tick and after every layout flush); the class counter is the fallback for a detached control. """ tree = self._tree return tree._layout_frame if tree is not None else Control._current_frame @property def _transform_dirty(self) -> bool: """True when this control's cached world transform may not be reused. A control's world position depends on :meth:`get_rect`, whose inputs include ``min_size``, ``max_size``, the parent's clamped rect and the screen size, several of which move with no invalidation hook at all. Rather than enumerate them, the cache is authoritative for at most ONE layout frame, which is exactly the lifetime :meth:`get_global_rect` has always had. So any input to the rect may change through any route and the world position is right on the next tick, with nothing hooked. The price, stated plainly: a control's world transform is recomputed once per frame rather than once per move. A plain ``Node2D`` pays nothing -- the descriptor is declared here, so a sprite's read stays a bare instance-attribute lookup. """ if self._transform_flag: return True return self._layout_epoch() != self._transform_epoch @_transform_dirty.setter def _transform_dirty(self, value: bool) -> None: self._transform_flag = bool(value) def _recompute_global_transform(self): """Recompute, stamp the layout epoch, and report a rect move downward. The epoch alone would fix only this control: a ``Sprite2D`` under it caches its own world position against a plain flag with no epoch, so it would keep answering from a cache taken before the panel moved. When the recompute finds the value moved, the move is reported to the children exactly as a direct write would report it, and their caches cannot outlive this one. """ previous = self._cached_world_position super()._recompute_global_transform() self._transform_epoch = self._layout_epoch() # The recompute fills the cache on both of its branches, so the value is # there whatever the node's parentage. current = self._cached_world_position assert current is not None if previous is not None and (previous.x != current.x or previous.y != current.y): for child in self.children: if isinstance(child, Node2D): child._invalidate_transform(_from_parent=True) # ------------------------------------------------------------------ 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 raised by a move and lowered by whatever reads the transform next, so it does not survive as a change record here). This sees a control's own move only. A move of an ancestor leaves every descendant's local position untouched, and is caught at replay time by the origin check in :meth:`_draw_self`. """ if not self._transform_flag: # ``Node2D``'s walk short-circuits on ``_transform_dirty``, reading it # as "my descendants have already been told". For a control that is # only ever true of the FLAG: an expired layout epoch says the cache # needs recomputing and says nothing about descendants, which cache # against plain flags of their own. Retire the epoch so the walk tests # the flag and reaches them. self._transform_epoch = self._layout_epoch() 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 parent = self.parent if isinstance(parent, Control) and parent._draws_children: # The parent recorded this child's draws into its own cache, at # the coordinates the child held then. The parent has not moved, # so its own origin check cannot catch this; the move has to be # reported upward the way ``queue_redraw`` reports a repaint. parent.queue_redraw() 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``) 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 # A widget asks to be redrawn because what it draws changed, and what it # draws is what it measures itself from, so the remembered minimum sizes # go with it. This is unconditional: the cache must be dropped even when # the draw-dirty flag below short-circuits a second request. self._invalidate_minimum_size() 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 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: if self._draw_cache is None: return # The recording holds absolute screen coordinates, so it may only be # replayed while this control still sits where it did at capture. A # move of an *ancestor* leaves this control's own position untouched, # which is why the self-move shortcut in ``_invalidate_transform`` # cannot see it; comparing the recorded origin does, and costs one # read of a rect that is already cached for the frame. if self._draw_cache_origin == self.get_global_rect()[:2]: # Replay cached commands (includes recorded child draws for # ``_draws_children`` widgets). for cmd_name, cmd_args, cmd_kwargs in self._draw_cache: getattr(renderer, cmd_name)(*cmd_args, **cmd_kwargs) return self._draw_dirty = True self._draw_cache = None if self._draw_caching: from .theme import theme_generation recorder = _DrawRecorder(renderer) self._draw_dispatch(recorder) self._draw_cache = recorder.commands self._draw_cache_origin = self.get_global_rect()[:2] self._draw_dirty = False self._cache_theme_gen = theme_generation() else: self._draw_dispatch(renderer) def _child_clip_rect(self) -> tuple[float, float, float, float] | None: """Screen-space rect this control confines its children to, or ``None``. The rect behind the public ``clip_contents`` switch: with it set, a control's children are drawn through the control's own rect, and content that does not fit is clipped away rather than drawn over the siblings around it. :meth:`_draw_children` wraps the ordinary child walk in this rect, and the item pipeline's collection walk opens a scope for the same one. Children are still drawn once, by the one walker, so each keeps its own retained items and its own draw cache. A control that shows a window onto content larger than itself and needs that window to be narrower than its rect -- a scrolling viewport reserving a scrollbar gutter -- overrides this to return the narrower rect, and keeps honouring ``clip_contents`` by returning ``None`` when it is off. The rect is returned in float screen space; both walkers round it to whole pixels before it becomes a scissor. """ return self.get_global_rect() if self.clip_contents else None def _draw_children(self, renderer): """Default child traversal -- skipped for widgets that draw their own. Wrapped in :meth:`_child_clip_rect` when the control declares one, so content outside the window is clipped away rather than drawn over its siblings. The rect is rounded to whole pixels here, the way the per-child wrap below rounds, so a control at a fractional position clips to the same rectangle on either walker. """ if getattr(self, "_skip_children", False): return clip = self._child_clip_rect() if clip is None or not hasattr(renderer, "push_clip"): self._draw_children_default(renderer) return x, y, w, h = clip renderer.push_clip(round(x), round(y), round(w), round(h)) try: self._draw_children_default(renderer) finally: renderer.pop_clip() 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, confining non-Control 2D nodes to this control's rect. Control children position themselves via get_global_rect() and need no wrapping. Non-Control children (Node2D game nodes like Line2D, Polygon2D etc.) draw at world coordinates, and those coordinates carry the anchor/margin layout, because a control's rect origin is part of the 2D transform chain (:meth:`_layout_offset`). So there is no offset to push here: the child draws where it says it is. What this adds is the clip, which confines a world-space child to its host's rect unconditionally, independently of the public ``clip_contents`` switch that :meth:`_child_clip_rect` and :meth:`_draw_children` handle for ``Control`` children. The ``get_global_rect()`` read is load-bearing beyond the scissor: it happens immediately before the child draws, so it is what recomputes this control's transform for the current layout epoch and reports a rect move down to the child, before the child reads its own world position. Set ``child.draw_overlay = True`` to draw in screen-space instead. """ 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_clip: x, y, w, h = self.get_global_rect() renderer.push_clip(round(x), round(y), round(w), round(h)) try: child._draw_recursive(renderer) finally: renderer.pop_clip() 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.0, self.min_size_x), max(0.0, self.min_size_y))
def _combined_minimum_size(self) -> Vec2: """:meth:`get_minimum_size`, remembered until something can have changed it. A container measures itself from its children, and each of those from theirs, so an unremembered read is O(subtree) and a caller that reads the same measurement several times in one gesture walks the whole tree each time. Godot answers this with a cached combined minimum size and a dirty flag; this is that cache. Every measurement in the subtree is taken once and reused until :meth:`_invalidate_minimum_size` clears it, which a resize, a redraw request (the engine-wide "my content changed" signal), a raised :attr:`min_size`, a child arriving or leaving, and a container marked for reflow all do. New text metrics do not clear anything: the measurement is stamped with the epoch it was taken under, so a metrics move retires every remembered minimum at once without a walk. Callers that measure OTHER controls -- containers laying out children, a scrolling viewport measuring its content -- read this. A control's own minimum stays :meth:`get_minimum_size`, which subclasses override and which is what this caches. """ cached = self._min_size_cache if cached is not None and self._min_size_epoch == _metrics_epoch: return Vec2(cached[0], cached[1]) measured = self.get_minimum_size() self._min_size_cache = (float(measured[0]), float(measured[1])) self._min_size_epoch = _metrics_epoch return measured def _invalidate_minimum_size(self) -> None: """Drop this control's remembered measurements, and every ancestor's. A container's minimum is a function of its children's, so a child whose measurement moved invalidates the chain above it. The walk is O(depth) and calls :meth:`_forget_measurements` once per level. """ node: Any = self while node is not None: if isinstance(node, Control): node._forget_measurements() node = getattr(node, "parent", None) def _forget_measurements(self) -> None: """Drop what this one control remembers about its own size. The per-control half of :meth:`_invalidate_minimum_size`, which calls it up the whole chain. A control that remembers more than its own minimum -- a scrolling viewport also remembers the extent of its content -- extends this rather than the walk. """ self._min_size_cache = None
[docs] def add_child(self, node: T) -> T: """Add a child, and forget the measurements it just changed (see :meth:`Node.add_child`).""" result = super().add_child(node) self._invalidate_minimum_size() return result
# ------------------------------------------------------------------ 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 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. The origin is :attr:`world_position`: the anchor/margin rect is part of the 2D transform chain (:meth:`_layout_offset`), so there is one walk up the tree and not two, and a control's drawn position and its reported position cannot disagree. The extent still comes from :meth:`get_rect`, which is why ``_rect_frame`` stays a field of its own rather than merging into the transform epoch: the two are stamped at different moments, and one field would let a fresh transform certify a stale ``(width, height)``. Cached per layout frame, and auto-expires when the epoch advances. One class diverges on purpose. While registered as an overlay, ``SplashScreen`` and ``AttributionWatermark`` (``_FullscreenOverlay``) override both this and :meth:`get_rect` to cover the whole render target wherever they are parented, so for them this does NOT equal ``world_position``. The overlay collector draws them from their rect and never reads their world position. """ frame = self._layout_epoch() if self._rect_frame == frame: return self._global_rect_cache _x, _y, w, h = self.get_rect() wp = self.world_position result = (float(wp[0]), float(wp[1]), 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
[docs] def activate(self) -> bool: """Act on this control as a completed click would; return whether it acted. This is what the ``ui_accept`` key (Enter by default) does to the focus owner. The router calls it only after the focused widget has declined the key, so a widget that wants Enter for itself keeps it by setting ``event.handled`` -- a text field submits, a code editor inserts a newline, and neither is activated. A plain ``Control`` has no activation semantics and returns ``False``. ``Button`` fires ``pressed``, ``CheckBox`` toggles and ``RadioButton`` selects, which is what Godot's ``ui_accept`` and Unity's ``ISubmitHandler`` do for the same widgets. Everything else is deliberately inert. It is public so activation is one call rather than a synthesised event: ``button.activate()`` is exactly what the keyboard does. The call itself is unconditional: it does not consult ``disabled`` or ``visible``, because the caller is the policy. Both engine paths into it apply their own -- a click never reaches a disabled control, and the router activates only a focusable owner -- so an application calling this directly is saying "do it now", the way emitting ``pressed`` would. """ return False
# ------------------------------------------------------------------ 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 the next control in tab order. An explicit ``focus_next`` link wins; otherwise the tab order is walked from this control's position in the tree, wrapping at the end. Hidden and disabled controls are skipped. When this control sits inside an open capturing overlay the walk is confined to that overlay; a control outside one is unaffected by it. """ self._move_focus(reverse=False)
[docs] def focus_previous_control(self): """Move focus to the previous control in tab order (see :meth:`focus_next_control`).""" self._move_focus(reverse=True)
def _move_focus(self, *, reverse: bool): from .focus import next_focus, scope_root_for target = next_focus(scope_root_for(self), self, reverse=reverse) if target is not None: target.set_focus() # ------------------------------------------------------------------ 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. The parent chain is measured without this control from here on, so the remembered minimum sizes above it are dropped while those links still exist. Every removal path runs this one, the deferred destroy included. """ self._invalidate_minimum_size() 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. A disabled control acts on nothing, but it still BLOCKS a click: a greyed-out button must not let the press fall through to whatever sits behind it, so button events are claimed unhandled-in-substance. The wheel is deliberately not claimed -- a disabled row inside a scrolling list must not stop the list from scrolling. """ if self.disabled: if event.button is not None: event.handled = True 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: """First tab stop in self's subtree, preferring descendants over self.""" from .focus import first_tab_stop, is_tab_stop descendant = first_tab_stop(self, skip=self) if descendant is not None: return descendant return self if is_tab_stop(self) 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"`` (a preset bundle of the flags below). ``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)
# Controls whose size came from their own content, held weakly so a control that # leaves the scene is not kept alive by having been measured once. _content_sized: weakref.WeakSet[Control] = weakref.WeakSet() #: Bumped whenever the text metrics move. Every remembered minimum size records #: the epoch it was measured under, so a new one retires all of them at once -- #: text width is what most of them are made of, and no control is told when the #: font behind it resolves. _metrics_epoch = 0 def _current_metrics_epoch() -> int: """The epoch the text metrics are on now. A remembered measurement stamped with an older one was taken against widths that are no longer the widths being drawn. Read through a call, not by importing the counter: the name is rebound on every metrics move, so an importer would hold the value it had at import time. """ return _metrics_epoch def _refit_content_sized(chars: str = "") -> None: """Re-fit content-sized controls after the text measurements moved. A renderer resolves the font it draws with when it draws, which can be after the scene is built, and a browser can gain a glyph mid-run. Either way the widths a control sized itself by are no longer the widths being drawn, so it is measured again. When only *chars* moved, controls whose content cannot use them are skipped. Every remembered minimum size retires here as well, whichever characters moved: a control that does not re-fit (a container owns its size) is still measured differently now, and its container has to see that. """ global _metrics_epoch _metrics_epoch += 1 for control in list(_content_sized): control._refit_to_content(chars) add_metrics_listener(_refit_content_sized)