Source code for simvx.core.ui.core

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


from __future__ import annotations

import logging

from ..nodes_2d.node2d import Node2D
from ..descriptors import Property, Signal
from ..math.types import Vec2
from .enums import AnchorPreset, FocusMode, SizeFlags
from .types import Colour, DragData, Theme, ThemeColour, ThemeSize, ThemeStyleBox, UIInputEvent

log = logging.getLogger(__name__)

__all__ = [
    "Control",
    "Theme",
    "ThemeColour",
    "ThemeSize",
    "ThemeStyleBox",
    "Colour",
    "UIInputEvent",
    "FocusMode",
    "AnchorPreset",
    "SizeFlags",
    "DragData",
]


def _get_default_theme():
    from .theme import get_theme as _get
    return _get()


# ============================================================================
# _DrawRecorder — Records draw commands for cache replay
# ============================================================================

# Draw methods that produce visual output (record + forward)
_DRAW_METHODS = frozenset({
    "draw_filled_rect", "draw_rect", "draw_rect_coloured", "draw_line", "draw_line_coloured",
    "draw_circle", "draw_text", "draw_text_coloured", "draw_texture", "draw_nine_patch",
    "push_clip", "pop_clip", "fill_rect", "fill_circle", "draw_thick_line",
    "draw_lines", "fill_triangle", "set_colour", "new_layer",
    "fill_rect_gradient", "draw_gradient_rect",
})

# Query methods that return values (forward only, don't record)
_QUERY_METHODS = frozenset({"text_width"})


class _DrawRecorder:
    """Wraps a renderer, forwarding all draw calls while recording them.

    Query methods like text_width() are forwarded without recording.
    """
    __slots__ = ("_renderer", "commands")

    def __init__(self, renderer):
        self._renderer = renderer
        self.commands: list[tuple[str, tuple]] = []

    def __getattr__(self, name):
        if name in _DRAW_METHODS:
            def _record_and_forward(*args, **kwargs):
                self.commands.append((name, args))
                getattr(self._renderer, name)(*args, **kwargs)
            return _record_and_forward
        # Query methods and anything else: forward directly
        return getattr(self._renderer, name)


# ============================================================================
# Control — Base UI element
# ============================================================================


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