diff --git a/examples/demo.py b/examples/demo.py index 8ce31af..664030e 100644 --- a/examples/demo.py +++ b/examples/demo.py @@ -37,9 +37,13 @@ # Note: in this demo we listen to all events (using '*'). In general # you want to select one or more specific events to handle. +cursor_index = 0 + @canvas.add_event_handler("*") async def process_event(event): + global cursor_index + if event["event_type"] not in ["pointer_move", "before_draw", "animate"]: print(event) @@ -62,10 +66,10 @@ async def process_event(event): elif event["key"] == "c": # Swap cursor shapes = list(rendercanvas.CursorShape) - canvas.cursor_index = getattr(canvas, "cursor_index", -1) + 1 - if canvas.cursor_index >= len(shapes): - canvas.cursor_index = 0 - cursor = shapes[canvas.cursor_index] + cursor_index += 1 + if cursor_index >= len(shapes): + cursor_index = 0 + cursor = shapes[cursor_index] canvas.set_cursor(cursor) print(f"Cursor: {cursor!r}") elif event["event_type"] == "close": diff --git a/rendercanvas/_coreutils.py b/rendercanvas/_coreutils.py index 5c5d65e..87ec4bf 100644 --- a/rendercanvas/_coreutils.py +++ b/rendercanvas/_coreutils.py @@ -41,7 +41,7 @@ def log_exception(kind): except Exception as err: # Store exc info for postmortem debugging exc_info = list(sys.exc_info()) - exc_info[2] = exc_info[2].tb_next # skip *this* function + exc_info[2] = exc_info[2].tb_next # type: ignore | skip *this* function sys.last_type, sys.last_value, sys.last_traceback = exc_info # Show traceback, or a one-line summary msg = str(err) diff --git a/rendercanvas/_events.py b/rendercanvas/_events.py index 1d45577..bc523ba 100644 --- a/rendercanvas/_events.py +++ b/rendercanvas/_events.py @@ -5,6 +5,7 @@ import time from inspect import iscoroutinefunction from collections import defaultdict, deque +from typing import Callable from ._coreutils import log_exception from ._enums import EventType @@ -44,7 +45,7 @@ def _release(self): self._pending_events.clear() self._event_handlers.clear() - def add_handler(self, *args, order: float = 0): + def add_handler(self, *args, order: float = 0) -> Callable: """Register an event handler to receive events. Arguments: @@ -89,9 +90,11 @@ def my_handler(event): """ order = float(order) - decorating = not callable(args[0]) - callback = None if decorating else args[0] - types = args if decorating else args[1:] + callback = None + types = args + if len(args) > 0 and callable(args[0]): + callback = args[0] + types = args[1:] if not types: raise TypeError("No event types are given to add_event_handler.") @@ -101,11 +104,11 @@ def my_handler(event): if not (type == "*" or type in valid_event_types): raise ValueError(f"Adding handler with invalid event_type: '{type}'") - def decorator(_callback): + def decorator(_callback: Callable): self._add_handler(_callback, order, *types) return _callback - if decorating: + if callback is None: return decorator return decorator(callback) diff --git a/rendercanvas/_loop.py b/rendercanvas/_loop.py index 423c578..92f1ce4 100644 --- a/rendercanvas/_loop.py +++ b/rendercanvas/_loop.py @@ -139,9 +139,14 @@ async def _loop_task(self): break elif self.__should_stop: # Close all remaining canvases. Loop will stop in a next iteration. + # We store a flag on the canvas, that we only use here. for canvas in self.get_canvases(): - if not getattr(canvas, "_rc_closed_by_loop", False): - canvas._rc_closed_by_loop = True + try: + closed_by_loop = canvas._rc_closed_by_loop # type: ignore + except AttributeError: + closed_by_loop = False + if not closed_by_loop: + canvas._rc_closed_by_loop = True # type: ignore canvas.close() del canvas diff --git a/rendercanvas/_scheduler.py b/rendercanvas/_scheduler.py index 119bd4d..edcbfc8 100644 --- a/rendercanvas/_scheduler.py +++ b/rendercanvas/_scheduler.py @@ -44,7 +44,7 @@ class Scheduler: # don't affect the scheduling loop; they are just extra draws. def __init__( - self, canvas, events, *, update_mode="ondemand", min_fps=0, max_fps=30 + self, canvas, events, *, update_mode="ondemand", min_fps=0.0, max_fps=30.0 ): self.name = f"{canvas.__class__.__name__} scheduler" @@ -65,7 +65,7 @@ def __init__( def get_task(self): """Get task. Can be called exactly once. Used by the canvas.""" task = self.__scheduler_task - self.__scheduler_task = None + self.__scheduler_task = None # type: ignore assert task is not None return task @@ -102,7 +102,7 @@ def request_draw(self): self._draw_requested = True async def __scheduler_task(self): - """The coro that reprsents the scheduling loop for a canvas.""" + """The coro that represents the scheduling loop for a canvas.""" last_draw_time = 0 last_tick_time = 0 diff --git a/rendercanvas/auto.py b/rendercanvas/auto.py index 8fe4663..a1987e8 100644 --- a/rendercanvas/auto.py +++ b/rendercanvas/auto.py @@ -40,6 +40,8 @@ def select_backend(): module = None failed_backends = {} # name -> error + backend_name = "none" + reason = "no reason" for backend_name, reason in backends_generator(): if "force" in reason.lower(): return _load_backend(backend_name) @@ -122,7 +124,7 @@ def get_env_var(*varnames): def backends_by_jupyter(): """Generate backend names that are appropriate for the current Jupyter session (if any).""" try: - ip = get_ipython() + ip = get_ipython() # type: ignore except NameError: return if not ip.has_trait("kernel"): diff --git a/rendercanvas/base.py b/rendercanvas/base.py index 22e589d..1f34903 100644 --- a/rendercanvas/base.py +++ b/rendercanvas/base.py @@ -11,7 +11,6 @@ from ._enums import ( EventTypeEnum, - UpdateMode, UpdateModeEnum, CursorShape, CursorShapeEnum, @@ -45,7 +44,7 @@ class BaseCanvasGroup: """Represents a group of canvas objects from the same class, that share a loop.""" - def __init__(self, default_loop): + def __init__(self, default_loop: BaseLoop): self._canvases = weakref.WeakSet() self._loop = None self.select_loop(default_loop) @@ -71,11 +70,11 @@ def select_loop(self, loop: BaseLoop) -> None: self._loop._unregister_canvas_group(self) self._loop = loop - def get_loop(self) -> BaseLoop: + def get_loop(self) -> BaseLoop | None: """Get the currently associated loop (can be None for canvases that don't run a scheduler).""" return self._loop - def get_canvases(self) -> List["BaseRenderCanvas"]: + def get_canvases(self) -> List[BaseRenderCanvas]: """Get a list of currently active (not-closed) canvases for this group.""" return [canvas for canvas in self._canvases if not canvas.get_closed()] @@ -123,7 +122,7 @@ def select_loop(cls, loop: BaseLoop) -> None: def __init__( self, *args, - size: Tuple[int] = (640, 480), + size: Tuple[int, int] = (640, 480), title: str = "$backend", update_mode: UpdateModeEnum = "ondemand", min_fps: float = 0.0, @@ -190,8 +189,8 @@ def _final_canvas_init(self): del self.__kwargs_for_later # Apply if not isinstance(self, WrapperRenderCanvas): - self.set_logical_size(*kwargs["size"]) - self.set_title(kwargs["title"]) + self.set_logical_size(*kwargs["size"]) # type: ignore + self.set_title(kwargs["title"]) # type: ignore def __del__(self): # On delete, we call the custom destroy method. @@ -202,7 +201,7 @@ def __del__(self): # Since this is sometimes used in a multiple inheritance, the # superclass may (or may not) have a __del__ method. try: - super().__del__() + super().__del__() # type: ignore except Exception: pass @@ -210,7 +209,7 @@ def __del__(self): _canvas_context = None # set in get_context() - def get_physical_size(self) -> Tuple[int]: + def get_physical_size(self) -> Tuple[int, int]: """Get the physical size of the canvas in integer pixels.""" return self._rc_get_physical_size() @@ -368,7 +367,10 @@ def set_update_mode( max_fps (float): The maximum fps with update mode 'ondemand' and 'continuous'. """ - self.__scheduler.set_update_mode(update_mode, min_fps=min_fps, max_fps=max_fps) + if self.__scheduler is not None: + self.__scheduler.set_update_mode( + update_mode, min_fps=min_fps, max_fps=max_fps + ) def request_draw(self, draw_function: Optional[DrawFunction] = None) -> None: """Schedule a new draw event. @@ -463,7 +465,7 @@ def _draw_frame_and_present(self): # %% Primary canvas management methods - def get_logical_size(self) -> Tuple[float]: + def get_logical_size(self) -> Tuple[float, float]: """Get the logical size (width, height) in float pixels. The logical size can be smaller than the physical size, e.g. on HiDPI @@ -485,12 +487,12 @@ def get_pixel_ratio(self) -> float: def close(self) -> None: """Close the canvas.""" # Clear the draw-function, to avoid it holding onto e.g. wgpu objects. - self._draw_frame = None + self._draw_frame = None # type: ignore # Clear the canvas context too. if hasattr(self._canvas_context, "_release"): # ContextInterface (and GPUCanvasContext) has _release() try: - self._canvas_context._release() + self._canvas_context._release() # type: ignore except Exception: pass self._canvas_context = None @@ -541,12 +543,12 @@ def set_cursor(self, cursor: CursorShapeEnum) -> None: cursor = "default" if not isinstance(cursor, str): raise TypeError("Canvas cursor must be str.") - cursor = cursor.lower().replace("_", "-") - if cursor not in CursorShape: + cursor_normed = cursor.lower().replace("_", "-") + if cursor_normed not in CursorShape: raise ValueError( f"Canvas cursor {cursor!r} not known, must be one of {CursorShape}" ) - self._rc_set_cursor(cursor) + self._rc_set_cursor(cursor_normed) # %% Methods for the subclass to implement @@ -609,19 +611,19 @@ def _rc_present_bitmap(self, *, data, format, **kwargs): """ raise NotImplementedError() - def _rc_get_physical_size(self): + def _rc_get_physical_size(self) -> Tuple[int, int]: """Get the physical size (with, height) in integer pixels.""" raise NotImplementedError() - def _rc_get_logical_size(self): + def _rc_get_logical_size(self) -> Tuple[float, float]: """Get the logical size (with, height) in float pixels.""" raise NotImplementedError() - def _rc_get_pixel_ratio(self): + def _rc_get_pixel_ratio(self) -> float: """Get ratio between physical and logical size.""" raise NotImplementedError() - def _rc_set_logical_size(self, width, height): + def _rc_set_logical_size(self, width: float, height: float): """Set the logical size. May be ignired when it makes no sense. The default implementation does nothing. @@ -644,18 +646,18 @@ def _rc_close(self): """ pass - def _rc_get_closed(self): + def _rc_get_closed(self) -> bool: """Get whether the canvas is closed.""" return False - def _rc_set_title(self, title): + def _rc_set_title(self, title: str): """Set the canvas title. May be ignored when it makes no sense. The default implementation does nothing. """ pass - def _rc_set_cursor(self, cursor): + def _rc_set_cursor(self, cursor: str): """Set the cursor shape. May be ignored. The default implementation does nothing. @@ -672,6 +674,7 @@ class WrapperRenderCanvas(BaseRenderCanvas): """ _rc_canvas_group = None # No grouping for these wrappers + _subwidget: BaseRenderCanvas @classmethod def select_loop(cls, loop: BaseLoop) -> None: @@ -679,8 +682,8 @@ def select_loop(cls, loop: BaseLoop) -> None: return m.RenderWidget.select_loop(loop) def add_event_handler( - self, *args: str | EventHandlerFunction, order: float = 0 - ) -> None: + self, *args: EventTypeEnum | EventHandlerFunction, order: float = 0 + ) -> Callable: return self._subwidget._events.add_handler(*args, order=order) def remove_event_handler(self, callback: EventHandlerFunction, *types: str) -> None: @@ -694,7 +697,7 @@ def get_context(self, context_type: str) -> object: def set_update_mode( self, - update_mode: UpdateMode, + update_mode: UpdateModeEnum, *, min_fps: Optional[float] = None, max_fps: Optional[float] = None, @@ -707,10 +710,10 @@ def request_draw(self, draw_function: Optional[DrawFunction] = None) -> None: def force_draw(self) -> None: self._subwidget.force_draw() - def get_physical_size(self) -> Tuple[int]: + def get_physical_size(self) -> Tuple[int, int]: return self._subwidget.get_physical_size() - def get_logical_size(self) -> Tuple[float]: + def get_logical_size(self) -> Tuple[float, float]: return self._subwidget.get_logical_size() def get_pixel_ratio(self) -> float: diff --git a/rendercanvas/utils/cube.py b/rendercanvas/utils/cube.py index 7e4c9f5..e7da9c8 100644 --- a/rendercanvas/utils/cube.py +++ b/rendercanvas/utils/cube.py @@ -4,6 +4,7 @@ """ import time +from typing import Callable import wgpu import numpy as np @@ -14,7 +15,7 @@ def setup_drawing_sync( canvas, power_preference="high-performance", limits=None, format=None -): +) -> Callable[[], None]: """Setup to draw a rotating cube on the given canvas. The given canvas must implement WgpuCanvasInterface, but nothing more. @@ -220,7 +221,7 @@ def create_pipeline_layout(device): def get_draw_function( canvas, device, render_pipeline, uniform_buffer, bind_groups, *, asynchronous -): +) -> Callable[[], None]: # Create vertex buffer, and upload data vertex_buffer = device.create_buffer_with_data( data=vertex_data, usage=wgpu.BufferUsage.VERTEX