Skip to content

Commit

Permalink
support Qt6 (#324)
Browse files Browse the repository at this point in the history
* mostly working

* back to pypi for qtpy

* add run name

* fix python-version

* fix 3.10 test

* fix partials on 3.10

* try fix weird win error

* fix tox

* wth

* fix flow

* fix pyqt6 combobox

* add egl

* try just egl1

* remove lib

* fix pyside6

* unskip test
  • Loading branch information
tlambert03 committed Dec 22, 2021
1 parent 12f78d2 commit e05b12d
Show file tree
Hide file tree
Showing 5 changed files with 54 additions and 30 deletions.
13 changes: 10 additions & 3 deletions .github/workflows/test_and_deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,17 +20,24 @@ jobs:
matrix:
python-version: ["3.7", "3.8", "3.9"]
platform: [ubuntu-latest, macos-latest, windows-latest]
backend: [pyqt5, pyside2]
include:
# until pyside2 is available for 3.10
- python-version: '3.10'
backend: pyqt
platform: ubuntu-latest
backend: pyqt5
- python-version: '3.10'
backend: pyqt
platform: macos-latest
backend: pyqt5
- python-version: '3.10'
backend: pyqt
platform: windows-latest
backend: pyqt5
- python-version: '3.10'
platform: ubuntu-latest
backend: pyqt6
- python-version: '3.10'
platform: ubuntu-latest
backend: pyside6
steps:
- uses: actions/checkout@v2
- name: Set up Python ${{ matrix.python-version }}
Expand Down
4 changes: 2 additions & 2 deletions magicgui/backends/_qtpy/application.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@ def _mgui_get_backend_name(self):

def _mgui_process_events(self):
app = self._mgui_get_native_app()
app.flush()
app.processEvents()

def _mgui_run(self):
Expand All @@ -31,7 +30,8 @@ def _mgui_get_native_app(self):
# Get native app
self._app = QApplication.instance()
if not self._app:
QApplication.setAttribute(Qt.AA_EnableHighDpiScaling)
if hasattr(Qt, "AA_EnableHighDpiScaling"):
QApplication.setAttribute(Qt.AA_EnableHighDpiScaling)
self._app = QApplication(sys.argv)
self._app.setApplicationName(APPLICATION_NAME)
return self._app
Expand Down
41 changes: 25 additions & 16 deletions magicgui/backends/_qtpy/widgets.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
from __future__ import annotations

import math
import re
from typing import TYPE_CHECKING, Any, Iterable, Sequence

import qtpy
Expand Down Expand Up @@ -144,7 +145,7 @@ def _mgui_render(self):
img = self._qwidget.grab().toImage()
bits = img.constBits()
h, w, c = img.height(), img.width(), 4
if qtpy.API_NAME == "PySide2":
if qtpy.API_NAME.startswith("PySide"):
arr = np.array(bits).reshape(h, w, c)
else:
bits.setsize(h * w * c)
Expand Down Expand Up @@ -662,7 +663,11 @@ def _mgui_get_value(self) -> Any:
return self._qwidget.itemData(self._qwidget.currentIndex())

def _mgui_set_value(self, value) -> None:
self._qwidget.setCurrentIndex(self._qwidget.findData(value))
# Note: there's a bug in PyQt6, where CombBox.findData(value) will not
# find the data if value is an Enum. So we do it manually
wdg = self._qwidget
idx = next((i for i in range(wdg.count()) if wdg.itemData(i) == value), -1)
self._qwidget.setCurrentIndex(idx)

def _mgui_set_choice(self, choice_name: str, data: Any) -> None:
"""Set data for ``choice_name``."""
Expand Down Expand Up @@ -722,7 +727,9 @@ def __init__(self):

def _emit_data(self):
data = self._qwidget.selectedItems()
self._event_filter.valueChanged.emit([d.data(Qt.UserRole) for d in data])
self._event_filter.valueChanged.emit(
[d.data(Qt.ItemDataRole.UserRole) for d in data]
)

def _mgui_bind_change_callback(self, callback):
self._event_filter.valueChanged.connect(callback)
Expand All @@ -733,33 +740,33 @@ def _mgui_get_count(self) -> int:

def _mgui_get_choice(self, choice_name: str) -> list[Any]:
items = self._qwidget.findItems(choice_name, Qt.MatchExactly)
return [i.data(Qt.UserRole) for i in items]
return [i.data(Qt.ItemDataRole.UserRole) for i in items]

def _mgui_get_current_choice(self) -> list[str]: # type: ignore[override]
return [i.text() for i in self._qwidget.selectedItems()]

def _mgui_get_value(self) -> Any:
return [i.data(Qt.UserRole) for i in self._qwidget.selectedItems()]
return [i.data(Qt.ItemDataRole.UserRole) for i in self._qwidget.selectedItems()]

def _mgui_set_value(self, value) -> None:
if not isinstance(value, (list, tuple)):
value = [value]
for i in range(self._qwidget.count()):
item = self._qwidget.item(i)
item.setSelected(item.data(Qt.UserRole) in value)
item.setSelected(item.data(Qt.ItemDataRole.UserRole) in value)

def _mgui_set_choice(self, choice_name: str, data: Any) -> None:
"""Set data for ``choice_name``."""
items = self._qwidget.findItems(choice_name, Qt.MatchExactly)
# if it's not in the list, add a new item
if not items:
item = QtW.QListWidgetItem(choice_name)
item.setData(Qt.UserRole, data)
item.setData(Qt.ItemDataRole.UserRole, data)
self._qwidget.addItem(item)
# otherwise update its data
else:
for item in items:
item.setData(Qt.UserRole, data)
item.setData(Qt.ItemDataRole.UserRole, data)

def _mgui_set_choices(self, choices: Iterable[tuple[str, Any]]) -> None:
"""Set current items in categorical type ``widget`` to ``choices``."""
Expand All @@ -786,7 +793,10 @@ def _mgui_del_choice(self, choice_name: str) -> None:
def _mgui_get_choices(self) -> tuple[tuple[str, Any], ...]:
"""Get available choices."""
return tuple(
(self._qwidget.item(i).text(), self._qwidget.item(i).data(Qt.UserRole))
(
self._qwidget.item(i).text(),
self._qwidget.item(i).data(Qt.ItemDataRole.UserRole),
)
for i in range(self._qwidget.count())
)

Expand Down Expand Up @@ -952,14 +962,13 @@ def show_file_dialog(
return result or None


def get_text_width(text) -> int:
"""Return the width required to render ``text`` (including rich text elements)."""
if qtpy.PYSIDE2:
from qtpy.QtGui import Qt as _Qt
else:
from qtpy.QtCore import Qt as _Qt
def _might_be_rich_text(text):
return bool(re.search("<[^\n]+>", text))


if _Qt.mightBeRichText(text):
def get_text_width(text: str) -> int:
"""Return the width required to render ``text`` (including rich text elements)."""
if _might_be_rich_text(text):
doc = QTextDocument()
doc.setHtml(text)
return doc.size().width()
Expand Down
11 changes: 7 additions & 4 deletions tests/test_backends.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,16 @@
from magicgui import event_loop, widgets


@pytest.mark.skipif(API_NAME == "PyQt5", reason="Couldn't delete app on PyQt")
@pytest.mark.skipif("PyQt" in API_NAME, reason="Couldn't delete app on PyQt")
def test_event():
"""Test that the event loop makes a Qt app."""
if QtW.QApplication.instance():
import shiboken2

shiboken2.delete(QtW.QApplication.instance())
if API_NAME == "PySide2":
__import__("shiboken2").delete(QtW.QApplication.instance())
elif API_NAME == "PySide6":
__import__("shiboken6").delete(QtW.QApplication.instance())
else:
raise AssertionError(f"known API name: {API_NAME}")
assert not QtW.QApplication.instance()
with event_loop():
app = QtW.QApplication.instance()
Expand Down
15 changes: 10 additions & 5 deletions tox.ini
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
[tox]
envlist = py{37,38,39,310}-{linux,macos,windows}-{pyqt,pyside}
envlist = py{37,38,39,310}-{linux,macos,windows}-{pyqt5,pyside2,pyqt6,pyside6}
toxworkdir=/tmp/.tox

[gh-actions]
Expand All @@ -15,8 +15,10 @@ PLATFORM =
macos-latest: macos
windows-latest: windows
BACKEND =
pyqt: pyqt
pyside: pyside
pyqt5: pyqt5
pyqt6: pyqt6
pyside2: pyside2
pyside6: pyside6

[testenv]
platform =
Expand All @@ -26,10 +28,13 @@ platform =
passenv = CI GITHUB_ACTIONS DISPLAY XAUTHORITY
setenv =
PYTHONPATH = {toxinidir}
deps =
pyqt6: PyQt6
pyside6: PySide6
extras =
testing
pyqt: PyQt5
pyside: PySide2
pyqt5: PyQt5
pyside2: PySide2
commands =
pytest -v --color=yes --cov-report=xml --cov=magicgui --basetemp={envtmpdir} {posargs}

Expand Down

0 comments on commit e05b12d

Please sign in to comment.