diff --git a/rendercanvas/_coreutils.py b/rendercanvas/_coreutils.py index 9114462..989889c 100644 --- a/rendercanvas/_coreutils.py +++ b/rendercanvas/_coreutils.py @@ -6,10 +6,19 @@ import re import sys import time +import queue import weakref import logging +import threading import ctypes.util from contextlib import contextmanager +from collections import namedtuple + + +# %% Constants + + +IS_WIN = sys.platform.startswith("win") # Note that IS_WIN is false on Pyodide # %% Logging @@ -93,6 +102,122 @@ def proxy(*args, **kwargs): return proxy +# %% Helper for scheduling call-laters + + +class CallLaterThread(threading.Thread): + """An object that can be used to do "call later" from a dedicated thread. + + Care is taken to realize precise timing, so it can be used to implement + precise sleeping and call_later on Windows (to overcome Windows' notorious + 15.6ms ticks). + """ + + Item = namedtuple("Item", ["time", "index", "callback", "args"]) + + def __init__(self): + super().__init__() + self._queue = queue.SimpleQueue() + self._count = 0 + self.daemon = True # don't let this thread prevent shutdown + self.start() + + def call_later_from_thread(self, delay, callback, *args): + """In delay seconds, call the callback from the scheduling thread.""" + self._count += 1 + item = CallLaterThread.Item( + time.perf_counter() + float(delay), self._count, callback, args + ) + self._queue.put(item) + + def run(self): + perf_counter = time.perf_counter + Empty = queue.Empty # noqa: N806 + q = self._queue + priority = [] + is_win = IS_WIN + + wait_until = None + timestep = 0.001 # for doing small sleeps + leeway = timestep / 2 # a little offset so waiting exactly right on average + + while True: + # == Wait for input + + if wait_until is None: + # Nothing to do but wait + new_item = q.get(True, None) + else: + # We wait for the queue with a timeout. But because the timeout is not very precise, + # we wait shorter, and then go in a loop with some hard sleeps. + # Windows has 15.6 ms resolution ticks. But also on other OSes, + # it benefits precision to do the last bit with hard sleeps. + offset = 0.016 if is_win else 0.004 + try: + new_item = q.get(True, max(0, wait_until - perf_counter() - offset)) + except Empty: + new_item = None + while perf_counter() < wait_until: + time.sleep(timestep) + try: + new_item = q.get_nowait() + break + except Empty: + pass + + # Put it in our priority queue + if new_item is not None: + priority.append(new_item) + priority.sort(reverse=True) + + del new_item + + # == Process items until we have to wait + + item = None + while True: + # Get item that is up next + try: + item = priority.pop(-1) + except IndexError: + wait_until = None + break + + # If it's not yet time for the item, put it back, and go wait + item_time_threshold = item.time - leeway + if perf_counter() < item_time_threshold: + priority.append(item) + wait_until = item_time_threshold + break + + # Otherwise, handle the callback + try: + item.callback(*item.args) + except Exception as err: + logger.error(f"Error in CallLaterThread callback: {err}") + + del item + + +_call_later_thread = None + + +def call_later_from_thread(delay: float, callback: object, *args: object): + """Utility that calls a callback after a specified delay, from a separate thread. + + The caller is responsible for the given callback to be thread-safe. + There is one global thread that handles all callbacks. This thread is spawned the first time + that this function is called. + + Note that this function should only be used in environments where threading is available. + E.g. on Pyodide this will raise ``RuntimeError: can't start new thread``. + """ + global _call_later_thread + if _call_later_thread is None: + _call_later_thread = CallLaterThread() + return _call_later_thread.call_later_from_thread(delay, callback, *args) + + # %% lib support diff --git a/rendercanvas/_scheduler.py b/rendercanvas/_scheduler.py index 5185318..1a196f8 100644 --- a/rendercanvas/_scheduler.py +++ b/rendercanvas/_scheduler.py @@ -2,7 +2,6 @@ The scheduler class/loop. """ -import sys import time import weakref @@ -10,9 +9,6 @@ from .utils.asyncs import sleep, Event -IS_WIN = sys.platform.startswith("win") - - class Scheduler: """Helper class to schedule event processing and drawing.""" @@ -121,20 +117,9 @@ async def __scheduler_task(self): # Determine amount of sleep sleep_time = delay - (time.perf_counter() - last_tick_time) - if IS_WIN: - # On Windows OS-level timers have an in accuracy of 15.6 ms. - # This can cause sleep to take longer than intended. So we sleep - # less, and then do a few small sync-sleeps that have high accuracy. - await sleep(max(0, sleep_time - 0.0156)) - sleep_time = delay - (time.perf_counter() - last_tick_time) - while sleep_time > 0: - time.sleep(min(sleep_time, 0.001)) # sleep hard for at most 1ms - await sleep(0) # Allow other tasks to run but don't wait - sleep_time = delay - (time.perf_counter() - last_tick_time) - else: - # Wait. Even if delay is zero, it gives control back to the loop, - # allowing other tasks to do work. - await sleep(max(0, sleep_time)) + # Wait. Even if delay is zero, it gives control back to the loop, + # allowing other tasks to do work. + await sleep(max(0, sleep_time)) # Below is the "tick" diff --git a/rendercanvas/contexts/bitmapcontext.py b/rendercanvas/contexts/bitmapcontext.py index 3a5a5c0..879d1d7 100644 --- a/rendercanvas/contexts/bitmapcontext.py +++ b/rendercanvas/contexts/bitmapcontext.py @@ -36,7 +36,7 @@ def set_bitmap(self, bitmap): """Set the rendered bitmap image. Call this in the draw event. The bitmap must be an object that can be - conveted to a memoryview, like a numpy array. It must represent a 2D + converted to a memoryview, like a numpy array. It must represent a 2D image in either grayscale or rgba format, with uint8 values """ diff --git a/rendercanvas/contexts/wgpucontext.py b/rendercanvas/contexts/wgpucontext.py index 8d9f96a..745172f 100644 --- a/rendercanvas/contexts/wgpucontext.py +++ b/rendercanvas/contexts/wgpucontext.py @@ -1,3 +1,4 @@ +import time from typing import Sequence from .basecontext import BaseContext @@ -99,7 +100,6 @@ def configure( # "tone_mapping": tone_mapping, "alpha_mode": alpha_mode, } - # Let subclass finnish the configuration, then store the config self._configure(config) self._config = config @@ -189,11 +189,28 @@ def __init__(self, present_info: dict): # The last used texture self._texture = None + # A ring-buffer to download the rendered images to the CPU/RAM. The + # image is first copied from the texture to an available copy-buffer. + # This is very fast (which is why we don't have a ring of textures). + # Mapping the buffers to RAM takes time, and we want to wait for this + # asynchronously. + # + # I feel that using just one buffer is sufficient. Adding more costs + # memory, and does not necessarily improve the FPS. It can actually + # strain the GPU more, because it would be busy mapping multiple buffers + # at once. I leave the ring-mechanism in-place for now, so we can + # experiment with it. + self._downloaders = [None] # Put as many None's as you want buffers + def _get_capabilities(self): """Get dict of capabilities and cache the result.""" import wgpu + # Store usage flags now that we have the wgpu namespace + self._our_texture_usage = wgpu.TextureUsage.COPY_SRC + self._our_buffer_usage = wgpu.BufferUsage.COPY_DST | wgpu.BufferUsage.MAP_READ + capabilities = {} # Query format capabilities from the info provided by the canvas @@ -260,8 +277,14 @@ def _configure(self, config: dict): f"Configure: unsupported alpha-mode: {alpha_mode} not in {cap_alpha_modes}" ) + # (re)create downloaders + self._downloaders[:] = [ + ImageDownloader(config["device"], self._our_buffer_usage) + ] + def _unconfigure(self) -> None: self._drop_texture() + self._downloaders[:] = [None for _ in self._downloaders] def _get_current_texture(self): # When the texture is active right now, we could either: @@ -271,8 +294,6 @@ def _get_current_texture(self): # Right now we return the existing texture, so user can retrieve it in different render passes that write to the same frame. if self._texture is None: - import wgpu - width, height = self.physical_size width, height = max(width, 1), max(height, 1) @@ -283,7 +304,7 @@ def _get_current_texture(self): label="present", size=(width, height, 1), format=self._config["format"], - usage=self._config["usage"] | wgpu.TextureUsage.COPY_SRC, + usage=self._config["usage"] | self._our_texture_usage, ) return self._texture @@ -292,17 +313,56 @@ def _rc_present(self) -> None: if not self._texture: return {"method": "skip"} - bitmap = self._get_bitmap() + # TODO: in some cases, like offscreen backend, we don't want to skip the first frame! + + # Get bitmap from oldest downloader + bitmap = None + downloader = self._downloaders.pop(0) + try: + bitmap = downloader.get_bitmap() + finally: + self._downloaders.append(downloader) + + # Select new downloader + downloader = self._downloaders[-1] + downloader.initiate_download(self._texture) + self._drop_texture() - return {"method": "bitmap", "format": "rgba-u8", "data": bitmap} + if bitmap is None: + return {"method": "skip"} + else: + return {"method": "bitmap", "format": "rgba-u8", "data": bitmap} + + def _rc_close(self): + self._drop_texture() + + +class ImageDownloader: + """A helper class that wraps a copy-buffer to async-download an image from a texture.""" + + def __init__(self, device, buffer_usage): + self._device = device + self._buffer_usage = buffer_usage + self._buffer = None + self._time = 0 + + def initiate_download(self, texture): + # TODO: assert not waiting - def _get_bitmap(self): - texture = self._texture - device = texture._device + self._parse_texture_metadata(texture) + nbytes = self._padded_stride * self._texture_size[1] + self._ensure_size(nbytes) + self._copy_texture(texture) + # Note: the buffer.map_async() method by default also does a flush, to hide a bug in wgpu-core (https://github.com/gfx-rs/wgpu/issues/5173). + # That bug does not affect this use-case, so we use a special (undocumented :/) map-mode to prevent wgpu-py from doing its sync thing. + self._awaitable = self._buffer.map_async("READ_NOSYNC", 0, nbytes) + + def _parse_texture_metadata(self, texture): size = texture.size format = texture.format nchannels = 4 # we expect rgba or bgra + if not format.startswith(("rgba", "bgra")): raise RuntimeError(f"Image present unsupported texture format {format}.") if "8" in format: @@ -316,21 +376,6 @@ def _get_bitmap(self): f"Image present unsupported texture format bitdepth {format}." ) - data = device.queue.read_texture( - { - "texture": texture, - "mip_level": 0, - "origin": (0, 0, 0), - }, - { - "offset": 0, - "bytes_per_row": bytes_per_pixel * size[0], - "rows_per_image": size[1], - }, - size, - ) - - # Derive struct dtype from wgpu texture format memoryview_type = "B" if "float" in format: memoryview_type = "e" if "16" in format else "f" @@ -344,10 +389,107 @@ def _get_bitmap(self): if "sint" in format: memoryview_type = memoryview_type.lower() - # Represent as memory object to avoid numpy dependency - # Equivalent: np.frombuffer(data, np.uint8).reshape(size[1], size[0], nchannels) + plain_stride = bytes_per_pixel * size[0] + extra_stride = (256 - plain_stride % 256) % 256 + padded_stride = plain_stride + extra_stride + + self._memoryview_type = memoryview_type + self._nchannels = nchannels + self._plain_stride = plain_stride + self._padded_stride = padded_stride + self._texture_size = size + + def _ensure_size(self, required_size): + # Get buffer and decide whether we can still use it + buffer = self._buffer + if buffer is None: + pass # No buffer + elif required_size > buffer.size: + buffer = None # Buffer too small + elif required_size < 0.25 * buffer.size: + buffer = None # Buffer too large + elif required_size > 0.75 * buffer.size: + self._time = time.perf_counter() # Size is fine + elif time.perf_counter() - self._time > 5.0: + buffer = None # Too large too long + + # Create a new buffer if we need one + if buffer is None: + buffer_size = required_size + buffer_size += (4096 - buffer_size % 4096) % 4096 + self._buffer = self._device.create_buffer( + label="copy-buffer", size=buffer_size, usage=self._buffer_usage + ) - return data.cast(memoryview_type, (size[1], size[0], nchannels)) + def _copy_texture(self, texture): + source = { + "texture": texture, + "mip_level": 0, + "origin": (0, 0, 0), + } - def _rc_close(self): - self._drop_texture() + destination = { + "buffer": self._buffer, + "offset": 0, + "bytes_per_row": self._padded_stride, + "rows_per_image": self._texture_size[1], + } + + # Copy data to temp buffer + encoder = self._device.create_command_encoder() + encoder.copy_texture_to_buffer(source, destination, texture.size) + command_buffer = encoder.finish() + self._device.queue.submit([command_buffer]) + + def get_bitmap(self): + if self._buffer is None: # todo: more explicit state tracking + return None + + memoryview_type = self._memoryview_type + plain_stride = self._plain_stride + padded_stride = self._padded_stride + + nbytes = plain_stride * self._texture_size[1] + plain_shape = (self._texture_size[1], self._texture_size[0], self._nchannels) + + # Download from mappable buffer + # Because we use `copy=False``, we *must* copy the data. + if self._buffer.map_state == "pending": + self._awaitable.sync_wait() + mapped_data = self._buffer.read_mapped(copy=False) + + # Copy the data + if padded_stride > plain_stride: + # Copy per row + data = memoryview(bytearray(nbytes)).cast(mapped_data.format) + i_start = 0 + for i in range(self._texture_size[1]): + row = mapped_data[i * padded_stride : i * padded_stride + plain_stride] + data[i_start : i_start + plain_stride] = row + i_start += plain_stride + else: + # Copy as a whole + data = memoryview(bytearray(mapped_data)).cast(mapped_data.format) + + # Alternative copy solution using Numpy. + # I expected this to be faster, but does not really seem to be. Seems not worth it + # since we technically don't depend on Numpy. Leaving here for reference. + # import numpy as np + # mapped_data = np.asarray(mapped_data)[:data_length] + # data = np.empty(nbytes, dtype=mapped_data.dtype) + # mapped_data.shape = -1, padded_stride + # data.shape = -1, plain_stride + # data[:] = mapped_data[:, :plain_stride] + # data.shape = -1 + # data = memoryview(data) + + # Since we use read_mapped(copy=False), we must unmap it *after* we've copied the data. + self._buffer.unmap() + + # Derive struct dtype from wgpu texture format + + # Represent as memory object to avoid numpy dependency + # Equivalent: np.frombuffer(data, np.uint8).reshape(plain_shape) + data = data.cast(memoryview_type, plain_shape) + + return data diff --git a/rendercanvas/qt.py b/rendercanvas/qt.py index bafbcd8..50ec151 100644 --- a/rendercanvas/qt.py +++ b/rendercanvas/qt.py @@ -17,8 +17,12 @@ get_alt_x11_display, get_alt_wayland_display, select_qt_lib, + IS_WIN, + call_later_from_thread, ) +USE_THREADED_TIMER = False # Default False, because we use Qt PreciseTimer instead + # Select GUI toolkit libname, already_had_app_on_import = select_qt_lib() @@ -28,26 +32,35 @@ QtWidgets = importlib.import_module(".QtWidgets", libname) # Uncomment the line below to try QtOpenGLWidgets.QOpenGLWidget instead of QWidget # QtOpenGLWidgets = importlib.import_module(".QtOpenGLWidgets", libname) - try: - # pyqt6 - WA_PaintOnScreen = QtCore.Qt.WidgetAttribute.WA_PaintOnScreen - WA_DeleteOnClose = QtCore.Qt.WidgetAttribute.WA_DeleteOnClose + if libname.startswith("PyQt"): + # PyQt5 or PyQt6 WA_InputMethodEnabled = QtCore.Qt.WidgetAttribute.WA_InputMethodEnabled KeyboardModifiers = QtCore.Qt.KeyboardModifier + WA_PaintOnScreen = QtCore.Qt.WidgetAttribute.WA_PaintOnScreen + WA_DeleteOnClose = QtCore.Qt.WidgetAttribute.WA_DeleteOnClose + PreciseTimer = QtCore.Qt.TimerType.PreciseTimer FocusPolicy = QtCore.Qt.FocusPolicy CursorShape = QtCore.Qt.CursorShape - Keys = QtCore.Qt.Key WinIdChange = QtCore.QEvent.Type.WinIdChange - except AttributeError: - # pyside6 - WA_PaintOnScreen = QtCore.Qt.WA_PaintOnScreen - WA_DeleteOnClose = QtCore.Qt.WA_DeleteOnClose + Signal = QtCore.pyqtSignal + Slot = QtCore.pyqtSlot + Keys = QtCore.Qt.Key + is_pyside = False + else: + # Pyside2 or PySide6 WA_InputMethodEnabled = QtCore.Qt.WA_InputMethodEnabled KeyboardModifiers = QtCore.Qt + WA_PaintOnScreen = QtCore.Qt.WA_PaintOnScreen + WA_DeleteOnClose = QtCore.Qt.WA_DeleteOnClose + PreciseTimer = QtCore.Qt.PreciseTimer FocusPolicy = QtCore.Qt CursorShape = QtCore.Qt - Keys = QtCore.Qt WinIdChange = QtCore.QEvent.WinIdChange + Signal = QtCore.Signal + Slot = QtCore.Slot + Keys = QtCore.Qt + is_pyside = True + else: raise ImportError( "Before importing rendercanvas.qt, import one of PySide6/PySide2/PyQt6/PyQt5 to select a Qt toolkit." @@ -181,6 +194,32 @@ def enable_hidpi(): ) +class CallbackWrapperHelper(QtCore.QObject): + """Little helper for the high-precision-timer call-laters.""" + + def __init__(self, pool, cb): + super().__init__() + self.pool = pool + self.pool.add(self) + self.cb = cb + + @Slot() + def callback(self): + self.pool.discard(self) + self.pool = None + self.cb() + + +class CallerHelper(QtCore.QObject): + """Little helper class for the threaded call-laters.""" + + call = Signal(object) + + def __init__(self): + super().__init__() + self.call.connect(lambda f: f()) + + class QtLoop(BaseLoop): _app = None _we_run_the_loop = False @@ -192,6 +231,8 @@ def _rc_init(self): self._app = QtWidgets.QApplication([]) if already_had_app_on_import: self._mark_as_interactive() + self._callback_pool = set() + self._caller = CallerHelper() def _rc_run(self): # Note: we could detect if asyncio is running (interactive session) and wheter @@ -227,8 +268,26 @@ def _rc_add_task(self, async_func, name): return super()._rc_add_task(async_func, name) def _rc_call_later(self, delay, callback): - delay_ms = int(max(0, delay * 1000)) - QtCore.QTimer.singleShot(delay_ms, callback) + if delay <= 0: + QtCore.QTimer.singleShot(0, callback) + elif USE_THREADED_TIMER: + call_later_from_thread(delay, self._caller.call.emit, callback) + elif IS_WIN: + # To get high-precision call_later in Windows, we can either use the threaded + # approach, or use Qt's own high-precision timer. We default to the latter, + # which seems slightly more accurate. It's a bit involved, because we need to + # make use of slots, and the signature for singleShot is not well-documented and + # differs between PyQt/PySide. + callback_wrapper = CallbackWrapperHelper(self._callback_pool, callback) + wrapper_args = (callback_wrapper.callback,) + if is_pyside: + wrapper_args = (callback_wrapper, QtCore.SLOT("callback()")) + QtCore.QTimer.singleShot( + int(max(delay * 1000, 1)), PreciseTimer, *wrapper_args + ) + else: + # Normal timer. Already precise for MacOS/Linux. + QtCore.QTimer.singleShot(int(max(delay * 1000, 1)), callback) loop = QtLoop() diff --git a/rendercanvas/raw.py b/rendercanvas/raw.py index 351144d..92c0184 100644 --- a/rendercanvas/raw.py +++ b/rendercanvas/raw.py @@ -8,38 +8,17 @@ __all__ = ["RawLoop", "loop"] -import time -import heapq -import logging -import threading -from itertools import count +import queue from .base import BaseLoop - - -logger = logging.getLogger("rendercanvas") -counter = count() - - -class CallAtWrapper: - def __init__(self, time, callback): - self.index = next(counter) - self.time = time - self.callback = callback - - def __lt__(self, other): - return (self.time, self.index) < (other.time, other.index) - - def cancel(self): - self.callback = None +from ._coreutils import logger, call_later_from_thread class RawLoop(BaseLoop): def __init__(self): super().__init__() - self._queue = [] # prioriry queue + self._queue = queue.SimpleQueue() self._should_stop = False - self._event = threading.Event() def _rc_init(self): # This gets called when the first canvas is created (possibly after having run and stopped before). @@ -47,31 +26,11 @@ def _rc_init(self): def _rc_run(self): while not self._should_stop: - self._event.clear() - - # Get wrapper for callback that is first to be called + callback = self._queue.get(True, None) try: - wrapper = heapq.heappop(self._queue) - except IndexError: - wrapper = None - - if wrapper is None: - # Empty queue, exit - break - else: - # Wait until its time for it to be called - # Note that on Windows, the accuracy of the timeout is 15.6 ms - wait_time = wrapper.time - time.perf_counter() - self._event.wait(max(wait_time, 0)) - - # Put it back or call it? - if time.perf_counter() < wrapper.time: - heapq.heappush(self._queue, wrapper) - elif wrapper.callback is not None: - try: - wrapper.callback() - except Exception as err: - logger.error(f"Error in callback: {err}") + callback() + except Exception as err: + logger.error(f"Error in RawLoop callback: {err}") async def _rc_run_async(self): raise NotImplementedError() @@ -79,18 +38,14 @@ async def _rc_run_async(self): def _rc_stop(self): # Note: is only called when we're inside _rc_run self._should_stop = True - self._event.set() + self._queue.put(lambda: None) # trigger an iter def _rc_add_task(self, async_func, name): # we use the async adapter with call_later return super()._rc_add_task(async_func, name) def _rc_call_later(self, delay, callback): - now = time.perf_counter() - time_at = now + max(0, delay) - wrapper = CallAtWrapper(time_at, callback) - heapq.heappush(self._queue, wrapper) - self._event.set() + call_later_from_thread(delay, self._queue.put, callback) loop = RawLoop() diff --git a/rendercanvas/stub.py b/rendercanvas/stub.py index 2986fb8..31a6857 100644 --- a/rendercanvas/stub.py +++ b/rendercanvas/stub.py @@ -42,7 +42,7 @@ def _rc_call_later(self, delay, callback): class StubCanvasGroup(BaseCanvasGroup): """ - The ``CanvasGroup`` representss a group of canvas objects from the same class, that share a loop. + The ``CanvasGroup`` represents a group of canvas objects from the same class, that share a loop. The initial/default loop is passed when the ``CanvasGroup`` is instantiated. diff --git a/rendercanvas/utils/asyncs.py b/rendercanvas/utils/asyncs.py index 3307885..8511e7c 100644 --- a/rendercanvas/utils/asyncs.py +++ b/rendercanvas/utils/asyncs.py @@ -12,14 +12,36 @@ """ import sys + import sniffio +from .._coreutils import IS_WIN, call_later_from_thread + + +USE_THREADED_TIMER = IS_WIN + async def sleep(delay): - """Generic async sleep. Works with trio, asyncio and rendercanvas-native.""" + """Generic async sleep. Works with trio, asyncio and rendercanvas-native. + + On Windows, with asyncio or trio, this uses a special sleep routine that is more accurate than the standard ``sleep()``. + """ libname = sniffio.current_async_library() - sleep = sys.modules[libname].sleep - await sleep(delay) + if libname == "asyncio" and delay > 0 and USE_THREADED_TIMER: + asyncio = sys.modules[libname] + loop = asyncio.get_running_loop() + event = asyncio.Event() + call_later_from_thread(delay, loop.call_soon_threadsafe, event.set) + await event.wait() + elif libname == "trio" and delay > 0 and USE_THREADED_TIMER: + trio = sys.modules[libname] + event = trio.Event() + token = trio.lowlevel.current_trio_token() + call_later_from_thread(delay, token.run_sync_soon, event.set) + await event.wait() + else: + sleep = sys.modules[libname].sleep + await sleep(delay) class Event: diff --git a/rendercanvas/wx.py b/rendercanvas/wx.py index 09b4cd0..7cf7516 100644 --- a/rendercanvas/wx.py +++ b/rendercanvas/wx.py @@ -17,6 +17,8 @@ SYSTEM_IS_WAYLAND, get_alt_x11_display, get_alt_wayland_display, + IS_WIN, + call_later_from_thread, ) from .base import ( WrapperRenderCanvas, @@ -26,6 +28,9 @@ ) +USE_THREADED_TIMER = IS_WIN + + BUTTON_MAP = { wx.MOUSE_BTN_LEFT: 1, wx.MOUSE_BTN_RIGHT: 2, @@ -179,7 +184,12 @@ def _rc_add_task(self, async_func, name): return super()._rc_add_task(async_func, name) def _rc_call_later(self, delay, callback): - wx.CallLater(max(int(delay * 1000), 1), callback) + if delay <= 0: + wx.CallAfter(callback) + elif USE_THREADED_TIMER: + call_later_from_thread(delay, wx.CallAfter, callback) + else: + wx.CallLater(int(max(delay * 1000, 1)), callback) def process_wx_events(self): old_loop = wx.GUIEventLoop.GetActive() diff --git a/tests/test_utils.py b/tests/test_utils.py index ebe43b2..382d8aa 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -1,4 +1,5 @@ import gc +import time import rendercanvas from testutils import run_tests, is_pypy @@ -38,5 +39,61 @@ def bar(self): assert len(xx) == 3 # f2 is gone! +def test_call_later_thread(): + t = rendercanvas._coreutils.CallLaterThread() + + results = [] + + # Call now + t.call_later_from_thread(0, results.append, 5) + + time.sleep(0.01) + assert results == [5] + + # Call later + t.call_later_from_thread(0.5, results.append, 5) + + time.sleep(0.1) + assert results == [5] + + time.sleep(0.5) + assert results == [5, 5] + + # Call multiple at same time + results.clear() + t.call_later_from_thread(0, results.append, 1) + t.call_later_from_thread(0, results.append, 2) + t.call_later_from_thread(0, results.append, 3) + t.call_later_from_thread(0.1, results.append, 4) + t.call_later_from_thread(0.1, results.append, 5) + t.call_later_from_thread(0.1, results.append, 6) + + time.sleep(0.11) + assert results == [1, 2, 3, 4, 5, 6] + + # Out of order + + def set(x): + results.append((x, time.perf_counter())) + + results.clear() + t.call_later_from_thread(0.9, set, 1) + t.call_later_from_thread(0.8, set, 2) + t.call_later_from_thread(0.41, set, 3) + t.call_later_from_thread(0.40, set, 4) + t.call_later_from_thread(0.11, set, 5) + t.call_later_from_thread(0.10, set, 6) + + now = time.perf_counter() + time.sleep(1.1) + + indices = [r[0] for r in results] + times = [r[1] - now for r in results] + + assert indices == [6, 5, 4, 3, 2, 1] + assert times[1] - times[0] < 0.015 + assert times[2] - times[3] < 0.015 + + if __name__ == "__main__": run_tests(globals())