Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 34 additions & 10 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,28 +22,52 @@ jobs:
- run: pipx run check-manifest

lint:
runs-on: ubuntu-latest
# We lint on Windows so we can lint wx code.
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v6
with:
enable-cache: true
python-version: "3.13"
- uses: tox-dev/action-pre-commit-uv@v1
with:
extra_args: --all-files --verbose
- run: uvx pre-commit run --all-files --verbose

test:
name: test ${{ matrix.os }} py${{ matrix.python-version }} ${{ matrix.gfx }}
name: test ${{ matrix.os }} py${{ matrix.python-version }} ${{ matrix.gfx }} ${{ matrix.canvas }}
runs-on: ${{ matrix.os }}
env:
UV_FROZEN: 1
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
python-version: ["3.10", "3.11", "3.12", "3.13"]
python-version: ["3.10", "3.13"]
gfx: [pygfx, vispy]
canvas: [pyqt, jupyter, wx]
exclude:
# glfw.GLFWError: (65545) b'NSGL: Failed to find a suitable pixel format'
# (Under the hood GLFW gives vispy the OpenGL context.)
- os: macos-latest
canvas: jupyter
gfx: vispy
# wxpython does not build wheels for ubuntu or macos-latest py3.10
- os: ubuntu-latest
canvas: wx
- os: macos-latest
canvas: wx
python-version: "3.10"
# FIXME: On CI: AttributeError: 'Shared' object has no attribute '_device'. Did you mean: 'device'?
# Related to pygfx v0.13.0
# Tests pass locally
- os: windows-latest
gfx: pygfx
include:
- python-version: "3.11"
gfx: pygfx
canvas: pyqt
os: ubuntu-latest
- python-version: "3.12"
gfx: pygfx
canvas: pyqt
os: ubuntu-latest

steps:
- uses: actions/checkout@v4
Expand All @@ -61,7 +85,7 @@ jobs:
sudo apt install -y libegl1-mesa-dev libgl1-mesa-dri libxcb-xfixes0-dev mesa-vulkan-drivers

- name: Install dependencies
run: uv sync --no-dev --group test --extra ${{ matrix.gfx }} ${{ matrix.python-version != '3.10' && '--extra imgui' || '' }}
run: uv sync --no-dev --group test --extra ${{ matrix.gfx }} --extra ${{matrix.canvas}} ${{ matrix.python-version != '3.10' && '--extra imgui' || '' }} ${{ matrix.canvas == 'pyqt' && '--group testqt' || '' }}

- name: Test
shell: bash
Expand All @@ -70,7 +94,7 @@ jobs:
- name: Upload coverage
uses: actions/upload-artifact@v4
with:
name: covreport-${{ matrix.os }}-py${{ matrix.python-version }}-${{ matrix.gfx }}
name: covreport-${{ matrix.os }}-py${{ matrix.python-version }}-${{ matrix.gfx }}-${{ matrix.canvas }}
path: ./.coverage*
include-hidden-files: true

Expand Down
8 changes: 4 additions & 4 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -27,26 +27,26 @@ repos:
language: system
types_or: [python, pyi]
require_serial: true
entry: uv run ruff format --force-exclude
entry: uv run --active ruff format --force-exclude
- id: ruff-check
name: ruff check
language: system
types_or: [python, pyi]
require_serial: true
entry: uv run ruff check --force-exclude
entry: uv run --active ruff check --force-exclude
args: [--output-format=full, --show-fixes, --exit-non-zero-on-fix]
- id: mypy
name: mypy
language: system
types_or: [python, pyi]
require_serial: true
entry: uv run mypy
entry: uv run --active mypy
- id: pyright
name: pyright
language: system
types_or: [python, pyi]
require_serial: true
entry: uv run pyright
entry: uv run --active pyright

- repo: https://github.com/crate-ci/typos
rev: v1.34.0
Expand Down
217 changes: 217 additions & 0 deletions examples/notebook.ipynb

Large diffs are not rendered by default.

