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.
@@ -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,12 @@
1
+ from os.path import dirname
2
+
3
+
4
+ HERE = dirname(__file__)
5
+
6
+
7
+ def get_hook_dirs():
8
+ return [HERE]
9
+
10
+
11
+ def get_test_dirs():
12
+ return [HERE]
@@ -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
+ """
@@ -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
@@ -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()