Skip to content

Commit

Permalink
feat: add Wx front-end (#62)
Browse files Browse the repository at this point in the history
* feat: support wx

* specify extra

* fix try start

* thinner track

* add mouse events

* warn on bad data

* fixup

* add fixme

* add to test matrix

* skip wx on ubuntu

* fix wx test

* style(pre-commit.ci): auto fixes [...]

* remove condition

* change any_app

* add pytest-qt to dev

* add wxpythong 3.9

* fix 3.9

* add windows jup 3.9

* remove test of range_slider

* remove wx-signal

* fix pygfx

* style(pre-commit.ci): auto fixes [...]

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
  • Loading branch information
tlambert03 and pre-commit-ci[bot] authored Dec 19, 2024
1 parent ca31223 commit 6c19a89
Show file tree
Hide file tree
Showing 12 changed files with 824 additions and 16 deletions.
16 changes: 15 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,12 +32,18 @@ jobs:
# using 3.12 as main current version, until 3.13 support
# is ubiquitous in upstream dependencies
python-version: ["3.10", "3.12"]
gui: [pyside, pyqt, jup]
gui: [pyside, pyqt, jup, wxpython]
canvas: [vispy, pygfx]
exclude:
# unsolved intermittent segfaults on this combo
- python-version: "3.10"
gui: pyside
# wxpython does not build wheels for ubuntu or macos-latest py3.10
- os: ubuntu-latest
gui: wxpython
- os: macos-latest
gui: wxpython
python-version: "3.10"
include:
# test a couple more python variants, without
# full os/gui/canvas matrix coverage
Expand All @@ -54,6 +60,14 @@ jobs:
python-version: "3.9"
gui: pyqt
canvas: vispy
- os: macos-13
gui: wxpython
python-version: "3.9"
canvas: vispy
- os: windows-latest
gui: jup
python-version: "3.9"
canvas: pygfx

steps:
- uses: actions/checkout@v4
Expand Down
3 changes: 3 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ dependencies = [
jup = ["ipywidgets", "jupyter", "jupyter_rfb", "glfw"]
pyqt = ["pyqt6"]
pyside = ["pyside6<6.8"]
wxpython = ["wxpython"]

# Supported Canavs backends
vispy = ["vispy>=0.14.3", "pyopengl"]
Expand All @@ -55,10 +56,12 @@ pygfx = ["pygfx>=0.6.0"]
# ready to go bundles with vispy
qt = ["ndv[vispy,pyqt]", "imageio[tifffile]"]
jupyter = ["ndv[jup,vispy]", "imageio[tifffile]"]
wx = ["ndv[vispy,wxpython]", "imageio[tifffile]"]

test = ["imageio[tifffile]", "pytest-cov", "pytest"]
dev = [
"ndv[test,vispy,pygfx,pyqt,jupyter]",
"pytest-qt",
"ipython",
"mypy",
"pdbpp",
Expand Down
46 changes: 45 additions & 1 deletion src/ndv/views/_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
class GuiFrontend(str, Enum):
QT = "qt"
JUPYTER = "jupyter"
WX = "wx"


class CanvasBackend(str, Enum):
Expand All @@ -44,6 +45,11 @@ def get_view_frontend_class() -> type[ArrayView]:

return JupyterArrayView

if frontend == GuiFrontend.WX:
from ._wx.wx_view import WxArrayView

return WxArrayView

raise RuntimeError("No GUI frontend found")


Expand All @@ -54,8 +60,11 @@ def get_canvas_class(backend: str | None = None) -> type[ArrayCanvas]:

from ndv.views._vispy._vispy import VispyViewerCanvas

if gui_frontend() == GuiFrontend.JUPYTER:
_frontend = gui_frontend()
if _frontend == GuiFrontend.JUPYTER:
use_app("jupyter_rfb")
elif _frontend == GuiFrontend.WX:
use_app("wx")

return VispyViewerCanvas

Expand Down Expand Up @@ -91,6 +100,12 @@ def _is_running_in_qapp() -> bool:
return False


def _is_running_in_wxapp() -> bool:
if wx := sys.modules.get("wx"):
return wx.App.Get() is not None
return False


def _try_start_qapp() -> bool:
global _APP_INSTANCE
try:
Expand All @@ -108,6 +123,22 @@ def _try_start_qapp() -> bool:
return False


def _try_start_wxapp() -> bool:
global _APP_INSTANCE
try:
import wx

wxapp: wx.App | None
if (wxapp := wx.App.Get()) is None:
wxapp = wx.App()

_install_excepthook()
_APP_INSTANCE = wxapp
return True
except Exception:
return False


def _install_excepthook() -> None:
"""Install a custom excepthook that does not raise sys.exit().
Expand Down Expand Up @@ -190,8 +221,12 @@ def gui_frontend() -> GuiFrontend:
return GuiFrontend.JUPYTER
if _is_running_in_qapp():
return GuiFrontend.QT
if _is_running_in_wxapp():
return GuiFrontend.WX
if _try_start_qapp():
return GuiFrontend.QT
if _try_start_wxapp():
return GuiFrontend.WX
raise RuntimeError(f"Could not find an appropriate GUI frontend: {valid!r}")


Expand Down Expand Up @@ -230,5 +265,14 @@ def run_app() -> None:
f"Got unexpected application type: {type(_APP_INSTANCE)}"
)
_APP_INSTANCE.exec()
elif frontend == GuiFrontend.WX:
import wx

_try_start_wxapp()
if not isinstance(_APP_INSTANCE, wx.App):
raise RuntimeError(
f"Got unexpected application type: {type(_APP_INSTANCE)}"
)
_APP_INSTANCE.MainLoop()
elif frontend == GuiFrontend.JUPYTER:
pass # nothing to do here
4 changes: 4 additions & 0 deletions src/ndv/views/_pygfx/_pygfx.py
Original file line number Diff line number Diff line change
Expand Up @@ -409,6 +409,10 @@ def sizeHint(self) -> QSize:
from wgpu.gui.jupyter import JupyterWgpuCanvas

return JupyterWgpuCanvas
if frontend == GuiFrontend.WX:
from wgpu.gui.wx import WxWgpuCanvas

return WxWgpuCanvas


class GfxArrayCanvas(ArrayCanvas):
Expand Down
1 change: 1 addition & 0 deletions src/ndv/views/_wx/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@

29 changes: 29 additions & 0 deletions src/ndv/views/_wx/_labeled_slider.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import wx


class WxLabeledSlider(wx.Panel):
"""A simple labeled slider widget for wxPython."""

def __init__(self, parent: wx.Window) -> None:
super().__init__(parent)

self.label = wx.StaticText(self)
self.slider = wx.Slider(self, style=wx.HORIZONTAL)

sizer = wx.BoxSizer(wx.HORIZONTAL)
sizer.Add(self.label, 0, wx.ALIGN_CENTER_VERTICAL | wx.RIGHT, 5)
sizer.Add(self.slider, 1, wx.EXPAND)
self.SetSizer(sizer)

def setRange(self, min_val: int, max_val: int) -> None:
self.slider.SetMin(min_val)
self.slider.SetMax(max_val)

def setValue(self, value: int) -> None:
self.slider.SetValue(value)

def value(self) -> int:
return self.slider.GetValue() # type: ignore [no-any-return]

def setSingleStep(self, step: int) -> None:
self.slider.SetLineSize(step)
Loading

0 comments on commit 6c19a89

Please sign in to comment.