rendercanvas 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- rendercanvas/__init__.py +17 -0
- rendercanvas/__pyinstaller/__init__.py +12 -0
- rendercanvas/__pyinstaller/conftest.py +1 -0
- rendercanvas/__pyinstaller/hook-rendercanvas.py +22 -0
- rendercanvas/__pyinstaller/test_rendercanvas.py +47 -0
- rendercanvas/_context.py +78 -0
- rendercanvas/_coreutils.py +264 -0
- rendercanvas/_events.py +218 -0
- rendercanvas/_loop.py +456 -0
- rendercanvas/_version.py +113 -0
- rendercanvas/asyncio.py +76 -0
- rendercanvas/auto.py +206 -0
- rendercanvas/base.py +568 -0
- rendercanvas/glfw.py +570 -0
- rendercanvas/jupyter.py +150 -0
- rendercanvas/offscreen.py +135 -0
- rendercanvas/pyqt5.py +11 -0
- rendercanvas/pyqt6.py +11 -0
- rendercanvas/pyside2.py +11 -0
- rendercanvas/pyside6.py +11 -0
- rendercanvas/qt.py +588 -0
- rendercanvas/stub.py +120 -0
- rendercanvas/utils/__init__.py +0 -0
- rendercanvas/utils/bitmappresentadapter.py +344 -0
- rendercanvas/utils/bitmaprenderingcontext.py +101 -0
- rendercanvas/utils/cube.py +446 -0
- rendercanvas/wx.py +522 -0
- rendercanvas-1.0.0.dist-info/LICENSE +25 -0
- rendercanvas-1.0.0.dist-info/METADATA +160 -0
- rendercanvas-1.0.0.dist-info/RECORD +32 -0
- rendercanvas-1.0.0.dist-info/WHEEL +4 -0
- rendercanvas-1.0.0.dist-info/entry_points.txt +4 -0
rendercanvas/__init__.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""
|
|
2
|
+
RenderCanvas: one canvas API, multiple backends.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
# ruff: noqa: F401
|
|
6
|
+
|
|
7
|
+
from ._version import __version__, version_info
|
|
8
|
+
from . import _coreutils
|
|
9
|
+
from ._events import EventType
|
|
10
|
+
from .base import BaseRenderCanvas, BaseLoop, BaseTimer
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"BaseRenderCanvas",
|
|
14
|
+
"EventType",
|
|
15
|
+
"BaseLoop",
|
|
16
|
+
"BaseTimer",
|
|
17
|
+
]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
from PyInstaller.utils.conftest import * # noqa: F403
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# ruff: noqa: N999
|
|
2
|
+
|
|
3
|
+
from PyInstaller.utils.hooks import collect_dynamic_libs
|
|
4
|
+
|
|
5
|
+
# Init variables that PyInstaller will pick up.
|
|
6
|
+
hiddenimports = []
|
|
7
|
+
datas = []
|
|
8
|
+
binaries = []
|
|
9
|
+
|
|
10
|
+
# Add modules that are safe to add, i.e. don't pull in dependencies that we don't want.
|
|
11
|
+
hiddenimports += ["rendercanvas.offscreen"]
|
|
12
|
+
|
|
13
|
+
# Since glfw does not have a hook like this, it does not include the glfw binary
|
|
14
|
+
# when freezing. We can solve this with the code below. Makes the binary a bit
|
|
15
|
+
# larger, but only marginally (less than 300kb).
|
|
16
|
+
try:
|
|
17
|
+
import glfw # noqa: F401
|
|
18
|
+
except ImportError:
|
|
19
|
+
pass
|
|
20
|
+
else:
|
|
21
|
+
hiddenimports += ["rendercanvas.glfw"]
|
|
22
|
+
binaries += collect_dynamic_libs("glfw")
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
script = """
|
|
2
|
+
# The script part
|
|
3
|
+
import sys
|
|
4
|
+
import importlib
|
|
5
|
+
|
|
6
|
+
from rendercanvas.auto import RenderCanvas
|
|
7
|
+
|
|
8
|
+
if "glfw" not in RenderCanvas.__name__.lower():
|
|
9
|
+
raise RuntimeError(f"Expected a glfw canvas, got {RenderCanvas.__name__}")
|
|
10
|
+
|
|
11
|
+
# The test part
|
|
12
|
+
if "is_test" in sys.argv:
|
|
13
|
+
included_modules = [
|
|
14
|
+
"rendercanvas.glfw",
|
|
15
|
+
"rendercanvas.offscreen",
|
|
16
|
+
"glfw",
|
|
17
|
+
]
|
|
18
|
+
excluded_modules = [
|
|
19
|
+
"PySide6.QtGui",
|
|
20
|
+
"PyQt6.QtGui",
|
|
21
|
+
]
|
|
22
|
+
for module_name in included_modules:
|
|
23
|
+
importlib.import_module(module_name)
|
|
24
|
+
for module_name in excluded_modules:
|
|
25
|
+
try:
|
|
26
|
+
importlib.import_module(module_name)
|
|
27
|
+
except ModuleNotFoundError:
|
|
28
|
+
continue
|
|
29
|
+
raise RuntimeError(module_name + " is not supposed to be importable.")
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_pyi_rendercanvas(pyi_builder):
|
|
34
|
+
pyi_builder.test_source(script, app_args=["is_test"])
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
# We could also test the script below, but it's not that interesting since it uses direct imports.
|
|
38
|
+
# To safe CI-time we don't actively test it.
|
|
39
|
+
script_qt = """
|
|
40
|
+
import sys
|
|
41
|
+
import importlib
|
|
42
|
+
|
|
43
|
+
import PySide6
|
|
44
|
+
from rendercanvas.qt import RenderCanvas
|
|
45
|
+
|
|
46
|
+
assert "qt" in RenderCanvas.__name__.lower()
|
|
47
|
+
"""
|
rendercanvas/_context.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""
|
|
2
|
+
A stub context implementation for documentation purposes.
|
|
3
|
+
It does actually work, but presents nothing.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import weakref
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def rendercanvas_context_hook(canvas, present_methods):
|
|
10
|
+
"""Hook function to allow ``rendercanvas`` to detect your context implementation.
|
|
11
|
+
|
|
12
|
+
If you make a function with this name available in the module ``your.module``,
|
|
13
|
+
``rendercanvas`` will detect and call this function in order to obtain the canvas object.
|
|
14
|
+
That way, anyone can use ``canvas.get_context("your.module")`` to use your context.
|
|
15
|
+
The arguments are the same as for ``ContextInterface``.
|
|
16
|
+
"""
|
|
17
|
+
return ContextInterface(canvas, present_methods)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ContextInterface:
|
|
21
|
+
"""The interface that a context must implement, to be usable with a ``RenderCanvas``.
|
|
22
|
+
|
|
23
|
+
Arguments:
|
|
24
|
+
canvas (BaseRenderCanvas): the canvas to render to.
|
|
25
|
+
present_methods (dict): The supported present methods of the canvas.
|
|
26
|
+
|
|
27
|
+
The ``present_methods`` dict has a field for each supported present-method. A
|
|
28
|
+
canvas must support either "screen" or "bitmap". It may support both, as well as
|
|
29
|
+
additional (specialized) present methods. Below we list the common methods and
|
|
30
|
+
what fields the subdicts have.
|
|
31
|
+
|
|
32
|
+
* Render method "screen":
|
|
33
|
+
* "window": the native window id.
|
|
34
|
+
* "display": the native display id (Linux only).
|
|
35
|
+
* "platform": to determine between "x11" and "wayland" (Linux only).
|
|
36
|
+
* Render method "bitmap":
|
|
37
|
+
* "formats": a list of supported formats. It should always include "rgba-u8".
|
|
38
|
+
Other options can be be "i-u8" (intensity/grayscale), "i-f32", "bgra-u8", "rgba-u16", etc.
|
|
39
|
+
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
def __init__(self, canvas, present_methods):
|
|
43
|
+
self._canvas_ref = weakref.ref(canvas)
|
|
44
|
+
self._present_methods = present_methods
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def canvas(self):
|
|
48
|
+
"""The associated canvas object. Internally, this should preferably be stored using a weakref."""
|
|
49
|
+
return self._canvas_ref()
|
|
50
|
+
|
|
51
|
+
def present(self):
|
|
52
|
+
"""Present the result to the canvas.
|
|
53
|
+
|
|
54
|
+
This is called by the canvas, and should not be called by user-code.
|
|
55
|
+
|
|
56
|
+
The implementation should always return a present-result dict, which
|
|
57
|
+
should have at least a field 'method'. The value of 'method' must be
|
|
58
|
+
one of the methods that the canvas supports, i.e. it must be in ``present_methods``.
|
|
59
|
+
|
|
60
|
+
* If there is nothing to present, e.g. because nothing was rendered yet:
|
|
61
|
+
* return ``{"method": "skip"}`` (special case).
|
|
62
|
+
* If presentation could not be done for some reason:
|
|
63
|
+
* return ``{"method": "fail", "message": "xx"}`` (special case).
|
|
64
|
+
* If ``present_method`` is "screen":
|
|
65
|
+
* Render to screen using the info in ``present_methods['screen']``).
|
|
66
|
+
* Return ``{"method", "screen"}`` as confirmation.
|
|
67
|
+
* If ``present_method`` is "bitmap":
|
|
68
|
+
* Return ``{"method": "bitmap", "data": data, "format": format}``.
|
|
69
|
+
* 'data' is a memoryview, or something that can be converted to a memoryview, like a numpy array.
|
|
70
|
+
* 'format' is the format of the bitmap, must be in ``present_methods['bitmap']['formats']`` ("rgba-u8" is always supported).
|
|
71
|
+
* If ``present_method`` is something else:
|
|
72
|
+
* Return ``{"method": "xx", ...}``.
|
|
73
|
+
* It's the responsibility of the context to use a render method that is supported by the canvas,
|
|
74
|
+
and that the appropriate arguments are supplied.
|
|
75
|
+
"""
|
|
76
|
+
|
|
77
|
+
# This is a stub
|
|
78
|
+
return {"method": "skip"}
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Core utilities that are loaded into the root namespace or used internally.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
import sys
|
|
8
|
+
import types
|
|
9
|
+
import weakref
|
|
10
|
+
import logging
|
|
11
|
+
import ctypes.util
|
|
12
|
+
from contextlib import contextmanager
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
# %% Logging
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger("rendercanvas")
|
|
19
|
+
logger.setLevel(logging.WARNING)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
err_hashes = {}
|
|
23
|
+
|
|
24
|
+
_re_wgpu_ob = re.compile(r"`<[a-z|A-Z]+-\([0-9]+, [0-9]+, [a-z|A-Z]+\)>`")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def error_message_hash(message):
|
|
28
|
+
# Remove wgpu object representations, because they contain id's that may change at each draw.
|
|
29
|
+
# E.g. `<CommandBuffer- (12, 4, Metal)>`
|
|
30
|
+
message = _re_wgpu_ob.sub("WGPU_OBJECT", message)
|
|
31
|
+
return hash(message)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@contextmanager
|
|
35
|
+
def log_exception(kind):
|
|
36
|
+
"""Context manager to log any exceptions, but only log a one-liner
|
|
37
|
+
for subsequent occurrences of the same error to avoid spamming by
|
|
38
|
+
repeating errors in e.g. a draw function or event callback.
|
|
39
|
+
"""
|
|
40
|
+
try:
|
|
41
|
+
yield
|
|
42
|
+
except Exception as err:
|
|
43
|
+
# Store exc info for postmortem debugging
|
|
44
|
+
exc_info = list(sys.exc_info())
|
|
45
|
+
exc_info[2] = exc_info[2].tb_next # skip *this* function
|
|
46
|
+
sys.last_type, sys.last_value, sys.last_traceback = exc_info
|
|
47
|
+
# Show traceback, or a one-line summary
|
|
48
|
+
msg = str(err)
|
|
49
|
+
msgh = error_message_hash(msg)
|
|
50
|
+
if msgh not in err_hashes:
|
|
51
|
+
# Provide the exception, so the default logger prints a stacktrace.
|
|
52
|
+
# IDE's can get the exception from the root logger for PM debugging.
|
|
53
|
+
err_hashes[msgh] = 1
|
|
54
|
+
logger.error(kind, exc_info=err)
|
|
55
|
+
else:
|
|
56
|
+
# We've seen this message before, return a one-liner instead.
|
|
57
|
+
err_hashes[msgh] = count = err_hashes[msgh] + 1
|
|
58
|
+
msg = kind + ": " + msg.split("\n")[0].strip()
|
|
59
|
+
msg = msg if len(msg) <= 70 else msg[:69] + "…"
|
|
60
|
+
logger.error(msg + f" ({count})")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
# %% Weak bindings
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def weakbind(method):
|
|
67
|
+
"""Replace a bound method with a callable object that stores the `self` using a weakref."""
|
|
68
|
+
ref = weakref.ref(method.__self__)
|
|
69
|
+
class_func = method.__func__
|
|
70
|
+
del method
|
|
71
|
+
|
|
72
|
+
def proxy(*args, **kwargs):
|
|
73
|
+
self = ref()
|
|
74
|
+
if self is not None:
|
|
75
|
+
return class_func(self, *args, **kwargs)
|
|
76
|
+
|
|
77
|
+
proxy.__name__ = class_func.__name__
|
|
78
|
+
return proxy
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
# %% Enum
|
|
82
|
+
|
|
83
|
+
# We implement a custom enum class that's much simpler than Python's enum.Enum,
|
|
84
|
+
# and simply maps to strings or ints. The enums are classes, so IDE's provide
|
|
85
|
+
# autocompletion, and documenting with Sphinx is easy. That does mean we need a
|
|
86
|
+
# metaclass though.
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class EnumType(type):
|
|
90
|
+
"""Metaclass for enums and flags."""
|
|
91
|
+
|
|
92
|
+
def __new__(cls, name, bases, dct):
|
|
93
|
+
# Collect and check fields
|
|
94
|
+
member_map = {}
|
|
95
|
+
for key, val in dct.items():
|
|
96
|
+
if not key.startswith("_"):
|
|
97
|
+
val = key if val is None else val
|
|
98
|
+
if not isinstance(val, (int, str)):
|
|
99
|
+
raise TypeError("Enum fields must be str or int.")
|
|
100
|
+
member_map[key] = val
|
|
101
|
+
# Some field values may have been updated
|
|
102
|
+
dct.update(member_map)
|
|
103
|
+
# Create class
|
|
104
|
+
klass = super().__new__(cls, name, bases, dct)
|
|
105
|
+
# Attach some fields
|
|
106
|
+
klass.__fields__ = tuple(member_map)
|
|
107
|
+
klass.__members__ = types.MappingProxyType(member_map) # enums.Enum compat
|
|
108
|
+
# Create bound methods
|
|
109
|
+
for name in ["__dir__", "__iter__", "__getitem__", "__setattr__", "__repr__"]:
|
|
110
|
+
setattr(klass, name, types.MethodType(getattr(cls, name), klass))
|
|
111
|
+
return klass
|
|
112
|
+
|
|
113
|
+
def __dir__(cls):
|
|
114
|
+
# Support dir(enum). Note that this order matches the definition, but dir() makes it alphabetic.
|
|
115
|
+
return cls.__fields__
|
|
116
|
+
|
|
117
|
+
def __iter__(cls):
|
|
118
|
+
# Support list(enum), iterating over the enum, and doing ``x in enum``.
|
|
119
|
+
return iter([getattr(cls, key) for key in cls.__fields__])
|
|
120
|
+
|
|
121
|
+
def __getitem__(cls, key):
|
|
122
|
+
# Support enum[key]
|
|
123
|
+
return cls.__dict__[key]
|
|
124
|
+
|
|
125
|
+
def __repr__(cls):
|
|
126
|
+
if cls is BaseEnum:
|
|
127
|
+
return "<rendercanvas.BaseEnum>"
|
|
128
|
+
pkg = cls.__module__.split(".")[0]
|
|
129
|
+
name = cls.__name__
|
|
130
|
+
options = []
|
|
131
|
+
for key in cls.__fields__:
|
|
132
|
+
val = cls[key]
|
|
133
|
+
options.append(f"'{key}' ({val})" if isinstance(val, int) else f"'{val}'")
|
|
134
|
+
return f"<{pkg}.{name} enum with options: {', '.join(options)}>"
|
|
135
|
+
|
|
136
|
+
def __setattr__(cls, name, value):
|
|
137
|
+
if name.startswith("_"):
|
|
138
|
+
super().__setattr__(name, value)
|
|
139
|
+
else:
|
|
140
|
+
raise RuntimeError("Cannot set values on an enum.")
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
class BaseEnum(metaclass=EnumType):
|
|
144
|
+
"""Base class for flags and enums.
|
|
145
|
+
|
|
146
|
+
Looks like Python's builtin Enum class, but is simpler; fields are simply ints or strings.
|
|
147
|
+
"""
|
|
148
|
+
|
|
149
|
+
def __init__(self):
|
|
150
|
+
raise RuntimeError("Cannot instantiate an enum.")
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
# %% lib support
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
QT_MODULE_NAMES = ["PySide6", "PyQt6", "PySide2", "PyQt5"]
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def select_qt_lib():
|
|
160
|
+
"""Select the qt lib to use, used by qt.py"""
|
|
161
|
+
# Check the override. This env var is meant for internal use only.
|
|
162
|
+
# Otherwise check imported libs.
|
|
163
|
+
|
|
164
|
+
libname = os.getenv("_RENDERCANVAS_QT_LIB")
|
|
165
|
+
if libname:
|
|
166
|
+
return libname, qt_lib_has_app(libname)
|
|
167
|
+
else:
|
|
168
|
+
return get_imported_qt_lib()
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def get_imported_qt_lib():
|
|
172
|
+
"""Get the name of the currently imported qt lib.
|
|
173
|
+
|
|
174
|
+
Returns (name, has_application). The name is None when no qt lib is currently imported.
|
|
175
|
+
"""
|
|
176
|
+
|
|
177
|
+
# Get all imported qt libs
|
|
178
|
+
imported_libs = []
|
|
179
|
+
for libname in QT_MODULE_NAMES:
|
|
180
|
+
qtlib = sys.modules.get(libname, None)
|
|
181
|
+
if qtlib is not None:
|
|
182
|
+
imported_libs.append(libname)
|
|
183
|
+
|
|
184
|
+
# Get which of these have an application object
|
|
185
|
+
imported_libs_with_app = [
|
|
186
|
+
libname for libname in imported_libs if qt_lib_has_app(libname)
|
|
187
|
+
]
|
|
188
|
+
|
|
189
|
+
# Return findings
|
|
190
|
+
if imported_libs_with_app:
|
|
191
|
+
return imported_libs_with_app[0], True
|
|
192
|
+
elif imported_libs:
|
|
193
|
+
return imported_libs[0], False
|
|
194
|
+
else:
|
|
195
|
+
return None, False
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def qt_lib_has_app(libname):
|
|
199
|
+
QtWidgets = sys.modules.get(libname + ".QtWidgets", None) # noqa: N806
|
|
200
|
+
if QtWidgets:
|
|
201
|
+
app = QtWidgets.QApplication.instance()
|
|
202
|
+
return app is not None
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def asyncio_is_running():
|
|
206
|
+
"""Get whether there is currently a running asyncio loop."""
|
|
207
|
+
asyncio = sys.modules.get("asyncio", None)
|
|
208
|
+
if asyncio is None:
|
|
209
|
+
return False
|
|
210
|
+
try:
|
|
211
|
+
loop = asyncio.get_running_loop()
|
|
212
|
+
except Exception:
|
|
213
|
+
loop = None
|
|
214
|
+
return loop is not None
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
# %% Linux window managers
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
SYSTEM_IS_WAYLAND = "wayland" in os.getenv("XDG_SESSION_TYPE", "").lower()
|
|
221
|
+
|
|
222
|
+
if sys.platform.startswith("linux") and SYSTEM_IS_WAYLAND:
|
|
223
|
+
# Force glfw to use X11. Note that this does not work if glfw is already imported.
|
|
224
|
+
if "glfw" not in sys.modules:
|
|
225
|
+
os.environ["PYGLFW_LIBRARY_VARIANT"] = "x11"
|
|
226
|
+
# Force Qt to use X11. Qt is more flexible - it ok if e.g. PySide6 is already imported.
|
|
227
|
+
os.environ["QT_QPA_PLATFORM"] = "xcb"
|
|
228
|
+
# Force wx to use X11, probably.
|
|
229
|
+
os.environ["GDK_BACKEND"] = "x11"
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
_x11_display = None
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def get_alt_x11_display():
|
|
236
|
+
"""Get (the pointer to) a process-global x11 display instance."""
|
|
237
|
+
# Ideally we'd get the real display object used by the backend.
|
|
238
|
+
# But this is not always possible. In that case, using an alt display
|
|
239
|
+
# object can be used.
|
|
240
|
+
global _x11_display
|
|
241
|
+
assert sys.platform.startswith("linux")
|
|
242
|
+
if _x11_display is None:
|
|
243
|
+
x11 = ctypes.CDLL(ctypes.util.find_library("X11"))
|
|
244
|
+
x11.XOpenDisplay.restype = ctypes.c_void_p
|
|
245
|
+
_x11_display = x11.XOpenDisplay(None)
|
|
246
|
+
return _x11_display
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
_wayland_display = None
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def get_alt_wayland_display():
|
|
253
|
+
"""Get (the pointer to) a process-global Wayland display instance."""
|
|
254
|
+
# Ideally we'd get the real display object used by the backend.
|
|
255
|
+
# This creates a global object, similar to what we do for X11.
|
|
256
|
+
# Unfortunately, this segfaults, so it looks like the real display object
|
|
257
|
+
# is needed? Leaving this here for reference.
|
|
258
|
+
global _wayland_display
|
|
259
|
+
assert sys.platform.startswith("linux")
|
|
260
|
+
if _wayland_display is None:
|
|
261
|
+
wl = ctypes.CDLL(ctypes.util.find_library("wayland-client"))
|
|
262
|
+
wl.wl_display_connect.restype = ctypes.c_void_p
|
|
263
|
+
_wayland_display = wl.wl_display_connect(None)
|
|
264
|
+
return _wayland_display
|
rendercanvas/_events.py
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
"""
|
|
2
|
+
The event system.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
from collections import defaultdict, deque
|
|
7
|
+
|
|
8
|
+
from ._coreutils import log_exception, BaseEnum
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class EventType(BaseEnum):
|
|
12
|
+
"""The EventType enum specifies the possible events for a RenderCanvas.
|
|
13
|
+
|
|
14
|
+
This includes the events from the jupyter_rfb event spec (see
|
|
15
|
+
https://jupyter-rfb.readthedocs.io/en/stable/events.html) plus some
|
|
16
|
+
rendercanvas-specific events.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
# Jupter_rfb spec
|
|
20
|
+
|
|
21
|
+
resize = None #: The canvas has changed size. Has 'width' and 'height' in logical pixels, 'pixel_ratio'.
|
|
22
|
+
close = None #: The canvas is closed. No additional fields.
|
|
23
|
+
pointer_down = None #: The pointing device is pressed down. Has 'x', 'y', 'button', 'butons', 'modifiers', 'ntouches', 'touches'.
|
|
24
|
+
pointer_up = None #: The pointing device is released. Same fields as pointer_down.
|
|
25
|
+
pointer_move = None #: The pointing device is moved. Same fields as pointer_down.
|
|
26
|
+
double_click = None #: A double-click / long-tap. This event looks like a pointer event, but without the touches.
|
|
27
|
+
wheel = None #: The mouse-wheel is used (scrolling), or the touchpad/touchscreen is scrolled/pinched. Has 'dx', 'dy', 'x', 'y', 'modifiers'.
|
|
28
|
+
key_down = None #: A key is pressed down. Has 'key', 'modifiers'.
|
|
29
|
+
key_up = None #: A key is released. Has 'key', 'modifiers'.
|
|
30
|
+
|
|
31
|
+
# Pending for the spec, may become part of key_down/key_up
|
|
32
|
+
char = None #: Experimental
|
|
33
|
+
|
|
34
|
+
# Our extra events
|
|
35
|
+
|
|
36
|
+
before_draw = (
|
|
37
|
+
None #: Event emitted right before a draw is performed. Has no extra fields.
|
|
38
|
+
)
|
|
39
|
+
animate = None #: Animation event. Has 'step' representing the step size in seconds. This is stable, except when the 'catch_up' field is nonzero.
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class EventEmitter:
|
|
43
|
+
"""The EventEmitter stores event handlers, collects incoming events, and dispatched them.
|
|
44
|
+
|
|
45
|
+
Subsequent events of ``event_type`` 'pointer_move' and 'wheel' are merged.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
_EVENTS_THAT_MERGE = {
|
|
49
|
+
"pointer_move": {
|
|
50
|
+
"match_keys": {"buttons", "modifiers", "ntouches"},
|
|
51
|
+
"accum_keys": {},
|
|
52
|
+
},
|
|
53
|
+
"wheel": {
|
|
54
|
+
"match_keys": {"modifiers"},
|
|
55
|
+
"accum_keys": {"dx", "dy"},
|
|
56
|
+
},
|
|
57
|
+
"resize": {
|
|
58
|
+
"match_keys": {},
|
|
59
|
+
"accum_keys": {},
|
|
60
|
+
},
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
def __init__(self):
|
|
64
|
+
self._pending_events = deque()
|
|
65
|
+
self._event_handlers = defaultdict(list)
|
|
66
|
+
self._closed = False
|
|
67
|
+
|
|
68
|
+
def add_handler(self, *args, order: float = 0):
|
|
69
|
+
"""Register an event handler to receive events.
|
|
70
|
+
|
|
71
|
+
Arguments:
|
|
72
|
+
callback (callable): The event handler. Must accept a single event argument.
|
|
73
|
+
*types (list of strings): A list of event types.
|
|
74
|
+
order (float): Set callback priority order. Callbacks with lower priorities
|
|
75
|
+
are called first. Default is 0.
|
|
76
|
+
|
|
77
|
+
For the available events, see
|
|
78
|
+
https://jupyter-rfb.readthedocs.io/en/stable/events.html.
|
|
79
|
+
|
|
80
|
+
When an event is emitted, callbacks with the same priority are called in
|
|
81
|
+
the order that they were added.
|
|
82
|
+
|
|
83
|
+
The callback is stored, so it can be a lambda or closure. This also
|
|
84
|
+
means that if a method is given, a reference to the object is held,
|
|
85
|
+
which may cause circular references or prevent the Python GC from
|
|
86
|
+
destroying that object.
|
|
87
|
+
|
|
88
|
+
Example:
|
|
89
|
+
|
|
90
|
+
.. code-block:: py
|
|
91
|
+
|
|
92
|
+
def my_handler(event):
|
|
93
|
+
print(event)
|
|
94
|
+
|
|
95
|
+
canvas.add_event_handler(my_handler, "pointer_up", "pointer_down")
|
|
96
|
+
|
|
97
|
+
Can also be used as a decorator:
|
|
98
|
+
|
|
99
|
+
.. code-block:: py
|
|
100
|
+
|
|
101
|
+
@canvas.add_event_handler("pointer_up", "pointer_down") def
|
|
102
|
+
my_handler(event):
|
|
103
|
+
print(event)
|
|
104
|
+
|
|
105
|
+
Catch 'm all:
|
|
106
|
+
|
|
107
|
+
.. code-block:: py
|
|
108
|
+
|
|
109
|
+
canvas.add_event_handler(my_handler, "*")
|
|
110
|
+
|
|
111
|
+
"""
|
|
112
|
+
order = float(order)
|
|
113
|
+
decorating = not callable(args[0])
|
|
114
|
+
callback = None if decorating else args[0]
|
|
115
|
+
types = args if decorating else args[1:]
|
|
116
|
+
|
|
117
|
+
if not types:
|
|
118
|
+
raise TypeError("No event types are given to add_event_handler.")
|
|
119
|
+
for type in types:
|
|
120
|
+
if not isinstance(type, str):
|
|
121
|
+
raise TypeError(f"Event types must be str, but got {type}")
|
|
122
|
+
if not (type == "*" or type in EventType):
|
|
123
|
+
raise ValueError(f"Adding handler with invalid event_type: '{type}'")
|
|
124
|
+
|
|
125
|
+
def decorator(_callback):
|
|
126
|
+
self._add_handler(_callback, order, *types)
|
|
127
|
+
return _callback
|
|
128
|
+
|
|
129
|
+
if decorating:
|
|
130
|
+
return decorator
|
|
131
|
+
return decorator(callback)
|
|
132
|
+
|
|
133
|
+
def _add_handler(self, callback, order, *types):
|
|
134
|
+
self.remove_handler(callback, *types)
|
|
135
|
+
for type in types:
|
|
136
|
+
self._event_handlers[type].append((order, callback))
|
|
137
|
+
self._event_handlers[type].sort(key=lambda x: x[0])
|
|
138
|
+
# Note: that sort is potentially expensive. I tried an approach with a custom dequeu to add the handler
|
|
139
|
+
# at the correct position, but the overhead was apparently larger than the benefit of avoiding sort.
|
|
140
|
+
|
|
141
|
+
def remove_handler(self, callback, *types):
|
|
142
|
+
"""Unregister an event handler.
|
|
143
|
+
|
|
144
|
+
Arguments:
|
|
145
|
+
callback (callable): The event handler.
|
|
146
|
+
*types (list of strings): A list of event types.
|
|
147
|
+
"""
|
|
148
|
+
for type in types:
|
|
149
|
+
self._event_handlers[type] = [
|
|
150
|
+
(o, cb) for o, cb in self._event_handlers[type] if cb is not callback
|
|
151
|
+
]
|
|
152
|
+
|
|
153
|
+
def submit(self, event):
|
|
154
|
+
"""Submit an event.
|
|
155
|
+
|
|
156
|
+
Events are emitted later by the scheduler.
|
|
157
|
+
"""
|
|
158
|
+
event_type = event["event_type"]
|
|
159
|
+
if event_type not in EventType:
|
|
160
|
+
raise ValueError(f"Submitting with invalid event_type: '{event_type}'")
|
|
161
|
+
if event_type == "close":
|
|
162
|
+
self._closed = True
|
|
163
|
+
|
|
164
|
+
event.setdefault("time_stamp", time.perf_counter())
|
|
165
|
+
event_merge_info = self._EVENTS_THAT_MERGE.get(event_type, None)
|
|
166
|
+
|
|
167
|
+
if event_merge_info and self._pending_events:
|
|
168
|
+
# Try merging the event with the last one
|
|
169
|
+
last_event = self._pending_events[-1]
|
|
170
|
+
if last_event["event_type"] == event_type:
|
|
171
|
+
match_keys = event_merge_info["match_keys"]
|
|
172
|
+
accum_keys = event_merge_info["accum_keys"]
|
|
173
|
+
if all(
|
|
174
|
+
event.get(key, None) == last_event.get(key, None)
|
|
175
|
+
for key in match_keys
|
|
176
|
+
):
|
|
177
|
+
# Merge-able event
|
|
178
|
+
self._pending_events.pop() # remove last_event
|
|
179
|
+
# Update new event
|
|
180
|
+
for key in accum_keys:
|
|
181
|
+
event[key] += last_event[key]
|
|
182
|
+
|
|
183
|
+
self._pending_events.append(event)
|
|
184
|
+
|
|
185
|
+
def flush(self):
|
|
186
|
+
"""Dispatch all pending events.
|
|
187
|
+
|
|
188
|
+
This should generally be left to the scheduler.
|
|
189
|
+
"""
|
|
190
|
+
while True:
|
|
191
|
+
try:
|
|
192
|
+
event = self._pending_events.popleft()
|
|
193
|
+
except IndexError:
|
|
194
|
+
break
|
|
195
|
+
self.emit(event)
|
|
196
|
+
|
|
197
|
+
def emit(self, event):
|
|
198
|
+
"""Directly emit the given event.
|
|
199
|
+
|
|
200
|
+
In most cases events should be submitted, so that they are flushed
|
|
201
|
+
with the rest at a good time.
|
|
202
|
+
"""
|
|
203
|
+
# Collect callbacks
|
|
204
|
+
event_type = event.get("event_type")
|
|
205
|
+
callbacks = self._event_handlers[event_type] + self._event_handlers["*"]
|
|
206
|
+
# Dispatch
|
|
207
|
+
for _order, callback in callbacks:
|
|
208
|
+
if event.get("stop_propagation", False):
|
|
209
|
+
break
|
|
210
|
+
with log_exception(f"Error during handling {event_type} event"):
|
|
211
|
+
callback(event)
|
|
212
|
+
|
|
213
|
+
def _rc_close(self):
|
|
214
|
+
"""Wrap up when the scheduler detects the canvas is closed/dead."""
|
|
215
|
+
# This is a little feature because detecting a widget from closing can be tricky.
|
|
216
|
+
if not self._closed:
|
|
217
|
+
self.submit({"event_type": "close"})
|
|
218
|
+
self.flush()
|