29 changes: 24 additions & 5 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -35,15 +35,34 @@ classifiers = [
dependencies = ["cmap>=0.5", "numpy>=1.24", "psygnal>=0.11.1", "pydantic>=2.10"]

[project.optional-dependencies]
pygfx = ["pygfx>=0.9.0"]
jupyter = [
"ipywidgets >=8.0.5",
"jupyter >=1.1",
"jupyter_rfb >=0.3.3",
# Note that this dep is only needed for Vispy, something like simplejpeg would work fine if just pygfx.
"glfw",
# Otherwise jupyter_rfb will use PNG, which is apparently slower
"simplejpeg",
]
pyqt = [
"pyqt6 >=6.4,!=6.6",
"pyqt6 >=6.5.3; python_version >= '3.12'",
"qtpy >=2",
"superqt[iconify] >=0.7.2",
]
wx= [
"wxpython >=4.2.2",
]
pygfx = ["pygfx>=0.13.0"]
vispy = ["vispy>=0.15.0", "pyopengl"]
imgui = [
# 1.6.3 breaks type checking, 1.92 not working with pygfx
"imgui-bundle>=1.6,!=1.6.3,<1.92.0",
]

[dependency-groups]
test = ["pytest>=8", "pytest-cov>=6", "PyQt6"]
test = ["pytest>=8", "pytest-cov>=6"]
testqt = [{ include-group = "test" }, "pytest-qt >=4.4"]
docs = [
"mike>=2.1.3",
"mkdocs>=1.6.1",
Expand All @@ -56,7 +75,7 @@ docs = [
dev = [
{ include-group = "test" },
{ include-group = "docs" },
"scenex[pygfx,vispy,imgui]",
"scenex[pygfx,vispy,imgui,jupyter,pyqt, wx]",
"imageio[tifffile] >=2.20",
"ipython",
"mypy",
Expand Down Expand Up @@ -122,14 +141,14 @@ disallow_subclassing_any = false
show_error_codes = true
pretty = true
plugins = ["pydantic.mypy"]
untyped_calls_exclude = ["rendercanvas"]
untyped_calls_exclude = ["rendercanvas", "IPython", "pytestqt"]

[[tool.mypy.overrides]]
module = ["rendercanvas.*"]
follow_untyped_imports = true

[[tool.mypy.overrides]]
module = ["pygfx.*", "vispy.*", "wgpu.*"]
module = ["pygfx.*", "vispy.*", "wgpu.*", "glfw.*", "pylinalg.*", "qtpy.*", "ipywidgets.*", "IPython.*", "jupyter", "jupyter_rfb.*", "wx.*", "pytestqt.*"]
ignore_missing_imports = true

[tool.pydantic-mypy]
Expand Down
4 changes: 3 additions & 1 deletion src/scenex/adaptors/_auto.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
from contextlib import suppress
from typing import TYPE_CHECKING, Any, Literal, TypeAlias, TypeGuard, cast, get_args

from scenex.app import app

if TYPE_CHECKING:
from collections.abc import Iterator

Expand Down Expand Up @@ -83,4 +85,4 @@ def use(backend: KnownBackend | None = None) -> None:

def run() -> None:
"""Enter the native GUI event loop."""
get_adaptor_registry().start_event_loop()
app().run()
60 changes: 51 additions & 9 deletions src/scenex/adaptors/_pygfx/_canvas.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from typing import TYPE_CHECKING, Any, TypeGuard, cast

from scenex.adaptors._base import CanvasAdaptor
from scenex.app import app, determine_app, GuiFrontend

from ._adaptor_registry import adaptors

Expand All @@ -24,13 +25,41 @@ def supports_hide_show(obj: Any) -> TypeGuard[SupportsHideShow]:
return hasattr(obj, "show") and hasattr(obj, "hide")


def _rendercanvas_class() -> BaseRenderCanvas:
"""Obtains the appropriate class for the current GUI backend.

Explicit since PyGFX's backend selection process may be different from ours.
"""
frontend = determine_app()

if frontend == GuiFrontend.QT:
from qtpy.QtCore import QSize # pyright: ignore[reportMissingImports]
from rendercanvas.qt import QRenderWidget

class _QRenderWidget(QRenderWidget):
def sizeHint(self) -> QSize:
return QSize(self.width(), self.height())

# Init Qt Application - otherwise we can't create the widget
app()
return _QRenderWidget() # type: ignore[no-untyped-call]

if frontend == GuiFrontend.JUPYTER:
import rendercanvas.jupyter

return rendercanvas.jupyter.JupyterRenderCanvas()
if frontend == GuiFrontend.WX:
import rendercanvas.wx

return rendercanvas.wx.WxRenderCanvas()

raise ValueError("No suitable render canvas found")

class Canvas(CanvasAdaptor):
"""Canvas interface for pygfx Backend."""

def __init__(self, canvas: model.Canvas, **backend_kwargs: Any) -> None:
from rendercanvas.auto import RenderCanvas

self._wgpu_canvas = RenderCanvas()
self._wgpu_canvas = _rendercanvas_class()
# Qt RenderCanvas calls show() in its __init__ method, so we need to hide it
if supports_hide_show(self._wgpu_canvas):
self._wgpu_canvas.hide()
Expand All @@ -39,13 +68,14 @@ def __init__(self, canvas: model.Canvas, **backend_kwargs: Any) -> None:
self._wgpu_canvas.set_title(canvas.title)
self._views = canvas.views

self._event_filter = app().install_event_filter(self._wgpu_canvas, canvas)

def _snx_get_native(self) -> BaseRenderCanvas:
return self._wgpu_canvas

def _snx_set_visible(self, arg: bool) -> None:
# show the qt canvas we patched earlier in __init__
if supports_hide_show(self._wgpu_canvas):
self._wgpu_canvas.show()
app().show(self, arg)
self._wgpu_canvas.request_draw(self._draw)

def _draw(self) -> None:
Expand All @@ -60,12 +90,24 @@ def _snx_add_view(self, view: model.View) -> None:
# self._views.append(adaptor)

def _snx_set_width(self, arg: int) -> None:
_, height = cast("tuple[float, float]", self._wgpu_canvas.get_logical_size())
self._wgpu_canvas.set_logical_size(arg, height)
width, height = cast(
"tuple[float, float]", self._wgpu_canvas.get_logical_size()
)
# FIXME: For some reason, on wx the size has already been updated, and
# updating it again causes erratic resizing behavior
if width != arg:
with app().block_events(self._snx_get_native()):
self._wgpu_canvas.set_logical_size(arg, height)

def _snx_set_height(self, arg: int) -> None:
width, _ = cast("tuple[float, float]", self._wgpu_canvas.get_logical_size())
self._wgpu_canvas.set_logical_size(width, arg)
width, height = cast(
"tuple[float, float]", self._wgpu_canvas.get_logical_size()
)
# FIXME: For some reason, on wx the size has already been updated, and
# updating it again causes erratic resizing behavior
if height != arg:
with app().block_events(self._snx_get_native()):
self._wgpu_canvas.set_logical_size(width, arg)

def _snx_set_background_color(self, arg: Color | None) -> None:
# not sure if pygfx has both a canavs and view background color...
Expand Down
16 changes: 9 additions & 7 deletions src/scenex/adaptors/_vispy/_canvas.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import numpy as np

from scenex.adaptors._base import CanvasAdaptor
from scenex.app import app

from ._adaptor_registry import get_adaptor

Expand Down Expand Up @@ -33,20 +34,21 @@ def __init__(self, canvas: model.Canvas, **backend_kwargs: Any) -> None:
title=canvas.title, size=(canvas.width, canvas.height)
)
# Qt RenderCanvas calls show() in its __init__ method, so we need to hide it
if supports_hide_show(self._canvas.native):
self._canvas.native.hide()
# if supports_hide_show(self._canvas.native):
# self._canvas.native.hide()
self._grid = cast("Grid", self._canvas.central_widget.add_grid())
for view in canvas.views:
self._snx_add_view(view)
self._views = canvas.views

self._event_filter = app().install_event_filter(self._canvas.native, canvas)

def _snx_get_native(self) -> Any:
return self._canvas.native

def _snx_set_visible(self, arg: bool) -> None:
# show the qt canvas we patched earlier in __init__
if supports_hide_show(self._canvas.native):
self._canvas.show()
app().show(self, arg)
self._canvas.update()

def _draw(self) -> None:
self._canvas.update()
Expand All @@ -55,10 +57,10 @@ def _snx_add_view(self, view: model.View) -> None:
self._grid.add_widget(get_adaptor(view)._snx_get_native())

def _snx_set_width(self, arg: int) -> None:
self._canvas.size = (self._canvas.size[0], arg)
self._canvas.size = (arg, self._canvas.size[1])

def _snx_set_height(self, arg: int) -> None:
self._canvas.size = (arg, self._canvas.size[1])
self._canvas.size = (self._canvas.size[0], arg)

def _snx_set_background_color(self, arg: Color | None) -> None:
if arg is None:
Expand Down
8 changes: 8 additions & 0 deletions src/scenex/app/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
"""The Scenex App Abstraction."""

from ._auto import App, GuiFrontend, app, determine_app

# Note that this package is designed to fully encapsulate app logic.
# It is designed to be cleanly extractable to a separate library if needed.

__all__ = ["App", "GuiFrontend", "app", "determine_app"]
Loading
Loading