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 Popup:
"""Base class for popup overlays managed by PopupManager.
Subclass and override the popup_* methods. Popups are lightweight objects
(not Nodes) managed entirely by PopupManager. They can be reused across
multiple open/close cycles.
"""
def __init__(self):
self.screen_w: float = 0
self.screen_h: float = 0
[docs]
def popup_input(self, dt: float) -> bool:
"""Handle input. Return True to stay open, False to close."""
return True
[docs]
def popup_draw(self, renderer, screen_w: float, screen_h: float) -> None:
"""Draw the popup in screen space."""
[docs]
def popup_click(self, mx: float, my: float) -> bool | None:
"""Handle mouse click. True=handled, False=close, None=pass-through."""
return None
[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 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))
# -- 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)