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
9 changes: 7 additions & 2 deletions rendercanvas/_loop.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,8 @@ class BaseLoop:

"""

_stop_when_no_canvases = True

def __init__(self):
self.__tasks = set() # only used by the async adapter
self.__canvas_groups = set()
Expand Down Expand Up @@ -207,7 +209,7 @@ async def _loop_task(self):
# Break?
canvas_count = len(canvases)
del canvases
if not canvas_count:
if not canvas_count and self._stop_when_no_canvases:
break

finally:
Expand All @@ -222,7 +224,7 @@ def add_task(
self,
async_func: Callable[[], Coroutine],
*args: Any,
name: str = "unnamed",
name: str | None = None,
) -> None:
"""Run an async function in the event-loop.

Expand All @@ -231,6 +233,8 @@ def add_task(
"""
if not (callable(async_func) and iscoroutinefunction(async_func)):
raise TypeError("add_task() expects an async function.")
if name is None:
name = async_func.__name__

async def wrapper():
with log_exception(f"Error in {name} task:"):
Expand Down Expand Up @@ -606,6 +610,7 @@ def _rc_call_later(self, delay, callback):
internally.
* Return None.
"""
# Default that always works, subclasses probably want to do something different when delay <= 0
call_later_from_thread(delay, self._rc_call_soon_threadsafe, callback)

def _rc_call_soon_threadsafe(self, callback):
Expand Down
6 changes: 3 additions & 3 deletions rendercanvas/_scheduler.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
import weakref

from ._enums import UpdateMode
from .utils.asyncs import sleep, Event
from .utils.asyncs import precise_sleep, Event


class Scheduler:
Expand Down Expand Up @@ -104,7 +104,7 @@ async def __scheduler_task(self):
last_tick_time = 0

# Little startup sleep
await sleep(0.05)
await precise_sleep(0.02)

while True:
# Determine delay
Expand All @@ -119,7 +119,7 @@ async def __scheduler_task(self):

# 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))
await precise_sleep(max(0, sleep_time))

# Below is the "tick"

Expand Down
38 changes: 1 addition & 37 deletions rendercanvas/qt.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,8 @@
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()
Expand Down Expand Up @@ -195,22 +191,6 @@ def enable_hidpi():
)


class CallbackWrapperHelper(QtCore.QObject):
"""Little helper for _rc_call_later with PreciseTimer"""

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 for _rc_call_soon_threadsafe"""

Expand Down Expand Up @@ -241,7 +221,6 @@ def _rc_init(self):
)
if already_had_app_on_import:
self._mark_as_interactive()
self._callback_pool = set()
self._caller = CallerHelper()

def _rc_run(self):
Expand Down Expand Up @@ -279,23 +258,8 @@ def _rc_add_task(self, async_func, name):
def _rc_call_later(self, delay, 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
)
# or self._caller.call.emit(callback)
else:
# Normal timer. Already precise for MacOS/Linux.
QtCore.QTimer.singleShot(int(max(delay * 1000, 1)), callback)

def _rc_call_soon_threadsafe(self, callback):
Expand Down
7 changes: 6 additions & 1 deletion rendercanvas/raw.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,12 @@ def _rc_add_task(self, async_func, name):
return super()._rc_add_task(async_func, name)

def _rc_call_later(self, delay, callback):
call_later_from_thread(delay, self._rc_call_soon_threadsafe, callback)
if delay <= 0:
self._queue.put(callback)
else:
# Using call_later_from_thread keeps the loop super-simple.
# Note that its high-precision-on-Windows feature is not why we use it; precision is handled in asyns.py.
call_later_from_thread(delay, self._rc_call_soon_threadsafe, callback)

def _rc_call_soon_threadsafe(self, callback):
self._queue.put(callback)
Expand Down
5 changes: 5 additions & 0 deletions rendercanvas/utils/asyncadapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ def __await__(self):

async def sleep(delay):
"""Async sleep for delay seconds."""
# This effectively runs via the call_later_func passed to the task.
await Sleeper(delay)


Expand All @@ -39,6 +40,10 @@ def __init__(self):
self._is_set = False
self._tasks = []

def __repr__(self):
set = "set" if self._is_set else "unset"
return f"<{self.__module__}.{self.__class__.__name__} object ({set}) at {hex(id(self))}>"

async def wait(self):
if self._is_set:
pass
Expand Down
19 changes: 16 additions & 3 deletions rendercanvas/utils/asyncs.py
Original file line number Diff line number Diff line change
Expand Up @@ -85,11 +85,24 @@ def detect_current_call_soon_threadsafe():


async def sleep(delay):
"""Generic async sleep. Works with trio, asyncio and rendercanvas-native.
"""Generic async sleep. Works with trio, asyncio and rendercanvas-native."""
# Note that we could decide to run all tasks (created via loop.add_task)
# via the asyncadapter, which would converge the async code paths more.
# But we deliberately chose for the async bits to be 'real'; on asyncio
# they are actual asyncio tasks using e.g. asyncio.Event.

libname = detect_current_async_lib()
if libname is not None:
sleep = sys.modules[libname].sleep
await sleep(delay)

On Windows, with asyncio or trio, this uses a special sleep routine that is more accurate than the ``sleep()`` of asyncio/trio.
"""

async def precise_sleep(delay):
"""Generic async sleep that is precise. Works with trio, asyncio and rendercanvas-native.

