Skip to content

Commit 9a9acea

Browse files
committed
Work with all gui frontends
1 parent edbc57d commit 9a9acea

File tree

9 files changed

+143
-35
lines changed

9 files changed

+143
-35
lines changed

src/ndv/_types.py

+26
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from collections.abc import Hashable, Sequence
66
from contextlib import suppress
77
from enum import Enum, IntFlag, auto
8+
from functools import lru_cache
89
from typing import TYPE_CHECKING, Annotated, Any, NamedTuple, cast
910

1011
from pydantic import PlainSerializer, PlainValidator
@@ -13,6 +14,7 @@
1314
if TYPE_CHECKING:
1415
from qtpy.QtCore import Qt
1516
from qtpy.QtWidgets import QWidget
17+
from wx import Cursor
1618

1719
from ndv.views.bases import Viewable
1820

@@ -123,3 +125,27 @@ def to_jupyter(self) -> str:
123125
CursorType.BDIAG_ARROW: "nesw-resize",
124126
CursorType.FDIAG_ARROW: "nwse-resize",
125127
}[self]
128+
129+
@lru_cache
130+
def to_wx(self) -> Cursor:
131+
"""Converts CursorType to jupyter cursor strings."""
132+
from wx import (
133+
CURSOR_ARROW,
134+
CURSOR_CROSS,
135+
CURSOR_SIZENESW,
136+
CURSOR_SIZENS,
137+
CURSOR_SIZENWSE,
138+
CURSOR_SIZEWE,
139+
CURSOR_SIZING,
140+
Cursor,
141+
)
142+
143+
return {
144+
CursorType.DEFAULT: Cursor(CURSOR_ARROW),
145+
CursorType.CROSS: Cursor(CURSOR_CROSS),
146+
CursorType.V_ARROW: Cursor(CURSOR_SIZENS),
147+
CursorType.H_ARROW: Cursor(CURSOR_SIZEWE),
148+
CursorType.ALL_ARROW: Cursor(CURSOR_SIZING),
149+
CursorType.BDIAG_ARROW: Cursor(CURSOR_SIZENESW),
150+
CursorType.FDIAG_ARROW: Cursor(CURSOR_SIZENWSE),
151+
}[self]

src/ndv/views/_jupyter/_app.py

+32-4
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,12 @@
66

77
from jupyter_rfb import RemoteFrameBuffer
88

9-
from ndv._types import MouseMoveEvent, MousePressEvent, MouseReleaseEvent
9+
from ndv._types import (
10+
MouseButton,
11+
MouseMoveEvent,
12+
MousePressEvent,
13+
MouseReleaseEvent,
14+
)
1015
from ndv.views.bases._app import NDVApp
1116

1217
if TYPE_CHECKING:
@@ -41,6 +46,19 @@ def array_view_class(self) -> type[ArrayView]:
4146

4247
return JupyterArrayView
4348

