Files
pymol-open-source/modules/pymol/Qt/utils.py
Jarrett Johnson 87d27daafb port over qt6 migrations
Fixes #498
2026-02-24 01:35:00 -05:00

368 lines
9.5 KiB
Python

from pymol.Qt import *
import shutil
import subprocess
class UpdateLock:
"""
Locking mechanism to prevent circular signal/slot updates.
Decorator notation:
>>> @lock.skipIfCircular
>>> def updatesomething():
... dosomething()
Context manager notation:
>>> def updatesomething():
... with lock:
... lock.acquire() # exits block if already acquired
... dosomething()
"""
class LockFailed(Exception):
pass
def __init__(self, silent_exc_types=()):
self.primed = False
self.acquired = False
self.silent_exc_types = tuple(silent_exc_types)
def __enter__(self):
assert not self.primed, 'missing acquire()'
self.primed = True
def __exit__(self, exc_type, exc_val, exc_tb):
assert not self.primed, 'missing acquire()'
if exc_type == self.LockFailed:
return True
assert self.acquired, 'inconsistency!?'
self.acquired = False
if exc_type in self.silent_exc_types:
return True
def acquire(self):
assert self.primed, 'missing with ...():'
self.primed = False
if self.acquired:
raise self.LockFailed
self.acquired = True
def skipIfCircular(self, func):
def wrapper(*args, **kwargs):
with self:
self.acquire()
return func(*args, **kwargs)
return wrapper
class WidgetMenu(QtWidgets.QMenu):
"""
QMenu that represents a single widget that pops up under a push button.
>>> btn = QtWidgets.QPushButton()
>>> btn.setMenu(WidgetMenu().setSetupUi(setupUi))
"""
def focusNextPrevChild(self, next):
'''Overload which prevents menu-like tab action'''
return QtWidgets.QWidget.focusNextPrevChild(self, next)
def setWidget(self, widget):
self.clear()
action = QtWidgets.QWidgetAction(self)
action.setDefaultWidget(widget)
self.addAction(action)
return self
def setSetupUi(self, setupUi):
'''Use a setup function for the widget. The widget will be created
and initialized as "setupUi(widget)" before the menu is shown for
the first time.'''
@self.aboutToShow.connect
def _():
self.aboutToShow.disconnect()
self._widget = QtWidgets.QWidget()
form = setupUi(self._widget)
self.setWidget(form)
return self
class AsyncFunc(QtCore.QThread):
"""
Decorator to call a function asynchronous.
Signals:
- returned(result)
Emitted if the function successfully returned
- finished(result, exception)
Emitted on success or failure. On success, exception is None,
on failure result is None.
Example:
>>> asyncsquare = AsyncFunc(lambda x: x * x, print)
>>> asyncsquare(5)
25
"""
# Warning: PySide crashes if passing None to an "object" type signal
returned = QtCore.Signal(object)
finished = QtCore.Signal(tuple)
def __init__(self, func, returnslot=None, finishslot=None):
super(AsyncFunc, self).__init__()
self.func = func
if returnslot is not None:
self.returned.connect(returnslot)
if finishslot is not None:
self.finished.connect(finishslot)
def __call__(self, *args, **kwargs):
self.args = args
self.kwargs = kwargs
self.start()
def run(self):
result = None
exception = None
try:
result = self.func(*self.args, **self.kwargs)
self.returned.emit(result)
except Exception as e:
exception = e
except:
exception = Exception()
self.finished.emit((result, exception))
class MainThreadCaller(QtCore.QObject):
"""
Allows calling a GUI function from a non-main thread. Will pause
the current thread until the function has returned from the main
thread.
>>> # in main thread
>>> callInMainThread = MainThreadCaller()
>>> # in async thread
>>> callInMainThread(lambda: 123)
123
Note: QMetaObject.invokeMethod with BlockingQueuedConnection could
potentially be used to achieve the same goal.
"""
mainthreadrequested = QtCore.Signal(object)
RESULT_RETURN = 0
RESULT_EXCEPTION = 1
def __init__(self):
super(MainThreadCaller, self).__init__()
self.waitcondition = QtCore.QWaitCondition()
self.mutex = QtCore.QMutex()
self.results = {}
self.mainthreadrequested.connect(self._mainThreadAction)
def _mainThreadAction(self, func):
try:
self.results[func] = (self.RESULT_RETURN, func())
except Exception as ex:
self.results[func] = (self.RESULT_EXCEPTION, ex)
self.waitcondition.wakeAll()
def __call__(self, func):
if self.thread() is QtCore.QThread.currentThread():
return func()
self.mainthreadrequested.emit(func)
while True:
self.mutex.lock()
self.waitcondition.wait(self.mutex)
self.mutex.unlock()
try:
result = self.results.pop(func)
break
except KeyError:
print(type(self).__name__ + ': result was not ready')
if result[0] == self.RESULT_EXCEPTION:
raise result[1]
return result[1]
def connectFontContextMenu(widget):
"""
Connects a custom context menu with a "Select Font..." entry
to the given widget.
@type widget: QWidget
"""
widget.setContextMenuPolicy(QtCore.Qt.ContextMenuPolicy.CustomContextMenu)
@widget.customContextMenuRequested.connect
def _(pt):
menu = widget.createStandardContextMenu()
menu.addSeparator()
action = menu.addAction("Select Font...")
@action.triggered.connect
def _():
font, ok = QtWidgets.QFontDialog.getFont(widget.font(), widget,
"Select Font", QtWidgets.QFontDialog.FontDialogOption.DontUseNativeDialog)
if ok:
widget.setFont(font)
menu.exec(widget.mapToGlobal(pt))
def getSaveFileNameWithExt(*args, **kwargs):
"""
Return a file name, append extension from filter if no extension provided.
"""
import os, re
fname, filter = QtWidgets.QFileDialog.getSaveFileName(*args, **kwargs)
if not fname:
return ''
if '.' not in os.path.split(fname)[-1]:
m = re.search(r'\*(\.[\w\.]+)', filter)
if m:
# append first extension from filter
fname += m.group(1)
return fname
def getMonospaceFont(size=9):
"""
Get the best looking monospace font for the current platform
"""
import sys
if sys.platform == 'darwin':
family = 'Monaco'
size += 3
elif sys.platform == 'win32':
family = 'Consolas'
else:
family = 'Monospace'
font = QtGui.QFont(family, size)
font.setStyleHint(QtGui.QFont.StyleHint.Monospace)
return font
def loadUi(uifile, widget):
"""
Load .ui file into widget
@param uifile: filename
@type uifile: str
@type widget: QtWidgets.QWidget
"""
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':
try:
import pyside2uic as pysideuic
except ImportError:
pysideuic = None
else:
import pysideuic
def find_valid_uic_exe(execs):
for exe in execs:
if shutil.which(exe):
return exe
if pysideuic is None:
uic_exec = find_valid_uic_exe(('uic', 'pyside2-uic'))
if uic_exec is None:
raise RuntimeError('uic not found')
p = subprocess.Popen([uic_exec, '-g', 'python', uifile],
stdout=subprocess.PIPE)
source, _ = p.communicate()
# workaround for empty retranslateUi bug
source += b'\n' + b' ' * 8 + b'pass'
else:
import io
stream = io.StringIO()
pysideuic.compileUi(uifile, stream)
source = stream.getvalue()
ns_locals = {}
exec(source, ns_locals)
if 'Ui_Form' in ns_locals:
form = ns_locals['Ui_Form']()
else:
form = ns_locals['Ui_Dialog']()
form.setupUi(widget)
return form
class PopupOnException:
"""
Context manager which shows a message box if an exception is raised.
>>> with PopupOnException():
... # do something
Decorator support:
>>> @PopupOnException.decorator
... def foo():
... # do something
"""
@classmethod
def decorator(cls, func):
def wrapper(*args, **kwargs):
with cls():
return func(*args, **kwargs)
return wrapper
def __enter__(self):
pass
def __exit__(self, exc_type, e, tb):
if e is not None:
import traceback
QMB = QtWidgets.QMessageBox
parent = QtWidgets.QApplication.focusWidget()
msg = str(e) or 'unknown error'
msgbox = QMB(QMB.Icon.Critical, 'Error', msg, QMB.StandardButton.Close, parent)
msgbox.setDetailedText(''.join(traceback.format_tb(tb)))
msgbox.exec()
return True
def conda_ask_install(packagespec, channel=None, msg="", parent=None, url=""):
"""
Install a conda package
"""
return True