Source code for simvx.core.ui.scene_host

"""SceneHost: a tree running inside another, reached through a node.

A hosted tree (an editor's game viewport, a game's arcade cabinet) is never a
second destination for input. It is reached through this node, which lives in
the host tree and receives input by that tree's ordinary rules, then forwards
what reached it: a pointer while it is over the host's rect or held by it; a
key, typed text or a controller while the host holds focus in a tree that has
it. A host that must see an event before its guest (the editor's F5) uses a
capture handler above the host, which runs first.

When input stops reaching the guest it is handed over rather than cut off
(``SceneTree.input_handover``): held actions close with a real edge, pointers
cancel, the hover leaves, the text session ends, and nothing presses on
arrival. It is per kind: turning ``forwarding`` off, or the host leaving its
tree, hiding or stopping, hands over everything; losing focus hands over the
keys and leaves a drag alone; a pointer leaving the rect unpressed only
leaves.

What the guest asks of the platform travels up: its text session opens over
the host's rect in the host tree, its ``cursor_shape`` is the host's while
the pointer is over it, and its pointer lock is the host tree's. What the
host's screen does travels down: the insets over the host's rect become the
guest's, in its own units.

A host given a node rather than a tree builds the guest itself, sizes it to
its own rect and steps it once a frame; a host given a tree someone else
steps (the editor's play mode) leaves stepping to its owner. Drawing the guest
is the owner's too.
"""

from __future__ import annotations

from dataclasses import replace
from typing import TYPE_CHECKING, Any

from ..decorators import on_input
from ..descriptors import Property
from ..input import devices as _devices
from ..input.enums import CursorShape, FocusMode, MouseCaptureMode, PointerPhase, PointerType
from ..input.events import AxisEvent, ButtonEvent, KeyEvent, PointerEvent, ScrollEvent, TextEvent
from ..input.router import InputRouter
from ..math.types import Vec2
from ..node import Node
from ..scene_step import step_scene_logic
from ..scene_tree import SceneTree
from .core import Control
from .enums import AcceptsPointers, PointerFilter

if TYPE_CHECKING:
    from ..input.events import InputEvent

__all__ = ["SceneHost"]


