diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ebb58745..5a721123 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -73,12 +73,9 @@ jobs: python -m pip install --upgrade pip pip install -e . - name: Test imports - env: - WGPU_FORCE_OFFSCREEN: true run: | python -c "print('wgpu'); import wgpu; print(wgpu)" python -c "print('wgpu.backends.wgpu_native'); import wgpu.backends.wgpu_native" - python -c "print('wgpu.gui.offscreen'); import wgpu.gui.offscreen" python -c "print('wgpu.utils'); import wgpu.utils" docs-build: @@ -144,7 +141,7 @@ jobs: run: | python -m pip install --upgrade pip pip install -U . - pip install -U pytest numpy psutil pyinstaller glfw + pip install -U pytest numpy psutil pyinstaller - name: Test PyInstaller run: | pushd $HOME diff --git a/README.md b/README.md index 668b5a30..ad6cce50 100644 --- a/README.md +++ b/README.md @@ -60,7 +60,11 @@ API closely resembling the [WebGPU spec](https://gpuweb.github.io/gpuweb/). ## Installation ``` -pip install wgpu glfw +# Just wgpu +pip install wgpu + +# If you want to render to screen +pip install wgpu rendercanvas glfw ``` Linux users should make sure that **pip >= 20.3**. That should do the @@ -80,16 +84,13 @@ import wgpu To render to the screen you can use a variety of GUI toolkits: ```py -# The auto backend selects either the glfw, qt or jupyter backend -from wgpu.gui.auto import WgpuCanvas, run, call_later +# The rendercanvas auto backend selects either the glfw, qt, wx, or jupyter backend +from rendercanvas.auto import RenderCanvas, loop # Visualizations can be embedded as a widget in a Qt application. # Import PySide6, PyQt6, PySide2 or PyQt5 before running the line below. # The code will detect and use the library that is imported. -from wgpu.gui.qt import WgpuCanvas - -# Visualizations can be embedded as a widget in a wx application. -from wgpu.gui.wx import WgpuCanvas +from rendercanvas.qt import RenderCanvas ``` Some functions in the original `wgpu-native` API are async. In the Python API, @@ -159,7 +160,7 @@ call it to see if an image is produced. To support this type of testing, ensure the following requirements are met: -* The `WgpuCanvas` class is imported from the `wgpu.gui.auto` module. +* The `RenderCanvas` class is imported from the `rendercanvas.auto` module. * The `canvas` instance is exposed as a global in the module. * A rendering callback has been registered with `canvas.request_draw(fn)`. diff --git a/docs/conf.py b/docs/conf.py index e3c5a31c..96555c3a 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -19,7 +19,7 @@ ROOT_DIR = os.path.abspath(os.path.join(__file__, "..", "..")) sys.path.insert(0, ROOT_DIR) -os.environ["WGPU_FORCE_OFFSCREEN"] = "true" +os.environ["RENDERCANVAS_FORCE_OFFSCREEN"] = "true" # Load wgpu so autodoc can query docstrings @@ -99,7 +99,7 @@ def resolve_crossrefs(text): # Tweak docstrings of classes and their methods -for module, hide_class_signature in [(wgpu.classes, True), (wgpu.gui, False)]: +for module, hide_class_signature in [(wgpu.classes, True)]: for cls_name in module.__all__: cls = getattr(module, cls_name) # Class docstring diff --git a/docs/gui.rst b/docs/gui.rst deleted file mode 100644 index 0680992f..00000000 --- a/docs/gui.rst +++ /dev/null @@ -1,212 +0,0 @@ -gui API -======= - -.. currentmodule:: wgpu.gui - -You can use vanilla wgpu for compute tasks and to render offscreen. To -render to a window on screen we need a *canvas*. Since the Python -ecosystem provides many different GUI toolkits, wgpu implements a base -canvas class, and has builtin support for a few GUI toolkits. At the -moment these include GLFW, Jupyter, Qt, and wx. - - -The Canvas base classes ------------------------ - -.. autosummary:: - :nosignatures: - :toctree: generated - :template: wgpu_class_layout.rst - - ~WgpuCanvasInterface - ~WgpuCanvasBase - ~WgpuAutoGui - - -For each supported GUI toolkit there is a module that implements a ``WgpuCanvas`` class, -which inherits from :class:`WgpuCanvasBase`, providing a common API. -The GLFW, Qt, and Jupyter backends also inherit from :class:`WgpuAutoGui` to include -support for events (interactivity). In the next sections we demonstrates the different -canvas classes that you can use. - - -The auto GUI backend --------------------- - -Generally the best approach for examples and small applications is to use the -automatically selected GUI backend. This ensures that the code is portable -across different machines and environments. Using ``wgpu.gui.auto`` selects a -suitable backend depending on the environment and more. See -:ref:`interactive_use` for details. - -To implement interaction, the ``canvas`` has a :func:`WgpuAutoGui.handle_event()` method -that can be overloaded. Alternatively you can use it's :func:`WgpuAutoGui.add_event_handler()` -method. See the `event spec `_ -for details about the event objects. - -Also see the `triangle auto `_ -and `cube `_ examples that demonstrate the auto gui. - -.. code-block:: py - - from wgpu.gui.auto import WgpuCanvas, run, call_later - - canvas = WgpuCanvas(title="Example") - canvas.request_draw(your_draw_function) - - run() - - -Support for GLFW ----------------- - -`GLFW `_ is a lightweight windowing toolkit. -Install it with ``pip install glfw``. The preferred approach is to use the auto backend, -but you can replace ``from wgpu.gui.auto`` with ``from wgpu.gui.glfw`` to force using GLFW. - -.. code-block:: py - - from wgpu.gui.glfw import WgpuCanvas, run, call_later - - canvas = WgpuCanvas(title="Example") - canvas.request_draw(your_draw_function) - - run() - - -Support for Qt --------------- - -There is support for PyQt5, PyQt6, PySide2 and PySide6. The wgpu library detects what -library you are using by looking what module has been imported. -For a toplevel widget, the ``gui.qt.WgpuCanvas`` class can be imported. If you want to -embed the canvas as a subwidget, use ``gui.qt.WgpuWidget`` instead. - -Also see the `Qt triangle example `_ -and `Qt triangle embed example `_. - -.. code-block:: py - - # Import any of the Qt libraries before importing the WgpuCanvas. - # This way wgpu knows which Qt library to use. - from PySide6 import QtWidgets - from wgpu.gui.qt import WgpuCanvas - - app = QtWidgets.QApplication([]) - - # Instantiate the canvas - canvas = WgpuCanvas(title="Example") - - # Tell the canvas what drawing function to call - canvas.request_draw(your_draw_function) - - app.exec_() - - -Support for wx --------------- - -There is support for embedding a wgpu visualization in wxPython. -For a toplevel widget, the ``gui.wx.WgpuCanvas`` class can be imported. If you want to -embed the canvas as a subwidget, use ``gui.wx.WgpuWidget`` instead. - -Also see the `wx triangle example `_ -and `wx triangle embed example `_. - -.. code-block:: py - - import wx - from wgpu.gui.wx import WgpuCanvas - - app = wx.App() - - # Instantiate the canvas - canvas = WgpuCanvas(title="Example") - - # Tell the canvas what drawing function to call - canvas.request_draw(your_draw_function) - - app.MainLoop() - - - -Support for offscreen ---------------------- - -You can also use a "fake" canvas to draw offscreen and get the result as a numpy array. -Note that you can render to a texture without using any canvas -object, but in some cases it's convenient to do so with a canvas-like API. - -.. code-block:: py - - from wgpu.gui.offscreen import WgpuCanvas - - # Instantiate the canvas - canvas = WgpuCanvas(size=(500, 400), pixel_ratio=1) - - # ... - - # Tell the canvas what drawing function to call - canvas.request_draw(your_draw_function) - - # Perform a draw - array = canvas.draw() # numpy array with shape (400, 500, 4) - - -Support for Jupyter lab and notebook ------------------------------------- - -WGPU can be used in Jupyter lab and the Jupyter notebook. This canvas -is based on `jupyter_rfb `_, an ipywidget -subclass implementing a remote frame-buffer. There are also some `wgpu examples `_. - -.. code-block:: py - - # from wgpu.gui.jupyter import WgpuCanvas # Direct approach - from wgpu.gui.auto import WgpuCanvas # Approach compatible with desktop usage - - canvas = WgpuCanvas() - - # ... wgpu code - - canvas # Use as cell output - - -.. _interactive_use: - -Using wgpu interactively ------------------------- - -The wgpu gui's are designed to support interactive use. Firstly, this is -realized by automatically selecting the appropriate GUI backend. Secondly, the -``run()`` function (which normally enters the event-loop) does nothing in an -interactive session. - -Many interactive environments have some sort of GUI support, allowing the repl -to stay active (i.e. you can run new code), while the GUI windows is also alive. -In wgpu we try to select the GUI that matches the current environment. - -On ``jupyter notebook`` and ``jupyter lab`` the jupyter backend (i.e. -``jupyter_rfb``) is normally selected. When you are using ``%gui qt``, wgpu will -honor that and use Qt instead. - -On ``jupyter console`` and ``qtconsole``, the kernel is the same as in ``jupyter notebook``, -making it (about) impossible to tell that we cannot actually use -ipywidgets. So it will try to use ``jupyter_rfb``, but cannot render anything. -It's theefore advised to either use ``%gui qt`` or set the ``WGPU_GUI_BACKEND`` env var -to "glfw". The latter option works well, because these kernels *do* have a -running asyncio event loop! - -On other environments that have a running ``asyncio`` loop, the glfw backend is -preferred. E.g on ``ptpython --asyncio``. - -On IPython (the old-school terminal app) it's advised to use ``%gui qt`` (or -``--gui qt``). It seems not possible to have a running asyncio loop here. - -On IDE's like Spyder or Pyzo, wgpu detects the integrated GUI, running on -glfw if asyncio is enabled or Qt if a qt app is running. - -On an interactive session without GUI support, one must call ``run()`` to make -the canvases interactive. This enters the main loop, which prevents entering new -code. Once all canvases are closed, the loop returns. If you make new canvases -afterwards, you can call ``run()`` again. This is similar to ``plt.show()`` in Matplotlib. diff --git a/docs/guide.rst b/docs/guide.rst index d88a5c27..f8107234 100644 --- a/docs/guide.rst +++ b/docs/guide.rst @@ -15,13 +15,13 @@ Creating a canvas +++++++++++++++++ If you want to render to the screen, you need a canvas. Multiple -GUI toolkits are supported, see the :doc:`gui`. In general, it's easiest to let ``wgpu`` select a GUI automatically: +GUI toolkits are supported, see https://rendercanvas.readthedocs.io/stable/backends.htm. In general, it's easiest to let ``rendercanvas`` select a GUI automatically: .. code-block:: py - from wgpu.gui.auto import WgpuCanvas, run + from rendercanvas.auto import RenderCanvas, loop - canvas = WgpuCanvas(title="a wgpu example") + canvas = RenderCanvas(title="a wgpu example") Next, we can setup the render context, which we will need later on. @@ -94,21 +94,24 @@ the previous step. render_pass.end() device.queue.submit([command_encoder.finish()]) - # If you want to draw continuously, request a new draw right now + # You can request a new draw when you know something has changed. canvas.request_draw() + # Alternatively you can tell the canvas to draw continuously with + # canvas = RenderCanvas(update_mode='continuous') + Starting the event loop +++++++++++++++++++++++ We can now pass the above render function to the canvas. The canvas will then -call the function whenever it (re)draws the window. And finally, we call ``run()`` to enter the mainloop. +call the function whenever it (re)draws the window. And finally, we call ``loop.run()`` to enter the mainloop. .. code-block:: py canvas.request_draw(draw_frame) - run() + loop.run() Offscreen @@ -247,8 +250,5 @@ In wgpu a PyInstaller-hook is provided to help simplify the freezing process (it e.g. ensures that the wgpu-native DLL is included). This hook requires PyInstaller version 4+. -Our hook also includes ``glfw`` when it is available, so code using ``wgpu.gui.auto`` -should Just Work. - Note that PyInstaller needs ``wgpu`` to be installed in `site-packages` for the hook to work (i.e. it seems not to work with a ``pip -e .`` dev install). diff --git a/docs/index.rst b/docs/index.rst index 817c9f2f..1e217919 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -11,7 +11,6 @@ Welcome to the wgpu-py docs! guide wgpu backends - gui utils diff --git a/docs/start.rst b/docs/start.rst index 189cfb2c..ce258bd9 100644 --- a/docs/start.rst +++ b/docs/start.rst @@ -14,22 +14,11 @@ Python 3.9 or higher is required. Pypy is supported. pip install wgpu -Since most users will want to render something to screen, we recommend installing GLFW as well: +Since most users will want to render something to screen, we recommend installing `rendercanvas `_ and `glfw `_ as well: .. code-block:: bash - pip install wgpu glfw - - -GUI libraries -------------- - -Multiple GUI backends are supported, see :doc:`the GUI API ` for details: - -* `glfw `_: a lightweight GUI for the desktop -* `jupyter_rfb `_: only needed if you plan on using wgpu in Jupyter -* qt (PySide6, PyQt6, PySide2, PyQt5) -* wx + pip install wgpu rendercanvas glfw The wgpu-native library diff --git a/docs/wgpu.rst b/docs/wgpu.rst index 04900403..0b1c8656 100644 --- a/docs/wgpu.rst +++ b/docs/wgpu.rst @@ -66,6 +66,18 @@ come in two flafours: is less portable (to e.g. pyodide/pyscript). +Canvas API +---------- + +In order for wgpu to render to a canvas (which can be on screen, inside a GUI, offscreen, etc.), +a canvas object is needed. We recommend using the `rendercanvas `_ library to get a wide variety of canvases. + +That said, the canvas object can be any object, as long as it adheres to the +``WgpuCanvasInterface``, see https://github.com/pygfx/wgpu-py/blob/main/wgpu/_canvas.py for details. + + + + Overview -------- diff --git a/examples/gui_direct.py b/examples/gui_direct.py index b7c6b567..232ff8bf 100644 --- a/examples/gui_direct.py +++ b/examples/gui_direct.py @@ -1,6 +1,6 @@ """ Direct integration of glfw and wgpu-py without using the -wgpu.gui Canvas abstraction/class hierarchy. +RenderCanvas abstraction/class hierarchy. Demonstration for hardcore users that need total low-level control. diff --git a/pyproject.toml b/pyproject.toml index b820811b..c3325068 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,15 +17,21 @@ dependencies = [ [project.optional-dependencies] # For users -jupyter = ["jupyter_rfb>=0.4.2"] -glfw = ["glfw>=1.9"] imgui = ["imgui-bundle>=1.92.0, <2"] # For devs / ci build = ["build", "hatchling", "requests", "twine"] codegen = ["pytest", "numpy", "ruff"] lint = ["ruff", "pre-commit"] -tests = ["numpy", "pytest", "psutil", "imageio", "anyio", "trio"] -examples = ["pypng", "rendercanvas<=2.1.2"] +tests = [ + "numpy", + "pytest", + "psutil", + "imageio", + "anyio", + "trio", + "rendercanvas", +] +examples = ["pypng", "rendercanvas", "glfw"] docs = ["sphinx>7.2", "sphinx_rtd_theme"] dev = ["wgpu[build,codegen,lint,tests,examples,docs]"] diff --git a/tests/renderutils.py b/tests/renderutils.py index 1d3178ea..468d5ed9 100644 --- a/tests/renderutils.py +++ b/tests/renderutils.py @@ -232,15 +232,13 @@ def render_to_screen( renderpass_callback=lambda *args: None, ): """Render to a window on screen, for debugging purposes.""" - import glfw - from wgpu.gui.glfw import WgpuCanvas, update_glfw_canvasses + from rendercanvas.glfw import RenderCanvas, loop vbos = vbos or [] vbo_views = vbo_views or [] # Setup canvas - glfw.init() - canvas = WgpuCanvas(title="wgpu test render with GLFW") + canvas = RenderCanvas(title="wgpu test render with GLFW") shader = device.create_shader_module(code=shader_source) @@ -327,6 +325,4 @@ def draw_frame(): canvas.request_draw(draw_frame) # Enter main loop - while update_glfw_canvasses(): - glfw.poll_events() - glfw.terminate() + loop.run() diff --git a/tests/test_api.py b/tests/test_api.py index 011fd641..c1e60325 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -30,7 +30,7 @@ def test_basic_api(): def test_api_subpackages_are_there(): - code = "import wgpu; x = [wgpu.resources, wgpu.utils, wgpu.backends, wgpu.gui]; print('ok')" + code = "import wgpu; x = [wgpu.resources, wgpu.utils, wgpu.backends]; print('ok')" result = subprocess.run( [sys.executable, "-c", code], stdout=subprocess.PIPE, diff --git a/tests/test_canvas.py b/tests/test_canvas.py new file mode 100644 index 00000000..f6573892 --- /dev/null +++ b/tests/test_canvas.py @@ -0,0 +1,202 @@ +"""Test that wgpu works together with rendercanvas.""" + +import sys + +import wgpu + +# from rendercanvas import BaseRenderCanvas +from rendercanvas.offscreen import RenderCanvas +from wgpu._canvas import WgpuCanvasInterface + +from pytest import skip +from testutils import run_tests, can_use_wgpu_lib + + +if not can_use_wgpu_lib: + skip("Skipping tests that need the wgpu lib", allow_module_level=True) + + +shader_source = """ +@vertex +fn vs_main(@builtin(vertex_index) vertex_index : u32) -> @builtin(position) vec4 { + var positions: array, 3> = array, 3>(vec2(0.0, -0.5), vec2(0.5, 0.5), vec2(-0.5, 0.7)); + let p: vec2 = positions[vertex_index]; + return vec4(p, 0.0, 1.0); +} + +@fragment +fn fs_main() -> @location(0) vec4 { + return vec4(1.0, 0.5, 0.0, 1.0); +} +""" + + +def test_rendercanvas(): + """Render an orange square ... in an offscreen RenderCanvas + + If this works, we can assume the other rendercanvas backends work too. + """ + + canvas = RenderCanvas(size=(640, 480)) + + device = wgpu.utils.get_default_device() + draw_frame1 = _get_draw_function(device, canvas) + + frame_counter = 0 + + def draw_frame2(): + nonlocal frame_counter + frame_counter += 1 + draw_frame1() + + canvas.request_draw(draw_frame2) + + m = canvas.draw() + assert isinstance(m, memoryview) + assert m.shape == (480, 640, 4) + assert frame_counter == 1 + + for i in range(5): + canvas.draw() + + assert frame_counter == 6 + + # Change the canvas size + canvas.set_logical_size(300, 200) + m = canvas.draw() + assert m.shape == (200, 300, 4) + + +def test_canvas_interface(): + """Render an orange square ... using the WgpuCanvasInterface.""" + canvas = WgpuCanvasInterface() + + device = wgpu.utils.get_default_device() + draw_frame = _get_draw_function(device, canvas) + + def draw(): + draw_frame() + info = canvas.get_context().present() + return info["data"] + + m = draw() + assert isinstance(m, memoryview) + assert m.shape == (480, 640, 4) + + +def test_custom_canvas(): + """Render an orange square ... in a custom offscreen canvas. + + This helps make sure that WgpuCanvasInterface is indeed + the minimal required canvas API. + """ + + class CustomCanvas: # implements wgpu.WgpuCanvasInterface + def __init__(self): + self._canvas_context = None + self._present_methods = { + "bitmap": { + "formats": ["rgba-u8"], + } + } + + def get_physical_size(self): + return 300, 200 + + def get_context(self, context_type="wgpu"): + assert context_type == "wgpu" + if self._canvas_context is None: + backend_module = sys.modules["wgpu"].gpu.__module__ + CC = sys.modules[backend_module].GPUCanvasContext # noqa N806 + self._canvas_context = CC(self, self._present_methods) + return self._canvas_context + + canvas = CustomCanvas() + + # Also pass canvas here, to touch that code somewhere + adapter = wgpu.gpu.request_adapter_sync( + canvas=canvas, power_preference="high-performance" + ) + device = adapter.request_device_sync() + draw_frame = _get_draw_function(device, canvas) + + def draw(): + draw_frame() + info = canvas.get_context().present() + return info["data"] + + m = draw() + assert isinstance(m, memoryview) + assert m.shape == (200, 300, 4) + + +def _get_draw_function(device, canvas): + # Bindings and layout + pipeline_layout = device.create_pipeline_layout(bind_group_layouts=[]) + + shader = device.create_shader_module(code=shader_source) + + present_context = canvas.get_context("wgpu") + render_texture_format = present_context.get_preferred_format(device.adapter) + present_context.configure(device=device, format=render_texture_format) + + render_pipeline = device.create_render_pipeline( + label="my-debug-pipeline", + layout=pipeline_layout, + vertex={ + "module": shader, + "entry_point": "vs_main", + "buffers": [], + }, + primitive={ + "topology": wgpu.PrimitiveTopology.triangle_strip, + "strip_index_format": wgpu.IndexFormat.uint32, + "front_face": wgpu.FrontFace.ccw, + "cull_mode": wgpu.CullMode.none, + }, + depth_stencil=None, + multisample={ + "count": 1, + "mask": 0xFFFFFFFF, + "alpha_to_coverage_enabled": False, + }, + fragment={ + "module": shader, + "entry_point": "fs_main", + "targets": [ + { + "format": render_texture_format, + "blend": { + "color": {}, # use defaults + "alpha": {}, # use defaults + }, + }, + ], + }, + ) + + def draw_frame(): + current_texture_view = present_context.get_current_texture().create_view() + command_encoder = device.create_command_encoder() + assert current_texture_view.size + ca = { + "view": current_texture_view, + "resolve_target": None, + "clear_value": (0, 0, 0, 0), + "load_op": wgpu.LoadOp.clear, + "store_op": wgpu.StoreOp.store, + } + render_pass = command_encoder.begin_render_pass( + color_attachments=[ca], + ) + + render_pass.set_pipeline(render_pipeline) + render_pass.draw(4, 1, 0, 0) + render_pass.end() + device.queue.submit([command_encoder.finish()]) + + return draw_frame + + +if __name__ == "__main__": + run_tests(globals()) diff --git a/tests/test_gui_auto_offscreen.py b/tests/test_gui_auto_offscreen.py deleted file mode 100644 index e01ef8a4..00000000 --- a/tests/test_gui_auto_offscreen.py +++ /dev/null @@ -1,66 +0,0 @@ -""" -Test the force offscreen auto gui mechanism. -""" - -import os -import gc -import weakref - -import wgpu -from pytest import fixture, skip -from testutils import can_use_wgpu_lib, is_pypy - - -if not can_use_wgpu_lib: - skip("Skipping tests that need the wgpu lib", allow_module_level=True) - - -@fixture(autouse=True, scope="module") -def force_offscreen(): - os.environ["WGPU_FORCE_OFFSCREEN"] = "true" - try: - yield - finally: - del os.environ["WGPU_FORCE_OFFSCREEN"] - - -def test_canvas_class(): - """Check if we get an offscreen canvas when the WGPU_FORCE_OFFSCREEN - environment variable is set.""" - from wgpu.gui.auto import WgpuCanvas - from wgpu.gui.offscreen import WgpuManualOffscreenCanvas - - assert WgpuCanvas is WgpuManualOffscreenCanvas - assert issubclass(WgpuCanvas, wgpu.gui.WgpuCanvasBase) - assert issubclass(WgpuCanvas, wgpu.gui.WgpuAutoGui) - - -def test_event_loop(): - """Check that the event loop handles queued tasks and then returns.""" - # Note: if this test fails, it may run forever, so it's a good idea to have a timeout on the CI job or something - - from wgpu.gui.auto import run, call_later - - ran = False - - def check(): - nonlocal ran - ran = True - - call_later(0, check) - run() - - assert ran - - -def test_offscreen_canvas_del(): - from wgpu.gui.offscreen import WgpuCanvas - - canvas = WgpuCanvas() - ref = weakref.ref(canvas) - - assert ref() is not None - del canvas - if is_pypy: - gc.collect() - assert ref() is None diff --git a/tests/test_gui_base.py b/tests/test_gui_base.py deleted file mode 100644 index 5f4d902c..00000000 --- a/tests/test_gui_base.py +++ /dev/null @@ -1,274 +0,0 @@ -""" -Test the canvas basics. -""" - -import gc -import sys -import subprocess - -import numpy as np -import wgpu.gui -from testutils import run_tests, can_use_wgpu_lib, is_pypy -from pytest import mark, raises - - -class TheTestCanvas(wgpu.gui.WgpuCanvasBase): - def __init__(self): - super().__init__() - self._count = 0 - - def draw_frame(self): - self._count += 1 - if self._count <= 4: - self.foo_method() - else: - self.spam_method() - - def foo_method(self): - self.bar_method() - - def bar_method(self): - raise Exception("call-failed-" + "but-test-passed") - - def spam_method(self): - msg = "intended-fail" # avoid line with the message to show in the tb - raise Exception(msg) - - -def test_base_canvas_context(): - assert not issubclass(wgpu.gui.WgpuCanvasInterface, wgpu.GPUCanvasContext) - assert hasattr(wgpu.gui.WgpuCanvasInterface, "get_context") - canvas = wgpu.gui.WgpuCanvasInterface() - # Cannot instantiate, because get_present_methods is not implemented - with raises(NotImplementedError): - wgpu.GPUCanvasContext(canvas, canvas.get_present_methods()) - - -def test_canvas_logging(caplog): - """As we attempt to draw, the canvas will error, which are logged. - Each first occurrence is logged with a traceback. Subsequent same errors - are much shorter and have a counter. - """ - - canvas = TheTestCanvas() - - canvas._draw_frame_and_present() # prints traceback - canvas._draw_frame_and_present() # prints short logs ... - canvas._draw_frame_and_present() - canvas._draw_frame_and_present() - - text = caplog.text - assert text.count("bar_method") == 2 # one traceback => 2 mentions - assert text.count("foo_method") == 2 - assert text.count("call-failed-but-test-passed") == 4 - assert text.count("(4)") == 1 - assert text.count("(5)") == 0 - - assert text.count("spam_method") == 0 - assert text.count("intended-fail") == 0 - - canvas._draw_frame_and_present() # prints traceback - canvas._draw_frame_and_present() # prints short logs ... - canvas._draw_frame_and_present() - canvas._draw_frame_and_present() - - text = caplog.text - assert text.count("bar_method") == 2 # one traceback => 2 mentions - assert text.count("foo_method") == 2 - assert text.count("call-failed-but-test-passed") == 4 - - assert text.count("spam_method") == 2 - assert text.count("intended-fail") == 4 - - -class MyOffscreenCanvas(wgpu.gui.WgpuCanvasBase): - def __init__(self): - super().__init__() - self.frame_count = 0 - self.physical_size = 100, 100 - - def get_present_methods(self): - return {"bitmap": {"formats": ["rgba-u8"]}} - - def present_image(self, image, **kwargs): - self.frame_count += 1 - self.array = np.frombuffer(image, np.uint8).reshape(image.shape) - - def get_pixel_ratio(self): - return 1 - - def get_logical_size(self): - return self.get_physical_size() - - def get_physical_size(self): - return self.physical_size - - def _request_draw(self): - # Note: this would normally schedule a call in a later event loop iteration - self._draw_frame_and_present() - - -@mark.skipif(not can_use_wgpu_lib, reason="Needs wgpu lib") -def test_run_bare_canvas(): - """Test that a bare canvas does not error.""" - - # This is (more or less) the equivalent of: - # - # from wgpu.gui.auto import WgpuCanvas, run - # canvas = WgpuCanvas() - # run() - # - # Note: run() calls _draw_frame_and_present() in event loop. - - canvas = MyOffscreenCanvas() - canvas._draw_frame_and_present() - - -def test_canvas_context_not_base(): - """Check that it is prevented that canvas context is instance of base context class.""" - return # skip - - code = "from wgpu.gui import WgpuCanvasBase; canvas = WgpuCanvasBase(); canvas.get_context()" - - result = subprocess.run( - [sys.executable, "-c", code], - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, - universal_newlines=True, - ) - out = result.stdout.rstrip() - - assert "RuntimeError" in out - assert "backend must be selected" in out.lower() - assert "canvas.get_context" in out.lower() - - -@mark.skipif(not can_use_wgpu_lib, reason="Needs wgpu lib") -def test_offscreen_canvas(): - canvas = MyOffscreenCanvas() - device = wgpu.utils.get_default_device() - present_context = canvas.get_context("wgpu") - present_context.configure(device=device, format=None) - - def draw_frame(): - # Note: we deliberately obtain the texture, and only create the view - # where the dict is constructed below. This covers the case where - # begin_render_pass() has to prevent the texture-view-object from being - # deleted before its native handle is passed to wgpu-native. - current_texture = present_context.get_current_texture() - command_encoder = device.create_command_encoder() - render_pass = command_encoder.begin_render_pass( - color_attachments=[ - { - "view": current_texture.create_view(), - "resolve_target": None, - "clear_value": (0, 1, 0, 1), - "load_op": wgpu.LoadOp.clear, - "store_op": wgpu.StoreOp.store, - } - ], - ) - render_pass.end() - device.queue.submit([command_encoder.finish()]) - - assert canvas.frame_count == 0 - - # Draw 1 - canvas.request_draw(draw_frame) - assert canvas.array.shape == (100, 100, 4) - assert np.all(canvas.array[:, :, 0] == 0) - assert np.all(canvas.array[:, :, 1] == 255) - - # Draw 2 - canvas.request_draw(draw_frame) - assert canvas.array.shape == (100, 100, 4) - assert np.all(canvas.array[:, :, 0] == 0) - assert np.all(canvas.array[:, :, 1] == 255) - - # Change resolution - canvas.physical_size = 120, 100 - - # Draw 3 - canvas.request_draw(draw_frame) - assert canvas.array.shape == (100, 120, 4) - assert np.all(canvas.array[:, :, 0] == 0) - assert np.all(canvas.array[:, :, 1] == 255) - - # Change resolution - canvas.physical_size = 120, 140 - - # Draw 4 - canvas.request_draw(draw_frame) - assert canvas.array.shape == (140, 120, 4) - assert np.all(canvas.array[:, :, 0] == 0) - assert np.all(canvas.array[:, :, 1] == 255) - - # We now have four unique texture objects - assert canvas.frame_count == 4 - - -def test_autogui_mixin(): - c = wgpu.gui.WgpuAutoGui() - - # It's a mixin - assert not isinstance(c, wgpu.gui.WgpuCanvasBase) - - # It's event handling mechanism should be fully functional - - events = [] - - def handler(event): - events.append(event["value"]) - - c.add_event_handler(handler, "foo", "bar") - c.handle_event({"event_type": "foo", "value": 1}) - c.handle_event({"event_type": "bar", "value": 2}) - c.handle_event({"event_type": "spam", "value": 3}) - c.remove_event_handler(handler, "foo") - c.handle_event({"event_type": "foo", "value": 4}) - c.handle_event({"event_type": "bar", "value": 5}) - c.handle_event({"event_type": "spam", "value": 6}) - c.remove_event_handler(handler, "bar") - c.handle_event({"event_type": "foo", "value": 7}) - c.handle_event({"event_type": "bar", "value": 8}) - c.handle_event({"event_type": "spam", "value": 9}) - - assert events == [1, 2, 5] - - -def test_weakbind(): - weakbind = wgpu.gui._gui_utils.weakbind - - xx = [] - - class Foo: - def bar(self): - xx.append(1) - - f1 = Foo() - f2 = Foo() - - b1 = f1.bar - b2 = weakbind(f2.bar) - - assert len(xx) == 0 - b1() - assert len(xx) == 1 - b2() - assert len(xx) == 2 - - del f1 - del f2 - - if is_pypy: - gc.collect() - - assert len(xx) == 2 - b1() - assert len(xx) == 3 # f1 still exists - b2() - assert len(xx) == 3 # f2 is gone! - - -if __name__ == "__main__": - run_tests(globals()) diff --git a/tests/test_gui_glfw.py b/tests/test_gui_glfw.py deleted file mode 100644 index ad9d10fd..00000000 --- a/tests/test_gui_glfw.py +++ /dev/null @@ -1,281 +0,0 @@ -""" -Test the canvas, and parts of the rendering that involves a canvas, -like the canvas context and surface texture. -""" - -import sys -import time -import weakref -import asyncio -import gc - -import wgpu -from pytest import skip -from testutils import run_tests, can_use_glfw, can_use_wgpu_lib, is_pypy -# from renderutils import render_to_texture, render_to_screen - - -if not can_use_glfw or not can_use_wgpu_lib: - skip("Skipping tests that need a window or the wgpu lib", allow_module_level=True) - - -def setup_module(): - import glfw - - glfw.init() - - -def teardown_module(): - from wgpu.gui.glfw import poll_glfw_briefly - - poll_glfw_briefly() - pass # Do not glfw.terminate() because other tests may still need glfw - - -def test_is_autogui(): - from wgpu.gui.glfw import WgpuCanvas - - assert issubclass(WgpuCanvas, wgpu.gui.WgpuCanvasBase) - assert issubclass(WgpuCanvas, wgpu.gui.WgpuAutoGui) - - -def test_glfw_canvas_basics(): - """Create a window and check some of its behavior. No wgpu calls here.""" - - import glfw - from wgpu.gui.glfw import WgpuCanvas - - canvas = WgpuCanvas() - - canvas.set_logical_size(300, 200) - etime = time.time() + 0.1 - while time.time() < etime: - glfw.poll_events() - lsize = canvas.get_logical_size() - assert isinstance(lsize, tuple) and len(lsize) == 2 - assert isinstance(lsize[0], float) and isinstance(lsize[1], float) - assert lsize == (300.0, 200.0) - - assert len(canvas.get_physical_size()) == 2 - assert isinstance(canvas.get_pixel_ratio(), float) - - # Close - assert not canvas.is_closed() - if sys.platform.startswith("win"): # On Linux we cant do this multiple times - canvas.close() - glfw.poll_events() - assert canvas.is_closed() - - -def test_glfw_canvas_del(): - from wgpu.gui.glfw import WgpuCanvas, update_glfw_canvasses - import glfw - - loop = asyncio.get_event_loop() - - async def miniloop(): - for i in range(10): - glfw.poll_events() - update_glfw_canvasses() - await asyncio.sleep(0.01) - - canvas = WgpuCanvas() - ref = weakref.ref(canvas) - - assert ref() is not None - loop.run_until_complete(miniloop()) - assert ref() is not None - del canvas - if is_pypy: - gc.collect() # force garbage collection for pypy - loop.run_until_complete(miniloop()) - assert ref() is None - - -shader_source = """ -@vertex -fn vs_main(@builtin(vertex_index) vertex_index : u32) -> @builtin(position) vec4 { - var positions: array, 3> = array, 3>(vec2(0.0, -0.5), vec2(0.5, 0.5), vec2(-0.5, 0.7)); - let p: vec2 = positions[vertex_index]; - return vec4(p, 0.0, 1.0); -} - -@fragment -fn fs_main() -> @location(0) vec4 { - return vec4(1.0, 0.5, 0.0, 1.0); -} -""" - - -def test_glfw_canvas_render(): - """Render an orange square ... in a glfw window.""" - - import glfw - from wgpu.gui.glfw import update_glfw_canvasses, WgpuCanvas - - loop = asyncio.get_event_loop() - - canvas = WgpuCanvas(max_fps=9999) - - device = wgpu.utils.get_default_device() - draw_frame1 = _get_draw_function(device, canvas) - - frame_counter = 0 - - def draw_frame2(): - nonlocal frame_counter - frame_counter += 1 - draw_frame1() - - canvas.request_draw(draw_frame2) - - # Give it a few rounds to start up - async def miniloop(): - for i in range(10): - glfw.poll_events() - update_glfw_canvasses() - await asyncio.sleep(0.01) - - loop.run_until_complete(miniloop()) - # There should have been exactly one draw now - assert frame_counter == 1 - - # Ask for a lot of draws - for i in range(5): - canvas.request_draw() - # Process evens for a while - loop.run_until_complete(miniloop()) - # We should have had just one draw - assert frame_counter == 2 - - # Change the canvas size - canvas.set_logical_size(300, 200) - canvas.set_logical_size(400, 300) - # We should have had just one draw - loop.run_until_complete(miniloop()) - assert frame_counter == 3 - - # canvas.close() - glfw.poll_events() - - -def test_glfw_canvas_render_custom_canvas(): - """Render an orange square ... in a glfw window. But not using WgpuCanvas. - This helps make sure that WgpuCanvasInterface is indeed the minimal - required canvas API. - """ - - import glfw - from wgpu.gui.glfw import get_glfw_present_methods - - class CustomCanvas: # implements wgpu.WgpuCanvasInterface - def __init__(self): - glfw.window_hint(glfw.CLIENT_API, glfw.NO_API) - glfw.window_hint(glfw.RESIZABLE, True) - self.window = glfw.create_window(300, 200, "canvas", None, None) - self._present_context = None - - def get_present_methods(self): - return get_glfw_present_methods(self.window) - - def get_physical_size(self): - psize = glfw.get_framebuffer_size(self.window) - return int(psize[0]), int(psize[1]) - - def get_context(self, kind="wgpu"): - if self._present_context is None: - backend_module = sys.modules["wgpu"].gpu.__module__ - PC = sys.modules[backend_module].GPUCanvasContext # noqa N806 - self._present_context = PC(self, self.get_present_methods()) - return self._present_context - - canvas = CustomCanvas() - - # Also pass canvas here, to touch that code somewhere - adapter = wgpu.gpu.request_adapter_sync( - canvas=canvas, power_preference="high-performance" - ) - device = adapter.request_device_sync() - draw_frame = _get_draw_function(device, canvas) - - for i in range(5): - time.sleep(0.01) - glfw.poll_events() - draw_frame() - canvas.get_context("wgpu").present() # WgpuCanvasBase normally automates this - - glfw.hide_window(canvas.window) - - -def _get_draw_function(device, canvas): - # Bindings and layout - pipeline_layout = device.create_pipeline_layout(bind_group_layouts=[]) - - shader = device.create_shader_module(code=shader_source) - - present_context = canvas.get_context("wgpu") - render_texture_format = present_context.get_preferred_format(device.adapter) - present_context.configure(device=device, format=render_texture_format) - - render_pipeline = device.create_render_pipeline( - label="my-debug-pipeline", - layout=pipeline_layout, - vertex={ - "module": shader, - "entry_point": "vs_main", - "buffers": [], - }, - primitive={ - "topology": wgpu.PrimitiveTopology.triangle_strip, - "strip_index_format": wgpu.IndexFormat.uint32, - "front_face": wgpu.FrontFace.ccw, - "cull_mode": wgpu.CullMode.none, - }, - depth_stencil=None, - multisample={ - "count": 1, - "mask": 0xFFFFFFFF, - "alpha_to_coverage_enabled": False, - }, - fragment={ - "module": shader, - "entry_point": "fs_main", - "targets": [ - { - "format": render_texture_format, - "blend": { - "color": {}, # use defaults - "alpha": {}, # use defaults - }, - }, - ], - }, - ) - - def draw_frame(): - current_texture_view = present_context.get_current_texture().create_view() - command_encoder = device.create_command_encoder() - assert current_texture_view.size - ca = { - "view": current_texture_view, - "resolve_target": None, - "clear_value": (0, 0, 0, 0), - "load_op": wgpu.LoadOp.clear, - "store_op": wgpu.StoreOp.store, - } - render_pass = command_encoder.begin_render_pass( - color_attachments=[ca], - ) - - render_pass.set_pipeline(render_pipeline) - render_pass.draw(4, 1, 0, 0) - render_pass.end() - device.queue.submit([command_encoder.finish()]) - - return draw_frame - - -if __name__ == "__main__": - setup_module() - run_tests(globals()) - teardown_module() diff --git a/tests/testutils.py b/tests/testutils.py index 03dac93a..49d874b1 100644 --- a/tests/testutils.py +++ b/tests/testutils.py @@ -2,7 +2,6 @@ import re import sys import logging -import subprocess from io import StringIO import wgpu @@ -69,19 +68,8 @@ def get_default_adapter_summary(): return adapter.summary -def _determine_can_use_glfw(): - code = "import glfw;exit(0) if glfw.init() else exit(1)" - try: - subprocess.check_output([sys.executable, "-c", code]) - except Exception: - return False - else: - return True - - adapter_summary = get_default_adapter_summary() can_use_wgpu_lib = bool(adapter_summary) -can_use_glfw = _determine_can_use_glfw() is_ci = bool(os.getenv("CI", None)) is_pypy = sys.implementation.name == "pypy" diff --git a/tests_mem/test_gui.py b/tests_mem/test_gui.py index e85cc8c4..1825bde0 100644 --- a/tests_mem/test_gui.py +++ b/tests_mem/test_gui.py @@ -55,7 +55,7 @@ def test_release_canvas_context(n): # user want to use the result. The context does drop its ref to the # textures, which is why we don't see textures in the measurements. - from wgpu.gui.offscreen import WgpuCanvas + from rendercanvas.offscreen import RenderCanvas yield { "expected_counts_after_create": { @@ -66,7 +66,7 @@ def test_release_canvas_context(n): canvases = weakref.WeakSet() for i in range(n): - c = WgpuCanvas() + c = RenderCanvas() canvases.add(c) c.request_draw(make_draw_func_for_canvas(c)) c.draw() diff --git a/tests_mem/test_gui_glfw.py b/tests_mem/test_gui_glfw.py index 50cd83b9..035c724b 100644 --- a/tests_mem/test_gui_glfw.py +++ b/tests_mem/test_gui_glfw.py @@ -38,16 +38,19 @@ def test_release_canvas_context(n): # Texture and a TextureView), but these are released in present(), # so we don't see them in the counts. - from wgpu.gui.glfw import WgpuCanvas, poll_glfw_briefly + from rendercanvas.glfw import RenderCanvas, poll_glfw_briefly yield { + "expected_counts_after_create": { + "CanvasContext": (n, 0), + }, "ignore": {"CommandBuffer"}, } canvases = weakref.WeakSet() for i in range(n): - c = WgpuCanvas() + c = RenderCanvas() canvases.add(c) c.request_draw(make_draw_func_for_canvas(c)) loop.run_until_complete(stub_event_loop()) diff --git a/tests_mem/test_gui_qt.py b/tests_mem/test_gui_qt.py index 85881630..59d9fcad 100644 --- a/tests_mem/test_gui_qt.py +++ b/tests_mem/test_gui_qt.py @@ -30,20 +30,23 @@ def test_release_canvas_context(n): # so we don't see them in the counts. import PySide6 - from wgpu.gui.qt import WgpuCanvas + from rendercanvas.qt import RenderCanvas app = PySide6.QtWidgets.QApplication.instance() if app is None: app = PySide6.QtWidgets.QApplication([""]) yield { + "expected_counts_after_create": { + "CanvasContext": (n, 0), + }, "ignore": {"CommandBuffer"}, } canvases = weakref.WeakSet() for i in range(n): - c = WgpuCanvas() + c = RenderCanvas() canvases.add(c) c.request_draw(make_draw_func_for_canvas(c)) app.processEvents() diff --git a/tests_mem/test_object_retention.py b/tests_mem/test_object_retention.py index 198d493a..a8e61628 100644 --- a/tests_mem/test_object_retention.py +++ b/tests_mem/test_object_retention.py @@ -3,7 +3,6 @@ import pytest import wgpu -from tests.testutils import run_tests from wgpu.backends.wgpu_native.extras import ( PipelineStatisticName, begin_pipeline_statistics_query, @@ -365,4 +364,10 @@ def test_copy_texture_to_texture(): if __name__ == "__main__": - run_tests(globals()) + test_object_retention_in_render() + test_object_retention_in_compute() + test_clear_buffer() + test_copy_buffer_to_buffer() + test_copy_buffer_to_texture() + test_copy_texture_to_buffer() + test_copy_texture_to_texture() diff --git a/wgpu/__init__.py b/wgpu/__init__.py index 68c166a3..f4816ff3 100644 --- a/wgpu/__init__.py +++ b/wgpu/__init__.py @@ -10,7 +10,6 @@ from .flags import * from .enums import * from .classes import * -from .gui import WgpuCanvasInterface from . import utils from . import backends from . import resources @@ -20,13 +19,21 @@ def rendercanvas_context_hook(canvas, present_methods): + """Get a new GPUCanvasContext, given a canvas and present_methods dict. + + This is a hook for rendercanvas, so that it can support ``canvas.get_context("wgpu")``. + + See https://github.com/pygfx/wgpu-py/blob/main/wgpu/_canvas.py and https://rendercanvas.readthedocs.io/stable/contextapi.html. + """ + import sys - backend_module = gpu.__module__ - if backend_module in ("", "wgpu._classes"): + backend_module_name = gpu.__module__ + if backend_module_name in ("", "wgpu._classes"): # Load backend now from .backends import auto - backend_module = gpu.__module__ + backend_module_name = gpu.__module__ - return sys.modules[backend_module].GPUCanvasContext(canvas, present_methods) + backend_module = sys.modules[backend_module_name] + return backend_module.GPUCanvasContext(canvas, present_methods) diff --git a/wgpu/__pyinstaller/hook-wgpu.py b/wgpu/__pyinstaller/hook-wgpu.py index ed16f81f..4e63f053 100644 --- a/wgpu/__pyinstaller/hook-wgpu.py +++ b/wgpu/__pyinstaller/hook-wgpu.py @@ -14,17 +14,3 @@ # Always include the wgpu-native backend. Since an import is not needed to # load this (default) backend, PyInstaller does not see it by itself. hiddenimports += ["wgpu.backends.auto", "wgpu.backends.wgpu_native"] - -# For the GUI backends, there always is an import. The auto backend is -# problematic because PyInstaller cannot follow it to a specific -# backend. Also, glfw does not have a hook like this, so it does not -# include the binary when freezing. We can solve both problems with the -# code below. Makes the binary a bit larger, but only marginally (less -# than 300kb). -try: - import glfw # noqa: F401 -except ImportError: - pass -else: - hiddenimports += ["wgpu.gui.glfw"] - binaries += collect_dynamic_libs("glfw") diff --git a/wgpu/__pyinstaller/test_wgpu.py b/wgpu/__pyinstaller/test_wgpu.py index 284c8dfc..65b2f704 100644 --- a/wgpu/__pyinstaller/test_wgpu.py +++ b/wgpu/__pyinstaller/test_wgpu.py @@ -1,15 +1,14 @@ script = """ # The script part import sys -import wgpu import importlib +import wgpu # The test part if "is_test" in sys.argv: included_modules = [ "wgpu.backends.auto", "wgpu.backends.wgpu_native", - "wgpu.gui.glfw", ] excluded_modules = [ "PySide6", diff --git a/wgpu/_canvas.py b/wgpu/_canvas.py new file mode 100644 index 00000000..e260627d --- /dev/null +++ b/wgpu/_canvas.py @@ -0,0 +1,66 @@ +import wgpu + + +class WgpuCanvasInterface: + """The minimal interface to be a valid canvas that wgpu can render to. + + Any object that implements these methods is a canvas that wgpu can work with. + The object does not even have to derive from this class. + In practice, we recommend using the `rendercanvas `_ library. + """ + + # This implementation serves as documentation, but it actually works! + + _canvas_context = None + + def get_physical_size(self) -> tuple[int, int]: + """Get the physical size of the canvas in integer pixels.""" + return (640, 480) + + def get_context(self, context_type: str = "wgpu") -> wgpu.GPUCanvasContext: + """Get the ``GPUCanvasContext`` object corresponding to this canvas. + + The context is used to obtain a texture to render to, and to + present that texture to the canvas. + + The canvas should get the context once, and then store it on ``self``. + Getting the context is best done using ``wgpu.rendercanvas_context_hook()``, + which accepts two arguments: the canvas object, and a dict with the present-methods + that this canvas supports. + + Each supported present-method is represented by a field in the dict. The value + is another dict with information specific to that present method. + A canvas must implement at least either the "screen" or "bitmap" method. + + With method "screen", the context will render directly to a surface + representing the region on the screen. The sub-dict should have a ``window`` + field containing the window id. On Linux there should also be ``platform`` + field to distinguish between "wayland" and "x11", and a ``display`` field + for the display id. This information is used by wgpu to obtain the required + surface id. + + With method "bitmap", the context will present the result as an image + bitmap. On GPU-based contexts, the result will first be rendered to an + offscreen texture, and then downloaded to RAM. The sub-dict must have a + field 'formats': a list of supported image formats. Examples are "rgba-u8" + and "i-u8". A canvas must support at least "rgba-u8". Note that srgb mapping + is assumed to be handled by the canvas. + + Also see https://rendercanvas.readthedocs.io/stable/contextapi.html + """ + + # Note that this function is analog to HtmlCanvas.getContext(), except + # here the only valid arg is 'webgpu', which is also made the default. + assert context_type in ("wgpu", "webgpu", None) + + # Support only bitmap-present, with rgba8unorm. + present_methods = { + "bitmap": { + "formats": ["rgba-u8"], + } + } + + if self._canvas_context is None: + self._canvas_context = wgpu.rendercanvas_context_hook(self, present_methods) + + return self._canvas_context diff --git a/wgpu/_classes.py b/wgpu/_classes.py index 7ef724d6..63fbf41c 100644 --- a/wgpu/_classes.py +++ b/wgpu/_classes.py @@ -212,7 +212,7 @@ def wgsl_language_features(self) -> set: class GPUCanvasContext: """Represents a context to configure a canvas and render to it. - Can be obtained via `gui.WgpuCanvasInterface.get_context("wgpu")`. + Can be obtained via `canvas.get_context("wgpu")`. The canvas-context plays a crucial role in connecting the wgpu API to the GUI layer, in a way that allows the GUI to be agnostic about wgpu. It diff --git a/wgpu/backends/wgpu_native/_api.py b/wgpu/backends/wgpu_native/_api.py index a48537fe..858f3dec 100644 --- a/wgpu/backends/wgpu_native/_api.py +++ b/wgpu/backends/wgpu_native/_api.py @@ -463,7 +463,7 @@ async def request_adapter_async( force_fallback_adapter (bool): whether to use a (probably CPU-based) fallback adapter. canvas : The canvas that the adapter should be able to render to. This can typically - be left to None. If given, the object must implement ``WgpuCanvasInterface``. + be left to None. If given, the object must implement ``WgpuCanvasInterface``. """ awaitable = self._request_adapter( feature_level=feature_level, diff --git a/wgpu/gui/__init__.py b/wgpu/gui/__init__.py index c35d54cd..203a8cd0 100644 --- a/wgpu/gui/__init__.py +++ b/wgpu/gui/__init__.py @@ -1,12 +1,8 @@ """ -Code to provide a canvas to render to. +Temporary shims for rendercanvas. """ -from . import _gui_utils # noqa: F401 -from .base import WgpuCanvasInterface, WgpuCanvasBase, WgpuAutoGui +from rendercanvas import BaseRenderCanvas -__all__ = [ - "WgpuAutoGui", - "WgpuCanvasBase", - "WgpuCanvasInterface", -] +WgpuCanvasBase = BaseRenderCanvas +del BaseRenderCanvas diff --git a/wgpu/gui/_gui_utils.py b/wgpu/gui/_gui_utils.py deleted file mode 100644 index 75fe3261..00000000 --- a/wgpu/gui/_gui_utils.py +++ /dev/null @@ -1,160 +0,0 @@ -"""Private gui utilities.""" - -import os -import sys -import weakref -import logging -import ctypes.util -from contextlib import contextmanager - -from .._coreutils import error_message_hash - - -logger = logging.getLogger("wgpu") - - -# ===== GUI lib support - -QT_MODULE_NAMES = ["PySide6", "PyQt6", "PySide2", "PyQt5"] - - -def get_imported_qt_lib(): - """Get the name of the currently imported qt lib. - - Returns (name, has_application). The name is None when no qt lib is currently imported. - """ - - # Get all imported qt libs - imported_libs = [] - for libname in QT_MODULE_NAMES: - qtlib = sys.modules.get(libname, None) - if qtlib is not None: - imported_libs.append(libname) - - # Get which of these have an application object - imported_libs_with_app = [] - for libname in imported_libs: - QtWidgets = sys.modules.get(libname + ".QtWidgets", None) # noqa: N806 - if QtWidgets: - app = QtWidgets.QApplication.instance() - if app is not None: - imported_libs_with_app.append(libname) - - # Return findings - if imported_libs_with_app: - return imported_libs_with_app[0], True - elif imported_libs: - return imported_libs[0], False - else: - return None, False - - -def asyncio_is_running(): - """Get whether there is currently a running asyncio loop.""" - asyncio = sys.modules.get("asyncio", None) - if asyncio is None: - return False - try: - loop = asyncio.get_running_loop() - except Exception: - loop = None - return loop is not None - - -# ===== Logging and more - -err_hashes = {} - - -@contextmanager -def log_exception(kind): - """Context manager to log any exceptions, but only log a one-liner - for subsequent occurrences of the same error to avoid spamming by - repeating errors in e.g. a draw function or event callback. - """ - try: - yield - 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 - sys.last_type, sys.last_value, sys.last_traceback = exc_info - # Show traceback, or a one-line summary - msg = str(err) - msgh = error_message_hash(msg) - if msgh not in err_hashes: - # Provide the exception, so the default logger prints a stacktrace. - # IDE's can get the exception from the root logger for PM debugging. - err_hashes[msgh] = 1 - logger.error(kind, exc_info=err) - else: - # We've seen this message before, return a one-liner instead. - err_hashes[msgh] = count = err_hashes[msgh] + 1 - msg = kind + ": " + msg.split("\n")[0].strip() - msg = msg if len(msg) <= 70 else msg[:69] + "…" - logger.error(msg + f" ({count})") - - -def weakbind(method): - """Replace a bound method with a callable object that stores the `self` using a weakref.""" - ref = weakref.ref(method.__self__) - class_func = method.__func__ - del method - - def proxy(*args, **kwargs): - self = ref() - if self is not None: - return class_func(self, *args, **kwargs) - - proxy.__name__ = class_func.__name__ - return proxy - - -# ===== Linux window managers - - -SYSTEM_IS_WAYLAND = "wayland" in os.getenv("XDG_SESSION_TYPE", "").lower() - -if sys.platform.startswith("linux") and SYSTEM_IS_WAYLAND: - # Force glfw to use X11. Note that this does not work if glfw is already imported. - if "glfw" not in sys.modules: - os.environ["PYGLFW_LIBRARY_VARIANT"] = "x11" - # Force Qt to use X11. Qt is more flexible - it ok if e.g. PySide6 is already imported. - os.environ["QT_QPA_PLATFORM"] = "xcb" - # Force wx to use X11, probably. - os.environ["GDK_BACKEND"] = "x11" - - -_x11_display = None - - -def get_alt_x11_display(): - """Get (the pointer to) a process-global x11 display instance.""" - # Ideally we'd get the real display object used by the GUI toolkit. - # But this is not always possible. In that case, using an alt display - # object can be used. - global _x11_display - assert sys.platform.startswith("linux") - if _x11_display is None: - x11 = ctypes.CDLL(ctypes.util.find_library("X11")) - x11.XOpenDisplay.restype = ctypes.c_void_p - _x11_display = x11.XOpenDisplay(None) - return _x11_display - - -_wayland_display = None - - -def get_alt_wayland_display(): - """Get (the pointer to) a process-global Wayland display instance.""" - # Ideally we'd get the real display object used by the GUI toolkit. - # This creates a global object, similar to what we do for X11. - # Unfortunately, this segfaults, so it looks like the real display object - # is needed? Leaving this here for reference. - global _wayland_display - assert sys.platform.startswith("linux") - if _wayland_display is None: - wl = ctypes.CDLL(ctypes.util.find_library("wayland-client")) - wl.wl_display_connect.restype = ctypes.c_void_p - _wayland_display = wl.wl_display_connect(None) - return _wayland_display diff --git a/wgpu/gui/auto.py b/wgpu/gui/auto.py index a3a11178..c032f102 100644 --- a/wgpu/gui/auto.py +++ b/wgpu/gui/auto.py @@ -1,191 +1,9 @@ -""" -Automatic GUI backend selection. +from rendercanvas.auto import RenderCanvas, loop +from .._coreutils import logger -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. -""" +logger.warning("The wgpu.gui.auto is deprecated, use rendercanvas.auto instead.") -__all__ = ["WgpuCanvas", "call_later", "run"] -import os -import sys -import importlib -from ._gui_utils import logger, QT_MODULE_NAMES, get_imported_qt_lib, asyncio_is_running - - -# Note that wx is not in here, because it does not (yet) implement base.WgpuAutoGui -WGPU_GUI_BACKEND_NAMES = ["glfw", "qt", "jupyter", "offscreen"] - - -def _load_backend(backend_name): - """Load a gui backend by name.""" - if backend_name == "glfw": - from . import glfw as module - elif backend_name == "qt": - from . import qt as module - elif backend_name == "jupyter": - from . import jupyter as module - elif backend_name == "wx": - from . import wx as module - elif backend_name == "offscreen": - from . import offscreen as module - else: # no-cover - raise ImportError("Unknown wgpu gui backend: '{backend_name}'") - return module - - -def select_backend(): - """Select a backend using a careful multi-stage selection process.""" - - module = None - failed_backends = {} # name -> error - - for backend_name, reason in backends_generator(): - if "force" in reason.lower(): - return _load_backend(backend_name) - if backend_name in failed_backends: - continue - try: - module = _load_backend(backend_name) - break - except Exception as err: - failed_backends[backend_name] = str(err) - - # Always report failed backends, because we only try them when it looks like we can. - if failed_backends: - msg = "WGPU could not load some backends:" - for key, val in failed_backends.items(): - msg += f"\n{key}: {val}" - logger.warning(msg) - - # Return or raise - if module is not None: - log = logger.warning if failed_backends else logger.info - log(f"WGPU selected {backend_name} gui because {reason}.") - return module - else: - msg = "WGPU Could not load any of the supported GUI backends." - if "jupyter" in failed_backends: - msg += "\n You may need to ``pip install -U jupyter_rfb``." - else: - msg += "\n Install glfw using e.g. ``pip install -U glfw``," - msg += ( - "\n or install a qt framework using e.g. ``pip install -U pyside6``." - ) - raise ImportError(msg) from None - - -def backends_generator(): - """Generator that iterates over all sub-generators.""" - for gen in [ - backends_by_env_vars, - backends_by_jupyter, - backends_by_imported_modules, - backends_by_trying_in_order, - ]: - yield from gen() - - -def backends_by_env_vars(): - """Generate backend names set via one the supported environment variables.""" - # Env var intended for testing, overrules everything else - if os.environ.get("WGPU_FORCE_OFFSCREEN", "").lower() in ("1", "true", "yes"): - yield "offscreen", "WGPU_FORCE_OFFSCREEN is set" - # Env var to force a backend for general use - backend_name = os.getenv("WGPU_GUI_BACKEND", "").lower().strip() or None - if backend_name: - if backend_name not in WGPU_GUI_BACKEND_NAMES: - logger.warning( - f"Ignoring invalid WGPU_GUI_BACKEND '{backend_name}', must be one of {WGPU_GUI_BACKEND_NAMES}" - ) - backend_name = None - if backend_name: - yield backend_name, "WGPU_GUI_BACKEND is set" - - -def backends_by_jupyter(): - """Generate backend names that are appropriate for the current Jupyter session (if any).""" - try: - ip = get_ipython() - except NameError: - return - if not ip.has_trait("kernel"): - # probably old-school ipython, we follow the normal selection process - return - - # We're in a Jupyter kernel. This might be a notebook, jupyter lab, jupyter - # console, qtconsole, etc. In the latter cases we cannot render ipywidgets. - # Unfortunately, there does not seem to be a (reasonable) way to detect - # whether we're in a console or notebook. Technically this kernel could be - # connected to a client of each. So we assume that ipywidgets can be used. - # User on jupyter console (or similar) should ``%gui qt`` or set - # WGPU_GUI_BACKEND to 'glfw'. - - # If GUI integration is enabled, we select the corresponding backend instead of jupyter - app = getattr(ip.kernel, "app", None) - if app: - gui_module_name = app.__class__.__module__.split(".")[0] - if gui_module_name in QT_MODULE_NAMES: - yield "qt", "running on Jupyter with qt gui" - # elif "wx" in app.__class__.__name__.lower() == "wx": - # yield "wx", "running on Jupyter with wx gui" - - yield "jupyter", "running on Jupyter" - - -def backends_by_imported_modules(): - """Generate backend names based on what modules are currently imported.""" - - # Get some info on loaded backends, and available apps/loops - qtlib, has_qt_app = get_imported_qt_lib() - has_asyncio_loop = asyncio_is_running() - - # If there is a qt app instance, chances are high that the user wants to run in Qt. - # More so than with asyncio, because asyncio may just be used by the runtime. - if has_qt_app: - yield "qt", "Qt app is running" - - # If there is an asyncio loop, we can nicely run glfw, if glfw is available. - if has_asyncio_loop: - try: - importlib.import_module("glfw") - except ModuleNotFoundError: - pass - else: - yield "glfw", "asyncio loop is running" - - # The rest is just "is the corresponding lib imported?" - - if "glfw" in sys.modules: - yield "glfw", "glfw is imported" - - if qtlib: - yield "qt", "qt is imported" - - # if "wx" in sys.modules: - # yield "wx", "wx is imported" - - -def backends_by_trying_in_order(): - """Generate backend names by trying to import the GUI lib in order. This is the final fallback.""" - - gui_lib_to_backend = { - "glfw": "glfw", - "PySide6": "qt", - "PyQt6": "qt", - "PySide2": "qt", - "PyQt5": "qt", - # "wx": "wx", - } - - for libname, backend_name in gui_lib_to_backend.items(): - try: - importlib.import_module(libname) - except ModuleNotFoundError: - continue - yield backend_name, f"{libname} can be imported" - - -# Load! -module = select_backend() -WgpuCanvas, run, call_later = module.WgpuCanvas, module.run, module.call_later +WgpuCanvas = RenderCanvas +run = loop.run +call_later = loop.call_later diff --git a/wgpu/gui/base.py b/wgpu/gui/base.py deleted file mode 100644 index 7595266b..00000000 --- a/wgpu/gui/base.py +++ /dev/null @@ -1,394 +0,0 @@ -import sys -import time -from collections import defaultdict - -from ._gui_utils import log_exception - - -def create_canvas_context(canvas, render_methods): - """Create a GPUCanvasContext for the given canvas. - - Helper function to keep the implementation of WgpuCanvasInterface - as small as possible. - """ - backend_module = sys.modules["wgpu"].gpu.__module__ - if backend_module == "wgpu._classes": - raise RuntimeError( - "A backend must be selected (e.g. with request_adapter()) before canvas.get_context() can be called." - ) - CanvasContext = sys.modules[backend_module].GPUCanvasContext # noqa: N806 - return CanvasContext(canvas, render_methods) - - -class WgpuCanvasInterface: - """The minimal interface to be a valid canvas. - - Any object that implements these methods is a canvas that wgpu can work with. - The object does not even have to derive from this class. - - In most cases it's more convenient to subclass :class:`WgpuCanvasBase `. - """ - - def __init__(self, *args, **kwargs): - # The args/kwargs are there because we may be mixed with e.g. a Qt widget - super().__init__(*args, **kwargs) - self._canvas_context = None - - def get_present_methods(self): - """Get info on the present methods supported by this canvas. - - Must return a small dict, used by the canvas-context to determine - how the rendered result will be presented to the canvas. - This method is only called once, when the context is created. - - Each supported method is represented by a field in the dict. The value - is another dict with information specific to that present method. - A canvas backend must implement at least either "screen" or "bitmap". - - With method "screen", the context will render directly to a surface - representing the region on the screen. The sub-dict should have a ``window`` - field containing the window id. On Linux there should also be ``platform`` - field to distinguish between "wayland" and "x11", and a ``display`` field - for the display id. This information is used by wgpu to obtain the required - surface id. - - With method "bitmap", the context will present the result as an image - bitmap. On GPU-based contexts, the result will first be rendered to an - offscreen texture, and then downloaded to RAM. The sub-dict must have a - field 'formats': a list of supported image formats. Examples are "rgba-u8" - and "i-u8". A canvas must support at least "rgba-u8". Note that srgb mapping - is assumed to be handled by the canvas. - """ - raise NotImplementedError() - - def get_physical_size(self): - """Get the physical size of the canvas in integer pixels.""" - raise NotImplementedError() - - def get_context(self, kind="wgpu"): - """Get the ``GPUCanvasContext`` object corresponding to this canvas. - - The context is used to obtain a texture to render to, and to - present that texture to the canvas. This class provides a - default implementation to get the appropriate context. - """ - # Note that this function is analog to HtmlCanvas.getContext(), except - # here the only valid arg is 'webgpu', which is also made the default. - assert kind in ("wgpu", "webgpu", None) - if self._canvas_context is None: - render_methods = self.get_present_methods() - self._canvas_context = create_canvas_context(self, render_methods) - - return self._canvas_context - - def present_image(self, image, **kwargs): - """Consume the final rendered image. - - This is called when using the "bitmap" method, see ``get_present_methods()``. - Canvases that don't support offscreen rendering don't need to implement - this method. - """ - raise NotImplementedError() - - -class WgpuCanvasBase(WgpuCanvasInterface): - """A convenient base canvas class. - - This class provides a uniform API and implements common - functionality, to increase consistency and reduce code duplication. - It is convenient (but not strictly necessary) for canvas classes - to inherit from this class (but all builtin canvases do). - - This class provides an API for scheduling draws (``request_draw()``) - and implements a mechanism to call the provided draw function - (``draw_frame()``) and then present the result to the canvas. - - This class also implements draw rate limiting, which can be set - with the ``max_fps`` attribute (default 30). For benchmarks you may - also want to set ``vsync`` to False. - """ - - def __init__(self, *args, max_fps=30, vsync=True, present_method=None, **kwargs): - super().__init__(*args, **kwargs) - self._last_draw_time = 0 - self._max_fps = float(max_fps) - self._vsync = bool(vsync) - present_method # noqa - We just catch the arg here in case a backend does implement support it - - def __del__(self): - # On delete, we call the custom close method. - try: - self.close() - except Exception: - pass - # Since this is sometimes used in a multiple inheritance, the - # superclass may (or may not) have a __del__ method. - try: - super().__del__() - except Exception: - pass - - def draw_frame(self): - """The function that gets called at each draw. - - You can implement this method in a subclass, or set it via a - call to request_draw(). - """ - pass - - def request_draw(self, draw_function=None): - """Schedule a new draw event. - - This function does not perform a draw directly, but schedules - a draw event at a suitable moment in time. In the draw event - the draw function is called, and the resulting rendered image - is presented to screen. - - Arguments: - draw_function (callable or None): The function to set as the new draw - function. If not given or None, the last set draw function is used. - - """ - if draw_function is not None: - self.draw_frame = draw_function - self._request_draw() - - def _draw_frame_and_present(self): - """Draw the frame and present the result. - - Errors are logged to the "wgpu" logger. Should be called by the - subclass at an appropriate time. - """ - self._last_draw_time = time.perf_counter() - # Perform the user-defined drawing code. When this errors, - # we should report the error and then continue, otherwise we crash. - # Returns the result of the context's present() call or None. - with log_exception("Draw error"): - self.draw_frame() - with log_exception("Present error"): - context = self._canvas_context - if context: - result = context.present() - method = result.pop("method") - if method == "bitmap": - image = result.pop("data") - self.present_image(image, **result) - else: - pass # method is "skip", "fail, ""screen" - - def _get_draw_wait_time(self): - """Get time (in seconds) to wait until the next draw in order to honour max_fps.""" - now = time.perf_counter() - target_time = self._last_draw_time + 1.0 / self._max_fps - return max(0, target_time - now) - - # Methods that must be overloaded - - def get_pixel_ratio(self): - """Get the float ratio between logical and physical pixels.""" - raise NotImplementedError() - - def get_logical_size(self): - """Get the logical size in float pixels.""" - raise NotImplementedError() - - def get_physical_size(self): - """Get the physical size in integer pixels.""" - raise NotImplementedError() - - def set_logical_size(self, width, height): - """Set the window size (in logical pixels).""" - raise NotImplementedError() - - def set_title(self, title): - """Set the window title.""" - raise NotImplementedError() - - def close(self): - """Close the window.""" - pass - - def is_closed(self): - """Get whether the window is closed.""" - raise NotImplementedError() - - def _request_draw(self): - """GUI-specific implementation for ``request_draw()``. - - * This should invoke a new draw at a later time. - * The call itself should return directly. - * Multiple calls should result in a single new draw. - * Preferably the ``max_fps`` and ``vsync`` are honored. - """ - raise NotImplementedError() - - -class WgpuAutoGui: - """Mixin class for canvases implementing autogui. - - This class provides a common API for handling events and registering - event handlers. It adds to :class:`WgpuCanvasBase ` - that interactive examples and applications can be written in a - generic way (no-GUI specific code). - """ - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self._last_event_time = 0 - self._pending_events = {} - self._event_handlers = defaultdict(list) - - def _get_event_wait_time(self): - """Calculate the time to wait for the next event dispatching. - - Used for rate-limited events. - """ - rate = 75 # events per second - now = time.perf_counter() - target_time = self._last_event_time + 1.0 / rate - return max(0, target_time - now) - - def _handle_event_rate_limited( - self, event, call_later_func, match_keys, accum_keys - ): - """Alternative `to handle_event()` for events that must be rate-limited. - - If any of the ``match_keys`` keys of the new event differ from the currently - pending event, the old event is dispatched now. The ``accum_keys`` keys of - the current and new event are added together (e.g. to accumulate wheel delta). - - The (accumulated) event is handled in the following cases: - * When the timer runs out. - * When a non-rate-limited event is dispatched. - * When a rate-limited event of the same type is scheduled - that has different match_keys (e.g. modifiers changes). - - Subclasses that use this method must use ``_handle_event_and_flush()`` - where they would otherwise call ``handle_event()``, to preserve event order. - """ - event_type = event["event_type"] - event.setdefault("time_stamp", time.perf_counter()) - # We may need to emit the old event. Otherwise, we need to update the new one. - old = self._pending_events.get(event_type, None) - if old: - if any(event[key] != old[key] for key in match_keys): - self.handle_event(old) - else: - for key in accum_keys: - event[key] = old[key] + event[key] - # Make sure that we have scheduled a moment to handle events - if not self._pending_events: - call_later_func(self._get_event_wait_time(), self._handle_pending_events) - # Store the event object - self._pending_events[event_type] = event - - def _handle_event_and_flush(self, event): - """Call handle_event after flushing any pending (rate-limited) events.""" - event.setdefault("time_stamp", time.perf_counter()) - self._handle_pending_events() - self.handle_event(event) - - def _handle_pending_events(self): - """Handle any pending rate-limited events.""" - if self._pending_events: - events = self._pending_events.values() - self._last_event_time = time.perf_counter() - self._pending_events = {} - for ev in events: - self.handle_event(ev) - - def handle_event(self, event): - """Handle an incoming event. - - Subclasses can overload this method. Events include widget - resize, mouse/touch interaction, key events, and more. An event - is a dict with at least the key event_type. For details, see - https://jupyter-rfb.readthedocs.io/en/stable/events.html - - The default implementation dispatches the event to the - registered event handlers. - - Arguments: - event (dict): the event to handle. - """ - # Collect callbacks - event_type = event.get("event_type") - callbacks = self._event_handlers[event_type] + self._event_handlers["*"] - # Dispatch - for _, callback in callbacks: - with log_exception(f"Error during handling {event['event_type']} event"): - if event.get("stop_propagation", False): - break - callback(event) - - def add_event_handler(self, *args, order=0): - """Register an event handler to receive events. - - Arguments: - callback (callable): The event handler. Must accept a single event argument. - *types (list of strings): A list of event types. - order (int): The order in which the handler is called. Lower numbers are called first. Default is 0. - - For the available events, see - https://jupyter-rfb.readthedocs.io/en/stable/events.html. - - The callback is stored, so it can be a lambda or closure. This also - means that if a method is given, a reference to the object is held, - which may cause circular references or prevent the Python GC from - destroying that object. - - Example: - - .. code-block:: py - - def my_handler(event): - print(event) - - canvas.add_event_handler(my_handler, "pointer_up", "pointer_down") - - Can also be used as a decorator: - - .. code-block:: py - - @canvas.add_event_handler("pointer_up", "pointer_down") - def my_handler(event): - print(event) - - Catch 'm all: - - .. code-block:: py - - canvas.add_event_handler(my_handler, "*") - - """ - decorating = not callable(args[0]) - callback = None if decorating else args[0] - types = args if decorating else args[1:] - - if not types: - raise TypeError("No event types are given to add_event_handler.") - for type in types: - if not isinstance(type, str): - raise TypeError(f"Event types must be str, but got {type}") - - def decorator(_callback): - for type in types: - self._event_handlers[type].append((order, _callback)) - self._event_handlers[type].sort(key=lambda x: x[0]) - return _callback - - if decorating: - return decorator - return decorator(callback) - - def remove_event_handler(self, callback, *types): - """Unregister an event handler. - - Arguments: - callback (callable): The event handler. - *types (list of strings): A list of event types. - """ - for type in types: - self._event_handlers[type] = [ - (o, cb) for o, cb in self._event_handlers[type] if cb is not callback - ] diff --git a/wgpu/gui/glfw.py b/wgpu/gui/glfw.py index fe3ed127..b5f19027 100644 --- a/wgpu/gui/glfw.py +++ b/wgpu/gui/glfw.py @@ -1,632 +1,9 @@ -""" -Support to render in a glfw window. The advantage of glfw is that it's -very lightweight. +from rendercanvas.glfw import RenderCanvas, loop +from .._coreutils import logger -Install pyGLFW using ``pip install glfw``. On Windows this is enough. -On Linux, install the glfw lib using ``sudo apt install libglfw3``, -or ``sudo apt install libglfw3-wayland`` when using Wayland. -""" +logger.warning("The wgpu.gui.glfw is deprecated, use rendercanvas.glfw instead.") -import sys -import time -import atexit -import weakref -import asyncio -import glfw - -from .base import WgpuCanvasBase, WgpuAutoGui -from ._gui_utils import SYSTEM_IS_WAYLAND, weakbind, logger - - -# Make sure that glfw is new enough -glfw_version_info = tuple(int(i) for i in glfw.__version__.split(".")[:2]) -if glfw_version_info < (1, 9): - raise ImportError("wgpu-py requires glfw 1.9 or higher.") - -# Do checks to prevent pitfalls on hybrid Xorg/Wayland systems -is_wayland = False -if sys.platform.startswith("linux") and SYSTEM_IS_WAYLAND: - if not hasattr(glfw, "get_x11_window"): - # Probably glfw was imported before we wgpu was, so we missed our chance - # to set the env var to make glfw use x11. - is_wayland = True - logger.warning("Using GLFW with Wayland, which is experimental.") - - -# Some glfw functions are not always available -set_window_content_scale_callback = lambda *args: None -set_window_maximize_callback = lambda *args: None -get_window_content_scale = lambda *args: (1, 1) - -if hasattr(glfw, "set_window_content_scale_callback"): - set_window_content_scale_callback = glfw.set_window_content_scale_callback -if hasattr(glfw, "set_window_maximize_callback"): - set_window_maximize_callback = glfw.set_window_maximize_callback -if hasattr(glfw, "get_window_content_scale"): - get_window_content_scale = glfw.get_window_content_scale - - -# Map keys to JS key definitions -# https://www.glfw.org/docs/3.3/group__keys.html -# https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/key/Key_Values -KEY_MAP = { - glfw.KEY_DOWN: "ArrowDown", - glfw.KEY_UP: "ArrowUp", - glfw.KEY_LEFT: "ArrowLeft", - glfw.KEY_RIGHT: "ArrowRight", - glfw.KEY_BACKSPACE: "Backspace", - glfw.KEY_CAPS_LOCK: "CapsLock", - glfw.KEY_DELETE: "Delete", - glfw.KEY_END: "End", - glfw.KEY_ENTER: "Enter", # aka return - glfw.KEY_ESCAPE: "Escape", - glfw.KEY_F1: "F1", - glfw.KEY_F2: "F2", - glfw.KEY_F3: "F3", - glfw.KEY_F4: "F4", - glfw.KEY_F5: "F5", - glfw.KEY_F6: "F6", - glfw.KEY_F7: "F7", - glfw.KEY_F8: "F8", - glfw.KEY_F9: "F9", - glfw.KEY_F10: "F10", - glfw.KEY_F11: "F11", - glfw.KEY_F12: "F12", - glfw.KEY_HOME: "Home", - glfw.KEY_INSERT: "Insert", - glfw.KEY_LEFT_ALT: "Alt", - glfw.KEY_LEFT_CONTROL: "Control", - glfw.KEY_LEFT_SHIFT: "Shift", - glfw.KEY_LEFT_SUPER: "Meta", # in glfw super means Windows or MacOS-command - glfw.KEY_NUM_LOCK: "NumLock", - glfw.KEY_PAGE_DOWN: "PageDown", - glfw.KEY_PAGE_UP: "Pageup", - glfw.KEY_PAUSE: "Pause", - glfw.KEY_PRINT_SCREEN: "PrintScreen", - glfw.KEY_RIGHT_ALT: "Alt", - glfw.KEY_RIGHT_CONTROL: "Control", - glfw.KEY_RIGHT_SHIFT: "Shift", - glfw.KEY_RIGHT_SUPER: "Meta", - glfw.KEY_SCROLL_LOCK: "ScrollLock", - glfw.KEY_TAB: "Tab", -} - -KEY_MAP_MOD = { - glfw.KEY_LEFT_SHIFT: "Shift", - glfw.KEY_RIGHT_SHIFT: "Shift", - glfw.KEY_LEFT_CONTROL: "Control", - glfw.KEY_RIGHT_CONTROL: "Control", - glfw.KEY_LEFT_ALT: "Alt", - glfw.KEY_RIGHT_ALT: "Alt", - glfw.KEY_LEFT_SUPER: "Meta", - glfw.KEY_RIGHT_SUPER: "Meta", -} - - -def get_glfw_present_methods(window): - if sys.platform.startswith("win"): - return { - "screen": { - "platform": "windows", - "window": int(glfw.get_win32_window(window)), - } - } - elif sys.platform.startswith("darwin"): - return { - "screen": { - "platform": "cocoa", - "window": int(glfw.get_cocoa_window(window)), - } - } - elif sys.platform.startswith("linux"): - if is_wayland: - return { - "screen": { - "platform": "wayland", - "window": int(glfw.get_wayland_window(window)), - "display": int(glfw.get_wayland_display()), - } - } - else: - return { - "screen": { - "platform": "x11", - "window": int(glfw.get_x11_window(window)), - "display": int(glfw.get_x11_display()), - } - } - else: - raise RuntimeError(f"Cannot get GLFW surafce info on {sys.platform}.") - - -def get_physical_size(window): - psize = glfw.get_framebuffer_size(window) - return int(psize[0]), int(psize[1]) - - -class GlfwWgpuCanvas(WgpuAutoGui, WgpuCanvasBase): - """A glfw window providing a wgpu canvas.""" - - # See https://www.glfw.org/docs/latest/group__window.html - - def __init__(self, *, size=None, title=None, **kwargs): - app.init_glfw() - super().__init__(**kwargs) - - # Handle inputs - if not size: - size = 640, 480 - title = str(title or "glfw wgpu canvas") - - # Set window hints - glfw.window_hint(glfw.CLIENT_API, glfw.NO_API) - glfw.window_hint(glfw.RESIZABLE, True) - - # Create the window (the initial size may not be in logical pixels) - self._window = glfw.create_window(int(size[0]), int(size[1]), title, None, None) - - # Other internal variables - self._need_draw = False - self._request_draw_timer_running = False - self._changing_pixel_ratio = False - self._is_minimized = False - - # Register ourselves - app.all_glfw_canvases.add(self) - - # Register callbacks. We may get notified too often, but that's - # ok, they'll result in a single draw. - glfw.set_framebuffer_size_callback(self._window, weakbind(self._on_size_change)) - glfw.set_window_close_callback(self._window, weakbind(self._check_close)) - glfw.set_window_refresh_callback(self._window, weakbind(self._on_window_dirty)) - glfw.set_window_focus_callback(self._window, weakbind(self._on_window_dirty)) - set_window_content_scale_callback( - self._window, weakbind(self._on_pixelratio_change) - ) - set_window_maximize_callback(self._window, weakbind(self._on_window_dirty)) - glfw.set_window_iconify_callback(self._window, weakbind(self._on_iconify)) - - # User input - self._key_modifiers = () - self._pointer_buttons = () - self._pointer_pos = 0, 0 - self._double_click_state = {"clicks": 0} - glfw.set_mouse_button_callback(self._window, weakbind(self._on_mouse_button)) - glfw.set_cursor_pos_callback(self._window, weakbind(self._on_cursor_pos)) - glfw.set_scroll_callback(self._window, weakbind(self._on_scroll)) - glfw.set_key_callback(self._window, weakbind(self._on_key)) - glfw.set_char_callback(self._window, weakbind(self._on_char)) - - # Initialize the size - self._pixel_ratio = -1 - self._screen_size_is_logical = False - self.set_logical_size(*size) - self._request_draw() - - # Callbacks to provide a minimal working canvas for wgpu - - def _on_pixelratio_change(self, *args): - if self._changing_pixel_ratio: - return - self._changing_pixel_ratio = True # prevent recursion (on Wayland) - try: - self._set_logical_size(self._logical_size) - finally: - self._changing_pixel_ratio = False - self._request_draw() - - def _on_size_change(self, *args): - self._determine_size() - self._request_draw() - - def _check_close(self, *args): - # Follow the close flow that glfw intended. - # This method can be overloaded and the close-flag can be set to False - # using set_window_should_close() if now is not a good time to close. - if self._window is not None and glfw.window_should_close(self._window): - self._on_close() - - def _on_close(self, *args): - app.all_glfw_canvases.discard(self) - if self._window is not None: - glfw.destroy_window(self._window) # not just glfw.hide_window - self._window = None - self._handle_event_and_flush({"event_type": "close"}) - - def _on_window_dirty(self, *args): - self._request_draw() - - def _on_iconify(self, window, iconified): - self._is_minimized = bool(iconified) - - # helpers - - def _mark_ready_for_draw(self): - self._request_draw_timer_running = False - self._need_draw = True # The event loop looks at this flag - glfw.post_empty_event() # Awake the event loop, if it's in wait-mode - - def _determine_size(self): - if self._window is None: - return - # Because the value of get_window_size is in physical-pixels - # on some systems and in logical-pixels on other, we use the - # framebuffer size and pixel ratio to derive the logical size. - pixel_ratio = get_window_content_scale(self._window)[0] - psize = get_physical_size(self._window) - - self._pixel_ratio = pixel_ratio - self._physical_size = psize - self._logical_size = psize[0] / pixel_ratio, psize[1] / pixel_ratio - - ev = { - "event_type": "resize", - "width": self._logical_size[0], - "height": self._logical_size[1], - "pixel_ratio": self._pixel_ratio, - } - self._handle_event_and_flush(ev) - - def _set_logical_size(self, new_logical_size): - if self._window is None: - return - # There is unclarity about the window size in "screen pixels". - # It appears that on Windows and X11 its the same as the - # framebuffer size, and on macOS it's logical pixels. - # See https://github.com/glfw/glfw/issues/845 - # Here, we simply do a quick test so we can compensate. - - # The current screen size and physical size, and its ratio - pixel_ratio = get_window_content_scale(self._window)[0] - ssize = glfw.get_window_size(self._window) - psize = glfw.get_framebuffer_size(self._window) - - # Apply - if is_wayland: - # Not sure why, but on Wayland things work differently - screen_ratio = ssize[0] / new_logical_size[0] - glfw.set_window_size( - self._window, - int(new_logical_size[0] / screen_ratio), - int(new_logical_size[1] / screen_ratio), - ) - else: - screen_ratio = ssize[0] / psize[0] - glfw.set_window_size( - self._window, - int(new_logical_size[0] * pixel_ratio * screen_ratio), - int(new_logical_size[1] * pixel_ratio * screen_ratio), - ) - self._screen_size_is_logical = screen_ratio != 1 - # If this causes the widget size to change, then _on_size_change will - # be called, but we may want force redetermining the size. - if pixel_ratio != self._pixel_ratio: - self._determine_size() - - # API - - def get_present_methods(self): - return get_glfw_present_methods(self._window) - - def get_pixel_ratio(self): - return self._pixel_ratio - - def get_logical_size(self): - return self._logical_size - - def get_physical_size(self): - return self._physical_size - - def set_logical_size(self, width, height): - if width < 0 or height < 0: - raise ValueError("Window width and height must not be negative") - self._set_logical_size((float(width), float(height))) - - def set_title(self, title): - glfw.set_window_title(self._window, title) - - def _request_draw(self): - if not self._request_draw_timer_running: - self._request_draw_timer_running = True - call_later(self._get_draw_wait_time(), self._mark_ready_for_draw) - - def close(self): - if self._window is not None: - glfw.set_window_should_close(self._window, True) - self._check_close() - - def is_closed(self): - return self._window is None - - # User events - - def _on_mouse_button(self, window, but, action, mods): - # Map button being changed, which we use to update self._pointer_buttons. - button_map = { - glfw.MOUSE_BUTTON_1: 1, # == MOUSE_BUTTON_LEFT - glfw.MOUSE_BUTTON_2: 2, # == MOUSE_BUTTON_RIGHT - glfw.MOUSE_BUTTON_3: 3, # == MOUSE_BUTTON_MIDDLE - glfw.MOUSE_BUTTON_4: 4, - glfw.MOUSE_BUTTON_5: 5, - glfw.MOUSE_BUTTON_6: 6, - glfw.MOUSE_BUTTON_7: 7, - glfw.MOUSE_BUTTON_8: 8, - } - button = button_map.get(but, 0) - - if action == glfw.PRESS: - event_type = "pointer_down" - buttons = set(self._pointer_buttons) - buttons.add(button) - self._pointer_buttons = tuple(sorted(buttons)) - elif action == glfw.RELEASE: - event_type = "pointer_up" - buttons = set(self._pointer_buttons) - buttons.discard(button) - self._pointer_buttons = tuple(sorted(buttons)) - else: - return - - ev = { - "event_type": event_type, - "x": self._pointer_pos[0], - "y": self._pointer_pos[1], - "button": button, - "buttons": tuple(self._pointer_buttons), - "modifiers": tuple(self._key_modifiers), - "ntouches": 0, # glfw does not have touch support - "touches": {}, - } - - # Emit the current event - self._handle_event_and_flush(ev) - - # Maybe emit a double-click event - self._follow_double_click(action, button) - - def _follow_double_click(self, action, button): - # If a sequence of down-up-down-up is made in nearly the same - # spot, and within a short time, we emit the double-click event. - - x, y = self._pointer_pos[0], self._pointer_pos[1] - state = self._double_click_state - - timeout = 0.25 - distance = 5 - - # Clear the state if it does no longer match - if state["clicks"] > 0: - d = ((x - state["x"]) ** 2 + (y - state["y"]) ** 2) ** 0.5 - if ( - d > distance - or time.perf_counter() - state["time"] > timeout - or button != state["button"] - ): - self._double_click_state = {"clicks": 0} - - clicks = self._double_click_state["clicks"] - - # Check and update order. Emit event if we make it to the final step - if clicks == 0 and action == glfw.PRESS: - self._double_click_state = { - "clicks": 1, - "button": button, - "time": time.perf_counter(), - "x": x, - "y": y, - } - elif clicks == 1 and action == glfw.RELEASE: - self._double_click_state["clicks"] = 2 - elif clicks == 2 and action == glfw.PRESS: - self._double_click_state["clicks"] = 3 - elif clicks == 3 and action == glfw.RELEASE: - self._double_click_state = {"clicks": 0} - ev = { - "event_type": "double_click", - "x": self._pointer_pos[0], - "y": self._pointer_pos[1], - "button": button, - "buttons": tuple(self._pointer_buttons), - "modifiers": tuple(self._key_modifiers), - "ntouches": 0, # glfw does not have touch support - "touches": {}, - } - self._handle_event_and_flush(ev) - - def _on_cursor_pos(self, window, x, y): - # Store pointer position in logical coordinates - if self._screen_size_is_logical: - self._pointer_pos = x, y - else: - self._pointer_pos = x / self._pixel_ratio, y / self._pixel_ratio - - ev = { - "event_type": "pointer_move", - "x": self._pointer_pos[0], - "y": self._pointer_pos[1], - "button": 0, - "buttons": tuple(self._pointer_buttons), - "modifiers": tuple(self._key_modifiers), - "ntouches": 0, # glfw does not have touch support - "touches": {}, - } - - match_keys = {"buttons", "modifiers", "ntouches"} - accum_keys = {} - self._handle_event_rate_limited(ev, call_later, match_keys, accum_keys) - - def _on_scroll(self, window, dx, dy): - # wheel is 1 or -1 in glfw, in jupyter_rfb this is ~100 - ev = { - "event_type": "wheel", - "dx": 100.0 * dx, - "dy": -100.0 * dy, - "x": self._pointer_pos[0], - "y": self._pointer_pos[1], - "buttons": tuple(self._pointer_buttons), - "modifiers": tuple(self._key_modifiers), - } - match_keys = {"modifiers"} - accum_keys = {"dx", "dy"} - self._handle_event_rate_limited(ev, call_later, match_keys, accum_keys) - - def _on_key(self, window, key, scancode, action, mods): - modifier = KEY_MAP_MOD.get(key, None) - - if action == glfw.PRESS: - event_type = "key_down" - if modifier: - modifiers = set(self._key_modifiers) - modifiers.add(modifier) - self._key_modifiers = tuple(sorted(modifiers)) - elif action == glfw.RELEASE: - event_type = "key_up" - if modifier: - modifiers = set(self._key_modifiers) - modifiers.discard(modifier) - self._key_modifiers = tuple(sorted(modifiers)) - else: # glfw.REPEAT - return - - # Note that if the user holds shift while pressing "5", will result in "5", - # and not in the "%" that you'd expect on a US keyboard. Glfw wants us to - # use set_char_callback for text input, but then we'd only get an event for - # key presses (down followed by up). So we accept that GLFW is less complete - # in this respect. - if key in KEY_MAP: - keyname = KEY_MAP[key] - else: - try: - keyname = chr(key) - except ValueError: - return # Probably a special key that we don't have in our KEY_MAP - if "Shift" not in self._key_modifiers: - keyname = keyname.lower() - - ev = { - "event_type": event_type, - "key": keyname, - "modifiers": tuple(self._key_modifiers), - } - self._handle_event_and_flush(ev) - - def _on_char(self, window, char): - # Undocumented char event to make imgui work, see https://github.com/pygfx/wgpu-py/issues/530 - ev = { - "event_type": "char", - "char_str": chr(char), - "modifiers": tuple(self._key_modifiers), - } - self._handle_event_and_flush(ev) - - def present_image(self, image, **kwargs): - raise NotImplementedError() - # AFAIK glfw does not have a builtin way to blit an image. It also does - # not really need one, since it's the most reliable GUI backend to - # render to the screen. - - -# Make available under a name that is the same for all gui backends -WgpuCanvas = GlfwWgpuCanvas - - -class AppState: - """Little container for state about the loop and glfw.""" - - def __init__(self): - self.all_glfw_canvases = weakref.WeakSet() - self._loop = None - self.stop_if_no_more_canvases = False - self._glfw_initialized = False - - def init_glfw(self): - glfw.init() # Safe to call multiple times - if not self._glfw_initialized: - self._glfw_initialized = True - atexit.register(glfw.terminate) - - def get_loop(self): - if self._loop is None: - self._loop = self._get_loop() - self._loop.create_task(keep_glfw_alive()) - return self._loop - - def _get_loop(self): - try: - return asyncio.get_running_loop() - except Exception: - pass - try: - return asyncio.get_event_loop() - except RuntimeError: - pass - loop = asyncio.new_event_loop() - asyncio.set_event_loop(loop) - return loop - - -app = AppState() - - -def update_glfw_canvasses(): - """Call this in your glfw event loop to draw each canvas that needs - an update. Returns the number of visible canvases. - """ - # Note that _draw_frame_and_present already catches errors, it can - # only raise errors if the logging system fails. - canvases = tuple(app.all_glfw_canvases) - for canvas in canvases: - if canvas._need_draw and not canvas._is_minimized: - canvas._need_draw = False - canvas._draw_frame_and_present() - return len(canvases) - - -async def keep_glfw_alive(): - """Co-routine that lives forever, keeping glfw going. - - Although it stops the event-loop if there are no more canvases (and we're - running the loop), this task stays active and continues when the loop is - restarted. - """ - # TODO: this is not particularly pretty. It'd be better to use normal asyncio to - # schedule draws and then also process events. But let's address when we do #355 / #391 - while True: - await asyncio.sleep(0.001) - glfw.poll_events() - n = update_glfw_canvasses() - if app.stop_if_no_more_canvases and not n: - loop = asyncio.get_running_loop() - loop.stop() - - -def poll_glfw_briefly(poll_time=0.1): - """Briefly poll glfw for a set amount of time. - - Intended to work around the bug that destroyed windows sometimes hang - around if the mainloop exits: https://github.com/glfw/glfw/issues/1766 - - I found that 10ms is enough, but make it 100ms just in case. You should - only run this right after your mainloop stops. - - """ - end_time = time.perf_counter() + poll_time - while time.perf_counter() < end_time: - glfw.wait_events_timeout(end_time - time.perf_counter()) - - -def call_later(delay, callback, *args): - loop = app.get_loop() - loop.call_later(delay, callback, *args) - - -def run(): - loop = app.get_loop() - if loop.is_running(): - return # Interactive mode! - - app.stop_if_no_more_canvases = True - loop.run_forever() - app.stop_if_no_more_canvases = False - poll_glfw_briefly() +WgpuCanvas = GlfwWgpuCanvas = RenderCanvas +run = loop.run +call_later = loop.call_later diff --git a/wgpu/gui/jupyter.py b/wgpu/gui/jupyter.py index 83ab6854..3d1d93af 100644 --- a/wgpu/gui/jupyter.py +++ b/wgpu/gui/jupyter.py @@ -1,119 +1,9 @@ -""" -Support for rendering in a Jupyter widget. Provides a widget subclass that -can be used as cell output, or embedded in an ipywidgets gui. -""" +from rendercanvas.jupyter import RenderCanvas, loop +from .._coreutils import logger -import weakref -import asyncio +logger.warning("The wgpu.gui.jupyter is deprecated, use rendercanvas.jupyter instead.") -from .base import WgpuAutoGui, WgpuCanvasBase -import numpy as np -from jupyter_rfb import RemoteFrameBuffer -from IPython.display import display - - -pending_jupyter_canvases = [] - - -class JupyterWgpuCanvas(WgpuAutoGui, WgpuCanvasBase, RemoteFrameBuffer): - """An ipywidgets widget providing a wgpu canvas. Needs the jupyter_rfb library.""" - - def __init__(self, *, size=None, title=None, **kwargs): - super().__init__(**kwargs) - - # Internal variables - self._last_image = None - self._pixel_ratio = 1 - self._logical_size = 0, 0 - self._is_closed = False - self._request_draw_timer_running = False - - # Register so this can be display'ed when run() is called - pending_jupyter_canvases.append(weakref.ref(self)) - - # Initialize size - if size is not None: - self.set_logical_size(*size) - - # Implementation needed for RemoteFrameBuffer - - def handle_event(self, event): - event_type = event.get("event_type") - if event_type == "close": - self._is_closed = True - elif event_type == "resize": - self._pixel_ratio = event["pixel_ratio"] - self._logical_size = event["width"], event["height"] - - # No need to rate-limit the pointer_move and wheel events; - # they're already rate limited by jupyter_rfb in the client. - super().handle_event(event) - - def get_frame(self): - self._request_draw_timer_running = False - # The _draw_frame_and_present() does the drawing and then calls - # present_context.present(), which calls our present() method. - # The result is either a numpy array or None, and this matches - # with what this method is expected to return. - self._draw_frame_and_present() - return self._last_image - - # Implementation needed for WgpuCanvasBase - - def get_pixel_ratio(self): - return self._pixel_ratio - - def get_logical_size(self): - return self._logical_size - - def get_physical_size(self): - return int(self._logical_size[0] * self._pixel_ratio), int( - self._logical_size[1] * self._pixel_ratio - ) - - def set_logical_size(self, width, height): - self.css_width = f"{width}px" - self.css_height = f"{height}px" - - def set_title(self, title): - pass # not supported yet - - def close(self): - RemoteFrameBuffer.close(self) - - def is_closed(self): - return self._is_closed - - def _request_draw(self): - if not self._request_draw_timer_running: - self._request_draw_timer_running = True - call_later(self._get_draw_wait_time(), RemoteFrameBuffer.request_draw, self) - - # Implementation needed for WgpuCanvasInterface - - def get_present_methods(self): - return {"bitmap": {"formats": ["rgba-u8"]}} - - def present_image(self, image, **kwargs): - # Convert memoryview to ndarray (no copy) - self._last_image = np.frombuffer(image, np.uint8).reshape(image.shape) - - -# Make available under a name that is the same for all gui backends -WgpuCanvas = JupyterWgpuCanvas - - -def call_later(delay, callback, *args): - loop = asyncio.get_event_loop() - loop.call_later(delay, callback, *args) - - -def run(): - # Show all widgets that have been created so far. - # No need to actually start an event loop, since Jupyter already runs it. - canvases = [r() for r in pending_jupyter_canvases] - pending_jupyter_canvases.clear() - for w in canvases: - if w and not w.is_closed(): - display(w) +WgpuCanvas = JupyterWgpuCanvas = RenderCanvas +run = loop.run +call_later = loop.call_later diff --git a/wgpu/gui/offscreen.py b/wgpu/gui/offscreen.py index 3942427d..e4c15492 100644 --- a/wgpu/gui/offscreen.py +++ b/wgpu/gui/offscreen.py @@ -1,96 +1,11 @@ -import time +from rendercanvas.offscreen import RenderCanvas, loop +from .._coreutils import logger -from .base import WgpuCanvasBase, WgpuAutoGui +logger.warning( + "The wgpu.gui.offscreen is deprecated, use rendercanvas.offscreen instead." +) -class WgpuManualOffscreenCanvas(WgpuAutoGui, WgpuCanvasBase): - """An offscreen canvas intended for manual use. - - Call the ``.draw()`` method to perform a draw and get the result. - """ - - def __init__(self, *args, size=None, pixel_ratio=1, title=None, **kwargs): - super().__init__(*args, **kwargs) - self._logical_size = (float(size[0]), float(size[1])) if size else (640, 480) - self._pixel_ratio = pixel_ratio - self._title = title - self._closed = False - self._last_image = None - - def get_present_methods(self): - return {"bitmap": {"formats": ["rgba-u8", "rgba-f16", "rgba-f32", "rgba-u16"]}} - - def present_image(self, image, **kwargs): - self._last_image = image - - def get_pixel_ratio(self): - return self._pixel_ratio - - def get_logical_size(self): - return self._logical_size - - def get_physical_size(self): - return int(self._logical_size[0] * self._pixel_ratio), int( - self._logical_size[1] * self._pixel_ratio - ) - - def set_logical_size(self, width, height): - self._logical_size = width, height - - def set_title(self, title): - pass - - def close(self): - self._closed = True - - def is_closed(self): - return self._closed - - def _request_draw(self): - # Deliberately a no-op, because people use .draw() instead. - pass - - def draw(self): - """Perform a draw and get the resulting image. - - The image array is returned as an NxMx4 memoryview object. - This object can be converted to a numpy array (without copying data) - using ``np.asarray(arr)``. - """ - self._draw_frame_and_present() - return self._last_image - - -WgpuCanvas = WgpuManualOffscreenCanvas - - -# If we consider the use-cases for using this offscreen canvas: -# -# * Using wgpu.gui.auto in test-mode: in this case run() should not hang, -# and call_later should not cause lingering refs. -# * Using the offscreen canvas directly, in a script: in this case you -# do not have/want an event system. -# * Using the offscreen canvas in an evented app. In that case you already -# have an app with a specific event-loop (it might be PySide6 or -# something else entirely). -# -# In summary, we provide a call_later() and run() that behave pretty -# well for the first case. - -_pending_calls = [] - - -def call_later(delay, callback, *args): - # Note that this module never calls call_later() itself; request_draw() is a no-op. - etime = time.time() + delay - _pending_calls.append((etime, callback, args)) - - -def run(): - # Process pending calls - for etime, callback, args in _pending_calls.copy(): - if time.time() >= etime: - callback(*args) - - # Clear any leftover scheduled calls, to avoid lingering refs. - _pending_calls.clear() +WgpuCanvas = WgpuManualOffscreenCanvas = RenderCanvas +run = loop.run +call_later = loop.call_later diff --git a/wgpu/gui/qt.py b/wgpu/gui/qt.py index 8b8c8e3e..58f439ee 100644 --- a/wgpu/gui/qt.py +++ b/wgpu/gui/qt.py @@ -1,558 +1,12 @@ -""" -Support for rendering in a Qt widget. Provides a widget subclass that -can be used as a standalone window or in a larger GUI. -""" +from rendercanvas.qt import RenderCanvas, RenderWidget, loop +from .._coreutils import logger -import sys -import ctypes -import importlib - -from .base import WgpuCanvasBase, WgpuAutoGui -from ._gui_utils import ( - logger, - SYSTEM_IS_WAYLAND, - get_alt_x11_display, - get_alt_wayland_display, - weakbind, - get_imported_qt_lib, -) - - -# Select GUI toolkit -libname, already_had_app_on_import = get_imported_qt_lib() -if libname: - QtCore = importlib.import_module(".QtCore", libname) - QtGui = importlib.import_module(".QtGui", libname) - QtWidgets = importlib.import_module(".QtWidgets", libname) - try: - WA_PaintOnScreen = QtCore.Qt.WidgetAttribute.WA_PaintOnScreen - WA_DeleteOnClose = QtCore.Qt.WidgetAttribute.WA_DeleteOnClose - WA_InputMethodEnabled = QtCore.Qt.WidgetAttribute.WA_InputMethodEnabled - PreciseTimer = QtCore.Qt.TimerType.PreciseTimer - KeyboardModifiers = QtCore.Qt.KeyboardModifier - FocusPolicy = QtCore.Qt.FocusPolicy - Keys = QtCore.Qt.Key - except AttributeError: - WA_PaintOnScreen = QtCore.Qt.WA_PaintOnScreen - WA_DeleteOnClose = QtCore.Qt.WA_DeleteOnClose - WA_InputMethodEnabled = QtCore.Qt.WA_InputMethodEnabled - PreciseTimer = QtCore.Qt.PreciseTimer - KeyboardModifiers = QtCore.Qt - FocusPolicy = QtCore.Qt - Keys = QtCore.Qt -else: - raise ImportError( - "Before importing wgpu.gui.qt, import one of PySide6/PySide2/PyQt6/PyQt5 to select a Qt toolkit." - ) - - -# Get version -if libname.startswith("PySide"): - qt_version_info = QtCore.__version_info__ -else: - try: - qt_version_info = tuple(int(i) for i in QtCore.QT_VERSION_STR.split(".")[:3]) - except Exception: # Failsafe - qt_version_info = (0, 0, 0) - - -BUTTON_MAP = { - QtCore.Qt.MouseButton.LeftButton: 1, # == MOUSE_BUTTON_LEFT - QtCore.Qt.MouseButton.RightButton: 2, # == MOUSE_BUTTON_RIGHT - QtCore.Qt.MouseButton.MiddleButton: 3, # == MOUSE_BUTTON_MIDDLE - QtCore.Qt.MouseButton.BackButton: 4, - QtCore.Qt.MouseButton.ForwardButton: 5, - QtCore.Qt.MouseButton.TaskButton: 6, - QtCore.Qt.MouseButton.ExtraButton4: 7, - QtCore.Qt.MouseButton.ExtraButton5: 8, -} - -MODIFIERS_MAP = { - KeyboardModifiers.ShiftModifier: "Shift", - KeyboardModifiers.ControlModifier: "Control", - KeyboardModifiers.AltModifier: "Alt", - KeyboardModifiers.MetaModifier: "Meta", -} - -KEY_MAP = { - int(Keys.Key_Down): "ArrowDown", - int(Keys.Key_Up): "ArrowUp", - int(Keys.Key_Left): "ArrowLeft", - int(Keys.Key_Right): "ArrowRight", - int(Keys.Key_Backspace): "Backspace", - int(Keys.Key_CapsLock): "CapsLock", - int(Keys.Key_Delete): "Delete", - int(Keys.Key_End): "End", - int(Keys.Key_Enter): "Enter", - int(Keys.Key_Escape): "Escape", - int(Keys.Key_F1): "F1", - int(Keys.Key_F2): "F2", - int(Keys.Key_F3): "F3", - int(Keys.Key_F4): "F4", - int(Keys.Key_F5): "F5", - int(Keys.Key_F6): "F6", - int(Keys.Key_F7): "F7", - int(Keys.Key_F8): "F8", - int(Keys.Key_F9): "F9", - int(Keys.Key_F10): "F10", - int(Keys.Key_F11): "F11", - int(Keys.Key_F12): "F12", - int(Keys.Key_Home): "Home", - int(Keys.Key_Insert): "Insert", - int(Keys.Key_Alt): "Alt", - int(Keys.Key_Control): "Control", - int(Keys.Key_Shift): "Shift", - int(Keys.Key_Meta): "Meta", # meta maps to control in QT on macOS, and vice-versa - int(Keys.Key_NumLock): "NumLock", - int(Keys.Key_PageDown): "PageDown", - int(Keys.Key_PageUp): "Pageup", - int(Keys.Key_Pause): "Pause", - int(Keys.Key_ScrollLock): "ScrollLock", - int(Keys.Key_Tab): "Tab", -} - - -def enable_hidpi(): - """Enable high-res displays.""" - set_dpi_aware = qt_version_info < (6, 4) # Pyside - if set_dpi_aware: - try: - # See https://github.com/pyzo/pyzo/pull/700 why we seem to need both - # See https://github.com/pygfx/pygfx/issues/368 for high Qt versions - ctypes.windll.shcore.SetProcessDpiAwareness(1) # global dpi aware - ctypes.windll.shcore.SetProcessDpiAwareness(2) # per-monitor dpi aware - except Exception: - pass # fail on non-windows - try: - # This flag is not needed on Qt6 - # But it has been definitely giving me a warning since PySide 6.6 - # I'm a little cautious of removing its application for much older - # PySide so I'm going to conditionally disable it for "newer" PySide - # hmaarrfk -- 2024/12 - # https://doc.qt.io/qt-6/highdpi.html - if qt_version_info < (6, 6): - QtWidgets.QApplication.setAttribute(QtCore.Qt.AA_EnableHighDpiScaling, True) - except Exception: - pass # fail on much older Qt's - - -# If you import this module, you want to use wgpu in a way that does not suck -# on high-res monitors. So we apply the minimal configuration to make this so. -# Most apps probably should also set AA_UseHighDpiPixmaps, but it's not -# needed for wgpu, so not our responsibility (some users may NOT want it set). -enable_hidpi() - -_show_image_method_warning = ( - "Qt falling back to offscreen rendering, which is less performant." +logger.warning( + "The wgpu.gui.qt is deprecated, use rendercanvas.qt instead (or .pyside or .pyqt6)." ) -class QWgpuWidget(WgpuAutoGui, WgpuCanvasBase, QtWidgets.QWidget): - """A QWidget representing a wgpu canvas that can be embedded in a Qt application.""" - - def __init__(self, *args, present_method=None, **kwargs): - super().__init__(*args, **kwargs) - - # Determine present method - self._surface_ids = self._get_surface_ids() - if not present_method: - self._present_to_screen = True - if SYSTEM_IS_WAYLAND: - # Trying to render to screen on Wayland segfaults. This might be because - # the "display" is not the real display id. We can tell Qt to use - # XWayland, so we can use the X11 path. This worked at some point, - # but later this resulted in a Rust panic. So, until this is sorted - # out, we fall back to rendering via an image. - self._present_to_screen = False - elif present_method == "screen": - self._present_to_screen = True - elif present_method == "bitmap": - self._present_to_screen = False - else: - raise ValueError(f"Invalid present_method {present_method}") - - self.setAttribute(WA_PaintOnScreen, self._present_to_screen) - self.setAutoFillBackground(False) - self.setAttribute(WA_DeleteOnClose, True) - self.setAttribute(WA_InputMethodEnabled, True) - self.setMouseTracking(True) - self.setFocusPolicy(FocusPolicy.StrongFocus) - - # A timer for limiting fps - self._request_draw_timer = QtCore.QTimer() - self._request_draw_timer.setTimerType(PreciseTimer) - self._request_draw_timer.setSingleShot(True) - self._request_draw_timer.timeout.connect(self.update) - - def paintEngine(self): # noqa: N802 - this is a Qt method - # https://doc.qt.io/qt-5/qt.html#WidgetAttribute-enum WA_PaintOnScreen - if self._present_to_screen: - return None - else: - return super().paintEngine() - - def paintEvent(self, event): # noqa: N802 - this is a Qt method - self._draw_frame_and_present() - - # Methods that we add from wgpu (snake_case) - - def _get_surface_ids(self): - if sys.platform.startswith("win") or sys.platform.startswith("darwin"): - return { - "window": int(self.winId()), - } - elif sys.platform.startswith("linux"): - if False: - # We fall back to XWayland, see _gui_utils.py - return { - "platform": "wayland", - "window": int(self.winId()), - "display": int(get_alt_wayland_display()), - } - else: - return { - "platform": "x11", - "window": int(self.winId()), - "display": int(get_alt_x11_display()), - } - - def get_present_methods(self): - global _show_image_method_warning - if self._surface_ids is None: - self._surface_ids = self._get_surface_ids() - - methods = {} - if self._present_to_screen: - methods["screen"] = self._surface_ids - else: - if _show_image_method_warning: - logger.warning(_show_image_method_warning) - _show_image_method_warning = None - methods["bitmap"] = {"formats": ["rgba-u8"]} - return methods - - def get_pixel_ratio(self): - # Observations: - # * On Win10 + PyQt5 the ratio is a whole number (175% becomes 2). - # * On Win10 + PyQt6 the ratio is correct (non-integer). - return self.devicePixelRatioF() - - def get_logical_size(self): - # Sizes in Qt are logical - lsize = self.width(), self.height() - return float(lsize[0]), float(lsize[1]) - - def get_physical_size(self): - # https://doc.qt.io/qt-5/qpaintdevice.html - # https://doc.qt.io/qt-5/highdpi.html - lsize = self.width(), self.height() - lsize = float(lsize[0]), float(lsize[1]) - ratio = self.devicePixelRatioF() - - # When the ratio is not integer (qt6), we need to somehow round - # it. It turns out that we need to round it, but also add a - # small offset. Tested on Win10 with several different OS - # scales. Would be nice if we could ask Qt for the exact - # physical size! Not an issue on qt5, because ratio is always - # integer then. - return round(lsize[0] * ratio + 0.01), round(lsize[1] * ratio + 0.01) - - def set_logical_size(self, width, height): - if width < 0 or height < 0: - raise ValueError("Window width and height must not be negative") - self.resize(width, height) # See comment on pixel ratio - - def set_title(self, title): - self.setWindowTitle(title) - - def _request_draw(self): - if not self._request_draw_timer.isActive(): - self._request_draw_timer.start(int(self._get_draw_wait_time() * 1000)) - - def close(self): - QtWidgets.QWidget.close(self) - - def is_closed(self): - return not self.isVisible() - - # User events to jupyter_rfb events - - def _key_event(self, event_type, event): - modifiers = tuple( - MODIFIERS_MAP[mod] - for mod in MODIFIERS_MAP.keys() - if mod & event.modifiers() - ) - - ev = { - "event_type": event_type, - "key": KEY_MAP.get(event.key(), event.text()), - "modifiers": modifiers, - } - self._handle_event_and_flush(ev) - - def _char_input_event(self, char_str): - ev = { - "event_type": "char", - "char_str": char_str, - "modifiers": None, - } - self._handle_event_and_flush(ev) - - def keyPressEvent(self, event): # noqa: N802 - self._key_event("key_down", event) - self._char_input_event(event.text()) - - def keyReleaseEvent(self, event): # noqa: N802 - self._key_event("key_up", event) - - def inputMethodEvent(self, event): # noqa: N802 - commit_string = event.commitString() - if commit_string: - self._char_input_event(commit_string) - - def _mouse_event(self, event_type, event, touches=True): - button = BUTTON_MAP.get(event.button(), 0) - buttons = tuple( - BUTTON_MAP[button] - for button in BUTTON_MAP.keys() - if button & event.buttons() - ) - - # For Qt on macOS Control and Meta are switched - modifiers = tuple( - MODIFIERS_MAP[mod] - for mod in MODIFIERS_MAP.keys() - if mod & event.modifiers() - ) - - ev = { - "event_type": event_type, - "x": event.pos().x(), - "y": event.pos().y(), - "button": button, - "buttons": buttons, - "modifiers": modifiers, - } - if touches: - ev.update( - { - "ntouches": 0, - "touches": {}, # TODO: Qt touch events - } - ) - - if event_type == "pointer_move": - match_keys = {"buttons", "modifiers", "ntouches"} - accum_keys = {} - self._handle_event_rate_limited(ev, call_later, match_keys, accum_keys) - else: - self._handle_event_and_flush(ev) - - def mousePressEvent(self, event): # noqa: N802 - self._mouse_event("pointer_down", event) - - def mouseMoveEvent(self, event): # noqa: N802 - self._mouse_event("pointer_move", event) - - def mouseReleaseEvent(self, event): # noqa: N802 - self._mouse_event("pointer_up", event) - - def mouseDoubleClickEvent(self, event): # noqa: N802 - super().mouseDoubleClickEvent(event) - self._mouse_event("double_click", event, touches=False) - - def wheelEvent(self, event): # noqa: N802 - # For Qt on macOS Control and Meta are switched - modifiers = tuple( - MODIFIERS_MAP[mod] - for mod in MODIFIERS_MAP.keys() - if mod & event.modifiers() - ) - buttons = tuple( - BUTTON_MAP[button] - for button in BUTTON_MAP.keys() - if button & event.buttons() - ) - - ev = { - "event_type": "wheel", - "dx": -event.angleDelta().x(), - "dy": -event.angleDelta().y(), - "x": event.position().x(), - "y": event.position().y(), - "buttons": buttons, - "modifiers": modifiers, - } - match_keys = {"modifiers"} - accum_keys = {"dx", "dy"} - self._handle_event_rate_limited(ev, call_later, match_keys, accum_keys) - - def resizeEvent(self, event): # noqa: N802 - ev = { - "event_type": "resize", - "width": float(event.size().width()), - "height": float(event.size().height()), - "pixel_ratio": self.get_pixel_ratio(), - } - self._handle_event_and_flush(ev) - - def closeEvent(self, event): # noqa: N802 - self._handle_event_and_flush({"event_type": "close"}) - - def present_image(self, image_data, **kwargs): - size = image_data.shape[1], image_data.shape[0] # width, height - - painter = QtGui.QPainter(self) - - # We want to simply blit the image (copy pixels one-to-one on framebuffer). - # Maybe Qt does this when the sizes match exactly (like they do here). - # Converting to a QPixmap and painting that only makes it slower. - - # Just in case, set render hints that may hurt performance. - painter.setRenderHints( - painter.RenderHint.Antialiasing | painter.RenderHint.SmoothPixmapTransform, - False, - ) - - image = QtGui.QImage( - image_data, - size[0], - size[1], - size[0] * 4, - QtGui.QImage.Format.Format_RGBA8888, - ) - - rect1 = QtCore.QRect(0, 0, size[0], size[1]) - rect2 = self.rect() - painter.drawImage(rect2, image, rect1) - - # Uncomment for testing purposes - # painter.setPen(QtGui.QColor("#0000ff")) - # painter.setFont(QtGui.QFont("Arial", 30)) - # painter.drawText(100, 100, "This is an image") - - -class QWgpuCanvas(WgpuAutoGui, WgpuCanvasBase, QtWidgets.QWidget): - """A toplevel Qt widget providing a wgpu canvas.""" - - # Most of this is proxying stuff to the inner widget. - # We cannot use a toplevel widget directly, otherwise the window - # size can be set to subpixel (logical) values, without being able to - # detect this. See https://github.com/pygfx/wgpu-py/pull/68 - - def __init__( - self, *, size=None, title=None, max_fps=30, present_method=None, **kwargs - ): - # When using Qt, there needs to be an - # application before any widget is created - get_app() - super().__init__(**kwargs) - - self.setAttribute(WA_DeleteOnClose, True) - self.set_logical_size(*(size or (640, 480))) - self.setWindowTitle(title or "qt wgpu canvas") - self.setMouseTracking(True) - - self._subwidget = QWgpuWidget( - self, max_fps=max_fps, present_method=present_method - ) - self._subwidget.add_event_handler(weakbind(self.handle_event), "*") - - # Note: At some point we called `self._subwidget.winId()` here. For some - # reason this was needed to "activate" the canvas. Otherwise the viz was - # not shown if no canvas was provided to request_adapter(). Removed - # later because could not reproduce. - - layout = QtWidgets.QHBoxLayout(self) - layout.setContentsMargins(0, 0, 0, 0) - self.setLayout(layout) - layout.addWidget(self._subwidget) - - self.show() - - # Qt methods - - def update(self): - super().update() - self._subwidget.update() - - # Methods that we add from wgpu (snake_case) - - @property - def draw_frame(self): - return self._subwidget.draw_frame - - @draw_frame.setter - def draw_frame(self, f): - self._subwidget.draw_frame = f - - def get_present_methods(self): - return self._subwidget.get_present_methods() - - def get_pixel_ratio(self): - return self._subwidget.get_pixel_ratio() - - def get_logical_size(self): - return self._subwidget.get_logical_size() - - def get_physical_size(self): - return self._subwidget.get_physical_size() - - def set_logical_size(self, width, height): - if width < 0 or height < 0: - raise ValueError("Window width and height must not be negative") - self.resize(width, height) # See comment on pixel ratio - - def set_title(self, title): - self.setWindowTitle(title) - - def _request_draw(self): - return self._subwidget._request_draw() - - def close(self): - self._subwidget.close() - QtWidgets.QWidget.close(self) - - def is_closed(self): - return not self.isVisible() - - # Methods that we need to explicitly delegate to the subwidget - - def get_context(self, *args, **kwargs): - return self._subwidget.get_context(*args, **kwargs) - - def request_draw(self, *args, **kwargs): - return self._subwidget.request_draw(*args, **kwargs) - - def present_image(self, image, **kwargs): - return self._subwidget.present_image(image, **kwargs) - - -# Make available under a name that is the same for all gui backends -WgpuWidget = QWgpuWidget -WgpuCanvas = QWgpuCanvas - - -def get_app(): - """Return global instance of Qt app instance or create one if not created yet.""" - app = QtWidgets.QApplication.instance() - if app is None: - app = QtWidgets.QApplication([]) - # Store instance bc pyqt will not store it by itself, see https://github.com/pygfx/pygfx/issues/1054 - QtWidgets.QApplication._wgpu_instance = app - return app - - -def run(): - if already_had_app_on_import: - return # Likely in an interactive session or larger application that will start the Qt app. - app = get_app() - - # todo: we could detect if asyncio is running (interactive session) and wheter we can use QtAsyncio. - # But let's wait how things look with new scheduler etc. - app.exec() if hasattr(app, "exec") else app.exec_() - - -def call_later(delay, callback, *args): - QtCore.QTimer.singleShot(int(delay * 1000), lambda: callback(*args)) +WgpuWidget = QWgpuWidget = RenderWidget +WgpuCanvas = QWgpuCanvas = RenderCanvas +run = loop.run +call_later = loop.call_later diff --git a/wgpu/gui/wx.py b/wgpu/gui/wx.py index 3705bcf8..fa89e1e6 100644 --- a/wgpu/gui/wx.py +++ b/wgpu/gui/wx.py @@ -1,517 +1,10 @@ -""" -Support for rendering in a wxPython window. Provides a widget that -can be used as a standalone window or in a larger GUI. -""" +from rendercanvas.wx import RenderCanvas, RenderWidget, loop +from .._coreutils import logger -import ctypes -import sys -from typing import Optional +logger.warning("The wgpu.gui.auto is deprecated, use rendercanvas.auto instead.") -import wx -from ._gui_utils import ( - logger, - SYSTEM_IS_WAYLAND, - get_alt_x11_display, - get_alt_wayland_display, - weakbind, -) -from .base import WgpuCanvasBase, WgpuAutoGui - - -BUTTON_MAP = { - wx.MOUSE_BTN_LEFT: 1, - wx.MOUSE_BTN_RIGHT: 2, - wx.MOUSE_BTN_MIDDLE: 3, - wx.MOUSE_BTN_AUX1: 4, - wx.MOUSE_BTN_AUX2: 5, - # wxPython doesn't have exact equivalents for TaskButton, ExtraButton4, and ExtraButton5 -} - -MOUSE_EVENT_MAP = { - "pointer_down": [ - wx.wxEVT_LEFT_DOWN, - wx.wxEVT_MIDDLE_DOWN, - wx.wxEVT_RIGHT_DOWN, - wx.wxEVT_AUX1_DOWN, - wx.wxEVT_AUX2_DOWN, - ], - "pointer_up": [ - wx.wxEVT_LEFT_UP, - wx.wxEVT_MIDDLE_UP, - wx.wxEVT_RIGHT_UP, - wx.wxEVT_AUX1_UP, - wx.wxEVT_AUX2_UP, - ], - "double_click": [ - wx.wxEVT_LEFT_DCLICK, - wx.wxEVT_MIDDLE_DCLICK, - wx.wxEVT_RIGHT_DCLICK, - wx.wxEVT_AUX1_DCLICK, - wx.wxEVT_AUX2_DCLICK, - ], - "wheel": [wx.wxEVT_MOUSEWHEEL], -} - -# reverse the mouse event map (from one-to-many to many-to-one) -MOUSE_EVENT_MAP_REVERSED = { - value: key for key, values in MOUSE_EVENT_MAP.items() for value in values -} - -MODIFIERS_MAP = { - wx.MOD_SHIFT: "Shift", - wx.MOD_CONTROL: "Control", - wx.MOD_ALT: "Alt", - wx.MOD_META: "Meta", -} - -KEY_MAP = { - wx.WXK_DOWN: "ArrowDown", - wx.WXK_UP: "ArrowUp", - wx.WXK_LEFT: "ArrowLeft", - wx.WXK_RIGHT: "ArrowRight", - wx.WXK_BACK: "Backspace", - wx.WXK_CAPITAL: "CapsLock", - wx.WXK_DELETE: "Delete", - wx.WXK_END: "End", - wx.WXK_RETURN: "Enter", - wx.WXK_ESCAPE: "Escape", - wx.WXK_F1: "F1", - wx.WXK_F2: "F2", - wx.WXK_F3: "F3", - wx.WXK_F4: "F4", - wx.WXK_F5: "F5", - wx.WXK_F6: "F6", - wx.WXK_F7: "F7", - wx.WXK_F8: "F8", - wx.WXK_F9: "F9", - wx.WXK_F10: "F10", - wx.WXK_F11: "F11", - wx.WXK_F12: "F12", - wx.WXK_HOME: "Home", - wx.WXK_INSERT: "Insert", - wx.WXK_ALT: "Alt", - wx.WXK_CONTROL: "Control", - wx.WXK_SHIFT: "Shift", - wx.WXK_COMMAND: "Meta", # wx.WXK_COMMAND is used for Meta (Command key on macOS) - wx.WXK_NUMLOCK: "NumLock", - wx.WXK_PAGEDOWN: "PageDown", - wx.WXK_PAGEUP: "PageUp", - wx.WXK_PAUSE: "Pause", - wx.WXK_SCROLL: "ScrollLock", - wx.WXK_TAB: "Tab", -} - - -def enable_hidpi(): - """Enable high-res displays.""" - try: - ctypes.windll.shcore.SetProcessDpiAwareness(1) - ctypes.windll.shcore.SetProcessDpiAwareness(2) - except Exception: - pass # fail on non-windows - - -enable_hidpi() - - -_show_image_method_warning = ( - "wx falling back to offscreen rendering, which is less performant." -) - - -class TimerWithCallback(wx.Timer): - def __init__(self, callback): - super().__init__() - self._callback = callback - - def Notify(self, *args): # noqa: N802 - try: - self._callback() - except RuntimeError: - pass # wrapped C/C++ object of type WxWgpuWindow has been deleted - - -class WxWgpuWindow(WgpuAutoGui, WgpuCanvasBase, wx.Window): - """A wx Window representing a wgpu canvas that can be embedded in a wx application.""" - - def __init__(self, *args, present_method=None, **kwargs): - super().__init__(*args, **kwargs) - - # Determine present method - self._surface_ids = self._get_surface_ids() - if not present_method: - self._present_to_screen = True - if SYSTEM_IS_WAYLAND: - # See comments in same place in qt.py - self._present_to_screen = False - elif present_method == "screen": - self._present_to_screen = True - elif present_method == "bitmap": - self._present_to_screen = False - else: - raise ValueError(f"Invalid present_method {present_method}") - - # A timer for limiting fps - self._request_draw_timer = TimerWithCallback(self.Refresh) - - # We keep a timer to prevent draws during a resize. This prevents - # issues with mismatching present sizes during resizing (on Linux). - self._resize_timer = TimerWithCallback(self._on_resize_done) - self._draw_lock = False - - self.Bind(wx.EVT_PAINT, self.on_paint) - self.Bind(wx.EVT_ERASE_BACKGROUND, lambda x: None) - self.Bind(wx.EVT_SIZE, self._on_resize) - - self.Bind(wx.EVT_KEY_DOWN, self._on_key_down) - self.Bind(wx.EVT_KEY_UP, self._on_key_up) - - self.Bind(wx.EVT_MOUSE_EVENTS, self._on_mouse_events) - self.Bind(wx.EVT_MOTION, self._on_mouse_move) - - def on_paint(self, event): - dc = wx.PaintDC(self) # needed for wx - if not self._draw_lock: - self._draw_frame_and_present() - del dc - event.Skip() - - def _on_resize(self, event: wx.SizeEvent): - self._draw_lock = True - self._resize_timer.Start(100, wx.TIMER_ONE_SHOT) - - # fire resize event - size: wx.Size = event.GetSize() - ev = { - "event_type": "resize", - "width": float(size.GetWidth()), - "height": float(size.GetHeight()), - "pixel_ratio": self.get_pixel_ratio(), - } - self._handle_event_and_flush(ev) - - def _on_resize_done(self, *args): - self._draw_lock = False - self._request_draw() - - # Methods for input events - - def _on_key_down(self, event: wx.KeyEvent): - char_str = self._get_char_from_event(event) - self._key_event("key_down", event, char_str) - - if char_str is not None: - self._char_input_event(char_str) - - def _on_key_up(self, event: wx.KeyEvent): - char_str = self._get_char_from_event(event) - self._key_event("key_up", event, char_str) - - def _key_event(self, event_type: str, event: wx.KeyEvent, char_str: Optional[str]): - modifiers = tuple( - MODIFIERS_MAP[mod] - for mod in MODIFIERS_MAP.keys() - if mod & event.GetModifiers() - ) - - ev = { - "event_type": event_type, - "key": KEY_MAP.get(event.GetKeyCode(), char_str), - "modifiers": modifiers, - } - self._handle_event_and_flush(ev) - - def _char_input_event(self, char_str: Optional[str]): - if char_str is None: - return - - ev = { - "event_type": "char", - "char_str": char_str, - "modifiers": None, - } - self._handle_event_and_flush(ev) - - @staticmethod - def _get_char_from_event(event: wx.KeyEvent) -> Optional[str]: - keycode = event.GetKeyCode() - modifiers = event.GetModifiers() - - # Check if keycode corresponds to a printable ASCII character - if 32 <= keycode <= 126: - char = chr(keycode) - if not modifiers & wx.MOD_SHIFT: - char = char.lower() - return char - - # Check for special keys (e.g., Enter, Tab) - if keycode == wx.WXK_RETURN: - return "\n" - if keycode == wx.WXK_TAB: - return "\t" - - # Handle non-ASCII characters and others - uni_char = event.GetUnicodeKey() - if uni_char != wx.WXK_NONE: - return chr(uni_char) - - return None - - def _mouse_event(self, event_type: str, event: wx.MouseEvent, touches: bool = True): - button = BUTTON_MAP.get(event.GetButton(), 0) - buttons = (button,) # in wx only one button is pressed per event - - modifiers = tuple( - MODIFIERS_MAP[mod] - for mod in MODIFIERS_MAP.keys() - if mod & event.GetModifiers() - ) - - ev = { - "event_type": event_type, - "x": event.GetX(), - "y": event.GetY(), - "button": button, - "buttons": buttons, - "modifiers": modifiers, - } - - if touches: - ev.update( - { - "ntouches": 0, - "touches": {}, # TODO: Wx touch events - } - ) - - if event_type == "wheel": - delta = event.GetWheelDelta() - axis = event.GetWheelAxis() - rotation = event.GetWheelRotation() - - dx = 0 - dy = 0 - - if axis == wx.MOUSE_WHEEL_HORIZONTAL: - dx = delta * rotation - elif axis == wx.MOUSE_WHEEL_VERTICAL: - dy = delta * rotation - - ev.update({"dx": -dx, "dy": -dy}) - - match_keys = {"modifiers"} - accum_keys = {"dx", "dy"} - self._handle_event_rate_limited( - ev, self._call_later, match_keys, accum_keys - ) - elif event_type == "pointer_move": - match_keys = {"buttons", "modifiers", "ntouches"} - accum_keys = {} - self._handle_event_rate_limited( - ev, self._call_later, match_keys, accum_keys - ) - else: - self._handle_event_and_flush(ev) - - def _on_mouse_events(self, event: wx.MouseEvent): - event_type = event.GetEventType() - - event_type_name = MOUSE_EVENT_MAP_REVERSED.get(event_type, None) - if event_type_name is None: - return - - self._mouse_event(event_type_name, event) - - def _on_mouse_move(self, event: wx.MouseEvent): - self._mouse_event("pointer_move", event) - - # Methods that we add from wgpu - - def _get_surface_ids(self): - if sys.platform.startswith("win") or sys.platform.startswith("darwin"): - return { - "window": int(self.GetHandle()), - } - elif sys.platform.startswith("linux"): - if False: - # We fall back to XWayland, see _gui_utils.py - return { - "platform": "wayland", - "window": int(self.GetHandle()), - "display": int(get_alt_wayland_display()), - } - else: - return { - "platform": "x11", - "window": int(self.GetHandle()), - "display": int(get_alt_x11_display()), - } - else: - raise RuntimeError(f"Cannot get Qt surafce info on {sys.platform}.") - - def get_present_methods(self): - global _show_image_method_warning - - methods = {} - if self._present_to_screen and self._surface_ids: - methods["screen"] = self._surface_ids - else: - if _show_image_method_warning: - logger.warning(_show_image_method_warning) - _show_image_method_warning = None - methods["bitmap"] = {"formats": ["rgba-u8"]} - return methods - - def get_pixel_ratio(self): - # todo: this is not hidpi-ready (at least on win10) - # Observations: - # * On Win10 this always returns 1 - so hidpi is effectively broken - return self.GetContentScaleFactor() - - def get_logical_size(self): - lsize = self.Size[0], self.Size[1] - return float(lsize[0]), float(lsize[1]) - - def get_physical_size(self): - lsize = self.Size[0], self.Size[1] - lsize = float(lsize[0]), float(lsize[1]) - ratio = self.GetContentScaleFactor() - return round(lsize[0] * ratio + 0.01), round(lsize[1] * ratio + 0.01) - - def set_logical_size(self, width, height): - if width < 0 or height < 0: - raise ValueError("Window width and height must not be negative") - self.SetSize(width, height) - - def set_title(self, title): - pass # only on frames - - def _request_draw(self): - # Despite the FPS limiting the delayed call to refresh solves - # that drawing only happens when the mouse is down, see #209. - if not self._request_draw_timer.IsRunning(): - self._request_draw_timer.Start( - max(1, int(self._get_draw_wait_time() * 1000)), wx.TIMER_ONE_SHOT - ) - - def close(self): - self.Hide() - - def is_closed(self): - return not self.IsShown() - - @staticmethod - def _call_later(delay, callback, *args): - delay_ms = int(delay * 1000) - if delay_ms <= 0: - callback(*args) - - wx.CallLater(max(delay_ms, 1), callback, *args) - - def present_image(self, image_data, **kwargs): - size = image_data.shape[1], image_data.shape[0] # width, height - - dc = wx.PaintDC(self) - bitmap = wx.Bitmap.FromBufferRGBA(*size, image_data) - dc.DrawBitmap(bitmap, 0, 0, False) - - -class WxWgpuCanvas(WgpuAutoGui, WgpuCanvasBase, wx.Frame): - """A toplevel wx Frame providing a wgpu canvas.""" - - # Most of this is proxying stuff to the inner widget. - - def __init__( - self, - *, - parent=None, - size=None, - title=None, - max_fps=30, - present_method=None, - **kwargs, - ): - get_app() - super().__init__(parent, **kwargs) - - self.set_logical_size(*(size or (640, 480))) - self.SetTitle(title or "wx wgpu canvas") - - self._subwidget = WxWgpuWindow( - parent=self, max_fps=max_fps, present_method=present_method - ) - self._subwidget.add_event_handler(weakbind(self.handle_event), "*") - self.Bind(wx.EVT_CLOSE, lambda e: self.Destroy()) - - self.Show() - - # wx methods - - def Refresh(self): # noqa: N802 - super().Refresh() - self._subwidget.Refresh() - - # Methods that we add from wgpu - - def get_present_methods(self): - return self._subwidget.get_present_methods() - - def get_pixel_ratio(self): - return self._subwidget.get_pixel_ratio() - - def get_logical_size(self): - return self._subwidget.get_logical_size() - - def get_physical_size(self): - return self._subwidget.get_physical_size() - - def set_logical_size(self, width, height): - if width < 0 or height < 0: - raise ValueError("Window width and height must not be negative") - self.SetSize(width, height) - - def set_title(self, title): - self.SetTitle(title) - - def _request_draw(self): - return self._subwidget._request_draw() - - def close(self): - self._handle_event_and_flush({"event_type": "close"}) - super().close() - - def is_closed(self): - return not self.isVisible() - - # Methods that we need to explicitly delegate to the subwidget - - def get_context(self, *args, **kwargs): - return self._subwidget.get_context(*args, **kwargs) - - def request_draw(self, *args, **kwargs): - return self._subwidget.request_draw(*args, **kwargs) - - def present_image(self, image, **kwargs): - return self._subwidget.present_image(image, **kwargs) - - -# Make available under a name that is the same for all gui backends -WgpuWidget = WxWgpuWindow -WgpuCanvas = WxWgpuCanvas - -_the_app = None - - -def get_app(): - global _the_app - app = wx.App.GetInstance() - if app is None: - print("zxc") - _the_app = app = wx.App() - wx.App.SetInstance(app) - return app - - -def run(): - get_app().MainLoop() +WgpuWidget = WxWgpuWindow = RenderWidget +WgpuCanvas = WxWgpuCanvas = RenderCanvas +run = loop.run +call_later = loop.call_later diff --git a/wgpu/utils/imgui/imgui_renderer.py b/wgpu/utils/imgui/imgui_renderer.py index 1b0caf0a..ce31e421 100644 --- a/wgpu/utils/imgui/imgui_renderer.py +++ b/wgpu/utils/imgui/imgui_renderer.py @@ -64,9 +64,7 @@ class ImguiRenderer: "Meta": imgui.Key.im_gui_mod_super, } - def __init__( - self, device, canvas: wgpu.gui.WgpuCanvasBase, render_target_format=None - ): + def __init__(self, device, canvas, render_target_format=None): # Prepare present context self._canvas_context = canvas.get_context("wgpu") diff --git a/wgpu/utils/imgui/stats.py b/wgpu/utils/imgui/stats.py index d7d84d16..fe4061e7 100644 --- a/wgpu/utils/imgui/stats.py +++ b/wgpu/utils/imgui/stats.py @@ -13,7 +13,7 @@ class Stats: ---------- device : wgpu.Device The device to use for rendering the stats. - canvas : wgpu.gui.WgpuCanvasBase + canvas : rendercanvas.BaseRenderCanvas The canvas to render the stats on. foreground : tuple(4) The color of the text.