Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion docs/api.rst
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@ These are the base classes that make up the rendercanvas API:

* The :class:`~rendercanvas.BaseRenderCanvas` represents the main API.
* The :class:`~rendercanvas.BaseLoop` provides functionality to work with the event-loop in a generic way.
* 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>`.
* The :class:`~rendercanvas.EventType` enum specifies the types of events for :func:`canvas.add_event_handler() <rendercanvas.BaseRenderCanvas.add_event_handler>`.
* The :class:`~rendercanvas.CursorShape` enum specifies the cursor shapes for :func:`canvas.set_cursor() <rendercanvas.BaseRenderCanvas.set_cursor>`.

.. autoclass:: rendercanvas.BaseRenderCanvas
:members:
Expand All @@ -18,3 +19,7 @@ These are the base classes that make up the rendercanvas API:
.. autoclass:: rendercanvas.EventType
:members:
:member-order: bysource

.. autoclass:: rendercanvas.CursorShape
:members:
:member-order: bysource
14 changes: 14 additions & 0 deletions examples/demo.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@
from rendercanvas.auto import RenderCanvas, loop
from rendercanvas.utils.cube import setup_drawing_sync
from rendercanvas.utils.asyncs import sleep
import rendercanvas


canvas = RenderCanvas(
size=(640, 480),
Expand All @@ -32,6 +34,9 @@
draw_frame = setup_drawing_sync(canvas)
canvas.request_draw(draw_frame)

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


@canvas.add_event_handler("*")
async def process_event(event):
Expand All @@ -54,6 +59,15 @@ async def process_event(event):
print("Async sleep ... zzzz")
await sleep(2)
print("waking up")
elif event["key"] == "c":
# Swap cursor
shapes = list(rendercanvas.CursorShape)
canvas.cursor_index = getattr(canvas, "cursor_index", -1) + 1
if canvas.cursor_index >= len(shapes):
canvas.cursor_index = 0
cursor = shapes[canvas.cursor_index]
canvas.set_cursor(cursor)
print(f"Cursor: {cursor!r}")
elif event["event_type"] == "close":
# Should see this exactly once, either when pressing escape, or
# when pressing the window close button.
Expand Down
8 changes: 2 additions & 6 deletions rendercanvas/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,6 @@
from ._version import __version__, version_info
from . import _coreutils
from ._events import EventType
from .base import BaseRenderCanvas, BaseLoop
from .base import BaseRenderCanvas, BaseLoop, CursorShape

__all__ = [
"BaseLoop",
"BaseRenderCanvas",
"EventType",
]
__all__ = ["BaseLoop", "BaseRenderCanvas", "CursorShape", "EventType"]
47 changes: 46 additions & 1 deletion rendercanvas/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
from ._events import EventEmitter, EventType # noqa: F401
from ._loop import BaseLoop
from ._scheduler import Scheduler
from ._coreutils import logger, log_exception
from ._coreutils import logger, log_exception, BaseEnum


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


class CursorShape(BaseEnum):
"""The CursorShape enum specifies the suppported cursor shapes, following CSS cursor names."""

default = None #: The platform-dependent default cursor, typically an arrow.
text = None #: The text input I-beam cursor shape.
crosshair = None #:
pointer = None #: The pointing hand cursor shape.
ew_resize = "ew-resize" #: The horizontal resize/move arrow shape.
ns_resize = "ns-resize" #: The vertical resize/move arrow shape.
nesw_resize = (
"nesw-resize" #: The top-left to bottom-right diagonal resize/move arrow shape.
)
nwse_resize = (
"nwse-resize" #: The top-right to bottom-left diagonal resize/move arrow shape.
)
not_allowed = "not-allowed" #: The operation-not-allowed shape.
none = "none" #: The cursor is hidden.


class BaseCanvasGroup:
"""Represents a group of canvas objects from the same class, that share a loop."""

Expand Down Expand Up @@ -486,6 +505,22 @@ def set_title(self, title):
title = title.replace("$" + k, v)
self._rc_set_title(title)

def set_cursor(self, cursor):
"""Set the cursor shape for the mouse pointer.

See :obj:`rendercanvas.CursorShape`:
"""
if cursor is None:
cursor = "default"
if not isinstance(cursor, str):
raise TypeError("Canvas cursor must be str.")
cursor = cursor.lower().replace("_", "-")
if cursor not in CursorShape:
raise ValueError(
f"Canvas cursor {cursor!r} not known, must be one of {CursorShape}"
)
self._rc_set_cursor(cursor)

# %% Methods for the subclass to implement

def _rc_gui_poll(self):
Expand Down Expand Up @@ -593,6 +628,13 @@ def _rc_set_title(self, title):
"""
pass

def _rc_set_cursor(self, cursor):
"""Set the cursor shape. May be ignored.

The default implementation does nothing.
"""
pass


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

def set_cursor(self, *args):
self._subwidget.set_cursor(*args)

def close(self):
self._subwidget.close()

Expand Down
36 changes: 36 additions & 0 deletions rendercanvas/glfw.py
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,21 @@
glfw.KEY_RIGHT_SUPER: "Meta",
}

CURSOR_MAP = {
"default": None,
# "arrow": glfw.ARROW_CURSOR, # CSS only has 'default', not 'arrow'
"text": glfw.IBEAM_CURSOR,
"crosshair": glfw.CROSSHAIR_CURSOR,
"pointer": glfw.POINTING_HAND_CURSOR,
"ew-resize": glfw.RESIZE_EW_CURSOR,
"ns-resize": glfw.RESIZE_NS_CURSOR,
"nesw-resize": glfw.RESIZE_NESW_CURSOR,
"nwse-resize": glfw.RESIZE_NWSE_CURSOR,
# "": glfw.RESIZE_ALL_CURSOR, # Looks like 'grabbing' in CSS
"not-allowed": glfw.NOT_ALLOWED_CURSOR,
"none": None, # handled in method
}


def get_glfw_present_methods(window):
if sys.platform.startswith("win"):
Expand Down Expand Up @@ -198,6 +213,7 @@ def __init__(self, *args, present_method=None, **kwargs):
self._changing_pixel_ratio = False
self._is_minimized = False
self._is_in_poll_events = False
self._cursor_object = None

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

def _rc_set_cursor(self, cursor):
if self._cursor_object is not None:
glfw.destroy_cursor(self._cursor_object)
self._cursor_object = None

cursor_flag = CURSOR_MAP.get(cursor)
if cursor == "none":
# Create a custom cursor that's simply empty
image = memoryview(bytearray(8 * 8 * 4))
image = image.cast("B", shape=(8, 8, 4))
image_for_glfw_wrapper = image.shape[1], image.shape[0], image.tolist()
self._cursor_object = glfw.create_cursor(image_for_glfw_wrapper, 0, 0)
elif cursor_flag is None:
# The default (arrow)
self._cursor_object = None
else:
self._cursor_object = glfw.create_standard_cursor(cursor_flag)

glfw.set_cursor(self._window, self._cursor_object)

# %% Turn glfw events into rendercanvas events

def _on_pixelratio_change(self, *args):
Expand Down
3 changes: 3 additions & 0 deletions rendercanvas/jupyter.py
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,9 @@ def _rc_get_closed(self):
def _rc_set_title(self, title):
pass # not supported yet

def _rc_set_cursor(self, cursor):
self.cursor = cursor

# %% Turn jupyter_rfb events into rendercanvas events

def handle_event(self, event):
Expand Down
3 changes: 3 additions & 0 deletions rendercanvas/offscreen.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,9 @@ def _rc_get_closed(self):
def _rc_set_title(self, title):
pass

def _rc_set_cursor(self, cursor):
pass

# %% events - there are no GUI events

# %% Extra API
Expand Down
23 changes: 23 additions & 0 deletions rendercanvas/qt.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
PreciseTimer = QtCore.Qt.TimerType.PreciseTimer
KeyboardModifiers = QtCore.Qt.KeyboardModifier
FocusPolicy = QtCore.Qt.FocusPolicy
CursorShape = QtCore.Qt.CursorShape
Keys = QtCore.Qt.Key
WinIdChange = QtCore.QEvent.Type.WinIdChange
except AttributeError:
Expand All @@ -44,6 +45,7 @@
PreciseTimer = QtCore.Qt.PreciseTimer
KeyboardModifiers = QtCore.Qt
FocusPolicy = QtCore.Qt
CursorShape = QtCore.Qt
Keys = QtCore.Qt
WinIdChange = QtCore.QEvent.WinIdChange
else:
Expand Down Expand Up @@ -125,6 +127,20 @@ def check_qt_libname(expected_libname):
int(Keys.Key_Tab): "Tab",
}

CURSOR_MAP = {
"default": CursorShape.ArrowCursor,
"text": CursorShape.IBeamCursor,
"crosshair": CursorShape.CrossCursor,
"pointer": CursorShape.PointingHandCursor,
"ew-resize": CursorShape.SizeHorCursor,
"ns-resize": CursorShape.SizeVerCursor,
"nesw-resize": CursorShape.SizeBDiagCursor,
"nwse-resize": CursorShape.SizeFDiagCursor,
"not-allowed": CursorShape.ForbiddenCursor,
"none": CursorShape.BlankCursor,
}


BITMAP_FORMAT_MAP = {
"rgba-u8": QtGui.QImage.Format.Format_RGBA8888,
"rgb-u8": QtGui.QImage.Format.Format_RGB888,
Expand Down Expand Up @@ -441,6 +457,13 @@ def _rc_set_title(self, title):
if isinstance(parent, QRenderCanvas):
parent.setWindowTitle(title)

def _rc_set_cursor(self, cursor):
cursor_flag = CURSOR_MAP.get(cursor)
if cursor_flag is None:
self.unsetCursor()
else:
self.setCursor(cursor_flag)

# %% Turn Qt events into rendercanvas events

def _key_event(self, event_type, event):
Expand Down
3 changes: 3 additions & 0 deletions rendercanvas/stub.py
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,9 @@ def _rc_get_closed(self):
def _rc_set_title(self, title):
pass

def _rc_set_cursor(self, cursor):
pass


class ToplevelRenderCanvas(WrapperRenderCanvas):
"""
Expand Down
21 changes: 21 additions & 0 deletions rendercanvas/wx.py
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,19 @@
wx.WXK_TAB: "Tab",
}

CURSOR_MAP = {
"default": None,
"text": wx.CURSOR_IBEAM,
"crosshair": wx.CURSOR_CROSS,
"pointer": wx.CURSOR_HAND,
"ew-resize": wx.CURSOR_SIZEWE,
"ns-resize": wx.CURSOR_SIZENS,
"nesw-resize": wx.CURSOR_SIZENESW,
"nwse-resize": wx.CURSOR_SIZENWSE,
"not-allowed": wx.CURSOR_NO_ENTRY,
"none": wx.CURSOR_BLANK,
}


def enable_hidpi():
"""Enable high-res displays."""
Expand Down Expand Up @@ -356,6 +369,14 @@ def _rc_set_title(self, title):
if isinstance(parent, WxRenderCanvas):
parent.SetTitle(title)

def _rc_set_cursor(self, cursor):
cursor_flag = CURSOR_MAP.get(cursor)
if cursor_flag is None:
self.SetCursor(wx.NullCursor) # System default
else:
cursor_object = wx.Cursor(cursor_flag)
self.SetCursor(cursor_object)

# %% Turn Qt events into rendercanvas events

def _on_resize(self, event: wx.SizeEvent):
Expand Down