Skip to content

Commit 5b6af06

Browse files
committed
Put an event filter on the view
This is a clean solution for implementing "on-leave" behavior. In NDV's case, it's needed to clear the hover info when the cursor leaves the image.
1 parent 22f7ad2 commit 5b6af06

File tree

5 files changed

+137
-25
lines changed

5 files changed

+137
-25
lines changed

examples/event_filters.py

Lines changed: 24 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
import numpy as np
33

44
import scenex as snx
5-
from scenex.app.events import Event, MouseEvent
5+
from scenex.app.events import Event, MouseMoveEvent
66

77
img = snx.Image(
88
data=np.zeros((200, 200)).astype(np.uint8),
@@ -15,26 +15,33 @@
1515
view = snx.View(scene=snx.Scene(children=[img]))
1616

1717

18-
def _img_filter(event: Event, node: snx.Node) -> bool:
18+
def _view_filter(event: Event) -> bool:
1919
"""Example event drawing a square that reacts to the cursor."""
20-
# TODO: How might we remove the square when the mouse leaves the image?
21-
22-
if isinstance(event, MouseEvent) and isinstance(node, snx.Image):
23-
data = np.zeros((200, 200), dtype=np.uint8)
24-
x = int(event.world_ray.origin[0])
25-
min_x = max(0, x - 5)
26-
max_x = min(data.shape[0], x + 5)
27-
28-
y = int(event.world_ray.origin[1])
29-
min_y = max(0, y - 5)
30-
max_y = min(data.shape[1], y + 5)
31-
32-
data[min_x:max_x, min_y:max_y] = 255
33-
node.data = data
20+
if isinstance(event, MouseMoveEvent):
21+
intersections = event.world_ray.intersections(view.scene)
22+
if not intersections:
23+
# Clear the image if the mouse is not over it
24+
img.data = np.zeros((200, 200), dtype=np.uint8)
25+
return True
26+
for node, distance in intersections:
27+
if not isinstance(node, snx.Image):
28+
continue
29+
intersection = event.world_ray.point_at_distance(distance)
30+
data = np.zeros((200, 200), dtype=np.uint8)
31+
x = int(intersection[0])
32+
min_x = max(0, x - 5)
33+
max_x = min(data.shape[0], x + 5)
34+
35+
y = int(intersection[1])
36+
min_y = max(0, y - 5)
37+
max_y = min(data.shape[1], y + 5)
38+
39+
data[min_x:max_x, min_y:max_y] = 255
40+
node.data = data
3441
return True
3542

3643

37-
img.set_event_filter(_img_filter)
44+
view.set_event_filter(_view_filter)
3845

3946
snx.show(view)
4047
snx.run()

src/scenex/app/events/_events.py

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,10 @@
22

33
from dataclasses import dataclass
44
from enum import IntFlag, auto
5-
from typing import NamedTuple
5+
from typing import TYPE_CHECKING, NamedTuple, TypeAlias
6+
7+
if TYPE_CHECKING:
8+
from scenex import Node
69

710

811
# Note that scenex follows the inheritance pattern for event subtypes.
@@ -24,6 +27,9 @@ class MouseButton(IntFlag):
2427
RIGHT = auto()
2528

2629

30+
Intersection: TypeAlias = tuple["Node", float]
31+
32+
2733
class Ray(NamedTuple):
2834
"""A ray passing through the world."""
2935

@@ -36,6 +42,19 @@ def point_at_distance(self, distance: float) -> tuple[float, float, float]:
3642
z = self.origin[2] + self.direction[2] * distance
3743
return (x, y, z)
3844

45+
def intersections(self, graph: Node) -> list[Intersection]:
46+
"""
47+
Find all intersections of this ray with the given scene graph.
48+
49+
Returns a list of (node, distance) tuples, sorted by distance.
50+
"""
51+
through: list[Intersection] = []
52+
for child in graph.children:
53+
if (d := child.passes_through(self)) is not None:
54+
through.append((child, d))
55+
through.extend(self.intersections(child))
56+
return sorted(through, key=lambda inter: inter[1])
57+
3958

4059
@dataclass
4160
class ResizeEvent(Event):

src/scenex/model/_canvas.py

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -116,18 +116,19 @@ def handle(self, event: Event) -> bool:
116116
handled = False
117117
if isinstance(event, MouseEvent):
118118
if view := self._containing_view(event.canvas_pos):
119-
through: list[tuple[Node, float]] = []
120-
for child in view.scene.children:
121-
if (d := child.passes_through(event.world_ray)) is not None:
122-
through.append((child, d))
119+
# Give the view a chance to observe the result
120+
if view.filter_event(event):
121+
return True
123122

123+
intersections = event.world_ray.intersections(view.scene)
124124
# FIXME: Consider only reporting the first?
125125
# Or do we only report until we hit a node with opacity=1?
126-
for node, _depth in sorted(through, key=lambda e: e[1]):
126+
for node, _distance in intersections:
127127
# Filter through parent scenes to child
128-
handled |= Canvas._filter_through(event, node, node)
128+
if Canvas._filter_through(event, node, node):
129+
return True
129130
# No nodes in the view handled the event - pass it to the camera
130-
if not handled and view.camera.interactive:
131+
if view.camera.interactive:
131132
handled |= view.camera.filter_event(event, view.camera)
132133
elif isinstance(event, ResizeEvent):
133134
# TODO: How might some event filter tap into the resize?

src/scenex/model/_view.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,12 @@
1313
from ._nodes.scene import Scene
1414

1515
if TYPE_CHECKING:
16+
from collections.abc import Callable
17+
1618
import numpy as np
1719

1820
from scenex.adaptors._base import ViewAdaptor
21+
from scenex.app.events import Event
1922

2023
from ._canvas import Canvas
2124

@@ -71,3 +74,24 @@ def render(self) -> np.ndarray:
7174
if adaptors := self._get_adaptors():
7275
return cast("ViewAdaptor", adaptors[0])._snx_render()
7376
raise RuntimeError("No adaptor found for View.")
77+
78+
_filter: Callable[[Event], bool] | None = PrivateAttr(default=None)
79+
80+
def set_event_filter(
81+
self, callable: Callable[[Event], bool] | None
82+
) -> Callable[[Event], bool] | None:
83+
old, self._filter = self._filter, callable
84+
return old
85+
86+
def filter_event(self, event: Event) -> bool:
87+
"""
88+
Filters the event.
89+
90+
This method allows the larger view to react to events that:
91+
1. Require summarization of multiple smaller event responses.
92+
2. Could not be picked up by a node (e.g. mouse leaving an image).
93+
94+
Note the name has parity with Node.filter_event, but there's much filtering
95+
going on.
96+
"""
97+
return self._filter(event) if self._filter else False

tests/model/_nodes/test_view.py

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
from __future__ import annotations
2+
3+
from unittest.mock import MagicMock
4+
5+
import numpy as np
6+
7+
import scenex as snx
8+
from scenex.app.events import MouseButton, MouseMoveEvent
9+
from scenex.utils import projections
10+
11+
12+
def test_events() -> None:
13+
# Create a view with an image
14+
img = snx.Image(data=np.ones((10, 10), dtype=np.uint8), interactive=True)
15+
img_filter = MagicMock()
16+
img.set_event_filter(img_filter)
17+
18+
view = snx.View(scene=snx.Scene(children=[img]))
19+
view_filter = MagicMock()
20+
view_filter.return_value = False
21+
view.set_event_filter(view_filter)
22+
23+
# Set up the camera
24+
# Such that the image is in the top right quadrant
25+
view.camera.transform = snx.Transform().translated((-0.5, -0.5))
26+
view.camera.projection = projections.orthographic(1, 1, 1)
27+
28+
# Put it on a canvas
29+
canvas = snx.Canvas(width=int(view.layout.width), height=int(view.layout.height))
30+
canvas.views.append(view)
31+
32+
# Mouse over that image in the top right corner
33+
canvas_pos = (view.layout.width, 0)
34+
world_ray = canvas.to_world(canvas_pos)
35+
assert world_ray is not None
36+
event = MouseMoveEvent(
37+
canvas_pos=canvas_pos, world_ray=world_ray, buttons=MouseButton.NONE
38+
)
39+
40+
# And show both the view and the image saw the event
41+
canvas.handle(event)
42+
view_filter.assert_called_once_with(event)
43+
img_filter.assert_called_once_with(event, img)
44+
45+
# Reset the mocks
46+
img_filter.reset_mock()
47+
view_filter.reset_mock()
48+
49+
# Mouse over empty space in the top left corner
50+
canvas_pos = (0, 0)
51+
world_ray = canvas.to_world(canvas_pos)
52+
assert world_ray is not None
53+
event = MouseMoveEvent(
54+
canvas_pos=canvas_pos, world_ray=world_ray, buttons=MouseButton.NONE
55+
)
56+
57+
# And show that the image did not see the event
58+
# but that the view still saw the event
59+
canvas.handle(event)
60+
img_filter.assert_not_called()
61+
view_filter.assert_called_once_with(event)

0 commit comments

Comments
 (0)