Skip to content

Commit e6d3232

Browse files
authored
Add canvas.set_cursor() (#81)
1 parent 3d82b37 commit e6d3232

File tree

10 files changed

+157
-8
lines changed

10 files changed

+157
-8
lines changed

docs/api.rst

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@ These are the base classes that make up the rendercanvas API:
55

66
* The :class:`~rendercanvas.BaseRenderCanvas` represents the main API.
77
* The :class:`~rendercanvas.BaseLoop` provides functionality to work with the event-loop in a generic way.
8-
* The :class:`~rendercanvas.EventType` specifies the different types of events that can be connected to with :func:`canvas.add_event_handler() <rendercanvas.BaseRenderCanvas.add_event_handler>`.
8+
* The :class:`~rendercanvas.EventType` enum specifies the types of events for :func:`canvas.add_event_handler() <rendercanvas.BaseRenderCanvas.add_event_handler>`.
9+
* The :class:`~rendercanvas.CursorShape` enum specifies the cursor shapes for :func:`canvas.set_cursor() <rendercanvas.BaseRenderCanvas.set_cursor>`.
910

1011
.. autoclass:: rendercanvas.BaseRenderCanvas
1112
:members:
@@ -18,3 +19,7 @@ These are the base classes that make up the rendercanvas API:
1819
.. autoclass:: rendercanvas.EventType
1920
:members:
2021
:member-order: bysource
22+
23+
.. autoclass:: rendercanvas.CursorShape
24+
:members:
25+
:member-order: bysource

examples/demo.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@
1919
from rendercanvas.auto import RenderCanvas, loop
2020
from rendercanvas.utils.cube import setup_drawing_sync
2121
from rendercanvas.utils.asyncs import sleep
22+
import rendercanvas
23+
2224

2325
canvas = RenderCanvas(
2426
size=(640, 480),
@@ -32,6 +34,9 @@
3234
draw_frame = setup_drawing_sync(canvas)
3335
canvas.request_draw(draw_frame)
3436

37+
# Note: in this demo we listen to all events (using '*'). In general
38+
# you want to select one or more specific events to handle.
39+
3540

3641
@canvas.add_event_handler("*")
3742
async def process_event(event):
@@ -54,6 +59,15 @@ async def process_event(event):
5459
print("Async sleep ... zzzz")
5560
await sleep(2)
5661
print("waking up")
62+
elif event["key"] == "c":
63+
# Swap cursor
64+
shapes = list(rendercanvas.CursorShape)
65+
canvas.cursor_index = getattr(canvas, "cursor_index", -1) + 1
66+
if canvas.cursor_index >= len(shapes):
67+
canvas.cursor_index = 0
68+
cursor = shapes[canvas.cursor_index]
69+
canvas.set_cursor(cursor)
70+
print(f"Cursor: {cursor!r}")
5771
elif event["event_type"] == "close":
5872
# Should see this exactly once, either when pressing escape, or
5973
# when pressing the window close button.

rendercanvas/__init__.py

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,6 @@
77
from ._version import __version__, version_info
88
from . import _coreutils
99
from ._events import EventType
10-
from .base import BaseRenderCanvas, BaseLoop
10+
from .base import BaseRenderCanvas, BaseLoop, CursorShape
1111

12-
__all__ = [
13-
"BaseLoop",
14-
"BaseRenderCanvas",
15-
"EventType",
16-
]
12+
__all__ = ["BaseLoop", "BaseRenderCanvas", "CursorShape", "EventType"]

rendercanvas/base.py

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
from ._events import EventEmitter, EventType # noqa: F401
1212
from ._loop import BaseLoop
1313
from ._scheduler import Scheduler
14-
from ._coreutils import logger, log_exception
14+
from ._coreutils import logger, log_exception, BaseEnum
1515

1616

1717
# Notes on naming and prefixes:
@@ -25,6 +25,25 @@
2525
# * `._rc_method`: Methods that the subclass must implement.
2626

2727

28+
class CursorShape(BaseEnum):
29+
"""The CursorShape enum specifies the suppported cursor shapes, following CSS cursor names."""
30+
31+
default = None #: The platform-dependent default cursor, typically an arrow.
32+
text = None #: The text input I-beam cursor shape.
33+
crosshair = None #:
34+
pointer = None #: The pointing hand cursor shape.
35+
ew_resize = "ew-resize" #: The horizontal resize/move arrow shape.
36+
ns_resize = "ns-resize" #: The vertical resize/move arrow shape.
37+
nesw_resize = (
38+
"nesw-resize" #: The top-left to bottom-right diagonal resize/move arrow shape.
39+
)
40+
nwse_resize = (
41+
"nwse-resize" #: The top-right to bottom-left diagonal resize/move arrow shape.
42+
)
43+
not_allowed = "not-allowed" #: The operation-not-allowed shape.
44+
none = "none" #: The cursor is hidden.
45+
46+
2847
class BaseCanvasGroup:
2948
"""Represents a group of canvas objects from the same class, that share a loop."""
3049

@@ -486,6 +505,22 @@ def set_title(self, title):
486505
title = title.replace("$" + k, v)
487506
self._rc_set_title(title)
488507

508+
def set_cursor(self, cursor):
509+
"""Set the cursor shape for the mouse pointer.
510+
511+
See :obj:`rendercanvas.CursorShape`:
512+
"""
513+
if cursor is None:
514+
cursor = "default"
515+
if not isinstance(cursor, str):
516+
raise TypeError("Canvas cursor must be str.")
517+
cursor = cursor.lower().replace("_", "-")
518+
if cursor not in CursorShape:
519+
raise ValueError(
520+
f"Canvas cursor {cursor!r} not known, must be one of {CursorShape}"
521+
)
522+
self._rc_set_cursor(cursor)
523+
489524
# %% Methods for the subclass to implement
490525

491526
def _rc_gui_poll(self):
@@ -593,6 +628,13 @@ def _rc_set_title(self, title):
593628
"""
594629
pass
595630

631+
def _rc_set_cursor(self, cursor):
632+
"""Set the cursor shape. May be ignored.
633+
634+
The default implementation does nothing.
635+
"""
636+
pass
637+
596638

597639
class WrapperRenderCanvas(BaseRenderCanvas):
598640
"""A base render canvas for top-level windows that wrap a widget, as used in e.g. Qt and wx.
@@ -642,6 +684,9 @@ def set_logical_size(self, width, height):
642684
def set_title(self, *args):
643685
self._subwidget.set_title(*args)
644686

687+
def set_cursor(self, *args):
688+
self._subwidget.set_cursor(*args)
689+
645690
def close(self):
646691
self._subwidget.close()
647692

rendercanvas/glfw.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,21 @@
103103
glfw.KEY_RIGHT_SUPER: "Meta",
104104
}
105105

106+
CURSOR_MAP = {
107+
"default": None,
108+
# "arrow": glfw.ARROW_CURSOR, # CSS only has 'default', not 'arrow'
109+
"text": glfw.IBEAM_CURSOR,
110+
"crosshair": glfw.CROSSHAIR_CURSOR,
111+
"pointer": glfw.POINTING_HAND_CURSOR,
112+
"ew-resize": glfw.RESIZE_EW_CURSOR,
113+
"ns-resize": glfw.RESIZE_NS_CURSOR,
114+
"nesw-resize": glfw.RESIZE_NESW_CURSOR,
115+
"nwse-resize": glfw.RESIZE_NWSE_CURSOR,
116+
# "": glfw.RESIZE_ALL_CURSOR, # Looks like 'grabbing' in CSS
117+
"not-allowed": glfw.NOT_ALLOWED_CURSOR,
118+
"none": None, # handled in method
119+
}
120+
106121

107122
def get_glfw_present_methods(window):
108123
if sys.platform.startswith("win"):
@@ -198,6 +213,7 @@ def __init__(self, *args, present_method=None, **kwargs):
198213
self._changing_pixel_ratio = False
199214
self._is_minimized = False
200215
self._is_in_poll_events = False
216+
self._cursor_object = None
201217

202218
# Register callbacks. We may get notified too often, but that's
203219
# ok, they'll result in a single draw.
@@ -366,6 +382,26 @@ def _rc_set_title(self, title):
366382
if self._window is not None:
367383
glfw.set_window_title(self._window, title)
368384

385+
def _rc_set_cursor(self, cursor):
386+
if self._cursor_object is not None:
387+
glfw.destroy_cursor(self._cursor_object)
388+
self._cursor_object = None
389+
390+
cursor_flag = CURSOR_MAP.get(cursor)
391+
if cursor == "none":
392+
# Create a custom cursor that's simply empty
393+
image = memoryview(bytearray(8 * 8 * 4))
394+
image = image.cast("B", shape=(8, 8, 4))
395+
image_for_glfw_wrapper = image.shape[1], image.shape[0], image.tolist()
396+
self._cursor_object = glfw.create_cursor(image_for_glfw_wrapper, 0, 0)
397+
elif cursor_flag is None:
398+
# The default (arrow)
399+
self._cursor_object = None
400+
else:
401+
self._cursor_object = glfw.create_standard_cursor(cursor_flag)
402+
403+
glfw.set_cursor(self._window, self._cursor_object)
404+
369405
# %% Turn glfw events into rendercanvas events
370406

371407
def _on_pixelratio_change(self, *args):

rendercanvas/jupyter.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,9 @@ def _rc_get_closed(self):
107107
def _rc_set_title(self, title):
108108
pass # not supported yet
109109

110+
def _rc_set_cursor(self, cursor):
111+
self.cursor = cursor
112+
110113
# %% Turn jupyter_rfb events into rendercanvas events
111114

112115
def handle_event(self, event):

rendercanvas/offscreen.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,9 @@ def _rc_get_closed(self):
7474
def _rc_set_title(self, title):
7575
pass
7676

77+
def _rc_set_cursor(self, cursor):
78+
pass
79+
7780
# %% events - there are no GUI events
7881

7982
# %% Extra API

rendercanvas/qt.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
PreciseTimer = QtCore.Qt.TimerType.PreciseTimer
3535
KeyboardModifiers = QtCore.Qt.KeyboardModifier
3636
FocusPolicy = QtCore.Qt.FocusPolicy
37+
CursorShape = QtCore.Qt.CursorShape
3738
Keys = QtCore.Qt.Key
3839
WinIdChange = QtCore.QEvent.Type.WinIdChange
3940
except AttributeError:
@@ -44,6 +45,7 @@
4445
PreciseTimer = QtCore.Qt.PreciseTimer
4546
KeyboardModifiers = QtCore.Qt
4647
FocusPolicy = QtCore.Qt
48+
CursorShape = QtCore.Qt
4749
Keys = QtCore.Qt
4850
WinIdChange = QtCore.QEvent.WinIdChange
4951
else:
@@ -125,6 +127,20 @@ def check_qt_libname(expected_libname):
125127
int(Keys.Key_Tab): "Tab",
126128
}
127129

