Skip to content

Commit

Permalink
Better definition of what backends need to do (#15)
Browse files Browse the repository at this point in the history
  • Loading branch information
almarklein authored Nov 12, 2024
1 parent 917b983 commit 5fdb7d8
Show file tree
Hide file tree
Showing 15 changed files with 581 additions and 67 deletions.
30 changes: 27 additions & 3 deletions docs/backendapi.rst
Original file line number Diff line number Diff line change
@@ -1,3 +1,27 @@
Backend API
===========
TODO
Internal backend API
====================

This page documents what's needed to implement a backend for ``rendercanvas``. The purpose of this documentation is
to help maintain current and new backends. Making this internal API clear helps understanding how the backend-system works.
Also see https://github.com/pygfx/rendercanvas/blob/main/rendercanvas/stub.py.

It is possible to create a custom backend (outside of the ``rendercanvas`` package). However, we consider this API an internal detail that may change
with each version without warning.

.. autoclass:: rendercanvas.base.WrapperRenderCanvas

.. autoclass:: rendercanvas.stub.StubRenderCanvas
:members:
:private-members:
:member-order: bysource


.. autoclass:: rendercanvas.stub.StubTimer
:members:
:private-members:
:member-order: bysource

.. autoclass:: rendercanvas.stub.StubLoop
:members:
:private-members:
:member-order: bysource
1 change: 1 addition & 0 deletions docs/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@

# Load wglibu so autodoc can query docstrings
import rendercanvas # noqa: E402
import rendercanvas.stub # noqa: E402 - we use the stub backend to generate doccs


# -- Project information -----------------------------------------------------
Expand Down
4 changes: 4 additions & 0 deletions rendercanvas/_events.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
"""
The event system.
"""

import time
from collections import defaultdict, deque

Expand Down
46 changes: 23 additions & 23 deletions rendercanvas/_loop.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
"""
Implemens loop mechanics: The base timer, base loop, and scheduler.
The loop mechanics: the base timer, base loop, and scheduler.
"""

import time
Expand Down Expand Up @@ -43,13 +43,13 @@ def start(self, interval):
restarted.
"""
if self._interval is None:
self._init()
self._rc_init()
if self.is_running:
self._stop()
self._rc_stop()
BaseTimer._running_timers.add(self)
self._interval = max(0.0, float(interval))
self._expect_tick_at = time.perf_counter() + self._interval
self._start()
self._rc_start()

def stop(self):
"""Stop the timer.
Expand All @@ -60,7 +60,7 @@ def stop(self):
"""
BaseTimer._running_timers.discard(self)
self._expect_tick_at = None
self._stop()
self._rc_stop()

def _tick(self):
"""The implementations must call this method."""
Expand All @@ -70,7 +70,7 @@ def _tick(self):
self._expect_tick_at = None
else:
self._expect_tick_at = time.perf_counter() + self._interval
self._start()
self._rc_start()
# Callback
with log_exception("Timer callback error"):
self._callback(*self._args)
Expand Down Expand Up @@ -107,25 +107,25 @@ def is_one_shot(self):
"""
return self._one_shot

def _init(self):
"""For the subclass to implement:
def _rc_init(self):
"""Initialize the (native) timer object.
Opportunity to initialize the timer object. This is called right
before the timer is first started.
"""
pass

def _start(self):
"""For the subclass to implement:
def _rc_start(self):
"""Start the timer.
* Must schedule for ``self._tick`` to be called in ``self._interval`` seconds.
* Must call it exactly once (the base class takes care of repeating the timer).
* When ``self._stop()`` is called before the timer finished, the call to ``self._tick()`` must be cancelled.
* When ``self._rc_stop()`` is called before the timer finished, the call to ``self._tick()`` must be cancelled.
"""
raise NotImplementedError()

def _stop(self):
"""For the subclass to implement:
def _rc_stop(self):
"""Stop the timer.
* If the timer is running, cancel the pending call to ``self._tick()``.
* Otherwise, this should do nothing.
Expand Down Expand Up @@ -183,7 +183,7 @@ def call_soon(self, callback, *args):
The callback will be called in the next iteration of the event-loop,
but other pending events/callbacks may be handled first. Returns None.
"""
self._call_soon(callback, *args)
self._rc_call_soon(callback, *args)

def call_later(self, delay, callback, *args):
"""Arrange for a callback to be called after the given delay (in seconds).
Expand Down Expand Up @@ -220,39 +220,39 @@ def run(self, stop_when_no_canvases=True):
its fine to start the loop in the normal way.
"""
self._stop_when_no_canvases = bool(stop_when_no_canvases)
self._run()
self._rc_run()

def stop(self):
"""Stop the currently running event loop."""
self._stop()
self._rc_stop()

def _run(self):
"""For the subclass to implement:
def _rc_run(self):
"""Start running the event-loop.
* Start the event loop.
* The rest of the loop object must work just fine, also when the loop is
started in the "normal way" (i.e. this method may not be called).
"""
raise NotImplementedError()

def _stop(self):
"""For the subclass to implement:
def _rc_stop(self):
"""Stop the event loop.
* Stop the running event loop.
* When running in an interactive session, this call should probably be ignored.
"""
raise NotImplementedError()

def _call_soon(self, callback, *args):
"""For the subclass to implement:
def _rc_call_soon(self, callback, *args):
"""Method to call a callback in the next iteraction of the event-loop.
* A quick path to have callback called in a next invocation of the event loop.
* This method is optional: the default implementation just calls ``call_later()`` with a zero delay.
"""
self.call_later(0, callback, *args)

def _rc_gui_poll(self):
"""For the subclass to implement:
"""Process GUI events.
Some event loops (e.g. asyncio) are just that and dont have a GUI to update.
Other loops (like Qt) already process events. So this is only intended for
Expand Down
22 changes: 16 additions & 6 deletions rendercanvas/asyncio.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
"""Implements an asyncio event loop."""
"""
Implements an asyncio event loop, used in some backends.
"""

# This is used for backends that don't have an event loop by themselves, like glfw.
# Would be nice to also allow a loop based on e.g. Trio. But we can likely fit that in
# when the time comes.

__all__ = ["AsyncioLoop", "AsyncioTimer"]

import asyncio

from .base import BaseLoop, BaseTimer
Expand All @@ -14,7 +18,10 @@ class AsyncioTimer(BaseTimer):

_handle = None

def _start(self):
def _rc_init(self):
pass

def _rc_start(self):
def tick():
self._handle = None
self._tick()
Expand All @@ -24,7 +31,7 @@ def tick():
asyncio_loop = self._loop._loop
self._handle = asyncio_loop.call_later(self._interval, tick)

def _stop(self):
def _rc_stop(self):
if self._handle:
self._handle.cancel()
self._handle = None
Expand Down Expand Up @@ -56,16 +63,19 @@ def _get_loop(self):
asyncio.set_event_loop(loop)
return loop

def _run(self):
def _rc_run(self):
if self._loop.is_running():
self._is_interactive = True
else:
self._is_interactive = False
self._loop.run_forever()

def _stop(self):
def _rc_stop(self):
if not self._is_interactive:
self._loop.stop()

def _call_soon(self, callback, *args):
def _rc_call_soon(self, callback, *args):
self._loop.call_soon(callback, *args)

def _rc_gui_poll(self):
pass
8 changes: 3 additions & 5 deletions rendercanvas/auto.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,8 @@
"""
Automatic backend selection.
Right now we only chose between GLFW, Qt and Jupyter. We might add support
for e.g. wx later. Or we might decide to stick with these three.
"""

__all__ = ["RenderCanvas", "loop", "run"]
__all__ = ["RenderCanvas", "loop"]

import os
import sys
Expand Down Expand Up @@ -188,5 +185,6 @@ def backends_by_trying_in_order():

# Load!
module = select_backend()
RenderCanvas, loop = module.RenderCanvas, module.loop
RenderCanvas = module.RenderCanvas
loop = module.loop
run = loop.run # backwards compat
25 changes: 18 additions & 7 deletions rendercanvas/base.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
"""
The base classes.
"""

__all__ = ["WrapperRenderCanvas", "BaseRenderCanvas", "BaseLoop", "BaseTimer"]

import sys

from ._events import EventEmitter, EventType # noqa: F401
from ._loop import Scheduler, BaseLoop, BaseTimer # noqa: F401
from ._loop import Scheduler, BaseLoop, BaseTimer
from ._coreutils import log_exception


Expand Down Expand Up @@ -213,7 +219,11 @@ def submit_event(self, event):
# %% Scheduling and drawing

def _process_events(self):
"""Process events and animations. Called from the scheduler."""
"""Process events and animations.
Called from the scheduler.
Subclasses *may* call this if the time between ``_rc_request_draw`` and the actual draw is relatively long.
"""

# We don't want this to be called too often, because we want the
# accumulative events to accumulate. Once per draw, and at max_fps
Expand Down Expand Up @@ -393,7 +403,7 @@ def _rc_request_draw(self):
"""Request the GUI layer to perform a draw.
Like requestAnimationFrame in JS. The draw must be performed
by calling _draw_frame_and_present(). It's the responsibility
by calling ``_draw_frame_and_present()``. It's the responsibility
for the canvas subclass to make sure that a draw is made as
soon as possible.
Expand All @@ -411,7 +421,7 @@ def _rc_force_draw(self):
"""Perform a synchronous draw.
When it returns, the draw must have been done.
The default implementation just calls _draw_frame_and_present().
The default implementation just calls ``_draw_frame_and_present()``.
"""
self._draw_frame_and_present()

Expand Down Expand Up @@ -441,7 +451,7 @@ def _rc_set_logical_size(self, width, height):
def _rc_close(self):
"""Close the canvas.
For widgets that are wrapped by a WrapperRenderCanvas, this should probably
For widgets that are wrapped by a ``WrapperRenderCanvas``, this should probably
close the wrapper instead.
Note that ``BaseRenderCanvas`` implements the ``close()`` method, which
Expand All @@ -456,7 +466,7 @@ def _rc_is_closed(self):
def _rc_set_title(self, title):
"""Set the canvas title. May be ignored when it makes no sense.
For widgets that are wrapped by a WrapperRenderCanvas, this should probably
For widgets that are wrapped by a ``WrapperRenderCanvas``, this should probably
set the title of the wrapper instead.
The default implementation does nothing.
Expand All @@ -468,7 +478,8 @@ class WrapperRenderCanvas(BaseRenderCanvas):
"""A base render canvas for top-level windows that wrap a widget, as used in e.g. Qt and wx.
This base class implements all the re-direction logic, so that the subclass does not have to.
Wrapper classes should not implement any of the ``_rc_`` methods.
Subclasses should not implement any of the ``_rc_`` methods. Subclasses must instantiate the
wrapped canvas and set it as ``_subwidget``.
"""

# Events
Expand Down
6 changes: 4 additions & 2 deletions rendercanvas/glfw.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
or ``sudo apt install libglfw3-wayland`` when using Wayland.
"""

__all__ = ["RenderCanvas", "loop"]

import sys
import time
import atexit
Expand Down Expand Up @@ -539,8 +541,8 @@ def _rc_gui_poll(self):
if self.stop_if_no_more_canvases and not tuple(self.all_glfw_canvases):
self.stop()

def _run(self):
super()._run()
def _rc_run(self):
super()._rc_run()
if not self._is_interactive:
poll_glfw_briefly()

Expand Down
3 changes: 3 additions & 0 deletions rendercanvas/jupyter.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
can be used as cell output, or embedded in an ipywidgets gui.
"""

__all__ = ["RenderCanvas", "loop"]

import time
import weakref

Expand Down Expand Up @@ -142,3 +144,4 @@ def run(self):


loop = JupyterAsyncioLoop()
run = loop.run
Loading

0 comments on commit 5fdb7d8

Please sign in to comment.