From 3f692a7d65ca0fb9b524e777ce6f4134a2bd215f Mon Sep 17 00:00:00 2001 From: Gabriel Selzer Date: Mon, 30 Mar 2026 13:51:55 -0500 Subject: [PATCH 1/6] First cut: Canvas events --- examples/keyboard_pan_zoom.py | 62 ++++++++++++++++++++ pyproject.toml | 2 +- src/scenex/app/_jupyter.py | 84 ++++++++++++--------------- src/scenex/app/_qt.py | 31 ++++++---- src/scenex/app/_wx.py | 81 ++++++++++++-------------- src/scenex/app/events/__init__.py | 10 ++++ src/scenex/app/events/_events.py | 61 +++++++++++++++++--- src/scenex/model/_canvas.py | 95 ++++++++++++++++++++++--------- src/scenex/model/_nodes/camera.py | 70 ++++++++++++----------- src/scenex/model/_view.py | 17 +++++- tests/app/test_qt.py | 35 +++++++----- tests/model/_nodes/test_camera.py | 73 +++++++++++++----------- tests/model/test_view.py | 11 ++-- 13 files changed, 401 insertions(+), 231 deletions(-) create mode 100644 examples/keyboard_pan_zoom.py diff --git a/examples/keyboard_pan_zoom.py b/examples/keyboard_pan_zoom.py new file mode 100644 index 00000000..08020c0e --- /dev/null +++ b/examples/keyboard_pan_zoom.py @@ -0,0 +1,62 @@ +"""Demonstrates keyboard-driven pan and zoom on top of the PanZoom controller. + +Arrow keys pan the view; + and - zoom in and out. Mouse drag and scroll +continue to work as normal via the PanZoom controller. +""" + +import numpy as np +from app_model.types import KeyCode + +import scenex as snx +from scenex.app.events import Event, KeyPressEvent + +# Build a recognisable test image: a grid of bright dots on a dark background. +rng = np.random.default_rng(0) +data = np.zeros((512, 512), dtype=np.uint8) +coords = rng.integers(8, 504, size=(200, 2)) +for y, x in coords: + data[y - 3 : y + 3, x - 3 : x + 3] = 255 + +view = snx.View( + scene=snx.Scene(children=[snx.Image(data=data)]), + camera=snx.Camera(controller=snx.PanZoom(), interactive=True), +) +canvas = snx.Canvas(views=[view]) + +_PAN_STEP = 20.0 # world units per arrow-key press +_ZOOM_STEP = 1.25 # multiplicative factor per +/- press + + +def _key_filter(event: Event) -> bool: + """Pan with arrow keys; zoom with + / -.""" + if not isinstance(event, KeyPressEvent): + return False + + key = event.key # KeyCode (or KeyCombo for modified keys) + cam = view.camera + + if key == KeyCode.UpArrow: + cam.transform = cam.transform.translated((0, _PAN_STEP)) + elif key == KeyCode.DownArrow: + cam.transform = cam.transform.translated((0, -_PAN_STEP)) + elif key == KeyCode.LeftArrow: + cam.transform = cam.transform.translated((-_PAN_STEP, 0)) + elif key == KeyCode.RightArrow: + cam.transform = cam.transform.translated((_PAN_STEP, 0)) + elif key in (KeyCode.Equal, KeyCode.NumpadAdd): # + / numpad + + s = _ZOOM_STEP + cam.projection = cam.projection.scaled((s, s, 1.0)) + elif key == KeyCode.Minus: # - + s = 1.0 / _ZOOM_STEP + cam.projection = cam.projection.scaled((s, s, 1.0)) + else: + print(f"Unhandled key: {key}") + return False + + return True + + +canvas.set_event_filter(_key_filter) + +snx.show(canvas) +snx.run() diff --git a/pyproject.toml b/pyproject.toml index 3b09b37f..7b40646c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,7 +32,7 @@ classifiers = [ "Programming Language :: Python :: 3.13", "Typing :: Typed", ] -dependencies = ["cmap>=0.5", "numpy>=1.24", "psygnal>=0.11.1", "pydantic>=2.10", "pylinalg"] +dependencies = ["app-model>=0.3", "cmap>=0.5", "numpy>=1.24", "psygnal>=0.11.1", "pydantic>=2.10", "pylinalg"] [project.optional-dependencies] jupyter = [ diff --git a/src/scenex/app/_jupyter.py b/src/scenex/app/_jupyter.py index 9eda4a70..1f6b54f0 100644 --- a/src/scenex/app/_jupyter.py +++ b/src/scenex/app/_jupyter.py @@ -52,53 +52,45 @@ def _handle_event(self: RemoteFrameBuffer, ev: dict) -> None: for b in btns: filter._active_button |= JupyterEventFilter.mouse_btn(b) canvas_pos = (ev["x"], ev["y"]) - if world_ray := filter._model_canvas.to_world(canvas_pos): - filter._model_canvas.handle( - MouseMoveEvent( - canvas_pos=canvas_pos, - world_ray=world_ray, - buttons=filter._active_button, - ) + filter._model_canvas.handle( + MouseMoveEvent( + canvas_pos=canvas_pos, + buttons=filter._active_button, ) + ) elif etype == "pointer_down": canvas_pos = (ev["x"], ev["y"]) btn = JupyterEventFilter.mouse_btn(ev["button"]) filter._active_button |= btn - if world_ray := filter._model_canvas.to_world(canvas_pos): - filter._model_canvas.handle( - MousePressEvent( - canvas_pos=canvas_pos, - world_ray=world_ray, - buttons=btn, - ) + filter._model_canvas.handle( + MousePressEvent( + canvas_pos=canvas_pos, + buttons=btn, ) + ) elif etype == "double_click": btn = JupyterEventFilter.mouse_btn(ev["button"]) canvas_pos = (ev["x"], ev["y"]) - if world_ray := filter._model_canvas.to_world(canvas_pos): - # FIXME: in Jupyter, a double_click event is not a pointer - # event. In other words, there will be no release following. - # This could cause unintended behavior. See - # https://github.com/vispy/jupyter_rfb/blob/62831dd5a87bc19b4fd5f921d802ed21141e61ec/js/lib/widget.js#L270 - filter._model_canvas.handle( - MouseDoublePressEvent( - canvas_pos=canvas_pos, - world_ray=world_ray, - buttons=btn, - ) + # FIXME: in Jupyter, a double_click event is not a pointer + # event. In other words, there will be no release following. + # This could cause unintended behavior. See + # https://github.com/vispy/jupyter_rfb/blob/62831dd5a87bc19b4fd5f921d802ed21141e61ec/js/lib/widget.js#L270 + filter._model_canvas.handle( + MouseDoublePressEvent( + canvas_pos=canvas_pos, + buttons=btn, ) + ) elif etype == "pointer_up": canvas_pos = (ev["x"], ev["y"]) btn = JupyterEventFilter.mouse_btn(ev["button"]) filter._active_button &= ~btn - if world_ray := filter._model_canvas.to_world(canvas_pos): - filter._model_canvas.handle( - MouseReleaseEvent( - canvas_pos=canvas_pos, - world_ray=world_ray, - buttons=btn, - ) + filter._model_canvas.handle( + MouseReleaseEvent( + canvas_pos=canvas_pos, + buttons=btn, ) + ) elif etype == "pointer_enter": canvas_pos = (ev["x"], ev["y"]) filter._active_button = MouseButton.NONE @@ -107,28 +99,24 @@ def _handle_event(self: RemoteFrameBuffer, ev: dict) -> None: elif btns := ev.get("buttons", None): for b in btns: filter._active_button |= JupyterEventFilter.mouse_btn(b) - if world_ray := filter._model_canvas.to_world(canvas_pos): - filter._model_canvas.handle( - MouseEnterEvent( - canvas_pos=canvas_pos, - world_ray=world_ray, - buttons=filter._active_button, - ) + filter._model_canvas.handle( + MouseEnterEvent( + canvas_pos=canvas_pos, + buttons=filter._active_button, ) + ) elif etype == "pointer_leave": filter._model_canvas.handle(MouseLeaveEvent()) elif etype == "wheel": canvas_pos = (ev["x"], ev["y"]) - if world_ray := filter._model_canvas.to_world(canvas_pos): - filter._model_canvas.handle( - WheelEvent( - canvas_pos=canvas_pos, - world_ray=world_ray, - buttons=filter._active_button, - # Note that Jupyter_rfb uses a different y convention - angle_delta=(ev["dx"], -ev["dy"]), - ) + filter._model_canvas.handle( + WheelEvent( + canvas_pos=canvas_pos, + buttons=filter._active_button, + # Note that Jupyter_rfb uses a different y convention + angle_delta=(ev["dx"], -ev["dy"]), ) + ) elif etype == "resize": filter._model_canvas.handle( ResizeEvent( diff --git a/src/scenex/app/_qt.py b/src/scenex/app/_qt.py index 1dfb63ad..65d5aec6 100644 --- a/src/scenex/app/_qt.py +++ b/src/scenex/app/_qt.py @@ -4,6 +4,8 @@ from concurrent.futures import Future from typing import TYPE_CHECKING, Any, ClassVar, cast +from app_model.backends.qt import qkeycombo2modelkey +from app_model.types import KeyBinding, SimpleKeyBinding from qtpy.QtCore import ( QCoreApplication, QEvent, @@ -13,12 +15,20 @@ QThread, QTimer, ) -from qtpy.QtGui import QEnterEvent, QMouseEvent, QResizeEvent, QWheelEvent +from qtpy.QtGui import ( + QEnterEvent, + QKeyEvent, + QMouseEvent, + QResizeEvent, + QWheelEvent, +) from qtpy.QtWidgets import QApplication, QWidget from scenex.app._auto import App, CursorType from scenex.app.events import ( EventFilter, + KeyPressEvent, + KeyReleaseEvent, MouseButton, MouseDoublePressEvent, MouseEnterEvent, @@ -82,42 +92,35 @@ def _convert_event(self, qevent: QEvent) -> Event | None: if isinstance(qevent, QMouseEvent | QEnterEvent): pos = qevent.position() canvas_pos = (pos.x(), pos.y()) - if not (ray := self._model_canvas.to_world(canvas_pos)): - return None etype = qevent.type() btn = self.mouse_btn(qevent.button()) if etype == QEvent.Type.MouseMove: return MouseMoveEvent( canvas_pos=canvas_pos, - world_ray=ray, buttons=self._active_buttons, ) elif etype == QEvent.Type.MouseButtonDblClick: self._active_buttons |= btn return MouseDoublePressEvent( canvas_pos=canvas_pos, - world_ray=ray, buttons=btn, ) elif etype == QEvent.Type.MouseButtonPress: self._active_buttons |= btn return MousePressEvent( canvas_pos=canvas_pos, - world_ray=ray, buttons=btn, ) elif etype == QEvent.Type.MouseButtonRelease: self._active_buttons &= ~btn return MouseReleaseEvent( canvas_pos=canvas_pos, - world_ray=ray, buttons=btn, ) elif etype == QEvent.Type.Enter: return MouseEnterEvent( canvas_pos=canvas_pos, - world_ray=ray, buttons=self._active_buttons, ) @@ -128,11 +131,8 @@ def _convert_event(self, qevent: QEvent) -> Event | None: # TODO: Figure out the buttons pos = qevent.position() canvas_pos = (pos.x(), pos.y()) - if not (ray := self._model_canvas.to_world(canvas_pos)): - return None return WheelEvent( canvas_pos=canvas_pos, - world_ray=ray, buttons=self._active_buttons, angle_delta=(qevent.angleDelta().x(), qevent.angleDelta().y()), ) @@ -144,6 +144,15 @@ def _convert_event(self, qevent: QEvent) -> Event | None: height=size.height(), ) + elif isinstance(qevent, QKeyEvent): + model_key = qkeycombo2modelkey(qevent.keyCombination()) + part = SimpleKeyBinding.from_int(model_key) + keys = KeyBinding(parts=[part]) + if qevent.type() == QEvent.Type.KeyPress: + return KeyPressEvent(key=keys) + elif qevent.type() == QEvent.Type.KeyRelease: + return KeyReleaseEvent(key=keys) + return None diff --git a/src/scenex/app/_wx.py b/src/scenex/app/_wx.py index 6fa568e8..ce47c192 100644 --- a/src/scenex/app/_wx.py +++ b/src/scenex/app/_wx.py @@ -67,15 +67,13 @@ def _on_leave_window(self, event: wx.MouseEvent) -> None: def _on_enter_window(self, event: wx.MouseEvent) -> None: pos = event.GetPosition() - if ray := self._model_canvas.to_world((pos.x, pos.y)): - self._model_canvas.handle( - MouseEnterEvent( - canvas_pos=(pos.x, pos.y), - world_ray=ray, - buttons=self._active_button, - ) + self._model_canvas.handle( + MouseEnterEvent( + canvas_pos=(pos.x, pos.y), + buttons=self._active_button, ) - event.Skip() + ) + event.Skip() def _on_resize(self, event: wx.SizeEvent) -> None: self._model_canvas.handle( @@ -90,57 +88,50 @@ def _on_mouse_down(self, event: wx.MouseEvent) -> None: btn = self._map_button(event) self._active_button |= btn pos = event.GetPosition() - if ray := self._model_canvas.to_world((pos.x, pos.y)): - self._model_canvas.handle( - MousePressEvent(canvas_pos=(pos.x, pos.y), world_ray=ray, buttons=btn) - ) - event.Skip() + self._model_canvas.handle( + MousePressEvent(canvas_pos=(pos.x, pos.y), buttons=btn) + ) + event.Skip() def _on_mouse_up(self, event: wx.MouseEvent) -> None: btn = self._map_button(event) self._active_button &= ~btn pos = event.GetPosition() - if ray := self._model_canvas.to_world((pos.x, pos.y)): - self._model_canvas.handle( - MouseReleaseEvent( - canvas_pos=(pos.x, pos.y), - world_ray=ray, - buttons=btn, - ) + self._model_canvas.handle( + MouseReleaseEvent( + canvas_pos=(pos.x, pos.y), + buttons=btn, ) - event.Skip() + ) + event.Skip() def _on_mouse_move(self, event: wx.MouseEvent) -> None: pos = event.GetPosition() - if ray := self._model_canvas.to_world((pos.x, pos.y)): - self._model_canvas.handle( - MouseMoveEvent( - canvas_pos=(pos.x, pos.y), - world_ray=ray, - buttons=self._active_button, - ) + self._model_canvas.handle( + MouseMoveEvent( + canvas_pos=(pos.x, pos.y), + buttons=self._active_button, ) - event.Skip() + ) + event.Skip() def _on_wheel(self, event: wx.MouseEvent) -> None: pos = event.GetPosition() - if ray := self._model_canvas.to_world((pos.x, pos.y)): - if event.GetWheelAxis() == 0: - # Vertical Scroll - angle_delta = (0, event.GetWheelRotation()) - else: - # Horizontal Scroll - angle_delta = (event.GetWheelRotation(), 0) - - self._model_canvas.handle( - WheelEvent( - canvas_pos=(pos.x, pos.y), - world_ray=ray, - buttons=self._active_button, - angle_delta=angle_delta, - ) + if event.GetWheelAxis() == 0: + # Vertical Scroll + angle_delta = (0, event.GetWheelRotation()) + else: + # Horizontal Scroll + angle_delta = (event.GetWheelRotation(), 0) + + self._model_canvas.handle( + WheelEvent( + canvas_pos=(pos.x, pos.y), + buttons=self._active_button, + angle_delta=angle_delta, ) - event.Skip() + ) + event.Skip() def _map_button(self, event: wx.MouseEvent) -> MouseButton: if event.LeftDown() or event.LeftUp(): diff --git a/src/scenex/app/events/__init__.py b/src/scenex/app/events/__init__.py index f3ff44b2..2e4c2713 100644 --- a/src/scenex/app/events/__init__.py +++ b/src/scenex/app/events/__init__.py @@ -7,6 +7,10 @@ Event Types ----------- +**Keyboard Events**: + - KeyPressEvent: Key pressed + - KeyReleaseEvent: Key released + **Mouse Events**: - MousePressEvent: Mouse button pressed - MouseReleaseEvent: Mouse button released @@ -67,6 +71,9 @@ def on_click(event): from ._events import ( Event, EventFilter, + KeyEvent, + KeyPressEvent, + KeyReleaseEvent, MouseButton, MouseDoublePressEvent, MouseEnterEvent, @@ -83,6 +90,9 @@ def on_click(event): __all__ = [ "Event", "EventFilter", + "KeyEvent", + "KeyPressEvent", + "KeyReleaseEvent", "MouseButton", "MouseDoublePressEvent", "MouseEnterEvent", diff --git a/src/scenex/app/events/_events.py b/src/scenex/app/events/_events.py index 6d95b918..5bd45196 100644 --- a/src/scenex/app/events/_events.py +++ b/src/scenex/app/events/_events.py @@ -5,6 +5,8 @@ from typing import TYPE_CHECKING, NamedTuple, TypeAlias if TYPE_CHECKING: + from app_model.types import KeyBinding + from scenex import Node, View @@ -41,7 +43,6 @@ class MouseButton(IntFlag): Check if left button is pressed: >>> event = MousePressEvent( ... canvas_pos=(100, 150), - ... world_ray=Ray(origin=(0, 0, 0), direction=(0, 0, -1), source=None), ... buttons=MouseButton.LEFT | MouseButton.RIGHT, ... ) >>> if event.buttons & MouseButton.LEFT: @@ -202,18 +203,16 @@ class MouseEvent(Event): """Base class for all mouse-related interaction events. MouseEvent provides common fields for all mouse interactions, including the - 2D canvas position, the 3D world ray for picking, and the state of mouse buttons. - Specific mouse event types (move, press, release, etc.) inherit from this base. + 2D canvas position and the state of mouse buttons. Specific mouse event types + (move, press, release, etc.) inherit from this base. + + To obtain the 3D world ray for a mouse event, use ``ViewMouseEvent.view.to_ray()``. Attributes ---------- canvas_pos : tuple[float, float] The (x, y) position of the mouse cursor in canvas pixel coordinates, with origin at the top-left corner. - world_ray : Ray - The 3D ray in world space corresponding to this mouse position, used for - 3D picking and intersection testing. The ray passes from the camera through - the cursor position. buttons : MouseButton Bit flags indicating which mouse buttons are currently pressed. Use bitwise operations to test button states (e.g., buttons & MouseButton.LEFT). @@ -224,11 +223,10 @@ class MouseEvent(Event): MousePressEvent : Mouse button press MouseReleaseEvent : Mouse button release WheelEvent : Mouse wheel scroll - Ray : 3D ray for picking + ViewMouseEvent : Mouse event enriched with view and ray access """ canvas_pos: tuple[float, float] - world_ray: Ray buttons: MouseButton @@ -344,6 +342,51 @@ class WheelEvent(MouseEvent): angle_delta: tuple[float, float] +@dataclass +class KeyEvent(Event): + """Base class for keyboard events. + + Attributes + ---------- + key : KeyBinding + The key (or chord) that was pressed or released. + Use ``KeyBinding.from_str("Ctrl+A")`` to construct from a string, + or access ``key.part0`` for the first (usually only) key and its + modifier state (``ctrl``, ``shift``, ``alt``, ``meta``). + + See Also + -------- + KeyPressEvent : Key press + KeyReleaseEvent : Key release + """ + + key: KeyBinding + + +@dataclass +class KeyPressEvent(KeyEvent): + """Keyboard key press. + + See Also + -------- + KeyReleaseEvent : Key release + """ + + pass + + +@dataclass +class KeyReleaseEvent(KeyEvent): + """Keyboard key release. + + See Also + -------- + KeyPressEvent : Key press + """ + + pass + + class EventFilter: """Base class for event filter handles. diff --git a/src/scenex/model/_canvas.py b/src/scenex/model/_canvas.py index 92247c4d..ee059d20 100644 --- a/src/scenex/model/_canvas.py +++ b/src/scenex/model/_canvas.py @@ -1,5 +1,6 @@ from __future__ import annotations +import logging from collections.abc import Sequence from typing import TYPE_CHECKING, Any, cast @@ -11,6 +12,8 @@ from scenex.app.events import ( Event, + KeyPressEvent, + KeyReleaseEvent, MouseEnterEvent, MouseEvent, MouseLeaveEvent, @@ -23,7 +26,7 @@ from ._view import View # noqa: TC001 if TYPE_CHECKING: - from collections.abc import Iterable + from collections.abc import Callable, Iterable from typing_extensions import TypedDict @@ -39,6 +42,9 @@ class CanvasKwargs(TypedDict, total=False): title: str +logger = logging.getLogger(__name__) + + class Canvas(EventedBase): """A rendering surface that displays one or more views. @@ -79,6 +85,7 @@ class Canvas(EventedBase): # Private state for tracking mouse view transitions _last_mouse_view: View | None = PrivateAttr(default=None) + _filter: Callable[[Event], bool] | None = PrivateAttr(default=None) model_config = ConfigDict(extra="forbid") @@ -170,52 +177,84 @@ def render(self) -> np.ndarray: return cast("CanvasAdaptor", adaptors[0])._snx_render() raise RuntimeError("No adaptor found for Canvas.") + def set_event_filter( + self, callable: Callable[[Event], bool] | None + ) -> Callable[[Event], bool] | None: + """Register a callable to filter all canvas events before view dispatch. + + Parameters + ---------- + callable : Callable[[Event], bool] | None + A callable that takes an Event and returns True if the event was handled + and should not be propagated further, False otherwise. Pass None to remove + any existing filter. + + Returns + ------- + Callable[[Event], bool] | None + The previous event filter, or None if there was no filter. + """ + old, self._filter = self._filter, callable + return old + + def filter_event(self, event: Event) -> bool: + """Pass *event* through the canvas-level filter, if any. + + Returns True iff the event was handled and should not propagate. + """ + if self._filter: + handled = self._filter(event) + if not isinstance(handled, bool): + logger.warning( + f"Canvas event filter {self._filter} did not return a boolean. " + "Returning False." + ) + handled = False + return handled + return False + def handle(self, event: Event) -> bool: """Handle the passed event.""" - handled = False + # 1. Canvas-level filter sees all events first. + if self.filter_event(event): + return True + if isinstance(event, MouseEvent): current_view = self._containing_view(event.canvas_pos) - # Check if we've moved between views and handle transitions - # BEGIN UNTESTED CODE! + # Handle view-transition enter/leave events. # TODO: Add a test for this once multiple views are better supported if self._last_mouse_view != current_view: - # Send leave event to the previous view if self._last_mouse_view is not None: - leave_event = MouseLeaveEvent() - self._last_mouse_view.filter_event(leave_event) - - # Send enter event to the new view (if any) - if current_view is not None: - enter_event = MouseEnterEvent( - canvas_pos=event.canvas_pos, - world_ray=event.world_ray, - buttons=event.buttons, - ) - current_view.filter_event(enter_event) - + self._last_mouse_view.filter_event(MouseLeaveEvent()) + if current_view is not None: + enter = MouseEnterEvent( + canvas_pos=event.canvas_pos, + buttons=event.buttons, + ) + current_view.filter_event(enter) self._last_mouse_view = current_view - # END UNTESTED CODE! - # Handle the original mouse event in the current view if current_view is not None: - # Give the view a chance to observe the result if current_view.filter_event(event): return True - - # No nodes in the view handled the event - pass it to the camera if current_view.camera.interactive: - if on_mouse := current_view.camera.controller: - handled |= on_mouse.handle_event(event, current_view.camera) + if ctrl := current_view.camera.controller: + return ctrl.handle_event(event, current_view) + elif isinstance(event, MouseLeaveEvent): - # Mouse left the entire canvas if self._last_mouse_view is not None: - handled = self._last_mouse_view.filter_event(event) + self._last_mouse_view.filter_event(event) self._last_mouse_view = None + elif isinstance(event, ResizeEvent): - # TODO: How might some event filter tap into the resize? self.size = (event.width, event.height) - return handled + + elif isinstance(event, KeyPressEvent | KeyReleaseEvent): + if self._last_mouse_view is not None: + return self._last_mouse_view.filter_event(event) + + return False def to_ndc(self, canvas_pos: tuple[float, float]) -> tuple[float, float] | None: """Map XY canvas position (pixels) to normalized device coordinates (NDC).""" diff --git a/src/scenex/model/_nodes/camera.py b/src/scenex/model/_nodes/camera.py index 929007ff..f0622e13 100644 --- a/src/scenex/model/_nodes/camera.py +++ b/src/scenex/model/_nodes/camera.py @@ -21,6 +21,7 @@ from .node import Node if TYPE_CHECKING: + from scenex import View from scenex.app.events import Event from scenex.model._transform import Transform @@ -203,7 +204,7 @@ class CameraController(EventedBase): """ @abstractmethod - def handle_event(self, event: Event, camera: Camera) -> bool: + def handle_event(self, event: Event, view: View) -> bool: """ Handle a user interaction event to control the camera. @@ -215,8 +216,8 @@ def handle_event(self, event: Event, camera: Camera) -> bool: event : Event The input event to handle (MouseMoveEvent, MousePressEvent, WheelEvent, KeyPressEvent, etc.) - camera : Camera - The camera node to manipulate. + view : View + The view containing the camera to manipulate. Returns ------- @@ -292,24 +293,21 @@ class PanZoom(CameraController): # Private state for tracking interactions _drag_pos: tuple[float, float] | None = PrivateAttr(default=None) - def handle_event(self, event: Event, camera: Camera) -> bool: + def handle_event(self, event: Event, view: View) -> bool: """Handle mouse and wheel events to pan/zoom the camera.""" - from scenex.app.events import ( - MouseButton, - MouseMoveEvent, - MousePressEvent, - WheelEvent, - ) - - if not camera.interactive: + if not view.camera.interactive: return False handled = False + if not isinstance(event, MouseEvent): + return False + if (ray := view.to_ray(event.canvas_pos)) is None: + return False # Panning involves keeping a particular position underneath the cursor. # That position is recorded on a left mouse button press. if isinstance(event, MousePressEvent) and MouseButton.LEFT in event.buttons: - self._drag_pos = event.world_ray.origin[:2] + self._drag_pos = ray.origin[:2] # Every time the cursor is moved, until the left mouse button is released, # We translate the camera such that the position is back under the cursor # (i.e. under the world ray origin) @@ -318,13 +316,13 @@ def handle_event(self, event: Event, camera: Camera) -> bool: and MouseButton.LEFT in event.buttons and self._drag_pos ): - new_pos = event.world_ray.origin[:2] + new_pos = ray.origin[:2] dx = self._drag_pos[0] - new_pos[0] if not self.lock_x: - camera.transform = camera.transform.translated((dx, 0)) + view.camera.transform = view.camera.transform.translated((dx, 0)) dy = self._drag_pos[1] - new_pos[1] if not self.lock_y: - camera.transform = camera.transform.translated((0, dy)) + view.camera.transform = view.camera.transform.translated((0, dy)) handled = True # Note that while panning adjusts the camera's transform matrix, zooming @@ -335,7 +333,7 @@ def handle_event(self, event: Event, camera: Camera) -> bool: if dy: # Step 1: Adjust the projection matrix to zoom in or out. zoom = self._zoom_factor(dy) - camera.projection = camera.projection.scaled( + view.camera.projection = view.camera.projection.scaled( (1 if self.lock_x else zoom, 1 if self.lock_y else zoom, 1.0) ) @@ -344,15 +342,15 @@ def handle_event(self, event: Event, camera: Camera) -> bool: # https://github.com/pygfx/pygfx/blob/520af2d5bb2038ec309ef645e4a60d502f00d181/pygfx/controllers/_panzoom.py#L164 # Find the distance between the world ray and the camera - zoom_center = np.asarray(event.world_ray.origin)[:2] - camera_center = np.asarray(camera.transform.map((0, 0)))[:2] + zoom_center = np.asarray(ray.origin)[:2] + camera_center = np.asarray(view.camera.transform.map((0, 0)))[:2] # Compute the world distance before the zoom delta_screen1 = zoom_center - camera_center # Compute the world distance after the zoom delta_screen2 = delta_screen1 * zoom # The pan is the difference between the two pan = (delta_screen2 - delta_screen1) / zoom - camera.transform = camera.transform.translated( + view.camera.transform = view.camera.transform.translated( ( pan[0] if not self.lock_x else 0, pan[1] if not self.lock_y else 0, @@ -470,14 +468,18 @@ class Orbit(CameraController): _last_canvas_pos: tuple[float, float] | None = PrivateAttr(default=None) _pan_ray: Any = PrivateAttr(default=None) # Ray type - def handle_event(self, event: Event, camera: Camera) -> bool: + def handle_event(self, event: Event, view: View) -> bool: """Handle mouse and wheel events to orbit the camera.""" - if not camera.interactive: + if not view.camera.interactive: return False handled = False center_array = np.asarray(self.center) + if not isinstance(event, MouseEvent): + return False + if (ray := view.to_ray(event.canvas_pos)) is None: + return False # Orbit on mouse move with left button held if ( isinstance(event, MouseMoveEvent) @@ -505,9 +507,9 @@ def handle_event(self, event: Event, camera: Camera) -> bool: # that centerpoint. # Step 0: Gather transform components, relative to camera center - orbit_mat = camera.transform.translated(-center_array) + orbit_mat = view.camera.transform.translated(-center_array) position, _rotation, _scale = la.mat_decompose(orbit_mat.T) - camera_right = np.cross(camera.forward, camera.up) + camera_right = np.cross(view.camera.forward, view.camera.up) # Step 1 d_azimuth = self._last_canvas_pos[0] - event.canvas_pos[0] @@ -521,8 +523,8 @@ def handle_event(self, event: Event, camera: Camera) -> bool: d_elevation = 180 - e_bound # Step 3 - camera.transform = ( - camera.transform.translated(-center_array) # 3a + view.camera.transform = ( + view.camera.transform.translated(-center_array) # 3a .rotated(d_elevation, camera_right) # 3b .rotated(d_azimuth, self.polar_axis) # 3c .translated(center_array) # 3d @@ -532,7 +534,7 @@ def handle_event(self, event: Event, camera: Camera) -> bool: # Pan on mouse press with right button elif isinstance(event, MousePressEvent) and event.buttons == MouseButton.RIGHT: - self._pan_ray = event.world_ray + self._pan_ray = ray # Pan on mouse move with right button held elif ( @@ -540,15 +542,13 @@ def handle_event(self, event: Event, camera: Camera) -> bool: and event.buttons == MouseButton.RIGHT and self._pan_ray is not None ): - dr = np.linalg.norm(camera.transform.map((0, 0, 0))[:3] - center_array) + dr = np.linalg.norm(view.camera.transform.map((0, 0, 0))[:3] - center_array) old_center = self._pan_ray.origin[:3] + np.multiply( dr, self._pan_ray.direction ) - new_center = event.world_ray.origin[:3] + np.multiply( - dr, event.world_ray.direction - ) + new_center = ray.origin[:3] + np.multiply(dr, ray.direction) diff = np.subtract(old_center, new_center) - camera.transform = camera.transform.translated(diff) + view.camera.transform = view.camera.transform.translated(diff) # Update the center new_center_array = center_array + diff new_center_tuple = ( @@ -562,9 +562,11 @@ def handle_event(self, event: Event, camera: Camera) -> bool: elif isinstance(event, WheelEvent): _dx, dy = event.angle_delta if dy: - dr = camera.transform.map((0, 0, 0))[:3] - center_array + dr = view.camera.transform.map((0, 0, 0))[:3] - center_array zoom = self._zoom_factor(dy) - camera.transform = camera.transform.translated(dr * (zoom - 1)) + view.camera.transform = view.camera.transform.translated( + dr * (zoom - 1) + ) handled = True if isinstance(event, MouseEvent): diff --git a/src/scenex/model/_view.py b/src/scenex/model/_view.py index 7cfd90d0..1a45014e 100644 --- a/src/scenex/model/_view.py +++ b/src/scenex/model/_view.py @@ -20,7 +20,7 @@ from scenex import Transform from scenex.adaptors._base import ViewAdaptor - from scenex.app.events import Event + from scenex.app.events import Event, Ray from ._canvas import Canvas @@ -144,6 +144,21 @@ def content_rect(self) -> tuple[int, int, int, int] | None: return self._canvas.content_rect_for(self) return None + def to_ray(self, canvas_pos: tuple[float, float]) -> Ray | None: + """Compute the world-space ray for a canvas position within this view. + + Parameters + ---------- + canvas_pos : tuple[float, float] + The (x, y) position in canvas pixel coordinates. + + Returns + ------- + Ray | None + The world-space ray, or None if this view has no canvas. + """ + return self._canvas.to_world(canvas_pos) if self._canvas else None + def render(self) -> np.ndarray: """Render the view to an array.""" if adaptors := self._get_adaptors(): diff --git a/tests/app/test_qt.py b/tests/app/test_qt.py index a240a143..5c4eae7d 100644 --- a/tests/app/test_qt.py +++ b/tests/app/test_qt.py @@ -6,10 +6,13 @@ from unittest.mock import patch import pytest +from app_model.types import KeyBinding import scenex as snx from scenex.app import CursorType, GuiFrontend, app, determine_app from scenex.app.events import ( + KeyPressEvent, + KeyReleaseEvent, MouseButton, MouseDoublePressEvent, MouseEnterEvent, @@ -17,7 +20,6 @@ MouseMoveEvent, MousePressEvent, MouseReleaseEvent, - Ray, ) from scenex.model._transform import Transform @@ -52,11 +54,6 @@ def evented_canvas(qtbot: QtBot) -> snx.Canvas: return canvas -def _validate_ray(maybe_ray: Ray | None) -> Ray: - assert maybe_ray is not None - return maybe_ray - - def test_mouse_press(evented_canvas: snx.Canvas, qtbot: QtBot) -> None: native = cast( "CanvasAdaptor", evented_canvas._get_adaptors(create=True)[0] @@ -70,7 +67,6 @@ def test_mouse_press(evented_canvas: snx.Canvas, qtbot: QtBot) -> None: mock_handle.assert_called_once_with( MousePressEvent( canvas_pos=press_point, - world_ray=_validate_ray(evented_canvas.to_world(press_point)), buttons=MouseButton.LEFT, ) ) @@ -82,7 +78,6 @@ def test_mouse_press(evented_canvas: snx.Canvas, qtbot: QtBot) -> None: mock_handle.assert_called_once_with( MousePressEvent( canvas_pos=press_point, - world_ray=_validate_ray(evented_canvas.to_world(press_point)), buttons=MouseButton.RIGHT, ) ) @@ -100,7 +95,6 @@ def test_mouse_release(evented_canvas: snx.Canvas, qtbot: QtBot) -> None: mock_handle.assert_called_once_with( MouseReleaseEvent( canvas_pos=press_point, - world_ray=_validate_ray(evented_canvas.to_world(press_point)), buttons=MouseButton.LEFT, ), ) @@ -120,7 +114,6 @@ def test_mouse_move(evented_canvas: snx.Canvas, qtbot: QtBot) -> None: mock_handle.assert_called_once_with( MouseMoveEvent( canvas_pos=press_point, - world_ray=_validate_ray(evented_canvas.to_world(press_point)), buttons=MouseButton.LEFT | MouseButton.RIGHT, ), ) @@ -138,14 +131,12 @@ def test_mouse_click(evented_canvas: snx.Canvas, qtbot: QtBot) -> None: assert mock_handle.call_args_list[0].args == ( MousePressEvent( canvas_pos=press_point, - world_ray=_validate_ray(evented_canvas.to_world(press_point)), buttons=MouseButton.LEFT, ), ) assert mock_handle.call_args_list[1].args == ( MouseReleaseEvent( canvas_pos=press_point, - world_ray=_validate_ray(evented_canvas.to_world(press_point)), buttons=MouseButton.LEFT, ), ) @@ -164,7 +155,6 @@ def test_mouse_double_click(evented_canvas: snx.Canvas, qtbot: QtBot) -> None: assert mock_handle.call_args_list[0].args == ( MouseDoublePressEvent( canvas_pos=press_point, - world_ray=_validate_ray(evented_canvas.to_world(press_point)), buttons=MouseButton.LEFT, ), ) @@ -210,7 +200,6 @@ def test_mouse_enter(evented_canvas: snx.Canvas, qtbot: QtBot) -> None: mock_handle.assert_called_once_with( MouseEnterEvent( canvas_pos=enter_point, - world_ray=_validate_ray(evented_canvas.to_world(enter_point)), buttons=MouseButton.NONE, ) ) @@ -244,6 +233,24 @@ def test_mouse_leave(evented_canvas: snx.Canvas, qtbot: QtBot) -> None: mock_handle.assert_called_once_with(MouseLeaveEvent()) +def test_key_event(evented_canvas: snx.Canvas, qtbot: QtBot) -> None: + native = cast( + "CanvasAdaptor", evented_canvas._get_adaptors(create=True)[0] + )._snx_get_native() + qtbot.add_widget(native) + + with patch.object(snx.Canvas, "handle") as mock_handle: + qtbot.keyPress(native, Qt.Key.Key_A) + qtbot.keyRelease(native, Qt.Key.Key_A) + + assert mock_handle.call_args_list[0].args == ( + KeyPressEvent(key=KeyBinding.from_str("A")), + ) + assert mock_handle.call_args_list[1].args == ( + KeyReleaseEvent(key=KeyBinding.from_str("A")), + ) + + def test_set_cursor(evented_canvas: snx.Canvas, qtbot: QtBot) -> None: native = cast( "CanvasAdaptor", evented_canvas._get_adaptors(create=True)[0] diff --git a/tests/model/_nodes/test_camera.py b/tests/model/_nodes/test_camera.py index d38a3f9d..9b884469 100644 --- a/tests/model/_nodes/test_camera.py +++ b/tests/model/_nodes/test_camera.py @@ -1,8 +1,9 @@ import math -from unittest.mock import MagicMock +from collections.abc import Generator import numpy as np import pylinalg as la +import pytest import scenex as snx from scenex.app.events import ( @@ -12,6 +13,7 @@ Ray, WheelEvent, ) +from scenex.utils import projections def test_camera_forward_property() -> None: @@ -73,46 +75,54 @@ def _validate_ray(maybe_ray: Ray | None) -> Ray: return maybe_ray -def test_panzoom_pan() -> None: +@pytest.fixture +def ortho_view() -> Generator[snx.View, None, None]: + # Create a camera showing min=(-50, 50), max=(50, -50) + cam = snx.Camera(projection=projections.orthographic(width=100, height=100)) + # Put it in a view... + view = snx.View(camera=cam) + # ...on a canvas, so that it has a size and can convert to world coordinates + canvas = snx.Canvas(views=[view], width=100, height=100) # noqa: F841 + # Note that we yield to hold onto the canvas ref + yield view + + +def test_panzoom_pan(ortho_view: snx.View) -> None: """Tests panning behavior of PanZoom.""" - interaction = snx.PanZoom() - cam = snx.Camera(interactive=True, controller=interaction) - # Simulate mouse press + interaction = ortho_view.camera.controller = snx.PanZoom() + # Simulate mouse press at canvas (0, 0), world (-50, 50) press_event = MousePressEvent( canvas_pos=(0, 0), - world_ray=Ray((10, 10, 0), (0, 0, -1), source=MagicMock(spec=snx.View)), buttons=MouseButton.LEFT, ) - interaction.handle_event(press_event, cam) - # Simulate mouse move + interaction.handle_event(press_event, ortho_view) + # Simulate mouse move to canvas (5, 10) move_event = MouseMoveEvent( - canvas_pos=(0, 0), - world_ray=Ray((15, 20, 0), (0, 0, -1), source=MagicMock(spec=snx.View)), + canvas_pos=(5, 10), buttons=MouseButton.LEFT, ) - interaction.handle_event(move_event, cam) - # The camera should have moved by (-5, -10) - expected = snx.Transform().translated((-5, -10)) - np.testing.assert_allclose(cam.transform.root, expected.root) + interaction.handle_event(move_event, ortho_view) + # The camera should have moved by (-5, 10) to keep (-50, 50) under the cursor + # (under canvas (0, 0) should now be world (-45, 60)) + expected = snx.Transform().translated((-5, 10)) + np.testing.assert_allclose(ortho_view.camera.transform.root, expected.root) -def test_panzoom_zoom() -> None: +def test_panzoom_zoom(ortho_view: snx.View) -> None: """Tests zooming behavior of PanZoom.""" - interaction = snx.PanZoom() - cam = snx.Camera(interactive=True, controller=interaction) + interaction = ortho_view.camera.controller = snx.PanZoom() # Simulate wheel event wheel_event = WheelEvent( canvas_pos=(0, 0), - world_ray=Ray((0, 0, 0), (0, 0, -1), source=MagicMock(spec=snx.View)), buttons=MouseButton.NONE, angle_delta=(0, 120), ) - before = cam.projection - interaction.handle_event(wheel_event, cam) + before = ortho_view.camera.projection + interaction.handle_event(wheel_event, ortho_view) # The projection should be scaled zoom = interaction._zoom_factor(wheel_event.angle_delta[1]) expected = before.scaled((zoom, zoom, 1)) - np.testing.assert_allclose(cam.projection.root, expected.root) + np.testing.assert_allclose(ortho_view.camera.projection.root, expected.root) def test_orbit_orbiting() -> None: @@ -138,18 +148,16 @@ def test_orbit_orbiting() -> None: click_pos = (w / 2, h / 2) press_event = MousePressEvent( canvas_pos=click_pos, - world_ray=_validate_ray(canvas.to_world(click_pos)), buttons=MouseButton.LEFT, ) - interaction.handle_event(press_event, cam) + interaction.handle_event(press_event, view) # Simulate mouse move (orbit) of one horizontal pixel and one vertical pixel move_pos = (click_pos[0] + 1, click_pos[1] + 1) move_event = MouseMoveEvent( canvas_pos=move_pos, - world_ray=_validate_ray(canvas.to_world(move_pos)), buttons=MouseButton.LEFT, ) - interaction.handle_event(move_event, cam) + interaction.handle_event(move_event, view) # Assert camera position conforms to expectation # (one degree around y axis and one degree around z axis) pos_after_exp = la.vec_transform_quat( @@ -173,15 +181,17 @@ def test_orbit_zoom() -> None: transform=snx.Transform().translated((0, 0, 10)), controller=interaction, ) + # Add cam to the canvas + view = snx.View(camera=cam) + canvas = snx.Canvas(views=[view]) # noqa: F841 tform_before = cam.transform # Simulate wheel event wheel_event = WheelEvent( canvas_pos=(0, 0), - world_ray=Ray((0, 0, 10), (0, 0, -1), source=MagicMock(spec=snx.View)), buttons=MouseButton.NONE, angle_delta=(0, 120), ) - interaction.handle_event(wheel_event, cam) + interaction.handle_event(wheel_event, view) # The camera should have moved closer to center zoom = interaction._zoom_factor(120) desired_tform = snx.Transform().translated((0, 0, 10 * zoom)) @@ -190,11 +200,10 @@ def test_orbit_zoom() -> None: # Simulate wheel event in other direction wheel_event = WheelEvent( canvas_pos=(0, 0), - world_ray=Ray((0, 0, 10), (0, 0, -1), source=MagicMock(spec=snx.View)), buttons=MouseButton.NONE, angle_delta=(0, -120), ) - interaction.handle_event(wheel_event, cam) + interaction.handle_event(wheel_event, view) # The camera should have moved back to the starting point zoom = interaction._zoom_factor(-120) desired_tform = snx.Transform().translated((0, 0, 10)) @@ -225,20 +234,18 @@ def test_orbit_pan() -> None: assert world_ray_before is not None press_event = MousePressEvent( canvas_pos=click_pos, - world_ray=world_ray_before, buttons=MouseButton.RIGHT, ) - interaction.handle_event(press_event, cam) + interaction.handle_event(press_event, view) # Simulate right mouse move (pan) click_pos = (click_pos[0], click_pos[1] + int(h) // 2) world_ray_after = canvas.to_world(click_pos) assert world_ray_after is not None move_event = MouseMoveEvent( canvas_pos=click_pos, - world_ray=world_ray_after, buttons=MouseButton.RIGHT, ) - interaction.handle_event(move_event, cam) + interaction.handle_event(move_event, view) # This should move the camera (world_ray_before - world_ray_after), so that the # center stays at the same point on the camera plane. distance = [ diff --git a/tests/model/test_view.py b/tests/model/test_view.py index d90f2679..c456e5d2 100644 --- a/tests/model/test_view.py +++ b/tests/model/test_view.py @@ -30,13 +30,12 @@ def test_events() -> None: canvas_pos = (w, 0) world_ray = canvas.to_world(canvas_pos) assert world_ray is not None - event = MouseMoveEvent( - canvas_pos=canvas_pos, world_ray=world_ray, buttons=MouseButton.NONE - ) + event = MouseMoveEvent(canvas_pos=canvas_pos, buttons=MouseButton.NONE) # And show the view saw the event canvas.handle(event) - view_filter.assert_called_once_with(event) + # NOTE: There will also be a MouseEnterEvent when the mouse enters the view + view_filter.assert_called_with(event) def test_filter_returning_None() -> None: @@ -56,9 +55,7 @@ def faulty_filter(event: Event) -> bool: canvas_pos = (0, 0) world_ray = canvas.to_world(canvas_pos) assert world_ray is not None - event = MouseMoveEvent( - canvas_pos=canvas_pos, world_ray=world_ray, buttons=MouseButton.NONE - ) + event = MouseMoveEvent(canvas_pos=canvas_pos, buttons=MouseButton.NONE) handled = view.filter_event(event) assert isinstance(handled, bool) From 8c21b09eccecb0b7fb000f8fe4bc87cc29c802de Mon Sep 17 00:00:00 2001 From: Gabriel Selzer Date: Mon, 30 Mar 2026 14:01:58 -0500 Subject: [PATCH 2/6] canvas_pos -> pos As these events become more general we should use broader language --- src/scenex/app/_jupyter.py | 12 ++++++------ src/scenex/app/_qt.py | 12 ++++++------ src/scenex/app/_wx.py | 12 +++++------- src/scenex/app/events/_events.py | 20 ++++++++++---------- src/scenex/imgui/_controls.py | 2 +- src/scenex/model/_canvas.py | 4 ++-- src/scenex/model/_nodes/camera.py | 10 +++++----- tests/app/test_qt.py | 16 ++++++++-------- tests/model/_nodes/test_camera.py | 18 +++++++++--------- tests/model/test_view.py | 4 ++-- 10 files changed, 54 insertions(+), 56 deletions(-) diff --git a/src/scenex/app/_jupyter.py b/src/scenex/app/_jupyter.py index 1f6b54f0..d45b8478 100644 --- a/src/scenex/app/_jupyter.py +++ b/src/scenex/app/_jupyter.py @@ -54,7 +54,7 @@ def _handle_event(self: RemoteFrameBuffer, ev: dict) -> None: canvas_pos = (ev["x"], ev["y"]) filter._model_canvas.handle( MouseMoveEvent( - canvas_pos=canvas_pos, + pos=canvas_pos, buttons=filter._active_button, ) ) @@ -64,7 +64,7 @@ def _handle_event(self: RemoteFrameBuffer, ev: dict) -> None: filter._active_button |= btn filter._model_canvas.handle( MousePressEvent( - canvas_pos=canvas_pos, + pos=canvas_pos, buttons=btn, ) ) @@ -77,7 +77,7 @@ def _handle_event(self: RemoteFrameBuffer, ev: dict) -> None: # https://github.com/vispy/jupyter_rfb/blob/62831dd5a87bc19b4fd5f921d802ed21141e61ec/js/lib/widget.js#L270 filter._model_canvas.handle( MouseDoublePressEvent( - canvas_pos=canvas_pos, + pos=canvas_pos, buttons=btn, ) ) @@ -87,7 +87,7 @@ def _handle_event(self: RemoteFrameBuffer, ev: dict) -> None: filter._active_button &= ~btn filter._model_canvas.handle( MouseReleaseEvent( - canvas_pos=canvas_pos, + pos=canvas_pos, buttons=btn, ) ) @@ -101,7 +101,7 @@ def _handle_event(self: RemoteFrameBuffer, ev: dict) -> None: filter._active_button |= JupyterEventFilter.mouse_btn(b) filter._model_canvas.handle( MouseEnterEvent( - canvas_pos=canvas_pos, + pos=canvas_pos, buttons=filter._active_button, ) ) @@ -111,7 +111,7 @@ def _handle_event(self: RemoteFrameBuffer, ev: dict) -> None: canvas_pos = (ev["x"], ev["y"]) filter._model_canvas.handle( WheelEvent( - canvas_pos=canvas_pos, + pos=canvas_pos, buttons=filter._active_button, # Note that Jupyter_rfb uses a different y convention angle_delta=(ev["dx"], -ev["dy"]), diff --git a/src/scenex/app/_qt.py b/src/scenex/app/_qt.py index 65d5aec6..15a2edc4 100644 --- a/src/scenex/app/_qt.py +++ b/src/scenex/app/_qt.py @@ -97,30 +97,30 @@ def _convert_event(self, qevent: QEvent) -> Event | None: btn = self.mouse_btn(qevent.button()) if etype == QEvent.Type.MouseMove: return MouseMoveEvent( - canvas_pos=canvas_pos, + pos=canvas_pos, buttons=self._active_buttons, ) elif etype == QEvent.Type.MouseButtonDblClick: self._active_buttons |= btn return MouseDoublePressEvent( - canvas_pos=canvas_pos, + pos=canvas_pos, buttons=btn, ) elif etype == QEvent.Type.MouseButtonPress: self._active_buttons |= btn return MousePressEvent( - canvas_pos=canvas_pos, + pos=canvas_pos, buttons=btn, ) elif etype == QEvent.Type.MouseButtonRelease: self._active_buttons &= ~btn return MouseReleaseEvent( - canvas_pos=canvas_pos, + pos=canvas_pos, buttons=btn, ) elif etype == QEvent.Type.Enter: return MouseEnterEvent( - canvas_pos=canvas_pos, + pos=canvas_pos, buttons=self._active_buttons, ) @@ -132,7 +132,7 @@ def _convert_event(self, qevent: QEvent) -> Event | None: pos = qevent.position() canvas_pos = (pos.x(), pos.y()) return WheelEvent( - canvas_pos=canvas_pos, + pos=canvas_pos, buttons=self._active_buttons, angle_delta=(qevent.angleDelta().x(), qevent.angleDelta().y()), ) diff --git a/src/scenex/app/_wx.py b/src/scenex/app/_wx.py index ce47c192..2254400d 100644 --- a/src/scenex/app/_wx.py +++ b/src/scenex/app/_wx.py @@ -69,7 +69,7 @@ def _on_enter_window(self, event: wx.MouseEvent) -> None: pos = event.GetPosition() self._model_canvas.handle( MouseEnterEvent( - canvas_pos=(pos.x, pos.y), + pos=(pos.x, pos.y), buttons=self._active_button, ) ) @@ -88,9 +88,7 @@ def _on_mouse_down(self, event: wx.MouseEvent) -> None: btn = self._map_button(event) self._active_button |= btn pos = event.GetPosition() - self._model_canvas.handle( - MousePressEvent(canvas_pos=(pos.x, pos.y), buttons=btn) - ) + self._model_canvas.handle(MousePressEvent(pos=(pos.x, pos.y), buttons=btn)) event.Skip() def _on_mouse_up(self, event: wx.MouseEvent) -> None: @@ -99,7 +97,7 @@ def _on_mouse_up(self, event: wx.MouseEvent) -> None: pos = event.GetPosition() self._model_canvas.handle( MouseReleaseEvent( - canvas_pos=(pos.x, pos.y), + pos=(pos.x, pos.y), buttons=btn, ) ) @@ -109,7 +107,7 @@ def _on_mouse_move(self, event: wx.MouseEvent) -> None: pos = event.GetPosition() self._model_canvas.handle( MouseMoveEvent( - canvas_pos=(pos.x, pos.y), + pos=(pos.x, pos.y), buttons=self._active_button, ) ) @@ -126,7 +124,7 @@ def _on_wheel(self, event: wx.MouseEvent) -> None: self._model_canvas.handle( WheelEvent( - canvas_pos=(pos.x, pos.y), + pos=(pos.x, pos.y), buttons=self._active_button, angle_delta=angle_delta, ) diff --git a/src/scenex/app/events/_events.py b/src/scenex/app/events/_events.py index 5bd45196..aeff4a52 100644 --- a/src/scenex/app/events/_events.py +++ b/src/scenex/app/events/_events.py @@ -42,7 +42,7 @@ class MouseButton(IntFlag): -------- Check if left button is pressed: >>> event = MousePressEvent( - ... canvas_pos=(100, 150), + ... pos=(100, 150), ... buttons=MouseButton.LEFT | MouseButton.RIGHT, ... ) >>> if event.buttons & MouseButton.LEFT: @@ -180,18 +180,18 @@ def intersections(self, graph: Node) -> list[Intersection]: @dataclass class ResizeEvent(Event): - """Canvas window resize event. + """Window resize event. - Fired when the canvas window changes dimensions, whether from user interaction + Fired when a window changes dimensions, whether from user interaction (dragging window edges), programmatic resizing, or window manager actions. This - event allows views and other components to adapt to new canvas dimensions. + event allows views and other components to adapt to new window dimensions. Attributes ---------- width : int - The new width of the canvas in pixels. + The new width of the window in pixels. height : int - The new height of the canvas in pixels. + The new height of the window in pixels. """ width: int # in pixels @@ -203,15 +203,15 @@ class MouseEvent(Event): """Base class for all mouse-related interaction events. MouseEvent provides common fields for all mouse interactions, including the - 2D canvas position and the state of mouse buttons. Specific mouse event types + 2D position and the state of mouse buttons. Specific mouse event types (move, press, release, etc.) inherit from this base. To obtain the 3D world ray for a mouse event, use ``ViewMouseEvent.view.to_ray()``. Attributes ---------- - canvas_pos : tuple[float, float] - The (x, y) position of the mouse cursor in canvas pixel coordinates, with + pos : tuple[float, float] + The (x, y) position of the mouse cursor in pixel coordinates, with origin at the top-left corner. buttons : MouseButton Bit flags indicating which mouse buttons are currently pressed. Use bitwise @@ -226,7 +226,7 @@ class MouseEvent(Event): ViewMouseEvent : Mouse event enriched with view and ray access """ - canvas_pos: tuple[float, float] + pos: tuple[float, float] buttons: MouseButton diff --git a/src/scenex/imgui/_controls.py b/src/scenex/imgui/_controls.py index eb48ba7b..f344119c 100644 --- a/src/scenex/imgui/_controls.py +++ b/src/scenex/imgui/_controls.py @@ -162,7 +162,7 @@ def __call__(self, event: Event) -> bool: # It may capture more events (notably, keypresses). # We will have to intercept scenex events here if that occurs if isinstance(event, MouseMoveEvent): - move_dict = {"x": event.canvas_pos[0], "y": event.canvas_pos[1]} + move_dict = {"x": event.pos[0], "y": event.pos[1]} imgui_renderer._on_mouse_move(move_dict) if move_dict.get("stop_propagation", False): return True diff --git a/src/scenex/model/_canvas.py b/src/scenex/model/_canvas.py index ee059d20..fc48b451 100644 --- a/src/scenex/model/_canvas.py +++ b/src/scenex/model/_canvas.py @@ -220,7 +220,7 @@ def handle(self, event: Event) -> bool: return True if isinstance(event, MouseEvent): - current_view = self._containing_view(event.canvas_pos) + current_view = self._containing_view(event.pos) # Handle view-transition enter/leave events. # TODO: Add a test for this once multiple views are better supported @@ -229,7 +229,7 @@ def handle(self, event: Event) -> bool: self._last_mouse_view.filter_event(MouseLeaveEvent()) if current_view is not None: enter = MouseEnterEvent( - canvas_pos=event.canvas_pos, + pos=event.pos, buttons=event.buttons, ) current_view.filter_event(enter) diff --git a/src/scenex/model/_nodes/camera.py b/src/scenex/model/_nodes/camera.py index f0622e13..3176802a 100644 --- a/src/scenex/model/_nodes/camera.py +++ b/src/scenex/model/_nodes/camera.py @@ -302,7 +302,7 @@ def handle_event(self, event: Event, view: View) -> bool: if not isinstance(event, MouseEvent): return False - if (ray := view.to_ray(event.canvas_pos)) is None: + if (ray := view.to_ray(event.pos)) is None: return False # Panning involves keeping a particular position underneath the cursor. # That position is recorded on a left mouse button press. @@ -478,7 +478,7 @@ def handle_event(self, event: Event, view: View) -> bool: if not isinstance(event, MouseEvent): return False - if (ray := view.to_ray(event.canvas_pos)) is None: + if (ray := view.to_ray(event.pos)) is None: return False # Orbit on mouse move with left button held if ( @@ -512,8 +512,8 @@ def handle_event(self, event: Event, view: View) -> bool: camera_right = np.cross(view.camera.forward, view.camera.up) # Step 1 - d_azimuth = self._last_canvas_pos[0] - event.canvas_pos[0] - d_elevation = self._last_canvas_pos[1] - event.canvas_pos[1] + d_azimuth = self._last_canvas_pos[0] - event.pos[0] + d_elevation = self._last_canvas_pos[1] - event.pos[1] # Step 2 e_bound = float(la.vec_angle(position, (0, 0, 1)) * 180 / math.pi) @@ -570,7 +570,7 @@ def handle_event(self, event: Event, view: View) -> bool: handled = True if isinstance(event, MouseEvent): - self._last_canvas_pos = event.canvas_pos + self._last_canvas_pos = event.pos return handled def _zoom_factor(self, delta: float) -> float: diff --git a/tests/app/test_qt.py b/tests/app/test_qt.py index 5c4eae7d..04101c99 100644 --- a/tests/app/test_qt.py +++ b/tests/app/test_qt.py @@ -66,7 +66,7 @@ def test_mouse_press(evented_canvas: snx.Canvas, qtbot: QtBot) -> None: mock_handle.assert_called_once_with( MousePressEvent( - canvas_pos=press_point, + pos=press_point, buttons=MouseButton.LEFT, ) ) @@ -77,7 +77,7 @@ def test_mouse_press(evented_canvas: snx.Canvas, qtbot: QtBot) -> None: mock_handle.assert_called_once_with( MousePressEvent( - canvas_pos=press_point, + pos=press_point, buttons=MouseButton.RIGHT, ) ) @@ -94,7 +94,7 @@ def test_mouse_release(evented_canvas: snx.Canvas, qtbot: QtBot) -> None: mock_handle.assert_called_once_with( MouseReleaseEvent( - canvas_pos=press_point, + pos=press_point, buttons=MouseButton.LEFT, ), ) @@ -113,7 +113,7 @@ def test_mouse_move(evented_canvas: snx.Canvas, qtbot: QtBot) -> None: qtbot.mouseMove(native, pos=QPoint(*press_point)) mock_handle.assert_called_once_with( MouseMoveEvent( - canvas_pos=press_point, + pos=press_point, buttons=MouseButton.LEFT | MouseButton.RIGHT, ), ) @@ -130,13 +130,13 @@ def test_mouse_click(evented_canvas: snx.Canvas, qtbot: QtBot) -> None: assert mock_handle.call_args_list[0].args == ( MousePressEvent( - canvas_pos=press_point, + pos=press_point, buttons=MouseButton.LEFT, ), ) assert mock_handle.call_args_list[1].args == ( MouseReleaseEvent( - canvas_pos=press_point, + pos=press_point, buttons=MouseButton.LEFT, ), ) @@ -154,7 +154,7 @@ def test_mouse_double_click(evented_canvas: snx.Canvas, qtbot: QtBot) -> None: assert mock_handle.call_args_list[0].args == ( MouseDoublePressEvent( - canvas_pos=press_point, + pos=press_point, buttons=MouseButton.LEFT, ), ) @@ -199,7 +199,7 @@ def test_mouse_enter(evented_canvas: snx.Canvas, qtbot: QtBot) -> None: # Verify MouseEnterEvent was passed to Canvas.handle mock_handle.assert_called_once_with( MouseEnterEvent( - canvas_pos=enter_point, + pos=enter_point, buttons=MouseButton.NONE, ) ) diff --git a/tests/model/_nodes/test_camera.py b/tests/model/_nodes/test_camera.py index 9b884469..fa3dd2d1 100644 --- a/tests/model/_nodes/test_camera.py +++ b/tests/model/_nodes/test_camera.py @@ -92,13 +92,13 @@ def test_panzoom_pan(ortho_view: snx.View) -> None: interaction = ortho_view.camera.controller = snx.PanZoom() # Simulate mouse press at canvas (0, 0), world (-50, 50) press_event = MousePressEvent( - canvas_pos=(0, 0), + pos=(0, 0), buttons=MouseButton.LEFT, ) interaction.handle_event(press_event, ortho_view) # Simulate mouse move to canvas (5, 10) move_event = MouseMoveEvent( - canvas_pos=(5, 10), + pos=(5, 10), buttons=MouseButton.LEFT, ) interaction.handle_event(move_event, ortho_view) @@ -113,7 +113,7 @@ def test_panzoom_zoom(ortho_view: snx.View) -> None: interaction = ortho_view.camera.controller = snx.PanZoom() # Simulate wheel event wheel_event = WheelEvent( - canvas_pos=(0, 0), + pos=(0, 0), buttons=MouseButton.NONE, angle_delta=(0, 120), ) @@ -147,14 +147,14 @@ def test_orbit_orbiting() -> None: # Simulate mouse press click_pos = (w / 2, h / 2) press_event = MousePressEvent( - canvas_pos=click_pos, + pos=click_pos, buttons=MouseButton.LEFT, ) interaction.handle_event(press_event, view) # Simulate mouse move (orbit) of one horizontal pixel and one vertical pixel move_pos = (click_pos[0] + 1, click_pos[1] + 1) move_event = MouseMoveEvent( - canvas_pos=move_pos, + pos=move_pos, buttons=MouseButton.LEFT, ) interaction.handle_event(move_event, view) @@ -187,7 +187,7 @@ def test_orbit_zoom() -> None: tform_before = cam.transform # Simulate wheel event wheel_event = WheelEvent( - canvas_pos=(0, 0), + pos=(0, 0), buttons=MouseButton.NONE, angle_delta=(0, 120), ) @@ -199,7 +199,7 @@ def test_orbit_zoom() -> None: # Simulate wheel event in other direction wheel_event = WheelEvent( - canvas_pos=(0, 0), + pos=(0, 0), buttons=MouseButton.NONE, angle_delta=(0, -120), ) @@ -233,7 +233,7 @@ def test_orbit_pan() -> None: world_ray_before = canvas.to_world(click_pos) assert world_ray_before is not None press_event = MousePressEvent( - canvas_pos=click_pos, + pos=click_pos, buttons=MouseButton.RIGHT, ) interaction.handle_event(press_event, view) @@ -242,7 +242,7 @@ def test_orbit_pan() -> None: world_ray_after = canvas.to_world(click_pos) assert world_ray_after is not None move_event = MouseMoveEvent( - canvas_pos=click_pos, + pos=click_pos, buttons=MouseButton.RIGHT, ) interaction.handle_event(move_event, view) diff --git a/tests/model/test_view.py b/tests/model/test_view.py index c456e5d2..a55e8769 100644 --- a/tests/model/test_view.py +++ b/tests/model/test_view.py @@ -30,7 +30,7 @@ def test_events() -> None: canvas_pos = (w, 0) world_ray = canvas.to_world(canvas_pos) assert world_ray is not None - event = MouseMoveEvent(canvas_pos=canvas_pos, buttons=MouseButton.NONE) + event = MouseMoveEvent(pos=canvas_pos, buttons=MouseButton.NONE) # And show the view saw the event canvas.handle(event) @@ -55,7 +55,7 @@ def faulty_filter(event: Event) -> bool: canvas_pos = (0, 0) world_ray = canvas.to_world(canvas_pos) assert world_ray is not None - event = MouseMoveEvent(canvas_pos=canvas_pos, buttons=MouseButton.NONE) + event = MouseMoveEvent(pos=canvas_pos, buttons=MouseButton.NONE) handled = view.filter_event(event) assert isinstance(handled, bool) From e74145cb4580263e961e07ea030bbf9eaecc3df2 Mon Sep 17 00:00:00 2001 From: Gabriel Selzer Date: Mon, 30 Mar 2026 14:30:32 -0500 Subject: [PATCH 3/6] Fix Jupyter/Wx tests --- tests/app/test_jupyter.py | 30 ++++++++---------------------- tests/app/test_wx.py | 24 ++++++------------------ 2 files changed, 14 insertions(+), 40 deletions(-) diff --git a/tests/app/test_jupyter.py b/tests/app/test_jupyter.py index 8b6ddb4e..b6bbe812 100644 --- a/tests/app/test_jupyter.py +++ b/tests/app/test_jupyter.py @@ -18,7 +18,6 @@ MouseMoveEvent, MousePressEvent, MouseReleaseEvent, - Ray, WheelEvent, ) from scenex.model._transform import Transform @@ -53,11 +52,6 @@ def evented_canvas() -> snx.Canvas: return canvas -def _validate_ray(maybe_ray: Ray | None) -> Ray: - assert maybe_ray is not None - return maybe_ray - - # See jupyter_rfb.events NONE = 0 LEFT_MOUSE = 1 @@ -84,8 +78,7 @@ def test_pointer_down(evented_canvas: snx.Canvas) -> None: mock_handle.assert_called_once_with( MousePressEvent( - canvas_pos=press_point, - world_ray=_validate_ray(evented_canvas.to_world(press_point)), + pos=press_point, buttons=MouseButton.LEFT, ), ) @@ -103,8 +96,7 @@ def test_pointer_down(evented_canvas: snx.Canvas) -> None: mock_handle.assert_called_once_with( MousePressEvent( - canvas_pos=press_point, - world_ray=_validate_ray(evented_canvas.to_world(press_point)), + pos=press_point, buttons=MouseButton.RIGHT, ), ) @@ -128,8 +120,7 @@ def test_pointer_up(evented_canvas: snx.Canvas) -> None: mock_handle.assert_called_once_with( MouseReleaseEvent( - canvas_pos=press_point, - world_ray=_validate_ray(evented_canvas.to_world(press_point)), + pos=press_point, buttons=MouseButton.LEFT, ), ) @@ -153,8 +144,7 @@ def test_pointer_move(evented_canvas: snx.Canvas) -> None: mock_handle.assert_called_once_with( MouseMoveEvent( - canvas_pos=press_point, - world_ray=_validate_ray(evented_canvas.to_world(press_point)), + pos=press_point, buttons=MouseButton.LEFT, ), ) @@ -171,8 +161,7 @@ def test_pointer_move(evented_canvas: snx.Canvas) -> None: mock_handle.assert_called_once_with( MouseMoveEvent( - canvas_pos=press_point, - world_ray=_validate_ray(evented_canvas.to_world(press_point)), + pos=press_point, buttons=MouseButton.LEFT | MouseButton.RIGHT, ), ) @@ -196,8 +185,7 @@ def test_mouse_double_click(evented_canvas: snx.Canvas) -> None: mock_handle.assert_called_once_with( MouseDoublePressEvent( - canvas_pos=press_point, - world_ray=_validate_ray(evented_canvas.to_world(press_point)), + pos=press_point, buttons=MouseButton.LEFT, ), ) @@ -222,8 +210,7 @@ def test_wheel(evented_canvas: snx.Canvas) -> None: mock_handle.assert_called_once_with( WheelEvent( - canvas_pos=press_point, - world_ray=_validate_ray(evented_canvas.to_world(press_point)), + pos=press_point, buttons=MouseButton.NONE, angle_delta=(0, 120), ), @@ -269,8 +256,7 @@ def test_pointer_enter(evented_canvas: snx.Canvas) -> None: # Verify MouseEnterEvent was passed to Canvas.handle mock_handle.assert_called_once_with( MouseEnterEvent( - canvas_pos=enter_point, - world_ray=_validate_ray(evented_canvas.to_world(enter_point)), + pos=enter_point, buttons=MouseButton.NONE, ) ) diff --git a/tests/app/test_wx.py b/tests/app/test_wx.py index d6b5de89..081948b1 100644 --- a/tests/app/test_wx.py +++ b/tests/app/test_wx.py @@ -16,7 +16,6 @@ MouseMoveEvent, MousePressEvent, MouseReleaseEvent, - Ray, WheelEvent, ) @@ -67,11 +66,6 @@ def _processEvent(evt: wx.PyEventBinder, wdg: wx.Control, **kwargs: Any) -> None evtLoop.YieldFor(wx.EVT_CATEGORY_ALL) # pyright: ignore[reportAttributeAccessIssue] -def _validate_ray(maybe_ray: Ray | None) -> Ray: - assert maybe_ray is not None - return maybe_ray - - def test_mouse_press(evented_canvas: snx.Canvas) -> None: native = cast( "CanvasAdaptor", evented_canvas._get_adaptors(create=True)[0] @@ -84,8 +78,7 @@ def test_mouse_press(evented_canvas: snx.Canvas) -> None: mock_handle.assert_called_once_with( MousePressEvent( - canvas_pos=press_point, - world_ray=_validate_ray(evented_canvas.to_world(press_point)), + pos=press_point, buttons=MouseButton.LEFT, ), ) @@ -96,8 +89,7 @@ def test_mouse_press(evented_canvas: snx.Canvas) -> None: mock_handle.assert_called_once_with( MousePressEvent( - canvas_pos=press_point, - world_ray=_validate_ray(evented_canvas.to_world(press_point)), + pos=press_point, buttons=MouseButton.RIGHT, ), ) @@ -114,8 +106,7 @@ def test_mouse_release(evented_canvas: snx.Canvas) -> None: mock_handle.assert_called_once_with( MouseReleaseEvent( - canvas_pos=press_point, - world_ray=_validate_ray(evented_canvas.to_world(press_point)), + pos=press_point, buttons=MouseButton.LEFT, ), ) @@ -136,8 +127,7 @@ def test_mouse_move(evented_canvas: snx.Canvas) -> None: mock_handle.assert_called_once_with( MouseMoveEvent( - canvas_pos=press_point, - world_ray=_validate_ray(evented_canvas.to_world(press_point)), + pos=press_point, buttons=MouseButton.LEFT | MouseButton.RIGHT, ), ) @@ -156,8 +146,7 @@ def test_mouse_wheel(evented_canvas: snx.Canvas) -> None: mock_handle.assert_called_once_with( WheelEvent( - canvas_pos=press_point, - world_ray=_validate_ray(evented_canvas.to_world(press_point)), + pos=press_point, buttons=MouseButton.NONE, angle_delta=(0, 120), ), @@ -188,8 +177,7 @@ def test_mouse_enter(evented_canvas: snx.Canvas) -> None: # Verify MouseEnterEvent was passed to Canvas.handle mock_handle.assert_called_once_with( MouseEnterEvent( - canvas_pos=enter_point, - world_ray=_validate_ray(evented_canvas.to_world(enter_point)), + pos=enter_point, buttons=MouseButton.NONE, ) ) From cb1d879fb9cd32b7ea45d8ad4bf587889722d9c1 Mon Sep 17 00:00:00 2001 From: Gabriel Selzer Date: Fri, 10 Apr 2026 10:06:23 -0500 Subject: [PATCH 4/6] Fix examples --- examples/basic_line.py | 14 ++++++++++---- examples/basic_mesh.py | 7 ++++--- examples/basic_points.py | 4 +++- examples/blending.py | 4 +++- examples/cursor_points.py | 4 +++- examples/event_filters.py | 6 ++++-- examples/histogram.py | 16 ++++++++++------ examples/multi_view.py | 6 ++++-- examples/rgb.py | 4 +++- 9 files changed, 44 insertions(+), 21 deletions(-) diff --git a/examples/basic_line.py b/examples/basic_line.py index a9a60e92..53f4335f 100644 --- a/examples/basic_line.py +++ b/examples/basic_line.py @@ -7,6 +7,7 @@ from scenex.app.events import ( Event, MouseButton, + MouseEvent, MouseMoveEvent, MousePressEvent, MouseReleaseEvent, @@ -42,15 +43,20 @@ def _create_line_data(angle: float = 0) -> np.ndarray: def _view_event_filter(event: Event) -> bool: - global pressed """Interactive mesh manipulation based on mouse events.""" + global pressed + + if not isinstance(event, MouseEvent): + return False + if not (ray := view.to_ray(event.pos)): + return False if isinstance(event, MouseMoveEvent): if pressed and event.buttons & MouseButton.LEFT: - x, y, _z = event.world_ray.origin + x, y, _z = ray.origin y = max(-1, min(1, y)) line.vertices = _create_line_data(angle=np.asin(y) - x) return True - if intersections := event.world_ray.intersections(view.scene): + if intersections := ray.intersections(view.scene): # Find mesh intersection for node, _distance in intersections: if isinstance(node, snx.Line): @@ -59,7 +65,7 @@ def _view_event_filter(event: Event) -> bool: line.color = line_color_model elif isinstance(event, MousePressEvent): if event.buttons & MouseButton.LEFT: - if intersections := event.world_ray.intersections(view.scene): + if intersections := ray.intersections(view.scene): # Find line intersection for node, _distance in intersections: if isinstance(node, snx.Line): diff --git a/examples/basic_mesh.py b/examples/basic_mesh.py index 985984bc..8733602e 100644 --- a/examples/basic_mesh.py +++ b/examples/basic_mesh.py @@ -83,14 +83,15 @@ def create_grid_mesh( def event_filter(event: Event) -> bool: """Interactive mesh manipulation based on mouse events.""" - global per_face_model if isinstance(event, MouseMoveEvent): - if intersections := event.world_ray.intersections(view.scene): + if not (ray := view.to_ray(event.pos)): + return False + if intersections := ray.intersections(view.scene): # Find mesh intersection for node, _distance in intersections: if isinstance(node, snx.Mesh): # Remove the intersected face - indices = [i for i, _d in node.intersecting_faces(event.world_ray)] + indices = [i for i, _d in node.intersecting_faces(ray)] node.faces = np.delete(node.faces, indices, axis=0) return True elif isinstance(event, MousePressEvent): diff --git a/examples/basic_points.py b/examples/basic_points.py index d6f1cdfe..2d602b83 100644 --- a/examples/basic_points.py +++ b/examples/basic_points.py @@ -71,7 +71,9 @@ def _on_view_event(event: Event) -> bool: if isinstance(event, MouseMoveEvent): - intersections = event.world_ray.intersections(view.scene) + if (ray := view.to_ray(event.pos)) is None: + return False + intersections = ray.intersections(view.scene) if points in [n for n, _ in intersections]: points.face_color = snx.UniformColor(color=cmap.Color("white")) points.edge_color = snx.VertexColors(color=colors) diff --git a/examples/blending.py b/examples/blending.py index 4dd77964..cbf5bfa4 100644 --- a/examples/blending.py +++ b/examples/blending.py @@ -61,7 +61,9 @@ def change_blend_mode(event: Event) -> bool: """Change the blend mode of a volume when it is clicked.""" if not isinstance(event, (MousePressEvent)): return False - intersected_nodes = [node for node, _ in event.world_ray.intersections(view.scene)] + if not (ray := view.to_ray(event.pos)): + return False + intersected_nodes = [node for node, _ in ray.intersections(view.scene)] if volume1 not in intersected_nodes: return False idx = blend_modes.index(volume1.blending) diff --git a/examples/cursor_points.py b/examples/cursor_points.py index 07479da4..06b42704 100644 --- a/examples/cursor_points.py +++ b/examples/cursor_points.py @@ -31,7 +31,9 @@ def _cursor_filter(event: Event) -> bool: if isinstance(event, MouseMoveEvent): - intersections = event.world_ray.intersections(view.scene) + if not (ray := view.to_ray(event.pos)): + return False + intersections = ray.intersections(view.scene) if points in [n for n, _ in intersections]: app().set_cursor(canvas, CursorType.CROSS) else: diff --git a/examples/event_filters.py b/examples/event_filters.py index 4b471a58..2da17c8b 100644 --- a/examples/event_filters.py +++ b/examples/event_filters.py @@ -25,7 +25,9 @@ def _view_filter(event: Event) -> bool: """Example event drawing a square that reacts to the cursor.""" if isinstance(event, MouseMoveEvent): - intersections = event.world_ray.intersections(view.scene) + if not (ray := view.to_ray(event.pos)): + return False + intersections = ray.intersections(view.scene) if not intersections: # Clear the image if the mouse is not over it img.data = np.zeros((200, 200), dtype=np.uint8) @@ -33,7 +35,7 @@ def _view_filter(event: Event) -> bool: for node, distance in intersections: if not isinstance(node, snx.Image): continue - intersection = event.world_ray.point_at_distance(distance) + intersection = ray.point_at_distance(distance) data = np.zeros((200, 200), dtype=np.uint8) x = int(intersection[0]) min_x = max(0, x - 5) diff --git a/examples/histogram.py b/examples/histogram.py index 0332cad4..efe6d836 100644 --- a/examples/histogram.py +++ b/examples/histogram.py @@ -222,10 +222,14 @@ def _init_main_view(self) -> None: self.view.set_event_filter(self._on_main_view) def _on_main_view(self, event: events.Event) -> bool: + if not isinstance(event, events.MouseEvent): + return False + if not (ray := self.view.to_ray(event.pos)): + return False if isinstance(event, events.MousePressEvent): intersections = [ node - for node, _dist in event.world_ray.intersections(self.controls) + for node, _dist in ray.intersections(self.controls) if node.interactive ] if len(intersections): @@ -234,7 +238,7 @@ def _on_main_view(self, event: events.Event) -> bool: elif isinstance(event, events.MouseDoublePressEvent): intersections = [ node - for node, _dist in event.world_ray.intersections(self.controls) + for node, _dist in ray.intersections(self.controls) if node.interactive ] if self.gamma_handle in intersections: @@ -242,24 +246,24 @@ def _on_main_view(self, event: events.Event) -> bool: if isinstance(event, events.MouseMoveEvent): if self._grabbed is self.left_clim: # The left clim must stay to the left of the right clim - new_left = min(event.world_ray.origin[0], self._clims[1]) + new_left = min(ray.origin[0], self._clims[1]) # ...and no less than the minimum value if self._bins is not None: new_left = max(new_left, self._bins[0]) self.set_clims((new_left, self._clims[1])) elif self._grabbed is self.right_clim: # The right clim must stay to the right of the left clim - new_right = max(self._clims[0], event.world_ray.origin[0]) + new_right = max(self._clims[0], ray.origin[0]) # ...and no more than the maximum value if self._bins is not None: new_right = min(new_right, self._bins[-1]) self.set_clims((self._clims[0], new_right)) elif self._grabbed is self.gamma_handle: - self.set_gamma(-np.log2(event.world_ray.origin[1])) + self.set_gamma(-np.log2(ray.origin[1])) elif self._grabbed is None: intersections = [ node - for node, _dist in event.world_ray.intersections(self.controls) + for node, _dist in ray.intersections(self.controls) if node.interactive ] if self.right_clim in intersections or self.left_clim in intersections: diff --git a/examples/multi_view.py b/examples/multi_view.py index 54fb7cef..e864f177 100644 --- a/examples/multi_view.py +++ b/examples/multi_view.py @@ -83,9 +83,11 @@ def _make_scene() -> snx.Scene: # -z axis and has no mouse interaction. def _view1_event_filter(event: Event) -> bool: if isinstance(event, MouseMoveEvent): - for node, distance in event.world_ray.intersections(view1.scene): + if not (ray := view1.to_ray(event.pos)): + return False + for node, distance in ray.intersections(view1.scene): if node in vols: - intersection = event.world_ray.point_at_distance(distance) + intersection = ray.point_at_distance(distance) idx = max(0, min(59, round(intersection[2]))) for img in imgs: img.data = img_data[idx] diff --git a/examples/rgb.py b/examples/rgb.py index cec87775..686d6e51 100644 --- a/examples/rgb.py +++ b/examples/rgb.py @@ -43,7 +43,9 @@ def _event_filter(event: events.Event) -> bool: if isinstance(event, events.MousePressEvent): - for node, _distance in event.world_ray.intersections(view.scene): + if not (ray := view.to_ray(event.pos)): + return False + for node, _distance in ray.intersections(view.scene): if node == img: global idx img.data = data[:, :, idx % 3] From c72de14a9bb436b9903882f197968204db7f36f5 Mon Sep 17 00:00:00 2001 From: Gabriel Selzer Date: Fri, 10 Apr 2026 13:59:10 -0500 Subject: [PATCH 5/6] Jupyter and wx key events Want to be able to play with these things. Likely will try moving to app-model at some point --- examples/keyboard_pan_zoom.py | 29 ++++--- src/scenex/app/_jupyter.py | 16 ++++ src/scenex/app/_jupyter_keymap.py | 117 ++++++++++++++++++++++++++++ src/scenex/app/_wx.py | 20 +++++ src/scenex/app/_wx_keymap.py | 125 ++++++++++++++++++++++++++++++ tests/app/test_jupyter.py | 21 +++++ tests/app/test_wx.py | 23 ++++++ 7 files changed, 339 insertions(+), 12 deletions(-) create mode 100644 src/scenex/app/_jupyter_keymap.py create mode 100644 src/scenex/app/_wx_keymap.py diff --git a/examples/keyboard_pan_zoom.py b/examples/keyboard_pan_zoom.py index 08020c0e..32b2e422 100644 --- a/examples/keyboard_pan_zoom.py +++ b/examples/keyboard_pan_zoom.py @@ -5,7 +5,7 @@ """ import numpy as np -from app_model.types import KeyCode +from app_model.types import KeyBinding, KeyCode import scenex as snx from scenex.app.events import Event, KeyPressEvent @@ -26,6 +26,13 @@ _PAN_STEP = 20.0 # world units per arrow-key press _ZOOM_STEP = 1.25 # multiplicative factor per +/- press +LEFT = KeyBinding.validate(KeyCode.LeftArrow) +RIGHT = KeyBinding.validate(KeyCode.RightArrow) +UP = KeyBinding.validate(KeyCode.UpArrow) +DOWN = KeyBinding.validate(KeyCode.DownArrow) +ZOOM_IN = KeyBinding.validate(KeyCode.NumpadAdd) # + key +ZOOM_OUT = KeyBinding.validate(KeyCode.NumpadSubtract) # - key + def _key_filter(event: Event) -> bool: """Pan with arrow keys; zoom with + / -.""" @@ -33,25 +40,23 @@ def _key_filter(event: Event) -> bool: return False key = event.key # KeyCode (or KeyCombo for modified keys) + print(f"key_down: {key}") cam = view.camera - if key == KeyCode.UpArrow: - cam.transform = cam.transform.translated((0, _PAN_STEP)) - elif key == KeyCode.DownArrow: + if key == UP: cam.transform = cam.transform.translated((0, -_PAN_STEP)) - elif key == KeyCode.LeftArrow: - cam.transform = cam.transform.translated((-_PAN_STEP, 0)) - elif key == KeyCode.RightArrow: + elif key == DOWN: + cam.transform = cam.transform.translated((0, _PAN_STEP)) + elif key == LEFT: cam.transform = cam.transform.translated((_PAN_STEP, 0)) - elif key in (KeyCode.Equal, KeyCode.NumpadAdd): # + / numpad + + elif key == RIGHT: + cam.transform = cam.transform.translated((-_PAN_STEP, 0)) + elif key == ZOOM_IN: s = _ZOOM_STEP cam.projection = cam.projection.scaled((s, s, 1.0)) - elif key == KeyCode.Minus: # - + elif key == ZOOM_OUT: s = 1.0 / _ZOOM_STEP cam.projection = cam.projection.scaled((s, s, 1.0)) - else: - print(f"Unhandled key: {key}") - return False return True diff --git a/src/scenex/app/_jupyter.py b/src/scenex/app/_jupyter.py index d45b8478..ac16642f 100644 --- a/src/scenex/app/_jupyter.py +++ b/src/scenex/app/_jupyter.py @@ -3,12 +3,16 @@ from types import MethodType from typing import TYPE_CHECKING, Any, cast +from app_model.types import KeyBinding, SimpleKeyBinding from IPython import display from jupyter_rfb import RemoteFrameBuffer from scenex.app._auto import App, CursorType +from scenex.app._jupyter_keymap import jupyterkey2modelkey from scenex.app.events._events import ( EventFilter, + KeyPressEvent, + KeyReleaseEvent, MouseButton, MouseDoublePressEvent, MouseEnterEvent, @@ -117,6 +121,18 @@ def _handle_event(self: RemoteFrameBuffer, ev: dict) -> None: angle_delta=(ev["dx"], -ev["dy"]), ) ) + elif etype == "key_down": + model_key = jupyterkey2modelkey(ev) + part = SimpleKeyBinding.from_int(model_key) + filter._model_canvas.handle( + KeyPressEvent(key=KeyBinding(parts=[part])) + ) + elif etype == "key_up": + model_key = jupyterkey2modelkey(ev) + part = SimpleKeyBinding.from_int(model_key) + filter._model_canvas.handle( + KeyReleaseEvent(key=KeyBinding(parts=[part])) + ) elif etype == "resize": filter._model_canvas.handle( ResizeEvent( diff --git a/src/scenex/app/_jupyter_keymap.py b/src/scenex/app/_jupyter_keymap.py new file mode 100644 index 00000000..98ce6d62 --- /dev/null +++ b/src/scenex/app/_jupyter_keymap.py @@ -0,0 +1,117 @@ +"""Maps jupyter_rfb (browser W3C KeyboardEvent.key) strings and app-model key types. + +This module has no scenex dependencies — only app_model.types — so it can be +upstreamed to app-model's backends in the future. + +Key values follow the W3C ``KeyboardEvent.key`` specification: +https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/key/Key_Values +""" + +from __future__ import annotations + +from typing import Any + +from app_model.types import KeyCode, KeyCombo, KeyMod + +# --------------------------------------------------------------------------- +# Static mapping: W3C key name → KeyCode +# --------------------------------------------------------------------------- + +_KEY_FROM_JUPYTER_STATIC: dict[str, KeyCode | KeyCombo] = { + # Functional keys + "Backspace": KeyCode.Backspace, + "Tab": KeyCode.Tab, + "Enter": KeyCode.Enter, + "Escape": KeyCode.Escape, + " ": KeyCode.Space, + "Delete": KeyCode.Delete, + "Insert": KeyCode.Insert, + # Arrow keys + "ArrowLeft": KeyCode.LeftArrow, + "ArrowRight": KeyCode.RightArrow, + "ArrowUp": KeyCode.UpArrow, + "ArrowDown": KeyCode.DownArrow, + # Navigation + "Home": KeyCode.Home, + "End": KeyCode.End, + "PageUp": KeyCode.PageUp, + "PageDown": KeyCode.PageDown, + # Lock keys + "NumLock": KeyCode.NumLock, + "CapsLock": KeyCode.CapsLock, + # Modifier keys (when the key itself is the modifier) + "Control": KeyCode.Ctrl, + "Shift": KeyCode.Shift, + "Alt": KeyCode.Alt, + "Meta": KeyCode.Meta, + # Function keys + "F1": KeyCode.F1, + "F2": KeyCode.F2, + "F3": KeyCode.F3, + "F4": KeyCode.F4, + "F5": KeyCode.F5, + "F6": KeyCode.F6, + "F7": KeyCode.F7, + "F8": KeyCode.F8, + "F9": KeyCode.F9, + "F10": KeyCode.F10, + "F11": KeyCode.F11, + "F12": KeyCode.F12, + # Punctuation / symbol keys (unshifted value) + "`": KeyCode.Backquote, + "\\": KeyCode.Backslash, + "[": KeyCode.BracketLeft, + "]": KeyCode.BracketRight, + ",": KeyCode.Comma, + "=": KeyCode.Equal, + "-": KeyCode.Minus, + ".": KeyCode.Period, + "'": KeyCode.Quote, + ";": KeyCode.Semicolon, + "/": KeyCode.Slash, +} + +# --------------------------------------------------------------------------- +# Build the full table by adding letters and digits dynamically. +# The browser sends the *actual* character, so "a" and "A" are distinct, but +# both map to the same physical key (KeyCode.KeyA). +# --------------------------------------------------------------------------- + +KEY_FROM_JUPYTER: dict[str, KeyCode | KeyCombo] = dict(_KEY_FROM_JUPYTER_STATIC) + +# Letters: lowercase and uppercase both map to the same KeyCode +for _c in "ABCDEFGHIJKLMNOPQRSTUVWXYZ": + KEY_FROM_JUPYTER[_c.lower()] = getattr(KeyCode, f"Key{_c}") + KEY_FROM_JUPYTER[_c] = getattr(KeyCode, f"Key{_c}") + +# Digits +for _d in "0123456789": + KEY_FROM_JUPYTER[_d] = getattr(KeyCode, f"Digit{_d}") + + +def jupyterkey2modelkey(ev: dict[str, Any]) -> KeyCode | KeyCombo: + """Convert a jupyter_rfb key event dict to an app-model KeyCode or KeyCombo. + + ``ev`` is expected to have a ``"key"`` field (W3C KeyboardEvent.key string) + and an optional ``"modifiers"`` field (iterable of strings such as + ``"Control"``, ``"Shift"``, ``"Alt"``, ``"Meta"``). + + The returned value encodes both the base key and any held modifiers so it + can be passed directly to ``SimpleKeyBinding.from_int()``. + """ + key: KeyCode | KeyCombo = KEY_FROM_JUPYTER.get(ev["key"], KeyCode.UNKNOWN) + + modifiers = ev.get("modifiers", ()) + mods = KeyMod.NONE + if "Control" in modifiers: + mods |= KeyMod.CtrlCmd + if "Shift" in modifiers: + mods |= KeyMod.Shift + if "Alt" in modifiers: + mods |= KeyMod.Alt + if "Meta" in modifiers: + mods |= KeyMod.WinCtrl + + if mods: + return mods | key # type: ignore[return-value] + return key diff --git a/src/scenex/app/_wx.py b/src/scenex/app/_wx.py index 2254400d..7844228b 100644 --- a/src/scenex/app/_wx.py +++ b/src/scenex/app/_wx.py @@ -4,10 +4,14 @@ from typing import TYPE_CHECKING, Any, cast import wx +from app_model.types import KeyBinding, SimpleKeyBinding from scenex.app._auto import App, CursorType +from scenex.app._wx_keymap import wxevent2modelkey from scenex.app.events._events import ( EventFilter, + KeyPressEvent, + KeyReleaseEvent, MouseButton, MouseEnterEvent, MouseLeaveEvent, @@ -49,6 +53,8 @@ def _install_events(self) -> None: self._canvas.Bind(wx.EVT_LEAVE_WINDOW, handler=self._on_leave_window) self._canvas.Bind(wx.EVT_ENTER_WINDOW, handler=self._on_enter_window) self._canvas.Bind(wx.EVT_SIZE, handler=self._on_resize) + self._canvas.Bind(wx.EVT_KEY_DOWN, handler=self._on_key_down) + self._canvas.Bind(wx.EVT_KEY_UP, handler=self._on_key_up) def uninstall(self) -> None: self._canvas.Unbind(wx.EVT_LEFT_DOWN) @@ -60,6 +66,8 @@ def uninstall(self) -> None: self._canvas.Unbind(wx.EVT_MOTION) self._canvas.Unbind(wx.EVT_MOUSEWHEEL) self._canvas.Unbind(wx.EVT_SIZE) + self._canvas.Unbind(wx.EVT_KEY_DOWN) + self._canvas.Unbind(wx.EVT_KEY_UP) def _on_leave_window(self, event: wx.MouseEvent) -> None: self._model_canvas.handle(MouseLeaveEvent()) @@ -131,6 +139,18 @@ def _on_wheel(self, event: wx.MouseEvent) -> None: ) event.Skip() + def _on_key_down(self, event: wx.KeyEvent) -> None: + model_key = wxevent2modelkey(event) + part = SimpleKeyBinding.from_int(model_key) + self._model_canvas.handle(KeyPressEvent(key=KeyBinding(parts=[part]))) + event.Skip() + + def _on_key_up(self, event: wx.KeyEvent) -> None: + model_key = wxevent2modelkey(event) + part = SimpleKeyBinding.from_int(model_key) + self._model_canvas.handle(KeyReleaseEvent(key=KeyBinding(parts=[part]))) + event.Skip() + def _map_button(self, event: wx.MouseEvent) -> MouseButton: if event.LeftDown() or event.LeftUp(): return MouseButton.LEFT diff --git a/src/scenex/app/_wx_keymap.py b/src/scenex/app/_wx_keymap.py new file mode 100644 index 00000000..7b11256c --- /dev/null +++ b/src/scenex/app/_wx_keymap.py @@ -0,0 +1,125 @@ +"""Mapping between wxPython key codes and app-model key types. + +This module has no scenex dependencies — only wx and app_model.types — so it +can be upstreamed to app-model's backends in the future. +""" + +from __future__ import annotations + +import wx +from app_model.types import KeyCode, KeyCombo, KeyMod + +# --------------------------------------------------------------------------- +# Special-key mapping: wx.WXK_* → KeyCode +# --------------------------------------------------------------------------- + +_KEY_FROM_WX_SPECIAL: dict[int, KeyCode | KeyCombo] = { + wx.WXK_BACK: KeyCode.Backspace, + wx.WXK_TAB: KeyCode.Tab, + wx.WXK_RETURN: KeyCode.Enter, + wx.WXK_ESCAPE: KeyCode.Escape, + wx.WXK_SPACE: KeyCode.Space, + wx.WXK_DELETE: KeyCode.Delete, + wx.WXK_INSERT: KeyCode.Insert, + wx.WXK_LEFT: KeyCode.LeftArrow, + wx.WXK_RIGHT: KeyCode.RightArrow, + wx.WXK_UP: KeyCode.UpArrow, + wx.WXK_DOWN: KeyCode.DownArrow, + wx.WXK_HOME: KeyCode.Home, + wx.WXK_END: KeyCode.End, + wx.WXK_PAGEUP: KeyCode.PageUp, + wx.WXK_PAGEDOWN: KeyCode.PageDown, + wx.WXK_NUMLOCK: KeyCode.NumLock, + wx.WXK_CAPITAL: KeyCode.CapsLock, + wx.WXK_SHIFT: KeyCode.Shift, + wx.WXK_ALT: KeyCode.Alt, + wx.WXK_CONTROL: KeyCode.Ctrl, + # Function keys + wx.WXK_F1: KeyCode.F1, + wx.WXK_F2: KeyCode.F2, + wx.WXK_F3: KeyCode.F3, + wx.WXK_F4: KeyCode.F4, + wx.WXK_F5: KeyCode.F5, + wx.WXK_F6: KeyCode.F6, + wx.WXK_F7: KeyCode.F7, + wx.WXK_F8: KeyCode.F8, + wx.WXK_F9: KeyCode.F9, + wx.WXK_F10: KeyCode.F10, + wx.WXK_F11: KeyCode.F11, + wx.WXK_F12: KeyCode.F12, + # Numpad digits + wx.WXK_NUMPAD0: KeyCode.Numpad0, + wx.WXK_NUMPAD1: KeyCode.Numpad1, + wx.WXK_NUMPAD2: KeyCode.Numpad2, + wx.WXK_NUMPAD3: KeyCode.Numpad3, + wx.WXK_NUMPAD4: KeyCode.Numpad4, + wx.WXK_NUMPAD5: KeyCode.Numpad5, + wx.WXK_NUMPAD6: KeyCode.Numpad6, + wx.WXK_NUMPAD7: KeyCode.Numpad7, + wx.WXK_NUMPAD8: KeyCode.Numpad8, + wx.WXK_NUMPAD9: KeyCode.Numpad9, + # Numpad operators + wx.WXK_NUMPAD_ADD: KeyCode.NumpadAdd, + wx.WXK_NUMPAD_SUBTRACT: KeyCode.NumpadSubtract, + wx.WXK_NUMPAD_MULTIPLY: KeyCode.NumpadMultiply, + wx.WXK_NUMPAD_DIVIDE: KeyCode.NumpadDivide, + wx.WXK_NUMPAD_DECIMAL: KeyCode.NumpadDecimal, + wx.WXK_NUMPAD_ENTER: KeyCode.Enter, +} + +# --------------------------------------------------------------------------- +# Build the full lookup table by adding ASCII-range keys dynamically. +# wx returns the *uppercase* ASCII value for letter keys regardless of Shift. +# Digit keys return their ASCII value (48-57). +# Punctuation keys return their ASCII value directly. +# --------------------------------------------------------------------------- + +KEY_FROM_WX: dict[int, KeyCode | KeyCombo] = dict(_KEY_FROM_WX_SPECIAL) + +# Letters: ord('A')=65 … ord('Z')=90 +for _c in "ABCDEFGHIJKLMNOPQRSTUVWXYZ": + KEY_FROM_WX[ord(_c)] = getattr(KeyCode, f"Key{_c}") + +# Digits: ord('0')=48 … ord('9')=57 +for _d in "0123456789": + KEY_FROM_WX[ord(_d)] = getattr(KeyCode, f"Digit{_d}") + +# Punctuation (ASCII values wx reports for these keys) +KEY_FROM_WX.update( + { + ord("`"): KeyCode.Backquote, + ord("-"): KeyCode.Minus, + ord("="): KeyCode.Equal, + ord("["): KeyCode.BracketLeft, + ord("]"): KeyCode.BracketRight, + ord("\\"): KeyCode.Backslash, + ord(";"): KeyCode.Semicolon, + ord("'"): KeyCode.Quote, + ord(","): KeyCode.Comma, + ord("."): KeyCode.Period, + ord("/"): KeyCode.Slash, + } +) + + +def wxevent2modelkey(event: wx.KeyEvent) -> KeyCode | KeyCombo: + """Convert a wx.KeyEvent to an app-model KeyCode or KeyCombo. + + The returned value encodes both the base key and any held modifiers so it + can be passed directly to ``SimpleKeyBinding.from_int()``. + """ + key: KeyCode | KeyCombo = KEY_FROM_WX.get(event.GetKeyCode(), KeyCode.UNKNOWN) + + mods = KeyMod.NONE + if event.ControlDown(): + mods |= KeyMod.CtrlCmd + if event.ShiftDown(): + mods |= KeyMod.Shift + if event.AltDown(): + mods |= KeyMod.Alt + if event.MetaDown(): + mods |= KeyMod.WinCtrl + + if mods: + return mods | key # type: ignore[return-value] + return key diff --git a/tests/app/test_jupyter.py b/tests/app/test_jupyter.py index b6bbe812..ec0c1db1 100644 --- a/tests/app/test_jupyter.py +++ b/tests/app/test_jupyter.py @@ -6,11 +6,14 @@ from unittest.mock import patch import pytest +from app_model.types import KeyBinding import scenex as snx from scenex.adaptors._auto import determine_backend from scenex.app import CursorType, GuiFrontend, app, determine_app from scenex.app.events import ( + KeyPressEvent, + KeyReleaseEvent, MouseButton, MouseDoublePressEvent, MouseEnterEvent, @@ -295,3 +298,21 @@ def test_set_cursor(evented_canvas: snx.Canvas) -> None: native = cast("CanvasAdaptor", adaptor)._snx_get_native() app().set_cursor(evented_canvas, CursorType.CROSS) assert native.cursor == "crosshair" + + +def test_key_event(evented_canvas: snx.Canvas) -> None: + snx.show(evented_canvas) + native = cast( + "CanvasAdaptor", evented_canvas._get_adaptors(create=True)[0] + )._snx_get_native() + + with patch.object(snx.Canvas, "handle") as mock_handle: + native.handle_event({"event_type": "key_down", "key": "a", "modifiers": []}) + native.handle_event({"event_type": "key_up", "key": "a", "modifiers": []}) + + assert mock_handle.call_args_list[0].args == ( + KeyPressEvent(key=KeyBinding.from_str("A")), + ) + assert mock_handle.call_args_list[1].args == ( + KeyReleaseEvent(key=KeyBinding.from_str("A")), + ) diff --git a/tests/app/test_wx.py b/tests/app/test_wx.py index 081948b1..800f261b 100644 --- a/tests/app/test_wx.py +++ b/tests/app/test_wx.py @@ -6,10 +6,13 @@ from unittest.mock import patch import pytest +from app_model.types import KeyBinding import scenex as snx from scenex.app import CursorType, GuiFrontend, app, determine_app from scenex.app.events import ( + KeyPressEvent, + KeyReleaseEvent, MouseButton, MouseEnterEvent, MouseLeaveEvent, @@ -49,6 +52,9 @@ def _processEvent(evt: wx.PyEventBinder, wdg: wx.Control, **kwargs: Any) -> None """ if evt == wx.EVT_SIZE: ev = wx.SizeEvent(kwargs["sz"], evt.typeId) + elif evt in (wx.EVT_KEY_DOWN, wx.EVT_KEY_UP): + ev = wx.KeyEvent(evt.typeId) + ev.m_keyCode = kwargs["keyCode"] else: ev = wx.MouseEvent(evt.typeId) ev.SetPosition(kwargs["pos"]) @@ -209,3 +215,20 @@ def test_set_cursor(evented_canvas: snx.Canvas) -> None: old = native.GetCursor() app().set_cursor(evented_canvas, CursorType.CROSS) assert not native.GetCursor().IsSameAs(old) + + +def test_key_event(evented_canvas: snx.Canvas) -> None: + native = cast( + "CanvasAdaptor", evented_canvas._get_adaptors(create=True)[0] + )._snx_get_native() + + with patch.object(snx.Canvas, "handle") as mock_handle: + _processEvent(wx.EVT_KEY_DOWN, native, keyCode=ord("A")) + _processEvent(wx.EVT_KEY_UP, native, keyCode=ord("A")) + + assert mock_handle.call_args_list[0].args == ( + KeyPressEvent(key=KeyBinding.from_str("A")), + ) + assert mock_handle.call_args_list[1].args == ( + KeyReleaseEvent(key=KeyBinding.from_str("A")), + ) From 798bbf5f8a824676ef92508140ce1c1aaf6fcb90 Mon Sep 17 00:00:00 2001 From: Gabriel Selzer Date: Fri, 10 Apr 2026 14:59:07 -0500 Subject: [PATCH 6/6] Fix wx tests --- tests/app/test_wx.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/app/test_wx.py b/tests/app/test_wx.py index 800f261b..2e37c8f4 100644 --- a/tests/app/test_wx.py +++ b/tests/app/test_wx.py @@ -54,7 +54,7 @@ def _processEvent(evt: wx.PyEventBinder, wdg: wx.Control, **kwargs: Any) -> None ev = wx.SizeEvent(kwargs["sz"], evt.typeId) elif evt in (wx.EVT_KEY_DOWN, wx.EVT_KEY_UP): ev = wx.KeyEvent(evt.typeId) - ev.m_keyCode = kwargs["keyCode"] + ev.SetKeyCode(kwargs["keyCode"]) else: ev = wx.MouseEvent(evt.typeId) ev.SetPosition(kwargs["pos"])