Source code for simvx.core.ui.popup_manager

"""PopupManager — manages popup overlays with automatic pause and input routing.

Only one popup is active at a time. Opening a popup pauses the scene tree
(configurable). Input is routed exclusively to the active popup: mouse clicks
first (three-state return), then keyboard.

PopupPanel and DrawCanvas provide widget-based popup creation with automatic
clipping and scrolling.
"""

from __future__ import annotations

from ..descriptors import ProcessMode, Property, Signal
from ..input.enums import Key, MouseButton
from ..input.state import Input
from ..math.types import Vec2
from ..nodes_2d.node2d import Node2D
from .core import Control
from .scroll import ScrollContainer






[docs] class DrawCanvas(Control): """Control for custom rendering within the widget system. Set content_size to declare the total drawable area. Override custom_draw() for raw rendering. When inside a ScrollContainer, content exceeding the viewport is clipped and scrollable. Example:: class MyCanvas(DrawCanvas): content_size = Property((400.0, 800.0)) def custom_draw(self, renderer): renderer.draw_filled_rect(10, 10, 100, 50, (1, 0, 0, 1)) """ content_size = Property((0.0, 0.0)) def __init__(self, content_size=(0.0, 0.0), **kwargs): super().__init__(**kwargs) self.content_size = content_size self.size = Vec2(content_size[0], content_size[1])
[docs] def custom_draw(self, renderer) -> None: """Override to draw custom content."""
[docs] def get_minimum_size(self) -> Vec2: return Vec2(self.content_size[0], self.content_size[1])
[docs] def draw(self, renderer): self.custom_draw(renderer) # Dynamic content — keep parent cache dirty so we're redrawn each frame if self.parent and hasattr(self.parent, "_draw_dirty"): self.parent._draw_dirty = True
[docs] class PopupPanel(Control, Popup): """Widget-based popup overlay managed by PopupManager. Add widget children for simple layouts via add_content(). Content that exceeds panel_size is automatically clipped and scrollable. Example (simple):: panel = PopupPanel(title="Paused", panel_size=(280, 220)) vbox = panel.add_content(VBoxContainer()) vbox.add_child(Button("Resume", on_press=panel.request_close)) popup_manager.open(panel) Example (custom draw):: class TreeCanvas(DrawCanvas): content_size = Property((700, 1200)) def custom_draw(self, renderer): # draw skill tree lines, icons, glows... panel = PopupPanel(title="Skills", panel_size=(750, 550)) panel.add_content(TreeCanvas()) popup_manager.open(panel) """ title = Property("") panel_size = Property((400.0, 300.0)) closable = Property(True) dim_colour = Property((0.0, 0.0, 0.0, 0.6)) def __init__(self, title="", panel_size=(400, 300), **kwargs): Control.__init__(self, name=title or "PopupPanel", **kwargs) Popup.__init__(self) self.title = title self.panel_size = panel_size self._pm: PopupManager | None = None self._scroll = ScrollContainer() Control.add_child(self, self._scroll)
[docs] def add_content(self, widget: Control) -> Control: """Add a widget to the scrollable content area. Returns the widget.""" self._scroll.add_child(widget) return widget
[docs] def request_close(self) -> None: """Close this popup via PopupManager.""" if self._pm: self._pm.close()
# -- Popup protocol (for PopupManager compatibility) --
[docs] def popup_draw(self, renderer, sw, sh): renderer.draw_filled_rect(0, 0, sw, sh, tuple(self.dim_colour))
[docs] def popup_click(self, mx, my): return True
[docs] def popup_input(self, dt): return True
[docs] def on_open(self): pass
# -- Control rendering --
[docs] def draw(self, renderer): sw, sh = self.screen_w, self.screen_h if sw == 0 and self._tree: sw, sh = self._tree.screen_size pw, ph = self.panel_size self.size = Vec2(pw, ph) self.position = Vec2((sw - pw) / 2, (sh - ph) / 2) x, y = self.position.x, self.position.y renderer.draw_filled_rect(x, y, pw, ph, (0.12, 0.12, 0.18, 0.95)) title_h = 0 if self.title: title_h = 36 renderer.draw_filled_rect(x, y, pw, title_h, (0.15, 0.15, 0.25, 1.0)) renderer.draw_text(self.title, (x + 12, y + 8), scale=1.5, colour=(1, 1, 1)) self._scroll.position = Vec2(0, title_h) self._scroll.size = Vec2(pw, ph - title_h)
[docs] class PopupManager(Node2D): """Manages popup overlays with automatic pause and input routing. Only one popup is active at a time. Opening a popup pauses the scene tree (configurable via pause_tree). Input is routed exclusively to the active popup: mouse clicks first (three-state return), then keyboard. Lives inside a CanvasLayer for screen-space rendering. Sets process_mode to ALWAYS automatically so it runs even when paused. Signals: on_opened(popup): Emitted after a popup opens and the tree is paused. on_closed(popup): Emitted after a popup closes and the tree is unpaused. Example:: popups = PopupManager() canvas_layer.add_child(popups) popups.open(my_pause_menu) # Pauses tree, routes input to menu popups.close() # Unpauses tree """ pause_tree = Property(True, hint="Pause the scene tree when a popup is open") input_skip_frames = Property(2, range=(0, 10), hint="Frames to skip input after opening") on_opened = Signal() on_closed = Signal() def __init__(self, **kwargs): super().__init__(name="PopupManager", **kwargs) self.process_mode = ProcessMode.ALWAYS self._active: Popup | None = None self._screen_w: float = 1280 self._screen_h: float = 720 self._skip_frames: int = 0 @property def is_open(self) -> bool: """Whether a popup is currently active.""" return self._active is not None @property def active(self) -> Popup | None: """The currently active popup, or None.""" return self._active def _close_active(self) -> None: """Close the active popup without unpausing or emitting signals.""" if self._active is not None: self._active.on_close() if isinstance(self._active, PopupPanel): Node2D.remove_child(self, self._active) self._active = None
[docs] def open(self, popup: Popup) -> None: """Open a popup, closing any currently active one first.""" if self._active is not None and self._active is not popup: self._close_active() self._active = popup if isinstance(popup, PopupPanel): popup._pm = self Node2D.add_child(self, popup) popup.on_open() self._skip_frames = self.input_skip_frames if self.pause_tree and self._tree: self._tree.paused = True # Clear per-frame input so the popup doesn't see the key that opened it Input._keys_just_pressed_typed.clear() Input._keys_just_released_typed.clear() self.on_opened.emit(popup)
[docs] def close(self) -> None: """Close the active popup and unpause.""" closing = self._active if closing is not None: closing.on_close() if isinstance(closing, PopupPanel): Node2D.remove_child(self, closing) self._active = None if self.pause_tree and self._tree: self._tree.paused = False if closing is not None: self.on_closed.emit(closing)
[docs] def process(self, dt: float): if self._active is None: return if self._skip_frames > 0: self._skip_frames -= 1 return if isinstance(self._active, PopupPanel): # Widget handles its own input via Control system; just handle escape-to-close if self._active.closable and Input.is_key_just_pressed(Key.ESCAPE): self.close() return # Mouse click routing (three-state) if Input.is_mouse_button_just_pressed(MouseButton.LEFT): mx, my = Input.get_mouse_position() current = self._active result = current.popup_click(mx, my) if result is False and self._active is current: self.close() return if result is True: return # Keyboard routing current = self._active stay_open = current.popup_input(dt) if not stay_open and self._active is current: self.close()
[docs] def draw(self, renderer): if self._tree: self._screen_w, self._screen_h = self._tree.screen_size if self._active is not None: if hasattr(renderer, 'new_layer'): renderer.new_layer() self._active.screen_w = self._screen_w self._active.screen_h = self._screen_h self._active.popup_draw(renderer, self._screen_w, self._screen_h)
[docs] def check_menu_click(mx: float, my: float, count: int, origin_x: float, origin_y: float, item_w: float, item_h: float, spacing: float) -> int | None: """Return the index of the clicked menu item, or None. Hit-tests a vertical list of items. Each item spans [origin_x, origin_x + item_w] x [origin_y + i*spacing - 4, origin_y + i*spacing + item_h]. """ for i in range(count): iy = origin_y + i * spacing if origin_x <= mx <= origin_x + item_w and iy - 4 <= my <= iy + item_h: return i return None