diff --git a/rendercanvas/_size.py b/rendercanvas/_size.py new file mode 100644 index 0000000..ffaa1fe --- /dev/null +++ b/rendercanvas/_size.py @@ -0,0 +1,71 @@ +class SizeInfo(dict): + """A dict with size information, for internal use. + + Handy to have a separate dict, so that it can be passed to objects that need it. + Also allows canvases to create size callbacks without holding a ref to the canvas. + """ + + def __init__(self): + super().__init__() + self["physical_size"] = 1, 1 + self["native_pixel_ratio"] = 1.0 + self["canvas_pixel_ratio"] = 1.0 + self["total_pixel_ratio"] = 1.0 + self["logical_size"] = 1.0, 1.0 + self["need_size_event"] = False + self["need_context_resize"] = False + + def set_physical_size(self, width: int, height: int, pixel_ratio: float): + """Must be called by subclasses when their size changes. + + The given pixel-ratio represents the 'native' pixel ratio. The canvas' + zoom factor is multiplied with it to obtain the final pixel-ratio for + this canvas. + """ + self["physical_size"] = int(width), int(height) + self["native_pixel_ratio"] = float(pixel_ratio) + self._resolve_total_pixel_ratio_and_logical_size() + + def _resolve_total_pixel_ratio_and_logical_size(self): + physical_size = self["physical_size"] + native_pixel_ratio = self["native_pixel_ratio"] + canvas_pixel_ratio = self["canvas_pixel_ratio"] + + total_pixel_ratio = native_pixel_ratio * canvas_pixel_ratio + logical_size = ( + physical_size[0] / total_pixel_ratio, + physical_size[1] / total_pixel_ratio, + ) + + self["total_pixel_ratio"] = total_pixel_ratio + self["logical_size"] = logical_size + + self["need_size_event"] = True + self["need_context_resize"] = True + + def set_logical_size(self, width: float, height: float): + """Called by the canvas when the logical size is set. + + This calculates the expected physical size (with the current pixel ratio), + to get the corrected logical size. But this *only* sets the logical_size + and total_pixel_ratio. The backend will, likely before the next draw, + adjust the size and call set_physical_size(), which re-sets the logical size. + """ + # Calculate adjusted logical size + ratio = self["native_pixel_ratio"] * self["canvas_pixel_ratio"] + pwidth = max(1, round(float(width) * ratio + 0.01)) + pheight = max(1, round(float(height) * ratio + 0.01)) + lwidth, lheight = pwidth / ratio, pheight / ratio + + # Update logical size and total ratio. You could see it as a temporary zoom factor being applied. + # Note that The backend will soon call set_physical_size(). + self["logical_size"] = lwidth, lheight + self["total_pixel_ratio"] = self["physical_size"][0] / lwidth + + self["need_size_event"] = True + self["need_context_resize"] = True + + def set_zoom(self, zoom: float): + """Set the zoom factor, i.e. the canvas pixel ratio.""" + self["canvas_pixel_ratio"] = float(zoom) + self._resolve_total_pixel_ratio_and_logical_size() diff --git a/rendercanvas/base.py b/rendercanvas/base.py index 4637d46..7a6e4a6 100644 --- a/rendercanvas/base.py +++ b/rendercanvas/base.py @@ -15,6 +15,7 @@ CursorShapeEnum, ) from . import contexts +from ._size import SizeInfo from ._events import EventEmitter from ._loop import BaseLoop from ._scheduler import Scheduler @@ -153,15 +154,7 @@ def __init__( if (self._rc_canvas_group and self._rc_canvas_group.get_loop()) else "no-loop", } - self.__size_info = { - "physical_size": (0, 0), - "native_pixel_ratio": 1.0, - "canvas_pixel_ratio": 1.0, - "total_pixel_ratio": 1.0, - "logical_size": (0.0, 0.0), - } - self.__need_size_event = False - self.__need_context_resize = True # True bc context may be created later + self._size_info = SizeInfo() # Events and scheduler self._events = EventEmitter() @@ -224,7 +217,7 @@ def __del__(self): def get_physical_size(self) -> tuple[int, int]: """Get the physical size of the canvas in integer pixels.""" - return self.__size_info["physical_size"] + return self._size_info["physical_size"] def get_bitmap_context(self) -> contexts.BitmapContext: """Get the ``BitmapContext`` to render to this canvas.""" @@ -323,41 +316,6 @@ def get_context( # %% Events - def _set_size_info( - self, physical_width: int, physical_height: int, pixel_ratio: float - ): - """Must be called by subclasses when their size changes. - - Backends must *not* submit a "resize" event; the base class takes care of that, because - it requires some more attention than the other events. - - The subclass must call this when the actual viewport has changed. So not in ``_rc_set_logical_size()``, - but e.g. when the underlying GUI layer fires a resize event, and maybe on init. - - The given pixel-ratio represents the 'native' pixel ratio. The canvas' - zoom factor is multiplied with it to obtain the final pixel-ratio for - this canvas. - """ - self.__size_info["physical_size"] = int(physical_width), int(physical_height) - self.__size_info["native_pixel_ratio"] = float(pixel_ratio) - self.__resolve_total_pixel_ratio_and_logical_size() - - def __resolve_total_pixel_ratio_and_logical_size(self): - physical_size = self.__size_info["physical_size"] - native_pixel_ratio = self.__size_info["native_pixel_ratio"] - canvas_pixel_ratio = self.__size_info["canvas_pixel_ratio"] - - total_pixel_ratio = native_pixel_ratio * canvas_pixel_ratio - logical_size = ( - physical_size[0] / total_pixel_ratio, - physical_size[1] / total_pixel_ratio, - ) - - self.__size_info["total_pixel_ratio"] = total_pixel_ratio - self.__size_info["logical_size"] = logical_size - self.__need_size_event = True - self.__need_context_resize = True - def add_event_handler( self, *args: EventTypeEnum | EventHandlerFunction, order: float = 0 ) -> Callable: @@ -380,23 +338,23 @@ def submit_event(self, event: dict) -> None: def __maybe_emit_resize_event(self): # Keep context up-to-date - if self.__need_context_resize and self._canvas_context is not None: - self.__need_context_resize = False - self._canvas_context._rc_set_size_dict(self.__size_info) + if self._size_info["need_context_resize"] and self._canvas_context is not None: + self._size_info["need_context_resize"] = False + self._canvas_context._rc_set_size_dict(self._size_info) # Keep event listeners up-to-date - if self.__need_size_event: - self.__need_size_event = False - lsize = self.__size_info["logical_size"] + if self._size_info["need_size_event"]: + self._size_info["need_size_event"] = False + lsize = self._size_info["logical_size"] self._events.emit( { "event_type": "resize", "width": lsize[0], "height": lsize[1], - "pixel_ratio": self.__size_info["total_pixel_ratio"], + "pixel_ratio": self._size_info["total_pixel_ratio"], # Would be nice to have more details. But as it is now, PyGfx errors if we add fields it does not know, so let's do later. - # "logical_size": self.__size_info["logical_size"], - # "physical_size": self.__size_info["physical_size"], + # "logical_size": self._size_info["logical_size"], + # "physical_size": self._size_info["physical_size"], } ) @@ -575,7 +533,7 @@ def get_logical_size(self) -> tuple[float, float]: The logical size can be smaller than the physical size, e.g. on HiDPI monitors or when the user's system has the display-scale set to e.g. 125%. """ - return self.__size_info["logical_size"] + return self._size_info["logical_size"] def get_pixel_ratio(self) -> float: """Get the float ratio between logical and physical pixels. @@ -585,7 +543,7 @@ def get_pixel_ratio(self) -> float: display-scale is set to e.g. 125%. An HiDPI screen can be assumed if the pixel ratio >= 2.0. """ - return self.__size_info["total_pixel_ratio"] + return self._size_info["total_pixel_ratio"] def close(self) -> None: """Close the canvas.""" @@ -628,6 +586,9 @@ def set_logical_size(self, width: float, height: float) -> None: width, height = float(width), float(height) if width < 0 or height < 0: raise ValueError("Canvas width and height must not be negative") + # Already adjust our logical size, so e.g. layout engines can get to work + self._size_info.set_logical_size(width, height) + # Tell the backend to adjust the size. It will likely set the new physical size before the next draw. self._rc_set_logical_size(width, height) def set_title(self, title: str) -> None: diff --git a/rendercanvas/glfw.py b/rendercanvas/glfw.py index 38f36e4..03e6244 100644 --- a/rendercanvas/glfw.py +++ b/rendercanvas/glfw.py @@ -272,7 +272,7 @@ def _determine_size(self): pwidth, pheight = get_physical_size(self._window) self._pixel_ratio = pixel_ratio # store - self._set_size_info(pwidth, pheight, pixel_ratio) + self._size_info.set_physical_size(pwidth, pheight, pixel_ratio) def _on_want_close(self, *args): # Called when the user attempts to close the window, for example by clicking the close widget in the title bar. diff --git a/rendercanvas/jupyter.py b/rendercanvas/jupyter.py index 96cfc2a..44eda92 100644 --- a/rendercanvas/jupyter.py +++ b/rendercanvas/jupyter.py @@ -107,7 +107,7 @@ def handle_event(self, event): pixel_ratio = event["pixel_ratio"] pwidth = int(logical_size[0] * pixel_ratio) pheight = int(logical_size[1] * pixel_ratio) - self._set_size_info(pwidth, pheight, pixel_ratio) + self._size_info.set_physical_size(pwidth, pheight, pixel_ratio) self.request_draw() return diff --git a/rendercanvas/offscreen.py b/rendercanvas/offscreen.py index ca861a7..29de76f 100644 --- a/rendercanvas/offscreen.py +++ b/rendercanvas/offscreen.py @@ -73,9 +73,9 @@ def _rc_present_bitmap(self, *, data, format, **kwargs): def _rc_set_logical_size(self, width, height): logical_size = float(width), float(height) pixel_ratio = self._pixel_ratio - pwidth = int(logical_size[0] * pixel_ratio) - pheight = int(logical_size[1] * pixel_ratio) - self._set_size_info(pwidth, pheight, pixel_ratio) + pwidth = max(1, round(logical_size[0] * pixel_ratio + 0.01)) + pheight = max(1, round(logical_size[1] * pixel_ratio + 0.01)) + self._size_info.set_physical_size(pwidth, pheight, pixel_ratio) def _rc_close(self): self._closed = True @@ -100,7 +100,7 @@ def set_physical_size(self, width: int, height: int): """ pwidth = int(width) pheight = int(height) - self._set_size_info(pwidth, pheight, self._pixel_ratio) + self._size_info.set_physical_size(pwidth, pheight, self._pixel_ratio) def set_pixel_ratio(self, pixel_ratio: float): """Set the pixel ratio, changing the logical size of the canvas. @@ -110,7 +110,7 @@ def set_pixel_ratio(self, pixel_ratio: float): """ self._pixel_ratio = float(pixel_ratio) pwidth, pheight = self.get_physical_size() - self._set_size_info(pwidth, pheight, self._pixel_ratio) + self._size_info.set_physical_size(pwidth, pheight, self._pixel_ratio) def draw(self): """Perform a draw and get the resulting image. diff --git a/rendercanvas/pyodide.py b/rendercanvas/pyodide.py index 4817b7a..7c2723b 100644 --- a/rendercanvas/pyodide.py +++ b/rendercanvas/pyodide.py @@ -203,7 +203,7 @@ def _resize_callback(entries, _=None): # Notify the base class, so it knows our new size pwidth, pheight = psize - self._set_size_info(pwidth, pheight, window.devicePixelRatio) + self._size_info.set_physical_size(pwidth, pheight, window.devicePixelRatio) self._resize_callback_proxy = create_proxy(_resize_callback) self._resize_observer = ResizeObserver.new(self._resize_callback_proxy) diff --git a/rendercanvas/qt.py b/rendercanvas/qt.py index 7f84679..6450131 100644 --- a/rendercanvas/qt.py +++ b/rendercanvas/qt.py @@ -584,7 +584,7 @@ def resizeEvent(self, event): # noqa: N802 # we can't. Not an issue on qt5, because ratio is always integer then. pwidth = round(lsize[0] * ratio + 0.01) pheight = round(lsize[1] * ratio + 0.01) - self._set_size_info(pwidth, pheight, ratio) + self._size_info.set_physical_size(pwidth, pheight, ratio) # self.update() / self.request_draw() is implicit def closeEvent(self, event): # noqa: N802 diff --git a/rendercanvas/stub.py b/rendercanvas/stub.py index f2a1e1b..2986fb8 100644 --- a/rendercanvas/stub.py +++ b/rendercanvas/stub.py @@ -59,27 +59,26 @@ class StubRenderCanvas(BaseRenderCanvas): """ The ``RenderCanvas`` represents the canvas to render to. - Backends must subclass ``BaseRenderCanvas`` and implement a set of methods prefixed with ``_rc_``. - This class also shows a few other private methods of the base canvas class, that a backend must be aware of. - """ - - # Note that the methods below don't have docstrings, but Sphinx recovers the docstrings from the base class. + Backends must subclass ``BaseRenderCanvas`` and implement a set of methods + prefixed with ``_rc_``. - # Just listed here so they end up in the docs + Backends must call ``self._final_canvas_init()`` at the end of its + ``__init__()``. This will set the canvas' logical size and title. - def _final_canvas_init(self): - return super()._final_canvas_init() + Backends must call ``self._draw_frame_and_present()`` to make the actual + draw. This should typically be done inside the backend's native draw event. - def _set_size_info(self, physical_width, physical_height, pixel_ratio): - return super()._set_size_info(physical_width, physical_height, pixel_ratio) + Backends must call ``self._size_info.set_physical_size(width, height, native_pixel_ratio)``, + whenever the size or pixel ratio changes. It must be called when the actual + viewport has changed, so typically not in ``_rc_set_logical_size()``, but + e.g. when the underlying GUI layer fires a resize event. - def _process_events(self): - return super()._process_events() - - def _draw_frame_and_present(self): - return super()._draw_frame_and_present() + Backends must also call ``self.submit_event()``, if applicable, to produce + events for mouse and keyboard. Backends must *not* submit a "resize" event; + the base class takes care of that. See the event spec for details. + """ - # Must be implemented by subclasses. + # Note that the methods below don't have docstrings, but Sphinx recovers the docstrings from the base class. _rc_canvas_group = StubCanvasGroup(loop) diff --git a/rendercanvas/wx.py b/rendercanvas/wx.py index b698407..369d1ed 100644 --- a/rendercanvas/wx.py +++ b/rendercanvas/wx.py @@ -386,7 +386,7 @@ def _on_resize(self, event: wx.SizeEvent): # We use the same logic as for Qt to derive the physical size. pwidth = round(lsize[0] * ratio + 0.01) pheight = round(lsize[1] * ratio + 0.01) - self._set_size_info(pwidth, pheight, ratio) + self._size_info.set_physical_size(pwidth, pheight, ratio) def _on_key_down(self, event: wx.KeyEvent): char_str = self._get_char_from_event(event) diff --git a/tests/test_base.py b/tests/test_base.py index cedbbd2..74da38a 100644 --- a/tests/test_base.py +++ b/tests/test_base.py @@ -76,7 +76,7 @@ class MyOffscreenCanvas(rendercanvas.BaseRenderCanvas): def __init__(self): super().__init__() self.frame_count = 0 - self._set_size_info(100, 100, 1) + self._size_info.set_physical_size(100, 100, 1) def _rc_get_present_methods(self): return { @@ -153,7 +153,7 @@ def draw_frame(): assert np.all(canvas.array[:, :, 1] == 255) # Change resolution - canvas._set_size_info(120, 100, 1) + canvas._size_info.set_physical_size(120, 100, 1) # Draw 3 canvas.force_draw() @@ -162,7 +162,7 @@ def draw_frame(): assert np.all(canvas.array[:, :, 1] == 255) # Change resolution - canvas._set_size_info(120, 140, 1) + canvas._size_info.set_physical_size(120, 140, 1) # Draw 4 canvas.force_draw() diff --git a/tests/test_offscreen.py b/tests/test_offscreen.py index ffcca1c..cf29daa 100644 --- a/tests/test_offscreen.py +++ b/tests/test_offscreen.py @@ -107,5 +107,33 @@ def test_offscreen_canvas_del(): assert ref() is None +def test_offscreen_extra_size_methods(): + from rendercanvas.offscreen import RenderCanvas + + c = RenderCanvas() + + assert c.get_physical_size() == (640, 480) + assert c.get_logical_size() == (640.0, 480.0) + assert c.get_pixel_ratio() == 1.0 + + c.set_physical_size(100, 100) + + assert c.get_physical_size() == (100, 100) + assert c.get_logical_size() == (100.0, 100.0) + assert c.get_pixel_ratio() == 1.0 + + c.set_pixel_ratio(2) + + assert c.get_physical_size() == (100, 100) + assert c.get_logical_size() == (50.0, 50.0) + assert c.get_pixel_ratio() == 2.0 + + c.set_logical_size(100, 100) + + assert c.get_physical_size() == (200, 200) + assert c.get_logical_size() == (100.0, 100.0) + assert c.get_pixel_ratio() == 2.0 + + if __name__ == "__main__": run_tests(globals()) diff --git a/tests/test_size.py b/tests/test_size.py new file mode 100644 index 0000000..b27c129 --- /dev/null +++ b/tests/test_size.py @@ -0,0 +1,160 @@ +""" +Test size mechanics. +""" + +from testutils import run_tests +from rendercanvas._size import SizeInfo +from rendercanvas.base import BaseRenderCanvas + + +def test_size_info_basic(): + si = SizeInfo() + assert si["physical_size"] == (1, 1) + assert si["logical_size"] == (1.0, 1.0) + assert si["total_pixel_ratio"] == 1.0 + assert si["need_size_event"] is False + + # Backends setting physical size + si.set_physical_size(10, 10, 1) + + assert si["physical_size"] == (10, 10) + assert si["logical_size"] == (10.0, 10.0) + assert si["total_pixel_ratio"] == 1.0 + assert si["need_size_event"] is True + + # Different pixel ratio + si.set_physical_size(10, 10, 2.5) + + assert si["physical_size"] == (10, 10) + assert si["logical_size"] == (4.0, 4.0) + assert si["total_pixel_ratio"] == 2.5 + assert si["need_size_event"] is True + + +def test_size_info_logical(): + si = SizeInfo() + assert si["physical_size"] == (1, 1) + assert si["logical_size"] == (1.0, 1.0) + assert si["total_pixel_ratio"] == 1.0 + assert si["need_size_event"] is False + + # Base class setting logical size + si.set_logical_size(100, 100) + + assert si["physical_size"] == (1, 1) # don't touch! + assert si["logical_size"] == (100.0, 100.0) + assert si["total_pixel_ratio"] == 0.01 + assert si["need_size_event"] is True + + # Again + si.set_logical_size(640, 480) + + assert si["physical_size"] == (1, 1) # don't touch! + assert si["logical_size"] == (640.0, 480.0) + assert si["total_pixel_ratio"] == 1 / 640 + + # Now backend adjusts its actual size + si.set_physical_size(1280, 960, 2.0) + + assert si["physical_size"] == (1280, 960) + assert si["logical_size"] == (640.0, 480.0) + assert si["total_pixel_ratio"] == 2.0 + + # Base class sets logical size again + si.set_logical_size(100.2, 100.2) + + assert si["physical_size"] == (1280, 960) # don't touch! + assert si["logical_size"] == (100.0, 100.0) # took ratio into account + assert si["total_pixel_ratio"] == 1280 / 100 + + # And again + si.set_logical_size(101.8, 101.8) + + assert si["physical_size"] == (1280, 960) # don't touch! + assert si["logical_size"] == (102.0, 102.0) # took ratio into account + assert si["total_pixel_ratio"] == 1280 / 102 + + # And backend adjusts its actual size + si.set_physical_size(204, 204, 2.0) + + assert si["physical_size"] == (204, 204) + assert si["logical_size"] == (102, 102.0) + assert si["total_pixel_ratio"] == 2.0 + + +def test_size_info_zoom(): + si = SizeInfo() + si.set_physical_size(1200, 1200, 2.0) + + assert si["physical_size"] == (1200, 1200) + assert si["logical_size"] == (600.0, 600.0) + assert si["total_pixel_ratio"] == 2.0 + + # Adjust zoom + si.set_zoom(3) + + assert si["physical_size"] == (1200, 1200) + assert si["logical_size"] == (200.0, 200.0) + assert si["native_pixel_ratio"] == 2.0 + assert si["total_pixel_ratio"] == 6.0 + + # Backend updates its size, zoom is maintained + si.set_physical_size(1800, 1800, 2.0) + + assert si["physical_size"] == (1800, 1800) + assert si["logical_size"] == (300.0, 300.0) + assert si["native_pixel_ratio"] == 2.0 + assert si["total_pixel_ratio"] == 6.0 + + # Backend updates its physical size + si.set_physical_size(600, 600, 1.0) + + assert si["physical_size"] == (600, 600) + assert si["logical_size"] == (200.0, 200.0) + assert si["native_pixel_ratio"] == 1.0 + assert si["total_pixel_ratio"] == 3.0 + + # Adjust zoom last time + si.set_zoom(2) + + assert si["physical_size"] == (600, 600) + assert si["logical_size"] == (300.0, 300.0) + assert si["native_pixel_ratio"] == 1.0 + assert si["total_pixel_ratio"] == 2.0 + + +class MyRenderCanvas(BaseRenderCanvas): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._requested_size = None + self._final_canvas_init() + + def _rc_set_logical_size(self, w, h): + self._requested_size = w, h + + def apply_size(self): + if self._requested_size: + self._size_info.set_physical_size(*self._requested_size, 1) + self._requested_size = None + + +def test_canvas_sizing(): + c = MyRenderCanvas(size=None) + + assert c.get_logical_size() == (1.0, 1.0) + + c = MyRenderCanvas() + + assert c.get_logical_size() == (640.0, 480.0) + assert c.get_physical_size() == (1, 1) + assert c.get_pixel_ratio() == 1 / 640 + + c.apply_size() + + assert c.get_logical_size() == (640.0, 480.0) + assert c.get_physical_size() == (640, 480) + assert c.get_pixel_ratio() == 1.0 + + +if __name__ == "__main__": + run_tests(globals())