On Windows, OS timers are notoriously imprecise, with a resolution of 15.625 ms (64 ticks per second).
This function, when on Windows, uses a thread to sleep with a much better precision.
"""
if delay > 0 and USE_THREADED_TIMER:
call_soon_threadsafe = detect_current_call_soon_threadsafe()
if call_soon_threadsafe:
Expand Down
7 changes: 0 additions & 7 deletions rendercanvas/wx.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,6 @@
SYSTEM_IS_WAYLAND,
get_alt_x11_display,
get_alt_wayland_display,
IS_WIN,
call_later_from_thread,
)
from .base import (
WrapperRenderCanvas,
Expand All @@ -28,9 +26,6 @@
)


USE_THREADED_TIMER = IS_WIN


BUTTON_MAP = {
wx.MOUSE_BTN_LEFT: 1,
wx.MOUSE_BTN_RIGHT: 2,
Expand Down Expand Up @@ -189,8 +184,6 @@ def _rc_add_task(self, async_func, name):
def _rc_call_later(self, delay, 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)

Expand Down
8 changes: 4 additions & 4 deletions tests/test_asyncadapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,8 @@ async def coro():

sleep_time1 = times[1] - times[0]
sleep_time2 = times[2] - times[1]
assert 0.04 < sleep_time1 < 0.6
assert 0.09 < sleep_time2 < 0.12
assert 0.04 < sleep_time1 < 0.15
assert 0.09 < sleep_time2 < 0.20


def test_event():
Expand Down Expand Up @@ -61,8 +61,8 @@ async def coro2():

sleep_time1 = times[1] - times[0]
sleep_time2 = times[2] - times[1]
assert 0.04 < sleep_time1 < 0.6
assert 0.09 < sleep_time2 < 0.12
assert 0.04 < sleep_time1 < 0.15
assert 0.09 < sleep_time2 < 0.20


if __name__ == "__main__":
Expand Down
120 changes: 120 additions & 0 deletions tests/test_asyncs.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
"""
Test basics of rendercanvas.utils.asyncs.
"""

# ruff: noqa: N803

import os
import time

from rendercanvas.asyncio import AsyncioLoop
from rendercanvas.trio import TrioLoop
from rendercanvas.raw import RawLoop

from rendercanvas.utils import asyncs
from testutils import run_tests

import pytest


loop_classes = [RawLoop, AsyncioLoop, TrioLoop]


@pytest.mark.parametrize("SomeLoop", loop_classes)
def test_sleep(SomeLoop):
leeway = 0.20 if os.getenv("CI") else 0

times = []

async def coro():
times.append(time.perf_counter())
await asyncs.sleep(0.05)
times.append(time.perf_counter())
await asyncs.sleep(0.1)
times.append(time.perf_counter())
loop.stop()

loop = SomeLoop()
loop._stop_when_no_canvases = False
loop.add_task(coro)
loop.run()

sleep_time1 = times[1] - times[0]
sleep_time2 = times[2] - times[1]
assert 0.04 < sleep_time1 < 0.08 + leeway
assert 0.09 < sleep_time2 < 0.13 + leeway


@pytest.mark.parametrize("SomeLoop", loop_classes)
def test_precise_sleep(SomeLoop):
leeway = 0.20 if os.getenv("CI") else 0

# This test uses the threaded timer on all os's
prev_use_threaded_timer = asyncs.USE_THREADED_TIMER
asyncs.USE_THREADED_TIMER = True

try:
times = []

async def coro():
times.append(time.perf_counter())
await asyncs.precise_sleep(0.05)
times.append(time.perf_counter())
await asyncs.precise_sleep(0.1)
times.append(time.perf_counter())
loop.stop()

loop = SomeLoop()
loop._stop_when_no_canvases = False
loop.add_task(coro)
loop.run()

sleep_time1 = times[1] - times[0]
sleep_time2 = times[2] - times[1]
assert 0.04 < sleep_time1 < 0.08 + leeway
assert 0.09 < sleep_time2 < 0.13 + leeway

finally:
asyncs.USE_THREADED_TIMER = prev_use_threaded_timer


@pytest.mark.parametrize("SomeLoop", loop_classes)
def test_event(SomeLoop):
leeway = 0.20 if os.getenv("CI") else 0

event1 = None
event2 = None

times = []

async def coro1():
await asyncs.sleep(0.05)
event1.set()
await asyncs.sleep(0.1)
event2.set()

async def coro2():
nonlocal event1, event2
event1 = asyncs.Event()
event2 = asyncs.Event()
times.append(time.perf_counter())
await event1.wait()
times.append(time.perf_counter())
await event2.wait()
times.append(time.perf_counter())
loop.stop()

loop = SomeLoop()
loop._stop_when_no_canvases = False
loop.add_task(coro1)
loop.add_task(coro2)
loop.run()

sleep_time1 = times[1] - times[0]
sleep_time2 = times[2] - times[1]
assert 0.04 < sleep_time1 < 0.08 + leeway
assert 0.09 < sleep_time2 < 0.13 + leeway


if __name__ == "__main__":
run_tests(globals())
1 change: 1 addition & 0 deletions tests/test_loop.py
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,7 @@ def test_loop_deletion1(SomeLoop):
assert loop_ref() is None


@pytest.mark.filterwarnings("ignore:.*was never awaited")
@pytest.mark.parametrize("SomeLoop", loop_classes)
def test_loop_deletion2(SomeLoop):
# Loops get gc'd when in ready state
Expand Down
1 change: 0 additions & 1 deletion tests/test_offscreen.py
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,6 @@ def check(arg):

# When run is called, the task is started, so the delay kicks in from
# that moment, so we need to wait here for the 3d to resolve
# The delay starts from
time.sleep(0.01)
loop.run()
assert 3 in ran # call_later nonzero
Expand Down
2 changes: 1 addition & 1 deletion tests/test_scheduling.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ def test_scheduling_manual():
canvas.request_draw()
canvas.active_sleep(0.11)
assert canvas.draw_count == 0
assert canvas.events_count in range(1, 20)
assert canvas.events_count in range(1, 30)

# Only when we force one
canvas.force_draw()
Expand Down