49+
@staticmethod
50+
def mouse_btn(btn: Any) -> MouseButton:
51+
if btn == 0:
52+
return MouseButton.NONE
53+
if btn == 1:
54+
return MouseButton.LEFT
55+
if btn == 2:
56+
return MouseButton.RIGHT
57+
if btn == 3:
58+
return MouseButton.MIDDLE
59+
60+
raise Exception(f"Jupyter mouse button {btn} is unknown")
61+
4462
def filter_mouse_events(
4563
self, canvas: Any, receiver: Mouseable
4664
) -> Callable[[], None]:
@@ -52,19 +70,29 @@ def filter_mouse_events(
5270
# patch the handle_event from _jupyter_rfb.CanvasBackend
5371
# to intercept various mouse events.
5472
super_handle_event = canvas.handle_event
73+
active_btn: MouseButton = MouseButton.NONE
5574

5675
def handle_event(self: RemoteFrameBuffer, ev: dict) -> None:
76+
nonlocal active_btn
77+
nonlocal canvas
78+
5779
etype = ev["event_type"]
80+
if "button" in ev:
81+
btn = JupyterAppWrap.mouse_btn(ev["button"])
5882
if etype == "pointer_move":
59-
mme = MouseMoveEvent(x=ev["x"], y=ev["y"])
83+
mme = MouseMoveEvent(x=ev["x"], y=ev["y"], btn=active_btn)
6084
receiver.on_mouse_move(mme)
85+
if cursor := receiver.get_cursor(mme):
86+
canvas.cursor = cursor.to_jupyter()
6187
receiver.mouseMoved.emit(mme)
6288
elif etype == "pointer_down":
63-
mpe = MousePressEvent(x=ev["x"], y=ev["y"])
89+
active_btn = btn
90+
mpe = MousePressEvent(x=ev["x"], y=ev["y"], btn=active_btn)
6491
receiver.on_mouse_press(mpe)
6592
receiver.mousePressed.emit(mpe)
6693
elif etype == "pointer_up":
67-
mre = MouseReleaseEvent(x=ev["x"], y=ev["y"])
94+
mre = MouseReleaseEvent(x=ev["x"], y=ev["y"], btn=active_btn)
95+
active_btn = MouseButton.NONE
6896
receiver.on_mouse_release(mre)
6997
receiver.mouseReleased.emit(mre)
7098
super_handle_event(ev)

src/ndv/views/_pygfx/_array_canvas.py

+14-9
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,17 @@
1010
import pygfx
1111
import pylinalg as la
1212

13-
from ndv._types import CursorType, MouseButton, MouseMoveEvent, MousePressEvent, MouseReleaseEvent
13+
from ndv._types import (
14+
CursorType,
15+
MouseButton,
16+
MouseMoveEvent,
17+
MousePressEvent,
18+
MouseReleaseEvent,
19+
)
1420
from ndv.models._viewer_model import ArrayViewerModel, InteractionMode
1521
from ndv.views._app import filter_mouse_events
1622
from ndv.views.bases import ArrayCanvas, CanvasElement, ImageHandle
17-
from ndv.views.bases._graphics._canvas_elements import ROIMoveMode, RectangularROI
23+
from ndv.views.bases._graphics._canvas_elements import RectangularROI, ROIMoveMode
1824

1925
if TYPE_CHECKING:
2026
from collections.abc import Sequence
@@ -100,7 +106,7 @@ def remove(self) -> None:
100106
if (par := self._image.parent) is not None:
101107
par.remove(self._image)
102108

103-
def get_cursor(self, pos: tuple[float, float]) -> CursorType | None:
109+
def get_cursor(self, mme: MouseMoveEvent) -> CursorType | None:
104110
return None
105111

106112

@@ -317,9 +323,9 @@ def _handle_under(self, pos: Sequence[float]) -> int | None:
317323
return i
318324
return None
319325

320-
def get_cursor(self, pos: tuple[float, float]) -> CursorType | None:
326+
def get_cursor(self, mme: MouseMoveEvent) -> CursorType | None:
321327
# Convert event pos (on canvas) to world pos
322-
world_pos = self._canvas_to_world(pos)
328+
world_pos = self._canvas_to_world((mme.x, mme.y))
323329
# Step 1: Handles
324330
# Preferred over the rectangle
325331
# Can only be moved if ROI is selected
@@ -648,11 +654,10 @@ def on_mouse_release(self, event: MouseReleaseEvent) -> bool:
648654
self._selection.on_mouse_release(event)
649655
return False
650656

651-
def get_cursor(self, pos: tuple[float, float]) -> CursorType:
657+
def get_cursor(self, event: MouseMoveEvent) -> CursorType:
652658
if self._viewer.interaction_mode == InteractionMode.CREATE_ROI:
653659
return CursorType.CROSS
654-
for vis in self.elements_at(pos):
655-
self.canvas_to_world(pos)[:2]
656-
if cursor := vis.get_cursor(pos):
660+
for vis in self.elements_at((event.x, event.y)):
661+
if cursor := vis.get_cursor(event):
657662
return cursor
658663
return CursorType.DEFAULT

src/ndv/views/_qt/_app.py

+21-8
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,15 @@
55

66
from qtpy.QtCore import QEvent, QObject
77
from qtpy.QtGui import QMouseEvent
8-
from qtpy.QtWidgets import QApplication
9-
10-
from ndv._types import MouseButton, MouseMoveEvent, MousePressEvent, MouseReleaseEvent
8+
from qtpy.QtWidgets import QApplication, QWidget
9+
10+
from ndv._types import (
11+
CursorType,
12+
MouseButton,
13+
MouseMoveEvent,
14+
MousePressEvent,
15+
MouseReleaseEvent,
16+
)
1117
from ndv.views.bases._app import NDVApp
1218

1319
if TYPE_CHECKING:
@@ -67,22 +73,22 @@ def array_view_class(self) -> type[ArrayView]:
6773
def filter_mouse_events(
6874
self, canvas: Any, receiver: Mouseable
6975
) -> Callable[[], None]:
70-
if not isinstance(canvas, QObject):
71-
raise TypeError(f"Expected canvas to be QObject, got {type(canvas)}")
76+
if not isinstance(canvas, QWidget):
77+
raise TypeError(f"Expected canvas to be QWidget, got {type(canvas)}")
7278

7379
f = MouseEventFilter(canvas, receiver)
7480
canvas.installEventFilter(f)
7581
return lambda: canvas.removeEventFilter(f)
7682

7783

7884
class MouseEventFilter(QObject):
79-
def __init__(self, canvas: QObject, receiver: Mouseable):
85+
def __init__(self, canvas: QWidget, receiver: Mouseable):
8086
super().__init__()
8187
self.canvas = canvas
8288
self.receiver = receiver
8389
self.active_button = MouseButton.NONE
8490

85-
def mouse_btn(self, btn : Any) -> MouseButton:
91+
def mouse_btn(self, btn: Any) -> MouseButton:
8692
from qtpy.QtCore import Qt
8793

8894
if btn == Qt.MouseButton.LeftButton:
@@ -94,6 +100,9 @@ def mouse_btn(self, btn : Any) -> MouseButton:
94100

95101
raise Exception(f"Qt mouse button {btn} is unknown")
96102

103+
def set_cursor(self, type: CursorType) -> None:
104+
self.canvas.setCursor(type.to_qt())
105+
97106
def eventFilter(self, obj: QObject | None, qevent: QEvent | None) -> bool:
98107
"""Event filter installed on the canvas to handle mouse events.
99108
@@ -121,14 +130,18 @@ def eventFilter(self, obj: QObject | None, qevent: QEvent | None) -> bool:
121130
if etype == QEvent.Type.MouseMove:
122131
mme = MouseMoveEvent(x=pos.x(), y=pos.y(), btn=self.active_button)
123132
intercept |= receiver.on_mouse_move(mme)
133+
if cursor := receiver.get_cursor(mme):
134+
self.set_cursor(cursor)
124135
receiver.mouseMoved.emit(mme)
125136
elif etype == QEvent.Type.MouseButtonPress:
126137
self.active_button = btn
127138
mpe = MousePressEvent(x=pos.x(), y=pos.y(), btn=self.active_button)
128139
intercept |= receiver.on_mouse_press(mpe)
129140
receiver.mousePressed.emit(mpe)
130141
elif etype == QEvent.Type.MouseButtonRelease:
131-
mre = MouseReleaseEvent(x=pos.x(), y=pos.y(), btn=self.active_button)
142+
mre = MouseReleaseEvent(
143+
x=pos.x(), y=pos.y(), btn=self.active_button
144+
)
132145
self.active_button = MouseButton.NONE
133146
intercept |= receiver.on_mouse_release(mre)
134147
receiver.mouseReleased.emit(mre)

src/ndv/views/_vispy/_array_canvas.py

+14-7
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,13 @@
1515
from vispy import scene
1616
from vispy.util.quaternion import Quaternion
1717

18-
from ndv._types import CursorType, MouseButton, MouseMoveEvent, MousePressEvent, MouseReleaseEvent
18+
from ndv._types import (
19+
CursorType,
20+
MouseButton,
21+
MouseMoveEvent,
22+
MousePressEvent,
23+
MouseReleaseEvent,
24+
)
1925
from ndv.models._viewer_model import ArrayViewerModel, InteractionMode
2026
from ndv.views._app import filter_mouse_events
2127
from ndv.views.bases import ArrayCanvas
@@ -106,7 +112,7 @@ def move(self, pos: Sequence[float]) -> None:
106112
def remove(self) -> None:
107113
self._visual.parent = None
108114

109-
def get_cursor(self, pos: tuple[float, float]) -> CursorType | None:
115+
def get_cursor(self, mme: MouseMoveEvent) -> CursorType | None:
110116
return None
111117

112118

@@ -239,7 +245,8 @@ def on_mouse_press(self, event: MousePressEvent) -> bool:
239245
def on_mouse_release(self, event: MouseReleaseEvent) -> bool:
240246
return False
241247

242-
def get_cursor(self, canvas_pos: tuple[float, float]) -> CursorType | None:
248+
def get_cursor(self, mme: MouseMoveEvent) -> CursorType | None:
249+
canvas_pos = (mme.x, mme.y)
243250
pos = self._tform.map(canvas_pos)[:2]
244251
if self._handle_under(pos) is not None:
245252
center = self._rect.center
@@ -415,7 +422,7 @@ def set_range(
415422
_y[1] = max(_y[1], shape[1])
416423
if len(shape) > 2:
417424
_z[1] = max(_z[1], shape[2])
418-
elif isinstance(handle, VispyRoiHandle):
425+
elif isinstance(handle, VispyBoundingBox):
419426
for v in handle.vertices:
420427
_x[0] = min(_x[0], v[0])
421428
_x[1] = max(_x[1], v[0])
@@ -503,11 +510,11 @@ def on_mouse_release(self, event: MouseReleaseEvent) -> bool:
503510
self._camera.interactive = True
504511
return False
505512

506-
def get_cursor(self, canvas_pos: tuple[float, float]) -> CursorType:
513+
def get_cursor(self, mme: MouseMoveEvent) -> CursorType:
507514
if self._viewer.interaction_mode == InteractionMode.CREATE_ROI:
508515
return CursorType.CROSS
509-
for vis in self.elements_at(canvas_pos):
510-
if cursor := vis.get_cursor(canvas_pos):
516+
for vis in self.elements_at((mme.x, mme.y)):
517+
if cursor := vis.get_cursor(mme):
511518
return cursor
512519
return CursorType.DEFAULT
513520

src/ndv/views/_vispy/_histogram.py

+3-2
Original file line numberDiff line numberDiff line change
@@ -272,7 +272,8 @@ def _update_lut_lines(self, npoints: int = 256) -> None:
272272
v._bounds_changed()
273273
self._gamma_handle._bounds_changed()
274274

275-
def get_cursor(self, pos: tuple[float, float]) -> CursorType:
275+
def get_cursor(self, event: MouseMoveEvent) -> CursorType:
276+
pos = (event.x, event.y)
276277
nearby = self._find_nearby_node(pos)
277278

278279
if nearby in [Grabbable.LEFT_CLIM, Grabbable.RIGHT_CLIM]:
@@ -332,7 +333,7 @@ def on_mouse_move(self, event: MouseMoveEvent) -> bool:
332333
self.gammaChanged.emit(-np.log2(y / y1))
333334
return False
334335

335-
self.get_cursor(pos).apply_to(self)
336+
self.get_cursor(event).apply_to(self)
336337
return False
337338

338339
def _find_nearby_node(

src/ndv/views/_wx/_app.py

+15-4
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
import wx
66
from wx import EVT_LEFT_DOWN, EVT_LEFT_UP, EVT_MOTION, EvtHandler, MouseEvent
77

8-
from ndv._types import MouseMoveEvent, MousePressEvent, MouseReleaseEvent
8+
from ndv._types import MouseButton, MouseMoveEvent, MousePressEvent, MouseReleaseEvent
99
from ndv.views.bases._app import NDVApp
1010

1111
from ._main_thread import call_in_main_thread
@@ -61,20 +61,31 @@ def filter_mouse_events(
6161

6262
# TIP: event.Skip() allows the event to propagate to other handlers.
6363

64+
active_button = MouseButton.NONE
65+
6466
def on_mouse_move(event: MouseEvent) -> None:
65-
mme = MouseMoveEvent(x=event.GetX(), y=event.GetY())
67+
nonlocal active_button
68+
mme = MouseMoveEvent(x=event.GetX(), y=event.GetY(), btn=active_button)
6669
if not receiver.on_mouse_move(mme):
6770
receiver.mouseMoved.emit(mme)
6871
event.Skip()
72+
# FIXME: get_cursor is VERY slow, unsure why.
73+
if cursor := receiver.get_cursor(mme):
74+
canvas.SetCursor(cursor.to_wx())
6975

7076
def on_mouse_press(event: MouseEvent) -> None:
71-
mpe = MousePressEvent(x=event.GetX(), y=event.GetY())
77+
nonlocal active_button
78+
# NB This function is bound to the left mouse button press
79+
active_button = MouseButton.LEFT
80+
mpe = MousePressEvent(x=event.GetX(), y=event.GetY(), btn=active_button)
7281
if not receiver.on_mouse_press(mpe):
7382
receiver.mousePressed.emit(mpe)
7483
event.Skip()
7584

7685
def on_mouse_release(event: MouseEvent) -> None:
77-
mre = MouseReleaseEvent(x=event.GetX(), y=event.GetY())
86+
nonlocal active_button
87+
mre = MouseReleaseEvent(x=event.GetX(), y=event.GetY(), btn=active_button)
88+
active_button = MouseButton.NONE
7889
if not receiver.on_mouse_release(mre):
7990
receiver.mouseReleased.emit(mre)
8091
event.Skip()

0 commit comments

Comments
 (0)