From 996b70185f3fb97e7d7335f1a70a712b2c99b878 Mon Sep 17 00:00:00 2001 From: Jarrett Johnson Date: Fri, 16 Aug 2024 17:40:22 -0400 Subject: [PATCH] Basic Qt6 support Co-authored-by: Jarrett Johnson Co-authored-by: Thomas Holder --- .github/workflows/build.yml | 4 +- INSTALL | 2 +- modules/pmg_qt/TextEditor.py | 2 +- modules/pmg_qt/file_dialogs.py | 2 +- modules/pmg_qt/forms/render.ui | 35 +---------- modules/pmg_qt/pymol_gl_widget.py | 8 ++- modules/pmg_qt/pymol_qt_gui.py | 12 +++- modules/pymol/Qt/__init__.py | 80 +++++++++++++++++++------- modules/pymol/Qt/utils.py | 13 +++-- modules/pymol/diagnosing.py | 2 +- modules/pymol/plugins/managergui_qt.py | 2 +- 11 files changed, 90 insertions(+), 72 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index eea67cf84..b5462115b 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -74,7 +74,7 @@ jobs: shell: cmd run: |- CALL %CONDA_ROOT%\\Scripts\\activate.bat - conda install -y -c conda-forge -c schrodinger python cmake libpng freetype pyqt glew libxml2 numpy=1.26.4 catch2=2.13.3 glm libnetcdf collada2gltf biopython pillow msgpack-python pytest pip python-build + conda install -y -c conda-forge -c schrodinger python cmake libpng freetype pyside6 glew libxml2 numpy=1.26.4 catch2=2.13.3 glm libnetcdf collada2gltf biopython pillow msgpack-python pytest pip python-build - name: Conda info shell: cmd @@ -117,7 +117,7 @@ jobs: bash $CONDA_ROOT.sh -b -p $CONDA_ROOT export PATH="$CONDA_ROOT/bin:$PATH" conda config --set quiet yes - conda install -y -c conda-forge -c schrodinger python cmake libpng freetype pyqt glew libxml2 numpy=1.26.4 catch2=2.13.3 glm libnetcdf collada2gltf biopython pillow msgpack-python pytest pip python-build + conda install -y -c conda-forge -c schrodinger python cmake libpng freetype pyside6 glew libxml2 numpy=1.26.4 catch2=2.13.3 glm libnetcdf collada2gltf biopython pillow msgpack-python pytest pip python-build conda info - name: Get additional sources diff --git a/INSTALL b/INSTALL index c8bcd84d0..5b80f3044 100644 --- a/INSTALL +++ b/INSTALL @@ -22,7 +22,7 @@ REQUIREMENTS - msgpack-c 2.1.5+ (optional, for fast MMTF loading and export, disable with --use-msgpackc=no) - mmtf-cpp (for fast MMTF export, disable with --use-msgpackc=no) - - PyQt5, PyQt4, PySide2 or PySide (optional, will fall back to Tk + - PyQt5, PyQt6, PySide2 or PySide6 (optional, will fall back to Tk interface if compiled with --glut) - glm - catch2 (optional, enable with --testing) diff --git a/modules/pmg_qt/TextEditor.py b/modules/pmg_qt/TextEditor.py index 19bc05bed..b2cad7f90 100644 --- a/modules/pmg_qt/TextEditor.py +++ b/modules/pmg_qt/TextEditor.py @@ -192,4 +192,4 @@ def _edit_pymolrc(app, _list=()): filename = '' app = QtWidgets.QApplication(['Test']) edit_pymolrc() - app.exec_() + app.exec() diff --git a/modules/pmg_qt/file_dialogs.py b/modules/pmg_qt/file_dialogs.py index 730aa170c..3b12dea82 100644 --- a/modules/pmg_qt/file_dialogs.py +++ b/modules/pmg_qt/file_dialogs.py @@ -85,7 +85,7 @@ def ask_partial(parent, kwargs, fname): form.check_rename.setChecked(parent.cmd.get_setting_boolean( 'auto_rename_duplicate_objects')) - if not form._dialog.exec_(): + if not form._dialog.exec(): return False if form.check_partial.isChecked(): diff --git a/modules/pmg_qt/forms/render.ui b/modules/pmg_qt/forms/render.ui index e81fe68ab..f9db0aee7 100644 --- a/modules/pmg_qt/forms/render.ui +++ b/modules/pmg_qt/forms/render.ui @@ -419,38 +419,5 @@ alpha-channel background. button_back - - - input_units - currentIndexChanged(QString) - input_height_units - setSuffix(QString) - - - 102 - 119 - - - 237 - 57 - - - - - input_units - currentIndexChanged(QString) - input_width_units - setSuffix(QString) - - - 102 - 119 - - - 237 - 30 - - - - + diff --git a/modules/pmg_qt/pymol_gl_widget.py b/modules/pmg_qt/pymol_gl_widget.py index 83dd17426..44b95822a 100644 --- a/modules/pmg_qt/pymol_gl_widget.py +++ b/modules/pmg_qt/pymol_gl_widget.py @@ -20,7 +20,8 @@ # no stereo support) USE_QOPENGLWIDGET = int( os.getenv("PYMOL_USE_QOPENGLWIDGET") or - (pymol.IS_MACOS and QtCore.QT_VERSION >= 0x50400)) + (QtCore.QT_VERSION >= 0x50400 and pymol.IS_MACOS or + QtCore.QT_VERSION >= 0x60000)) if USE_QOPENGLWIDGET: BaseGLWidget = QtWidgets.QOpenGLWidget @@ -167,9 +168,10 @@ def gestureEvent(self, ev): return True def _event_x_y_mod(self, ev): + pos = ev.position() if hasattr(ev, "position") else ev.pos() return ( - int(self.fb_scale * ev.x()), - int(self.fb_scale * (self.height() - ev.y())), + int(self.fb_scale * pos.x()), + int(self.fb_scale * (self.height() - pos.y())), get_modifiers(ev), ) diff --git a/modules/pmg_qt/pymol_qt_gui.py b/modules/pmg_qt/pymol_qt_gui.py index f9be5f3df..35fd84179 100644 --- a/modules/pmg_qt/pymol_qt_gui.py +++ b/modules/pmg_qt/pymol_qt_gui.py @@ -376,8 +376,9 @@ def _(): # some experimental window control menu = self.menudict['Display'].addSeparator() menu = self.menudict['Display'].addMenu('External GUI') - menu.addAction('Toggle floating', self.toggle_ext_window_dockable, - QtGui.QKeySequence('Ctrl+E')) + menu.addAction('Toggle dockable', self.toggle_ext_window_dockable).setShortcut( + QtGui.QKeySequence('Ctrl+E')) + ext_vis_action = self.ext_window.toggleViewAction() ext_vis_action.setText('Visible') menu.addAction(ext_vis_action) @@ -757,6 +758,10 @@ def run_copy_clipboard(): form.input_dpi.setEditText(str(dpi)) form.input_dpi.setValidator(QtGui.QIntValidator()) + # This connection used to be in the .ui file, but that fails with Qt6 + form.input_units.currentTextChanged.connect(lambda s: form.input_height_units.setSuffix(s)) + form.input_units.currentTextChanged.connect(lambda s: form.input_width_units.setSuffix(s)) + form.input_units.currentIndexChanged.connect(update_units) form.input_dpi.editTextChanged.connect(update_pixels) form.input_width.valueChanged.connect(update_units) @@ -782,6 +787,7 @@ def run_copy_clipboard(): if widget is None: form._dialog.show() + return form @PopupOnException.decorator def _file_save(self, filter, format): @@ -1255,4 +1261,4 @@ def _call_with_opengl_context_gui_thread(func): if options.plugins: window.initializePlugins() - app.exec_() + app.exec() diff --git a/modules/pymol/Qt/__init__.py b/modules/pymol/Qt/__init__.py index bcc990a7b..22191d068 100644 --- a/modules/pymol/Qt/__init__.py +++ b/modules/pymol/Qt/__init__.py @@ -8,7 +8,16 @@ http://pyqt.sourceforge.net/Docs/PyQt5/pyqt4_differences.html """ -DEBUG = False +DEBUG = True # Turn off for open-source + +PYQT_NAME = None +QtWidgets = None + +try: + from pymol._Qt_pre import * +except ImportError: + if DEBUG: + print('import _Qt_pre failed') PYQT_NAME = None QtWidgets = None @@ -39,26 +48,23 @@ if DEBUG: print('import PySide2 failed') -if not PYQT_NAME and qt_api in ('', 'pyqt4'): +if not PYQT_NAME and qt_api in ('', 'pyqt6'): try: - try: - import PyQt4.sip as sip - except ImportError: - import sip - sip.setapi("QString", 2) - from PyQt4 import QtGui, QtCore, QtOpenGL - PYQT_NAME = 'PyQt4' + from PyQt6 import QtGui, QtCore, QtOpenGL, QtWidgets + from PyQt6 import QtOpenGLWidgets + PYQT_NAME = 'PyQt6' except ImportError: if DEBUG: - print('import PyQt4 failed') + print('import PyQt6 failed') -if not PYQT_NAME and qt_api in ('', 'pyside'): +if not PYQT_NAME and qt_api in ('', 'pyside6'): try: - from PySide import QtGui, QtCore, QtOpenGL - PYQT_NAME = 'PySide' + from PySide6 import QtGui, QtCore, QtOpenGL, QtWidgets + from PySide6 import QtOpenGLWidgets + PYQT_NAME = 'PySide6' except ImportError: if DEBUG: - print('import PySide failed') + print('import PySide6 failed') if not PYQT_NAME: raise ImportError(__name__) @@ -74,12 +80,46 @@ else: QtCoreModels = QtGui -if PYQT_NAME == 'PyQt4': - QFileDialog = QtWidgets.QFileDialog - QFileDialog.getOpenFileName = QFileDialog.getOpenFileNameAndFilter - QFileDialog.getOpenFileNames = QFileDialog.getOpenFileNamesAndFilter - QFileDialog.getSaveFileName = QFileDialog.getSaveFileNameAndFilter - del QFileDialog +if PYQT_NAME.endswith('6'): + QtWidgets.QOpenGLWidget = QtOpenGLWidgets.QOpenGLWidget + QtWidgets.QActionGroup = QtGui.QActionGroup + QtWidgets.QAction = QtGui.QAction + QtWidgets.QShortcut = QtGui.QShortcut + QtCore.QSortFilterProxyModel.setFilterRegExp = QtCore.QSortFilterProxyModel.setFilterRegularExpression + QtGui.QFont.Monospace = QtGui.QFont.StyleHint.Monospace + + def copy_attributes(target_class, source_class): + for attr in dir(source_class): + if not attr.startswith('_'): + setattr(target_class, attr, getattr(source_class, attr)) + + copy_attributes(QtCore.QEvent, QtCore.QEvent.Type) + copy_attributes(QtCore.Qt, QtCore.Qt.AlignmentFlag) + copy_attributes(QtCore.Qt, QtCore.Qt.CaseSensitivity) + copy_attributes(QtCore.Qt, QtCore.Qt.CheckState) + copy_attributes(QtCore.Qt, QtCore.Qt.ContextMenuPolicy) + copy_attributes(QtCore.Qt, QtCore.Qt.DockWidgetArea) + copy_attributes(QtCore.Qt, QtCore.Qt.FocusPolicy) + copy_attributes(QtCore.Qt, QtCore.Qt.GestureType) + copy_attributes(QtCore.Qt, QtCore.Qt.ItemFlag) + copy_attributes(QtCore.Qt, QtCore.Qt.Key) + copy_attributes(QtCore.Qt, QtCore.Qt.KeyboardModifier) + copy_attributes(QtCore.Qt, QtCore.Qt.MouseButton) + copy_attributes(QtCore.Qt, QtCore.Qt.Orientation) + copy_attributes(QtCore.Qt, QtCore.Qt.WindowType) + copy_attributes(QtGui.QFont, QtGui.QFont.StyleHint) + copy_attributes(QtWidgets.QAbstractItemView, QtWidgets.QAbstractItemView.ScrollHint) + copy_attributes(QtWidgets.QAbstractItemView, QtWidgets.QAbstractItemView.SelectionBehavior) + copy_attributes(QtWidgets.QAbstractItemView, QtWidgets.QAbstractItemView.SelectionMode) + copy_attributes(QtWidgets.QBoxLayout, QtWidgets.QBoxLayout.Direction) + copy_attributes(QtWidgets.QMainWindow, QtWidgets.QMainWindow.DockOption) + copy_attributes(QtWidgets.QOpenGLWidget, QtOpenGLWidgets.QOpenGLWidget.UpdateBehavior) + copy_attributes(QtWidgets.QSizePolicy, QtWidgets.QSizePolicy.Policy) + copy_attributes(QtWidgets.QTreeWidgetItem, QtWidgets.QTreeWidgetItem.ChildIndicatorPolicy) + + QtCore.Qt.MidButton = QtCore.Qt.MiddleButton + QtCore.Qt.WA_LayoutUsesWidgetRect = QtCore.Qt.WidgetAttribute.WA_LayoutUsesWidgetRect + if PYQT_NAME[:4] == 'PyQt': QtCore.Signal = QtCore.pyqtSignal diff --git a/modules/pymol/Qt/utils.py b/modules/pymol/Qt/utils.py index db68f26ca..0f22a58db 100644 --- a/modules/pymol/Qt/utils.py +++ b/modules/pymol/Qt/utils.py @@ -88,9 +88,9 @@ def setSetupUi(self, setupUi): @self.aboutToShow.connect def _(): self.aboutToShow.disconnect() - widget = QtWidgets.QWidget() - setupUi(widget) - self.setWidget(widget) + self._widget = QtWidgets.QWidget() + form = setupUi(self._widget) + self.setWidget(form) return self @@ -272,7 +272,10 @@ def loadUi(uifile, widget): @type uifile: str @type widget: QtWidgets.QWidget """ - if PYQT_NAME.startswith('PyQt'): + if PYQT_NAME == "PySide6": + from PySide6.QtUiTools import QUiLoader + return QUiLoader().load(uifile, widget) + elif PYQT_NAME.startswith('PyQt'): m = __import__(PYQT_NAME + '.uic') return m.uic.loadUi(uifile, widget) elif PYQT_NAME == 'PySide2': @@ -343,7 +346,7 @@ def __exit__(self, exc_type, e, tb): msg = str(e) or 'unknown error' msgbox = QMB(QMB.Critical, 'Error', msg, QMB.Close, parent) msgbox.setDetailedText(''.join(traceback.format_tb(tb))) - msgbox.exec_() + msgbox.exec() return True diff --git a/modules/pymol/diagnosing.py b/modules/pymol/diagnosing.py index 08c8976c1..68a0f7197 100644 --- a/modules/pymol/diagnosing.py +++ b/modules/pymol/diagnosing.py @@ -142,7 +142,7 @@ def diagnostics_qt(): from pymol.Qt import QtCore return u'{} {} (Qt {})\n'.format( QtCore.__name__.split('.')[0], - QtCore.PYQT_VERSION_STR, + getattr(QtCore, "PYQT_VERSION_STR", ""), QtCore.QT_VERSION_STR) except Exception as e: return u'({})\n'.format(e) diff --git a/modules/pymol/plugins/managergui_qt.py b/modules/pymol/plugins/managergui_qt.py index 628fd28f0..112650da5 100644 --- a/modules/pymol/plugins/managergui_qt.py +++ b/modules/pymol/plugins/managergui_qt.py @@ -379,7 +379,7 @@ def add_line(label, text): table.resizeColumnsToContents() dialog.resize(600, dialog.height()) - dialog.exec_() + dialog.exec() def add_path(self): from .installation import get_default_user_plugin_path as userpath