[docs] class SceneHost(Control): """A node that runs a tree inside the tree it belongs to, and forwards input into it. Example:: class Cabinet(Control): def on_ready(self): self.host = self.add_child(SceneHost(ArcadeGame(), focus_mode=FocusMode.HOVER)) self.host.set_anchor_preset(Anchor.FULL_RECT) @on_input(Key.ESCAPE, capture=True) # heard before the guest def _leave(self, event): self.host.forwarding = False return True """ pointer_filter = Property(PointerFilter.STOP, hint="How this control takes part in the pointer hit test") accepts_pointers = Property(AcceptsPointers.ALL, hint="Which pointers this control takes") focus_mode = Property(FocusMode.CLICK, hint="Whether this node can take keyboard focus, and how") forwarding = Property( True, hint="Whether input reaches the guest; off hands it over", on_change="_on_forwarding_changed" ) def __init__(self, guest: SceneTree | Node | None = None, **kwargs: Any) -> None: super().__init__(**kwargs) self._guest: SceneTree | None = None self._owns_guest = False self._router: InputRouter | None = None #: The presses this host forwarded and the guest has not seen end, by #: pointer: its kind, the buttons down and where it last was. self._pressed: dict[int, tuple[PointerType, set[Any], tuple[float, float]]] = {} #: Whether anything, and whether a key, reached the guest since the last handover. self._was_live = False self._keys_were_live = False #: The mouse or pen whose motion the guest last heard, and so which must leave. self._hovering: set[tuple[int, PointerType]] = set() #: The relays this host opened in its own tree for the guest, revoked on handover. self._relayed_text = False self._relayed_lock = False if guest is not None: self.guest = guest # -- The guest ------------------------------------------------------------ @property def guest(self) -> SceneTree | None: """The tree this host runs. Assigning a node builds a tree for it, owned and stepped here.""" return self._guest
[docs] @guest.setter def guest(self, guest: SceneTree | Node | None) -> None: tree: SceneTree | None if isinstance(guest, Node): tree = SceneTree(screen_size=self._guest_size()) tree.set_root(guest) owns = True elif guest is None or isinstance(guest, SceneTree): tree, owns = guest, False else: raise TypeError(f"SceneHost.guest is a SceneTree, a Node to build one around, or None; got {guest!r}") if tree is self._guest: return if tree is not None: self._refuse_cycle(tree) old = self._guest if old is not None: self._hand_over(pointers=True, keys=True) self._detach(old) self._guest = tree self._owns_guest = owns self._router = None if tree is not None: self._attach(tree)
def _attach(self, tree: SceneTree) -> None: """Make *tree* this host's guest: its platform requests relay here.""" previous = tree._host if previous is not None and previous is not self: # A tree has one host: taking it moves it here, and the host it # leaves hands over what it was forwarding. previous.guest = None tree._host = self inp = tree._own_input inp._text_input_callback = self._relay_text inp._cursor_shape_callback = self._relay_cursor inp._capture_mode_callback = self._relay_capture def _detach(self, tree: SceneTree) -> None: if tree._host is self: tree._host = None inp = tree._own_input inp._text_input_callback = None inp._cursor_shape_callback = None inp._capture_mode_callback = None def _refuse_cycle(self, tree: SceneTree) -> None: """A guest that contains this host's own tree would host itself.""" outer = self._tree chain = [outer] while outer is not None: if outer is tree: path = " -> ".join(repr(t) for t in chain) raise ValueError(f"SceneHost {self.name!r} cannot host {tree!r}: it contains the host ({path})") host = outer._host outer = host.tree if host is not None else None chain.append(outer) def _guest_size(self) -> tuple[float, float]: w, h = float(self.size.x), float(self.size.y) return (w, h) if w > 0 and h > 0 else (1.0, 1.0) # -- Coordinates ---------------------------------------------------------- def _guest_scale(self) -> tuple[float, float]: guest = self._guest w, h = float(self.size.x), float(self.size.y) if guest is None or w <= 0 or h <= 0: return 1.0, 1.0 gw, gh = guest.screen_size return float(gw) / w, float(gh) / h
[docs] def to_guest(self, point) -> Vec2: """A point in the host tree's space, in the guest's screen space.""" local = self.to_local(point) sx, sy = self._guest_scale() return Vec2(float(local[0]) * sx, float(local[1]) * sy)
[docs] def to_host(self, point) -> Vec2: """A point in the guest's screen space, in the host tree's space.""" sx, sy = self._guest_scale() return self.to_global(Vec2(float(point[0]) / sx, float(point[1]) / sy))
# -- Forwarding ----------------------------------------------------------- def _live(self) -> bool: """Whether input reaches the guest at all now.""" return bool( self.forwarding and self._guest is not None and self._tree is not None and self.visible_in_tree and self._can_receive_input() ) def _keys_live(self) -> bool: """Whether keys, text and controllers reach the guest now.""" tree = self._tree return self._live() and self.focused and tree is not None and tree.focus_active def _guest_router(self) -> InputRouter | None: guest = self._guest if guest is None: return None router = self._router if router is None or router.tree is not guest: router = self._router = InputRouter(guest) return router def _publish(self, event: InputEvent) -> bool: router = self._guest_router() return router is not None and router.publish(event) is not None def _into_guest(self, event: PointerEvent | ScrollEvent) -> PointerEvent | ScrollEvent: position = self.to_guest(event.position) if isinstance(event, PointerEvent): sx, sy = self._guest_scale() return replace( event, position=(float(position.x), float(position.y)), delta=(event.delta[0] * sx, event.delta[1] * sy), viewport=None, ) return replace(event, position=(float(position.x), float(position.y)), viewport=None) def _on_gui_input(self, event): """A pointer over the host, or held by it: forwarded into the guest in its own space. The host owns every press it forwards until it ends, so a drag that leaves the rect goes on reaching the game and its release is never stranded there; the positions outside the rect are forwarded as they are. A press is claimed here whatever the guest did with it, because it belongs to the guest now. """ if not isinstance(event, PointerEvent | ScrollEvent) or not self._live(): return None self._was_live = True forwarded = self._into_guest(event) claimed = self._publish(forwarded) if isinstance(event, ScrollEvent): return claimed or None phase = event.phase pointer = event.pointer_id if phase is PointerPhase.DOWN: _kind, held, _where = self._pressed.get(pointer, (event.pointer_type, set(), forwarded.position)) held.add(event.button) self._pressed[pointer] = (event.pointer_type, held, forwarded.position) self.capture_pointer(pointer) return True if phase is PointerPhase.UP or phase is PointerPhase.CANCEL: entry = self._pressed.get(pointer) if entry is not None: entry[1].discard(event.button) if not entry[1] or phase is PointerPhase.CANCEL: del self._pressed[pointer] self.release_pointer(pointer) return True if pointer in self._pressed: kind, held, _where = self._pressed[pointer] self._pressed[pointer] = (kind, held, forwarded.position) if phase is PointerPhase.MOVE and event.pointer_type is not PointerType.TOUCH: self._hovering.add((pointer, event.pointer_type)) return claimed or None def _forward_focused(self, event: InputEvent) -> bool | None: """A key, text or a controller, forwarded while this host holds focus; a focused host claims all of them.""" if not self._keys_live(): return None self._was_live = self._keys_were_live = True self._publish(event) return True @on_input(KeyEvent, capture=True) def _forward_key(self, event: KeyEvent) -> bool | None: return self._forward_focused(event) @on_input(TextEvent, capture=True) def _forward_text(self, event: TextEvent) -> bool | None: return self._forward_focused(event) @on_input(ButtonEvent, capture=True) def _forward_button(self, event: ButtonEvent) -> bool | None: return self._forward_focused(event) @on_input(AxisEvent, capture=True) def _forward_axis(self, event: AxisEvent) -> bool | None: return self._forward_focused(event) # -- The handover --------------------------------------------------------- def _on_forwarding_changed(self, _old: object = None, new: object = None) -> None: """Turning forwarding off hands everything over at once, not at the next frame.""" if not self.forwarding and self._guest is not None: self._hand_over(pointers=True, keys=True) def _audit(self) -> None: """Once a frame, from the host tree: hand over whatever stopped reaching the guest, and relay the insets.""" live = self._live() keys_live = live and self._keys_live() if self._was_live and not live: self._hand_over(pointers=True, keys=True) elif self._keys_were_live and not keys_live: self._hand_over(pointers=False, keys=True) elif live and self._hovering and not self.mouse_over and not self._pressed: self._leave() guest = self._guest if keys_live and guest is not None and not self._relayed_text: # A session the guest opened before the host had the keys is relayed once it does. rect = guest._own_input.text_input_rect if rect is not None: self._relay_text(rect) if guest is not None: if self._owns_guest: size = self._guest_size() if tuple(guest.screen_size) != size: guest.screen_size = size guest.screen_insets = self._insets_for_guest() def _leave(self) -> None: """The pointer left the rect with nothing pressed: the guest's hover leaves, nothing else changes.""" for pointer, kind in self._hovering: self._publish(PointerEvent(phase=PointerPhase.LEAVE, pointer_id=pointer, pointer_type=kind)) self._hovering.clear() self.cursor_shape = None def _hand_over(self, *, pointers: bool, keys: bool) -> None: """Input of these kinds stops reaching the guest: it closes what it held, and the relays are revoked.""" guest = self._guest if guest is None: return if pointers: # Each press in flight is cancelled in the guest, which tells # whoever holds it and closes the button with no release edge. for pointer, (kind, held, where) in list(self._pressed.items()): for button in held: self._publish( PointerEvent( phase=PointerPhase.CANCEL, pointer_id=pointer, pointer_type=kind, button=button, position=where, ) ) self.release_pointer(pointer) self._pressed.clear() self._hovering.clear() self.cursor_shape = None self._revoke_lock() if keys: self._revoke_text() guest.input_handover(pointers=pointers, keys=keys) if pointers and keys: self._was_live = False if keys: self._keys_were_live = False # -- Relays --------------------------------------------------------------- def _relay_text(self, rect: tuple[float, float, float, float] | None) -> None: """The guest opened, moved or closed its text session: the host tree's is the same one.""" tree = self._tree if tree is None: return if rect is None or not self._keys_live(): self._revoke_text() return x, y, w, h = rect top_left = self.to_host((x, y)) bottom_right = self.to_host((x + w, y + h)) with tree.activate_input(): tree.input.begin_text_input( ( float(top_left.x), float(top_left.y), float(bottom_right.x - top_left.x), float(bottom_right.y - top_left.y), ) ) self._relayed_text = True def _revoke_text(self) -> None: tree = self._tree if self._relayed_text and tree is not None: with tree.activate_input(): tree.input.end_text_input() self._relayed_text = False def _relay_cursor(self, shape: CursorShape) -> None: """The guest's pointer shape is the host's while the pointer is over it.""" if self._live(): self.cursor_shape = shape def _relay_capture(self, mode: MouseCaptureMode) -> None: """The guest locked or freed the pointer: the host tree does, while the guest has input.""" tree = self._tree if tree is None: return wants = mode is not MouseCaptureMode.VISIBLE if wants and not self._live(): return with tree.activate_input(): tree.input.set_mouse_capture_mode(mode) self._relayed_lock = wants def _revoke_lock(self) -> None: tree = self._tree if self._relayed_lock and tree is not None: with tree.activate_input(): tree.input.set_mouse_capture_mode(MouseCaptureMode.VISIBLE) self._relayed_lock = False def _insets_for_guest(self) -> tuple[float, float, float, float]: """The host screen's insets where they cover this rect, in the guest's units.""" tree = self._tree if tree is None: return (0.0, 0.0, 0.0, 0.0) left, top, right, bottom = tree.screen_insets if not (left or top or right or bottom): return (0.0, 0.0, 0.0, 0.0) sw, sh = tree.screen_size x, y, w, h = self.get_global_rect() sx, sy = self._guest_scale() return ( max(0.0, left - x) * sx, max(0.0, top - y) * sy, max(0.0, (x + w) - (sw - right)) * sx, max(0.0, (y + h) - (sh - bottom)) * sy, ) # -- Life ----------------------------------------------------------------- def _enter_tree(self, tree): super()._enter_tree(tree) if self._guest is not None: self._refuse_cycle(self._guest) tree._hosts.add(self) def _exit_tree(self): tree = self._tree if tree is not None: self._hand_over(pointers=True, keys=True) self._was_live = self._keys_were_live = False tree._hosts.discard(self) super()._exit_tree()
[docs] def on_update(self, dt: float) -> None: """Step a guest this host built, inside the guest's own input frame.""" guest = self._guest if guest is None or not self._owns_guest or guest.root is None: return inp = guest._own_input with _devices.hold_frame(): inp._new_frame() with guest.activate_input(): try: step_scene_logic(guest, dt) finally: inp._end_frame() guest._flush_deletes()