"""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()