130+
CURSOR_MAP = {
131+
"default": CursorShape.ArrowCursor,
132+
"text": CursorShape.IBeamCursor,
133+
"crosshair": CursorShape.CrossCursor,
134+
"pointer": CursorShape.PointingHandCursor,
135+
"ew-resize": CursorShape.SizeHorCursor,
136+
"ns-resize": CursorShape.SizeVerCursor,
137+
"nesw-resize": CursorShape.SizeBDiagCursor,
138+
"nwse-resize": CursorShape.SizeFDiagCursor,
139+
"not-allowed": CursorShape.ForbiddenCursor,
140+
"none": CursorShape.BlankCursor,
141+
}
142+
143+
128144
BITMAP_FORMAT_MAP = {
129145
"rgba-u8": QtGui.QImage.Format.Format_RGBA8888,
130146
"rgb-u8": QtGui.QImage.Format.Format_RGB888,
@@ -441,6 +457,13 @@ def _rc_set_title(self, title):
441457
if isinstance(parent, QRenderCanvas):
442458
parent.setWindowTitle(title)
443459

460+
def _rc_set_cursor(self, cursor):
461+
cursor_flag = CURSOR_MAP.get(cursor)
462+
if cursor_flag is None:
463+
self.unsetCursor()
464+
else:
465+
self.setCursor(cursor_flag)
466+
444467
# %% Turn Qt events into rendercanvas events
445468

446469
def _key_event(self, event_type, event):

rendercanvas/stub.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,9 @@ def _rc_get_closed(self):
116116
def _rc_set_title(self, title):
117117
pass
118118

119+
def _rc_set_cursor(self, cursor):
120+
pass
121+
119122

120123
class ToplevelRenderCanvas(WrapperRenderCanvas):
121124
"""

rendercanvas/wx.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,19 @@
109109
wx.WXK_TAB: "Tab",
110110
}
111111

112+
CURSOR_MAP = {
113+
"default": None,
114+
"text": wx.CURSOR_IBEAM,
115+
"crosshair": wx.CURSOR_CROSS,
116+
"pointer": wx.CURSOR_HAND,
117+
"ew-resize": wx.CURSOR_SIZEWE,
118+
"ns-resize": wx.CURSOR_SIZENS,
119+
"nesw-resize": wx.CURSOR_SIZENESW,
120+
"nwse-resize": wx.CURSOR_SIZENWSE,
121+
"not-allowed": wx.CURSOR_NO_ENTRY,
122+
"none": wx.CURSOR_BLANK,
123+
}
124+
112125

113126
def enable_hidpi():
114127
"""Enable high-res displays."""
@@ -356,6 +369,14 @@ def _rc_set_title(self, title):
356369
if isinstance(parent, WxRenderCanvas):
357370
parent.SetTitle(title)
358371

372+
def _rc_set_cursor(self, cursor):
373+
cursor_flag = CURSOR_MAP.get(cursor)
374+
if cursor_flag is None:
375+
self.SetCursor(wx.NullCursor) # System default
376+
else:
377+
cursor_object = wx.Cursor(cursor_flag)
378+
self.SetCursor(cursor_object)
379+
359380
# %% Turn Qt events into rendercanvas events
360381

361382
def _on_resize(self, event: wx.SizeEvent):

0 commit comments

Comments
 (0)