Merge pull request #1549 from wmvanvliet/qt6

PyQt6 support
This commit is contained in:
Adam Yoblick 2024-07-31 14:46:58 -05:00 committed by GitHub
commit dc58df149f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 289 additions and 17 deletions

View file

@ -26,6 +26,7 @@ GUI_WX = 'wx'
GUI_QT = 'qt'
GUI_QT4 = 'qt4'
GUI_QT5 = 'qt5'
GUI_QT6 = 'qt6'
GUI_GTK = 'gtk'
GUI_TK = 'tk'
GUI_OSX = 'osx'
@ -173,8 +174,10 @@ class InputHookManager(object):
self.clear_inputhook()
def enable_qt(self, app=None):
from pydev_ipython.qt_for_kernel import QT_API, QT_API_PYQT5
if QT_API == QT_API_PYQT5:
from pydev_ipython.qt_for_kernel import QT_API, QT_API_PYQT5, QT_API_PYQT6
if QT_API == QT_API_PYQT6:
self.enable_qt6(app)
elif QT_API == QT_API_PYQT5:
self.enable_qt5(app)
else:
self.enable_qt4(app)
@ -234,6 +237,21 @@ class InputHookManager(object):
self._apps[GUI_QT5]._in_event_loop = False
self.clear_inputhook()
def enable_qt6(self, app=None):
from pydev_ipython.inputhookqt6 import create_inputhook_qt6
app, inputhook_qt6 = create_inputhook_qt6(self, app)
self.set_inputhook(inputhook_qt6)
self._current_gui = GUI_QT6
app._in_event_loop = True
self._apps[GUI_QT6] = app
return app
def disable_qt6(self):
if GUI_QT6 in self._apps:
self._apps[GUI_QT6]._in_event_loop = False
self.clear_inputhook()
def enable_gtk(self, app=None):
"""Enable event loop integration with PyGTK.

View file

@ -0,0 +1,199 @@
# -*- coding: utf-8 -*-
"""
Qt6's inputhook support function
Author: Christian Boos, Marijn van Vliet
"""
#-----------------------------------------------------------------------------
# Copyright (C) 2011 The IPython Development Team
#
# Distributed under the terms of the BSD License. The full license is in
# the file COPYING, distributed as part of this software.
#-----------------------------------------------------------------------------
#-----------------------------------------------------------------------------
# Imports
#-----------------------------------------------------------------------------
import os
import signal
import threading
from pydev_ipython.qt_for_kernel import QtCore, QtGui
from pydev_ipython.inputhook import allow_CTRL_C, ignore_CTRL_C, stdin_ready
# To minimise future merging complexity, rather than edit the entire code base below
# we fake InteractiveShell here
class InteractiveShell:
_instance = None
@classmethod
def instance(cls):
if cls._instance is None:
cls._instance = cls()
return cls._instance
def set_hook(self, *args, **kwargs):
# We don't consider the pre_prompt_hook because we don't have
# KeyboardInterrupts to consider since we are running under PyDev
pass
#-----------------------------------------------------------------------------
# Module Globals
#-----------------------------------------------------------------------------
got_kbdint = False
sigint_timer = None
#-----------------------------------------------------------------------------
# Code
#-----------------------------------------------------------------------------
def create_inputhook_qt6(mgr, app=None):
"""Create an input hook for running the Qt6 application event loop.
Parameters
----------
mgr : an InputHookManager
app : Qt Application, optional.
Running application to use. If not given, we probe Qt for an
existing application object, and create a new one if none is found.
Returns
-------
A pair consisting of a Qt Application (either the one given or the
one found or created) and a inputhook.
Notes
-----
We use a custom input hook instead of PyQt6's default one, as it
interacts better with the readline packages (issue #481).
The inputhook function works in tandem with a 'pre_prompt_hook'
which automatically restores the hook as an inputhook in case the
latter has been temporarily disabled after having intercepted a
KeyboardInterrupt.
"""
if app is None:
app = QtCore.QCoreApplication.instance()
if app is None:
from PyQt6 import QtWidgets
app = QtWidgets.QApplication([" "])
# Re-use previously created inputhook if any
ip = InteractiveShell.instance()
if hasattr(ip, '_inputhook_qt6'):
return app, ip._inputhook_qt6
# Otherwise create the inputhook_qt6/preprompthook_qt6 pair of
# hooks (they both share the got_kbdint flag)
def inputhook_qt6():
"""PyOS_InputHook python hook for Qt6.
Process pending Qt events and if there's no pending keyboard
input, spend a short slice of time (50ms) running the Qt event
loop.
As a Python ctypes callback can't raise an exception, we catch
the KeyboardInterrupt and temporarily deactivate the hook,
which will let a *second* CTRL+C be processed normally and go
back to a clean prompt line.
"""
try:
allow_CTRL_C()
app = QtCore.QCoreApplication.instance()
if not app: # shouldn't happen, but safer if it happens anyway...
return 0
app.processEvents(QtCore.QEventLoop.ProcessEventsFlag.AllEvents, 300)
if not stdin_ready():
# Generally a program would run QCoreApplication::exec()
# from main() to enter and process the Qt event loop until
# quit() or exit() is called and the program terminates.
#
# For our input hook integration, we need to repeatedly
# enter and process the Qt event loop for only a short
# amount of time (say 50ms) to ensure that Python stays
# responsive to other user inputs.
#
# A naive approach would be to repeatedly call
# QCoreApplication::exec(), using a timer to quit after a
# short amount of time. Unfortunately, QCoreApplication
# emits an aboutToQuit signal before stopping, which has
# the undesirable effect of closing all modal windows.
#
# To work around this problem, we instead create a
# QEventLoop and call QEventLoop::exec(). Other than
# setting some state variables which do not seem to be
# used anywhere, the only thing QCoreApplication adds is
# the aboutToQuit signal which is precisely what we are
# trying to avoid.
timer = QtCore.QTimer()
event_loop = QtCore.QEventLoop()
timer.timeout.connect(event_loop.quit)
while not stdin_ready():
timer.start(50)
event_loop.exec()
timer.stop()
except KeyboardInterrupt:
global got_kbdint, sigint_timer
ignore_CTRL_C()
got_kbdint = True
mgr.clear_inputhook()
# This generates a second SIGINT so the user doesn't have to
# press CTRL+C twice to get a clean prompt.
#
# Since we can't catch the resulting KeyboardInterrupt here
# (because this is a ctypes callback), we use a timer to
# generate the SIGINT after we leave this callback.
#
# Unfortunately this doesn't work on Windows (SIGINT kills
# Python and CTRL_C_EVENT doesn't work).
if(os.name == 'posix'):
pid = os.getpid()
if(not sigint_timer):
sigint_timer = threading.Timer(.01, os.kill,
args=[pid, signal.SIGINT])
sigint_timer.start()
else:
print("\nKeyboardInterrupt - Ctrl-C again for new prompt")
except: # NO exceptions are allowed to escape from a ctypes callback
ignore_CTRL_C()
from traceback import print_exc
print_exc()
print("Got exception from inputhook_qt6, unregistering.")
mgr.clear_inputhook()
finally:
allow_CTRL_C()
return 0
def preprompthook_qt6(ishell):
"""'pre_prompt_hook' used to restore the Qt6 input hook
(in case the latter was temporarily deactivated after a
CTRL+C)
"""
global got_kbdint, sigint_timer
if(sigint_timer):
sigint_timer.cancel()
sigint_timer = None
if got_kbdint:
mgr.set_inputhook(inputhook_qt6)
got_kbdint = False
ip._inputhook_qt6 = inputhook_qt6
ip.set_hook('pre_prompt_hook', preprompthook_qt6)
return app, inputhook_qt6

View file

@ -8,6 +8,7 @@ backends = {'tk': 'TkAgg',
'qt': 'QtAgg', # Auto-choose qt4/5
'qt4': 'Qt4Agg',
'qt5': 'Qt5Agg',
'qt6': 'Qt6Agg',
'osx': 'MacOSX'}
# We also need a reverse backends2guis mapping that will properly choose which

View file

@ -8,15 +8,15 @@ Do not use this if you need PyQt with the old QString/QVariant API.
import os
from pydev_ipython.qt_loaders import (load_qt, QT_API_PYSIDE,
QT_API_PYQT, QT_API_PYQT5)
from pydev_ipython.qt_loaders import (load_qt, QT_API_PYSIDE, QT_API_PYSIDE2,
QT_API_PYQT, QT_API_PYQT5, QT_API_PYQT6)
QT_API = os.environ.get('QT_API', None)
if QT_API not in [QT_API_PYSIDE, QT_API_PYQT, QT_API_PYQT5, None]:
raise RuntimeError("Invalid Qt API %r, valid values are: %r, %r" %
(QT_API, QT_API_PYSIDE, QT_API_PYQT, QT_API_PYQT5))
if QT_API not in [QT_API_PYSIDE, QT_API_PYSIDE2, QT_API_PYQT, QT_API_PYQT5, QT_API_PYQT6, None]:
raise RuntimeError("Invalid Qt API %r, valid values are: %r, %r, %r, %r, %r" %
(QT_API, QT_API_PYSIDE, QT_API_PYSIDE2, QT_API_PYQT, QT_API_PYQT5, QT_API_PYQT6))
if QT_API is None:
api_opts = [QT_API_PYSIDE, QT_API_PYQT, QT_API_PYQT5]
api_opts = [QT_API_PYSIDE, QT_API_PYSIDE2, QT_API_PYQT, QT_API_PYQT5, QT_API_PYQT6]
else:
api_opts = [QT_API]

View file

@ -37,7 +37,7 @@ import sys
from pydev_ipython.version import check_version
from pydev_ipython.qt_loaders import (load_qt, QT_API_PYSIDE, QT_API_PYSIDE2,
QT_API_PYQT, QT_API_PYQT_DEFAULT,
loaded_api, QT_API_PYQT5)
loaded_api, QT_API_PYQT5, QT_API_PYQT6)
# Constraints placed on an imported matplotlib
@ -71,10 +71,21 @@ def matplotlib_options(mpl):
raise ImportError("unhandled value for backend.qt5 from matplotlib: %r" %
mpqt)
elif backend == 'Qt6Agg':
mpqt = mpl.rcParams.get('backend.qt6', None)
if mpqt is None:
return None
if mpqt.lower() == 'pyqt6':
return [QT_API_PYQT6]
raise ImportError("unhandled value for backend.qt6 from matplotlib: %r" %
mpqt)
# Fallback without checking backend (previous code)
mpqt = mpl.rcParams.get('backend.qt4', None)
if mpqt is None:
mpqt = mpl.rcParams.get('backend.qt5', None)
if mpqt is None:
mpqt = mpl.rcParams.get('backend.qt6', None)
if mpqt is None:
return None
@ -84,6 +95,8 @@ def matplotlib_options(mpl):
return [QT_API_PYQT_DEFAULT]
elif mpqt.lower() == 'pyqt5':
return [QT_API_PYQT5]
elif mpqt.lower() == 'pyqt6':
return [QT_API_PYQT6]
raise ImportError("unhandled value for qt backend from matplotlib: %r" %
mpqt)
@ -105,7 +118,7 @@ def get_options():
if os.environ.get('QT_API', None) is None:
# no ETS variable. Ask mpl, then use either
return matplotlib_options(mpl) or [QT_API_PYQT_DEFAULT, QT_API_PYSIDE, QT_API_PYSIDE2, QT_API_PYQT5]
return matplotlib_options(mpl) or [QT_API_PYQT_DEFAULT, QT_API_PYSIDE, QT_API_PYSIDE2, QT_API_PYQT5, QT_API_PYQT6]
# ETS variable present. Will fallback to external.qt
return None

View file

@ -19,7 +19,9 @@ QT_API_PYQTv1 = 'pyqtv1'
QT_API_PYQT_DEFAULT = 'pyqtdefault' # don't set SIP explicitly
QT_API_PYSIDE = 'pyside'
QT_API_PYSIDE2 = 'pyside2'
QT_API_PYSIDE6 = 'pyside6'
QT_API_PYQT5 = 'pyqt5'
QT_API_PYQT6 = 'pyqt6'
class ImportDenier(object):
@ -58,9 +60,11 @@ def commit_api(api):
if api == QT_API_PYSIDE:
ID.forbid('PyQt4')
ID.forbid('PyQt5')
ID.forbid('PyQt6')
else:
ID.forbid('PySide')
ID.forbid('PySide2')
ID.forbid('PySide6')
def loaded_api():
@ -71,7 +75,7 @@ def loaded_api():
Returns
-------
None, 'pyside', 'pyside2', 'pyqt', or 'pyqtv1'
None, 'pyside', 'pyside2', 'pyside6', 'pyqt5', 'pyqt6', or 'pyqtv1'
"""
if 'PyQt4.QtCore' in sys.modules:
if qtapi_version() == 2:
@ -82,13 +86,17 @@ def loaded_api():
return QT_API_PYSIDE
elif 'PySide2.QtCore' in sys.modules:
return QT_API_PYSIDE2
elif 'PySide6.QtCore' in sys.modules:
return QT_API_PYSIDE6
elif 'PyQt5.QtCore' in sys.modules:
return QT_API_PYQT5
elif 'PyQt6.QtCore' in sys.modules:
return QT_API_PYQT6
return None
def has_binding(api):
"""Safely check for PyQt4 or PySide, without importing
"""Safely check for PyQt or PySide, without importing
submodules
Parameters
@ -105,10 +113,12 @@ def has_binding(api):
# check for complete presence before importing
module_name = {QT_API_PYSIDE: 'PySide',
QT_API_PYSIDE2: 'PySide2',
QT_API_PYSIDE6: 'PySide6',
QT_API_PYQT: 'PyQt4',
QT_API_PYQTv1: 'PyQt4',
QT_API_PYQT_DEFAULT: 'PyQt4',
QT_API_PYQT5: 'PyQt5',
QT_API_PYQT6: 'PyQt6',
}
module_name = module_name[api]
@ -154,7 +164,7 @@ def can_import(api):
current = loaded_api()
if api == QT_API_PYQT_DEFAULT:
return current in [QT_API_PYQT, QT_API_PYQTv1, QT_API_PYQT5, None]
return current in [QT_API_PYQT, QT_API_PYQTv1, QT_API_PYQT5, QT_API_PYQT6, None]
else:
return current in [api, None]
@ -211,6 +221,21 @@ def import_pyqt5():
return QtCore, QtGui, QtSvg, QT_API_PYQT5
def import_pyqt6():
"""
Import PyQt6
ImportErrors raised within this function are non-recoverable
"""
from PyQt6 import QtGui, QtCore, QtSvg
# Alias PyQt-specific functions for PySide compatibility.
QtCore.Signal = QtCore.pyqtSignal
QtCore.Slot = QtCore.pyqtSlot
return QtCore, QtGui, QtSvg, QT_API_PYQT6
def import_pyside():
"""
Import PySide
@ -228,7 +253,17 @@ def import_pyside2():
ImportErrors raised within this function are non-recoverable
"""
from PySide2 import QtGui, QtCore, QtSvg # @UnresolvedImport
return QtCore, QtGui, QtSvg, QT_API_PYSIDE
return QtCore, QtGui, QtSvg, QT_API_PYSIDE2
def import_pyside6():
"""
Import PySide6
ImportErrors raised within this function are non-recoverable
"""
from PySide6 import QtGui, QtCore, QtSvg # @UnresolvedImport
return QtCore, QtGui, QtSvg, QT_API_PYSIDE6
def load_qt(api_options):
@ -259,19 +294,21 @@ def load_qt(api_options):
"""
loaders = {QT_API_PYSIDE: import_pyside,
QT_API_PYSIDE2: import_pyside2,
QT_API_PYSIDE6: import_pyside6,
QT_API_PYQT: import_pyqt4,
QT_API_PYQTv1: partial(import_pyqt4, version=1),
QT_API_PYQT_DEFAULT: partial(import_pyqt4, version=None),
QT_API_PYQT5: import_pyqt5,
QT_API_PYQT6: import_pyqt6,
}
for api in api_options:
if api not in loaders:
raise RuntimeError(
"Invalid Qt API %r, valid values are: %r, %r, %r, %r, %r, %r" %
(api, QT_API_PYSIDE, QT_API_PYSIDE, QT_API_PYQT,
QT_API_PYQTv1, QT_API_PYQT_DEFAULT, QT_API_PYQT5))
"Invalid Qt API %r, valid values are: %r, %r, %r, %r, %r, %r, %r" %
(api, QT_API_PYSIDE, QT_API_PYSIDE2, QT_API_PYQT,
QT_API_PYQTv1, QT_API_PYQT_DEFAULT, QT_API_PYQT5, QT_API_PYQT6))
if not can_import(api):
continue
@ -290,12 +327,16 @@ def load_qt(api_options):
Currently-imported Qt library: %r
PyQt4 installed: %s
PyQt5 installed: %s
PyQt6 installed: %s
PySide >= 1.0.3 installed: %s
PySide2 installed: %s
PySide6 installed: %s
Tried to load: %r
""" % (loaded_api(),
has_binding(QT_API_PYQT),
has_binding(QT_API_PYQT5),
has_binding(QT_API_PYQT6),
has_binding(QT_API_PYSIDE),
has_binding(QT_API_PYSIDE2),
has_binding(QT_API_PYSIDE6),
api_options))