"""Core UI types: Control, Theme, Colour, FocusMode, Anchor, SizeFlags, DragData."""
import logging
import weakref
from typing import Any
from .._draw_context import READS_UI_SCALE, draw_context
from ..descriptors import Property
from ..input.controls import Key
from ..input.enums import CursorShape, FocusReason, PointerType
from ..input.events import KeyEvent, PointerEvent, TextEvent
from ..math.types import Vec2
from ..node import Node, 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 AcceptsPointers, Anchor, FocusMode, PointerFilter, SizeFlags
from .types import DragData, GuiInputEvent, Theme, ThemeColour, ThemeSize, ThemeStyleBox
log = logging.getLogger(__name__)
__all__ = [
"Control",
"Theme",
"ThemeColour",
"ThemeSize",
"ThemeStyleBox",
"Colour",
"FocusMode",
"Anchor",
"SizeFlags",
"DragData",
]
def _get_default_theme():
from .theme import get_theme as _get
return _get()
# ============================================================================
# _DrawRecorder: Records draw commands for cache replay
# ============================================================================
# Calls that answer a question about the drawing surface instead of changing
# what appears on it. These are forwarded live and never recorded: their answer
# is returned to the caller on the spot, and replaying one would paint nothing.
#
# Everything else a widget calls is recorded, which is the way round that keeps
# a recording complete. The opposite policy, a list of the calls worth
# recording, silently drops any drawing call missing from it: the widget paints
# on the frame that was captured and nothing on the replayed frames after,
# with no error anywhere. A drawing call added to a surface later needs no
# entry here.
_QUERY_METHODS = frozenset(
{
"text_width",
"text_height",
"text_size",
"text_ascent",
"text_pixels",
"fit_scale",
}
)
class _DrawRecorder:
"""Wraps a renderer, forwarding every call while recording the drawing ones.
Measurement queries (:data:`_QUERY_METHODS`) are forwarded without
recording, and a plain attribute is read straight off the wrapped surface.
Everything else is recorded and forwarded, so the recording holds the whole
drawing whatever the widget called to produce it.
A name the wrapped surface does not have raises ``AttributeError`` here,
where it is asked for, rather than being accepted and then failing at replay
or, worse, being accepted and doing nothing. That also means ``hasattr`` on
a recorder answers for the real surface, which is what the callers probing
for optional calls such as ``push_clip`` rely on.
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", "_wrappers", "commands")
def __init__(self, renderer):
self._renderer = renderer
self._wrappers: dict[str, Any] = {}
self.commands: list[tuple[str, tuple, dict]] = []
def __getattr__(self, name):
wrapper = self._wrappers.get(name)
if wrapper is not None:
return wrapper
# Resolved here, so a call the surface does not implement fails at the
# point it is asked for.
attr = getattr(self._renderer, name)
if name in _QUERY_METHODS or not callable(attr):
return attr
commands = self.commands
def _record_and_forward(*args, **kwargs):
commands.append((name, args, kwargs))
return attr(*args, **kwargs)
self._wrappers[name] = _record_and_forward
return _record_and_forward
# ============================================================================
# Control: Base UI element
# ============================================================================
def _as_vec2_or_none(value: object) -> Vec2 | None:
"""Coerce a maximum-size assignment to a ``Vec2``, passing ``None`` through.
``max_size`` is the one real ``Vec2`` Property on a control (``size`` and
``min_size`` are accessor pairs over scalar Properties), and its default is
``None``, so the descriptor cannot infer the type from the default the way it
does for a ``Vec2``-defaulted slot. Without this the tuple was stored raw and
``get_rect`` raised ``AttributeError: 'tuple' object has no attribute 'x'``
from inside layout, a long way from the assignment that caused it, and the
serialiser would have written a tuple back out.
Named rather than a lambda because :class:`Property` puts the callable's name
in the message it raises when coercion fails.
"""
if value is None or isinstance(value, Vec2):
return value
if isinstance(value, str):
raise TypeError(f"max_size takes a Vec2 or a two-item sequence, not {value!r}")
x, y = value # a wrong-length sequence raises here, at the assignment
return Vec2(float(x), float(y))
_ANCHOR_FRACTIONS: dict[Anchor, tuple[float, float, float, float]] = {
Anchor.TOP_LEFT: (0.0, 0.0, 0.0, 0.0),
Anchor.TOP_RIGHT: (1.0, 0.0, 1.0, 0.0),
Anchor.BOTTOM_LEFT: (0.0, 1.0, 0.0, 1.0),
Anchor.BOTTOM_RIGHT: (1.0, 1.0, 1.0, 1.0),
Anchor.CENTER_LEFT: (0.0, 0.5, 0.0, 0.5),
Anchor.CENTER_RIGHT: (1.0, 0.5, 1.0, 0.5),
Anchor.CENTER_TOP: (0.5, 0.0, 0.5, 0.0),
Anchor.CENTER_BOTTOM: (0.5, 1.0, 0.5, 1.0),
Anchor.CENTER: (0.5, 0.5, 0.5, 0.5),
Anchor.LEFT_WIDE: (0.0, 0.0, 0.0, 1.0),
Anchor.RIGHT_WIDE: (1.0, 0.0, 1.0, 1.0),
Anchor.TOP_WIDE: (0.0, 0.0, 1.0, 0.0),
Anchor.BOTTOM_WIDE: (0.0, 1.0, 1.0, 1.0),
Anchor.FULL_RECT: (0.0, 0.0, 1.0, 1.0),
}
def _as_anchor_or_none(value) -> Anchor | None:
"""Coerce for :attr:`Control.anchor`: an ``Anchor``, its string, or ``None``."""
return None if value is None else Anchor(value)
#: What :attr:`Control.inset` holds: one number, ``(x, y)`` or ``(left, top, right, bottom)``.
_Inset = float | tuple[float, float] | tuple[float, float, float, float]
def _as_inset(value) -> _Inset:
"""Coerce for :attr:`Control.inset`: a number, ``(x, y)`` or ``(left, top, right, bottom)``, kept in that form."""
if isinstance(value, int | float):
return float(value)
if isinstance(value, str):
raise TypeError(f"inset takes a number, (x, y) or (left, top, right, bottom), not {value!r}")
values = [float(v) for v in value]
if len(values) == 2:
return (values[0], values[1])
if len(values) == 4:
return (values[0], values[1], values[2], values[3])
raise ValueError(f"inset takes a number, (x, y) or (left, top, right, bottom), not {len(values)} values")
def _expand_inset(inset: _Inset) -> tuple[float, float, float, float]:
"""An inset in any of its forms as ``(left, top, right, bottom)``."""
if isinstance(inset, float):
return (inset, inset, inset, inset)
if len(inset) == 2:
return (inset[0], inset[1], inset[0], inset[1])
return inset
def _axis_margins(start: float, end: float, near: float, far: float, extent: float) -> tuple[float, float]:
"""The margin pair that places one axis between insets ``near`` and ``far`` (see ``Control._place_from_anchor``)."""
if start != end:
return (near, far)
margin = near * (1.0 - start) - (far + extent) * start
return (margin, margin)
def _cursor_shape(value: CursorShape | str | None) -> CursorShape | None:
"""Coerce a ``cursor_shape`` assignment: a member, its plain string, or None to inherit."""
return None if value is None else CursorShape(value)
def _produces_text(event: GuiInputEvent) -> bool:
"""Whether *event* is typing: a ``TextEvent``, or a key press that prints with no Ctrl, Alt or Meta held."""
if isinstance(event, TextEvent):
return True
if not isinstance(event, KeyEvent) or not event.pressed or event.ctrl or event.alt or event.meta:
return False
label = event.label
return event.key is Key.SPACE or (len(label) == 1 and label.isprintable())
[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:
hint = Label("Hint", anchor=Anchor.BOTTOM_LEFT, inset=16)
panel = Panel(anchor=Anchor.TOP_RIGHT, size=(220, 120), inset=10)
"""
_constructor_views = frozenset({"size", "min_size"})
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")
# What the author declares this control may not go below; :meth:`size_floor`
# is the one place every sizing path reads it from. 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). ``anchor`` and ``inset`` set them and the margins together.
# 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")
# Placement in one call: ``anchor`` picks the edges, ``inset`` the distance in
# from them, and the size the extent on any axis that does not stretch. The
# engine derives the four anchors and the signed margins from the three and
# derives them again whenever one changes, so a caller never writes a
# negative margin. The derived values are not saved: a scene records
# ``anchor``, ``inset`` and ``size`` and rebuilds the rest when it loads.
anchor = Property(
None,
group="Layout",
hint="Where this control sits in its parent (an Anchor), or None to place it by anchors and margins",
coerce=_as_anchor_or_none,
on_change="_on_placement_changed",
)
# Distance in from the parent's edges, as in CSS: one number for all four,
# ``(x, y)`` for the sides and the top and bottom, or ``(left, top, right,
# bottom)``. On each axis of the anchor, a side keeps the control that far in
# from its edge, a centre centres it between the two insets, and a wide axis
# stretches it from one inset to the other. Read only while ``anchor`` is set.
inset = Property(
0.0,
group="Layout",
hint="Distance in from the parent's edges: a number, (x, y) or (left, top, right, bottom)",
coerce=_as_inset,
on_change="_on_placement_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 its string (``Control(focus_mode="all")``).
focus_mode = Property(
FocusMode.NONE, hint="Whether this control can take keyboard focus", on_change="_on_focus_mode_changed"
)
# 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)")
# How this control takes part in the pointer hit test: ``IGNORE`` is
# invisible to it, ``PASS`` is hit-testable without blocking what sits
# behind, ``STOP`` is hit-testable and blocks. See :class:`PointerFilter`.
# ``PASS`` by default, so a panel or a container never swallows a click
# meant for the game behind it; a widget that is only ever looked at
# declares ``IGNORE`` and one the player operates declares ``STOP``, each
# by redeclaring the descriptor.
pointer_filter = Property(PointerFilter.PASS, hint="How this control takes part in the pointer hit test")
# The operating system's pointer over this control, or None to show what
# the control it sits in shows (``ARROW`` at the top). The tree reads it
# from the control the mouse or a pen is over once a frame, and a captor
# keeps its own for the length of its drag. A widget whose shape depends
# on where in it the pointer is (a splitter's divider) assigns it as the
# pointer moves.
cursor_shape = Property(
None,
coerce=_cursor_shape,
hint="The pointer's shape over this control; None shows its parent's",
on_change="_on_cursor_shape_changed",
)
#: The kinds of pointer this control is under, or None for every kind. A
#: pointer of another kind passes through it as though it were not there:
#: it takes no press, hides nothing behind it and takes no focus. What an
#: on-screen pad sets to touch alone, so a desktop click in its area reaches
#: the game.
pointer_types: frozenset[PointerType] | None = None
# Whether a finger aims at this control, and so whether a finger hits it
# anywhere in its rect grown to the theme's ``min_hit_size`` (see
# :meth:`is_hit`). Only the hit area grows: the control is laid out and drawn
# at the size its author chose. True on the widgets a player aims at (a
# button, a checkbox, a slider) and False on the ones they only read (a
# label, a panel, a progress bar). A game whose own control is aimed at sets
# it, on the class or on the instance.
#
# A plain class attribute rather than a ``Property``: it states what a kind
# of control is, not what this one was authored to be, so there is nothing
# for a scene file to record and nothing for the inspector to offer.
touch_target: bool = False
# Whether focusing this control should open the platform's text-input
# session: a soft keyboard on a phone, an IME candidate window on a
# desktop. True on the widgets that take typing. A game's own control that
# accepts text sets it, on the class or on the instance, and gets a
# keyboard on a phone without calling anything.
#
# A plain class attribute for the same reason as ``touch_target``: it says
# what a kind of control is, so there is nothing for a scene file to
# record. It only does anything on a control that can hold focus, since
# focus is what opens the session: pair it with a ``focus_mode`` that is
# not ``NONE``.
#
# It is also the claim: a control that takes typing claims every
# ``TextEvent`` and every key press that produces text, so a game action
# bound to ``W`` stays shut while the player types a ``w``. A subclass
# overrides this property, returning a constant or an answer derived from
# its state (read-only, an edit session open).
[docs]
@property
def wants_text_input(self) -> bool:
"""Whether this control takes typing while focused."""
return False
# 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",
coerce=_as_vec2_or_none,
)
@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 resizes a control only where the control says so
through :meth:`_content_changed`, as ``Label`` does. A metrics 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._combined_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 _content_changed(self) -> None:
"""Re-measure after this control's own content changed, if it still holds its measured size.
The ``on_change`` hook for a content Property. A control that was given
a size, or that a container has sized since, keeps that size.
"""
if self._content_size is not None and (self.size_x, self.size_y) == self._content_size:
self.autosize_to_content()
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.
"""
if self.anchor is not None:
self._place_from_anchor() # a side or centre anchor places by the extent
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()
# A container's extent comes from its rect, which an anchor or margin
# write can resize without touching the size: lay its children out again.
mark = getattr(self, "mark_layout_dirty", None)
if mark is not None and not self._laying_out:
mark()
# Which pointers this control takes once the hit test has found it: the
# primary one alone, or every finger tracked on its own. Separate from
# ``pointer_filter``, which decides whether it is under the pointer at all;
# a control that wants no pointer says so there, with ``IGNORE``.
accepts_pointers = Property(AcceptsPointers.PRIMARY, hint="Which pointers this control takes")
# 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 that says whether it still stands. 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
# The ``(width, height)`` of the rect in the control's own units, stamped with
# the global rect: the two differ by the world scale.
_local_size_cache: tuple[float, float] = (0.0, 0.0)
_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._disabled = False
# Explicit focus links, for tab order and for the four directions.
# Node references, so there is no source form a scene file could carry:
# these stay plain attributes. Each wins over the automatic search when
# it points at something focusable inside the scope, which is how a
# layout the geometry reads wrongly is corrected by hand.
self.focus_next: Control | None = None
self.focus_previous: Control | None = None
self.focus_up: Control | None = None
self.focus_down: Control | None = None
self.focus_left: Control | None = None
self.focus_right: Control | None = None
# Signals
self.mouse_entered = Signal()
self.mouse_exited = Signal()
# Every event this control was offered, whether or not it acted on one.
# An observer seam and only that: a connection cannot claim the event,
# because a signal has no answer to give and the claim is the handler's
# answer. Claim from ``on_gui_input`` by returning True.
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()
# The mirror: emitted by the overlay router on a ui_accept that no
# focused widget claimed and no focus owner consumed, so a chain owner
# can act on "confirm" without the router picking a default button.
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
# An anchor a keyword set was placed as it was set; one a subclass
# declared as its default never went through a setter, so place it here.
if self.anchor is not None and "anchor" not in kwargs:
self._place_from_anchor()
# ------------------------------------------------------------------ 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 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.
The rect is measured in UI units, so an outermost control scales its
origin by the tree's ``ui_scale`` (:meth:`_ui_scale_here`) to express it
in its parent's space.
"""
x, y, _w, _h = self.get_rect()
scale = self._ui_scale_here()
if scale != 1.0:
return Vec2(x * scale, y * scale)
return Vec2(x, y)
def _ui_scale_here(self) -> float:
"""The tree's ``ui_scale`` if the factor enters the transform chain at this control, else 1.0.
It enters once per interface, at the outermost control: walking up the
transform chain (through ``Node2D`` parents, stopping at a ``top_level``
node or at a parent with no 2D transform) meets no other ``Control``. A
control nested under another, directly or through plain ``Node2D``
nodes, inherits the factor through its parent's world scale instead.
"""
tree = self._tree
if tree is None:
return 1.0
scale = tree._ui_scale
if scale == 1.0:
return 1.0
node: Node2D = self
while not node.top_level:
parent = node.parent
if not isinstance(parent, Node2D):
return scale
if isinstance(parent, Control):
return 1.0
node = parent
return scale
def _ui_space(self) -> tuple[float, float]:
"""The screen measured in UI units, ``screen_size / ui_scale``: what an outermost control lays out in."""
tree = self._tree
assert tree is not None
width, height = tree.screen_size
scale = tree._ui_scale
if scale != 1.0:
return (float(width) / scale, float(height) / scale)
return (float(width), float(height))
[docs]
@property
def viewport_size(self) -> Vec2:
"""The size of the render target this control draws into, in UI pixels.
:attr:`Node.viewport_size`, divided by the tree's ``ui_scale``: a
control draws in UI pixels, and a render target that is 1600 pixels
wide is 800 of them at ``ui_scale == 2.0``. At 1.0 the two are the
same number. A draw that read it depends on ``ui_scale`` as well as on
the target, and is re-captured when either changes.
"""
reader = draw_context.node
if reader is not None:
reader._ambient_reads |= READS_UI_SCALE
size = super().viewport_size
tree = self._tree
if tree is not None and tree._ui_scale != 1.0:
return Vec2(size / tree._ui_scale)
return size
[docs]
@property
def viewport_insets(self) -> tuple[float, float, float, float]:
""":attr:`Node.viewport_insets` in UI pixels, divided by the tree's ``ui_scale`` as :attr:`viewport_size` is."""
reader = draw_context.node
if reader is not None:
reader._ambient_reads |= READS_UI_SCALE
insets = super().viewport_insets
tree = self._tree
if tree is not None and tree._ui_scale != 1.0:
scale = tree._ui_scale
left, top, right, bottom = insets
return (left / scale, top / scale, right / scale, bottom / scale)
return insets
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()
# The UI scale enters the chain here, at an outermost control, and every
# descendant inherits it through this world scale.
scale = self._ui_scale_here()
world_scale = self._cached_world_scale
if scale != 1.0 and world_scale is not None:
self._cached_world_scale = Vec2(world_scale * scale)
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_own(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.rect((x, y), (w, h), fill=(0.8, 0.1, 0.1, 0.3))
renderer.rect((x, y), (w, h), stroke=(1.0, 0.0, 0.0, 1.0))
renderer.text(f"ERR: {self.name}", (x + 4, y + 4), fill=(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 size_floor(self) -> tuple[float, float]:
"""The smallest this control may be, whatever is asking: its declared :attr:`min_size`.
This is the one statement of that floor: every sizing path reads it
rather than restating the rule, because a control is sized down three
different routes and a floor written into one of them is absent from
the other two.
- :meth:`get_minimum_size` measures content against it, in the base and
in every override, so a container laying out children respects it.
- :meth:`_combined_minimum_size` remembers the measurement it bounds.
- :meth:`get_rect` clamps to it, which is the path a control placed
with anchors and margins takes.
A finger is not a reason to grow it: a touch target keeps the size its
author chose and only its hit area grows (:meth:`is_hit`).
A tuple rather than a ``Vec2``: :meth:`get_rect` reads this for every
control on every layout pass, and it has no use for an array.
"""
return (max(0.0, self.min_size_x), max(0.0, self.min_size_y))
[docs]
def get_minimum_size(self) -> Vec2:
"""Minimum size needed to display content. Subclasses override.
An override measures its content and returns the larger of that and
:meth:`size_floor` on each axis. The base control has no content, so
the floor is the whole answer.
"""
return Vec2(*self.size_floor())
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) and not self.top_level:
_, _, pw, ph = parent.get_rect()
elif self._tree is not None:
pw, ph = self._ui_space()
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
floor_x, floor_y = self.size_floor()
width = max(width, floor_x)
height = max(height, floor_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, ws, _rotation = self.world_transform
# The extent is on the screen too: a scaled control, under the UI scale or
# a scaled parent, covers its local size times its world scale.
result = (float(wp[0]), float(wp[1]), w * abs(float(ws[0])), h * abs(float(ws[1])))
self._global_rect_cache = result
self._local_size_cache = (w, h)
self._rect_frame = frame
return result
[docs]
@property
def focus_rect(self) -> tuple[float, float, float, float] | None:
"""Where this control is on screen, as directional focus reads it, or None when it is off screen.
The same rect the eye and the pointer use, so the control a player
sees above another is the one a press of Up moves to. A control that
occupies a different area than it draws in overrides this.
"""
rect = self.get_global_rect()
tree = self._tree
if tree is not None:
sw, sh = tree.screen_size
x, y, w, h = rect
if x + w <= 0 or y + h <= 0 or x >= sw or y >= sh:
return None
return rect
def _local_rect(self) -> tuple[float, float, float, float]:
"""``(0, 0, width, height)``: this control's rect in its own drawing space."""
self.get_global_rect()
w, h = self._local_size_cache
return (0.0, 0.0, w, h)
def _get_parent_size(self) -> Vec2:
"""Get parent control size, or screen size (always the screen for a top-level control)."""
if self.parent and isinstance(self.parent, Control) and not self.top_level:
_, _, pw, ph = self.parent.get_rect()
return Vec2(pw, ph)
if self._tree:
return Vec2(*self._ui_space())
return Vec2(800, 600)
[docs]
def is_point_inside(self, point) -> bool:
"""Check if point (screen coords) is inside this control, moved, rotated and scaled as it is drawn."""
_x, _y, w, h = self._local_rect()
try:
px, py = self.to_local(point)
except ValueError:
return False # scaled to zero: nothing to hit
return bool(0.0 <= px < w and 0.0 <= py < h)
[docs]
def is_hit(self, event: PointerEvent) -> bool:
"""Whether *event*'s pointer lands on this control.
Its drawn rect, and for a finger on a :attr:`touch_target` that rect
grown evenly about its centre to at least the theme's ``min_hit_size``
on each axis, so a 30-unit button is pressed by a thumb landing a few
units off it while it stays 30 units on screen. A mouse and a pen are
precise and hit the drawn rect alone. A widget deciding whether a press
is its own asks this, which is the same question the hit test asked
when it chose the widget.
"""
if self.is_point_inside(event.position):
return True
grow_x, grow_y = self._hit_growth(event)
if not grow_x and not grow_y:
return False
_x, _y, w, h = self._local_rect()
try:
px, py = self.to_local(event.position)
except ValueError:
return False
return bool(-grow_x <= px < w + grow_x and -grow_y <= py < h + grow_y)
def _hit_growth(self, event: PointerEvent) -> tuple[float, float]:
"""How far this control's hit area reaches past its rect on each side, for *event*.
Zero on both axes unless a finger aims at a touch target smaller than
the theme's ``min_hit_size``, which is the cheap answer every other
control gives from one attribute read.
A fingertip is a size on the glass, so the theme's number is taken
there: divided by the tree's ``ui_scale`` and its letterbox scale to
reach the units this control is laid out in. A player who doubles the
interface doubles the buttons and not the fingertip.
"""
if not self.touch_target or event.pointer_type is not PointerType.TOUCH:
return (0.0, 0.0)
size = self.get_theme().min_hit_size
tree = self._tree
if tree is not None:
size /= tree.ui_scale * tree.content_scale
_x, _y, w, h = self._local_rect()
return (max(0.0, (size - w) / 2.0), max(0.0, (size - h) / 2.0))
# ------------------------------------------------------------------ placement
[docs]
def __init_subclass__(cls, **kwargs):
"""Keep placement working under a subclass that redeclares ``anchor`` to change its default.
``anchor = Property(Anchor.FULL_RECT)`` in a class body is how a full-screen
dialog says where it sits. A redeclared descriptor starts from scratch, so
the change hook and the ``None``-accepting coerce are given back to it here
rather than asking every such class to restate them.
"""
super().__init_subclass__(**kwargs)
prop = cls.__dict__.get("anchor")
if isinstance(prop, Property):
if prop.on_change is None:
prop.on_change = "_on_placement_changed"
if hasattr(prop, "coerce"):
prop.coerce = _as_anchor_or_none
def _on_placement_changed(self) -> None:
"""Derive the anchors and margins again after ``anchor`` or ``inset`` changed."""
if self.anchor is not None:
self._place_from_anchor()
def _place_from_anchor(self) -> None:
"""Write the anchors and signed margins that put this control where ``anchor`` and ``inset`` say.
On an axis whose two anchors differ the margins are the insets, and the
control stretches between them. On a collapsed axis at fraction ``f`` the
control is ``extent`` long and sits at ``f`` of the room the insets leave,
so 0 is against the near inset, 1 against the far one and 0.5 centred
between them; both margins get the same value, which makes
:meth:`get_rect` take the extent from the size, so a later resize needs
only this method again. Every value written is recorded as derived, so a
save writes ``anchor`` and ``inset`` rather than the numbers.
"""
al, at, ar, ab = _ANCHOR_FRACTIONS[self.anchor]
near_x, near_y, far_x, far_y = _expand_inset(self.inset)
floor_x, floor_y = self.size_floor()
width, height = max(self.size_x, floor_x), max(self.size_y, floor_y)
if self.max_size is not None:
width, height = min(width, self.max_size.x), min(height, self.max_size.y)
ml, mr = _axis_margins(al, ar, near_x, far_x, width)
mt, mb = _axis_margins(at, ab, near_y, far_y, height)
for name, value in (
("anchor_left", al),
("anchor_top", at),
("anchor_right", ar),
("anchor_bottom", ab),
("margin_left", ml),
("margin_top", mt),
("margin_right", mr),
("margin_bottom", mb),
):
setattr(self, name, value)
self._record_derived(name, value)
# ------------------------------------------------------------------ input
[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 returning True
-- 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 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 and self._tree is not None:
self._tree._set_focused_control(target, FocusReason.TAB)
def _enter_tree(self, tree):
super()._enter_tree(tree)
if self.cursor_shape is not None:
tree._cursor_shaped.add(self)
tree._ui._cursor_dirty = True
def _on_cursor_shape_changed(self, _old: object = None, _new: object = None) -> None:
"""Keep the tree's index of controls naming a shape in step, and have the shape read again."""
tree = self._tree
if tree is None:
return
if self.cursor_shape is None:
tree._cursor_shaped.discard(self)
else:
tree._cursor_shaped.add(self)
tree._ui._cursor_dirty = True
def _exit_tree(self):
"""Drop the remembered sizes and close an open overlay before detaching.
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.
Focus and pointer capture are released by ``Node._exit_tree``, which
every node runs: capture is not a control's privilege.
"""
self._invalidate_minimum_size()
if self._tree is not None:
if self in self._tree._cursor_shaped:
self._tree._cursor_shaped.discard(self)
self._tree._ui._cursor_dirty = True
# 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)
super()._exit_tree()
def _hovering_point(self, positions):
"""Which of *positions* is over this control, or None.
For a subclass that tracks more than a bool, a tab strip knowing which
header the cursor is on. Two windows onto one tree have two cursors and
both may be here; the first is as good an answer as any, and with one
window, which is every game, it is the only one.
"""
return next((point for point in positions if self.is_point_inside(point)), None)
def _update_mouse_over(self, positions):
"""Update mouse-over state from every source's pointer, and fire signals.
*positions* is one point per input source, because a window is a
source and two of them onto one tree have two cursors. The control is
hovered when any of them is over it.
"""
was_over = self._mouse_over
# property setter handles queue_redraw
self.mouse_over = any(self.is_point_inside(point) for point in positions)
if self._mouse_over != was_over:
if self._mouse_over:
self.mouse_entered.emit()
else:
self.mouse_exited.emit()
def _internal_gui_input(self, event: GuiInputEvent) -> bool | None:
"""Route input to handler and signal, and report what was claimed.
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.
The claim is the handler's return value, validated by the rule every
input handler answers to (:meth:`~simvx.core.node.Node._safe_decide`).
The ``gui_input`` signal is notified either way and cannot claim: a
signal has no answer to give, and an observer is not a handler. A
control whose ``wants_text_input`` is True claims typing its handler
declined, so the keystroke never reaches a game action.
"""
if self.disabled:
return True if isinstance(event, PointerEvent) and event.button is not None else None
decided = super()._internal_gui_input(event)
self.gui_input.emit(event)
if decided is not True and self.wants_text_input and _produces_text(event):
return True
return decided
# ------------------------------------------------------------------ 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) -> Node | 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.focus_owner 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)