diff --git a/Dockerfile b/Dockerfile index c82476b6..65ea7dcb 100644 --- a/Dockerfile +++ b/Dockerfile @@ -10,8 +10,7 @@ MAINTAINER marcus@abstractfactory.io RUN apt-get update && apt-get install -y \ build-essential \ git \ - python3-pyqt5 \ - python3-pyqt5.qtquick \ + python3-pyqt5* \ python3-pip \ python3-nose && \ pip3 install \ diff --git a/pyblish_qml/app.py b/pyblish_qml/app.py index 3de9cb8c..acab20b6 100644 --- a/pyblish_qml/app.py +++ b/pyblish_qml/app.py @@ -8,11 +8,9 @@ import traceback import threading -# Dependencies -from PyQt5 import QtCore, QtGui, QtQuick, QtTest - # Local libraries from . import util, compat, control, settings, ipc +from .vendor.Qt5 import QtCore, QtGui, QtQuick MODULE_DIR = os.path.dirname(__file__) QML_IMPORT_DIR = os.path.join(MODULE_DIR, "qml") @@ -64,17 +62,17 @@ class Application(QtGui.QGuiApplication): """ - shown = QtCore.pyqtSignal(QtCore.QVariant) - hidden = QtCore.pyqtSignal() - quitted = QtCore.pyqtSignal() - published = QtCore.pyqtSignal() - validated = QtCore.pyqtSignal() + shown = QtCore.Signal("QVariant") + hidden = QtCore.Signal() + quitted = QtCore.Signal() + published = QtCore.Signal() + validated = QtCore.Signal() - targeted = QtCore.pyqtSignal(QtCore.QVariant) + targeted = QtCore.Signal("QVariant") - risen = QtCore.pyqtSignal() - inFocused = QtCore.pyqtSignal() - outFocused = QtCore.pyqtSignal() + risen = QtCore.Signal() + inFocused = QtCore.Signal() + outFocused = QtCore.Signal() def __init__(self, source, targets=[]): super(Application, self).__init__(sys.argv) @@ -88,7 +86,7 @@ def __init__(self, source, targets=[]): engine.addImportPath(QML_IMPORT_DIR) host = ipc.client.Proxy() - controller = control.Controller(host, targets=targets) + controller = control.Controller(host, targets=targets, parent=window) controller.finished.connect(lambda: window.alert(0)) context = engine.rootContext() @@ -176,11 +174,7 @@ def show(self, client_settings=None): for state in ["ready", "finished"]): util.timer("ready") - ready = QtTest.QSignalSpy(self.controller.ready) - - count = len(ready) - ready.wait(1000) - if len(ready) != count + 1: + if not self.controller.is_ready(): print("Warning: Could not enter ready state") util.timer_end("ready", "Awaited statemachine for %.2f ms") @@ -214,12 +208,14 @@ def inFocus(self): previous_flags = self.window.flags() self.window.setFlags(previous_flags | QtCore.Qt.WindowStaysOnTopHint) + self.window.setFlags(previous_flags) def outFocus(self): """Remove GUI on-top flag""" previous_flags = self.window.flags() self.window.setFlags(previous_flags ^ QtCore.Qt.WindowStaysOnTopHint) + self.window.setFlags(previous_flags) def publish(self): """Fire up the publish sequence""" diff --git a/pyblish_qml/control.py b/pyblish_qml/control.py index 478e5a69..bf7f062a 100644 --- a/pyblish_qml/control.py +++ b/pyblish_qml/control.py @@ -4,52 +4,52 @@ import collections # Dependencies -from PyQt5 import QtCore import pyblish.logic # Local libraries from . import util, models, version, settings +from .vendor.Qt5 import QtCore -qtproperty = util.pyqtConstantProperty +qtproperty = util.qtConstantProperty class Controller(QtCore.QObject): """Communicate with QML""" - # PyQt Signals - info = QtCore.pyqtSignal(str, arguments=["message"]) - error = QtCore.pyqtSignal(str, arguments=["message"]) + # Signals + info = QtCore.Signal(str, arguments=["message"]) + error = QtCore.Signal(str, arguments=["message"]) - show = QtCore.pyqtSignal() - hide = QtCore.pyqtSignal() + show = QtCore.Signal() + hide = QtCore.Signal() - firstRun = QtCore.pyqtSignal() + firstRun = QtCore.Signal() - collecting = QtCore.pyqtSignal() - validating = QtCore.pyqtSignal() - extracting = QtCore.pyqtSignal() - integrating = QtCore.pyqtSignal() + collecting = QtCore.Signal() + validating = QtCore.Signal() + extracting = QtCore.Signal() + integrating = QtCore.Signal() - repairing = QtCore.pyqtSignal() - stopping = QtCore.pyqtSignal() - saving = QtCore.pyqtSignal() - initialising = QtCore.pyqtSignal() - acting = QtCore.pyqtSignal() - acted = QtCore.pyqtSignal() + repairing = QtCore.Signal() + stopping = QtCore.Signal() + saving = QtCore.Signal() + initialising = QtCore.Signal() + acting = QtCore.Signal() + acted = QtCore.Signal() # A plug-in/instance pair is about to be processed - about_to_process = QtCore.pyqtSignal(object, object) + about_to_process = QtCore.Signal(object, object) - changed = QtCore.pyqtSignal() + changed = QtCore.Signal() - ready = QtCore.pyqtSignal() - saved = QtCore.pyqtSignal() - finished = QtCore.pyqtSignal() - initialised = QtCore.pyqtSignal() - commented = QtCore.pyqtSignal() - commenting = QtCore.pyqtSignal(str, arguments=["comment"]) + ready = QtCore.Signal() + saved = QtCore.Signal() + finished = QtCore.Signal() + initialised = QtCore.Signal() + commented = QtCore.Signal() + commenting = QtCore.Signal(str, arguments=["comment"]) - state_changed = QtCore.pyqtSignal(str, arguments=["state"]) + state_changed = QtCore.Signal(str, arguments=["state"]) # Statically expose these members to the QML run-time. itemModel = qtproperty(lambda self: self.data["models"]["item"]) @@ -107,6 +107,7 @@ def __init__(self, host, parent=None, targets=[]): }, "state": { "is_running": False, + "readyCount": 0, "current": None, "all": list(), @@ -125,6 +126,7 @@ def __init__(self, host, parent=None, targets=[]): self.info.connect(self.on_info) self.error.connect(self.on_error) self.finished.connect(self.on_finished) + self.ready.connect(self.on_ready) self.show.connect(self.on_show) # NOTE: Listeners to this signal are run in the main thread @@ -291,20 +293,20 @@ def setup_statemachine(self): machine.start() return machine - @QtCore.pyqtSlot(result=str) + @QtCore.Slot(result=str) def comment(self): """Return first line of comment""" return self.data["comment"] - @QtCore.pyqtProperty(str, notify=state_changed) + @QtCore.Property(str, notify=state_changed) def state(self): return self.data["state"]["current"] - @QtCore.pyqtProperty(bool, notify=commented) + @QtCore.Property(bool, notify=commented) def hasComment(self): return True if self.data["comment"] else False - @QtCore.pyqtProperty(bool, constant=True) + @QtCore.Property(bool, constant=True) def commentEnabled(self): return "comment" in self.host.cached_context.data @@ -312,7 +314,7 @@ def commentEnabled(self): def states(self): return self.data["state"]["all"] - @QtCore.pyqtSlot(result=float) + @QtCore.Slot(result=float) def time(self): return time.time() @@ -377,7 +379,7 @@ def iterator(self, plugins, context): yield result - @QtCore.pyqtSlot(int, result=QtCore.QVariant) + @QtCore.Slot(int, result="QVariant") def getPluginActions(self, index): """Return actions from plug-in at `index` @@ -439,7 +441,7 @@ def getPluginActions(self, index): return remaining_actions - @QtCore.pyqtSlot(str) + @QtCore.Slot(str) def runPluginAction(self, action): if "acting" in self.states: return self.error.emit("Busy") @@ -491,7 +493,7 @@ def on_finished(result): util.defer(run, callback=on_finished) - @QtCore.pyqtSlot(int) + @QtCore.Slot(int) def toggleInstance(self, index): models = self.data["models"] proxies = self.data["proxies"] @@ -506,7 +508,7 @@ def toggleInstance(self, index): else: self.error.emit("Cannot toggle") - @QtCore.pyqtSlot(bool, str) + @QtCore.Slot(bool, str) def toggleSection(self, checkState, sectionLabel): model = self.data["models"]["item"] @@ -535,7 +537,7 @@ def toggleSection(self, checkState, sectionLabel): if item.isToggled != checkState and item.optional: self.__toggle_item(model, model.items.index(item)) - @QtCore.pyqtSlot(bool, str) + @QtCore.Slot(bool, str) def hideSection(self, hideState, sectionLabel): model = self.data["models"]["item"] @@ -549,7 +551,7 @@ def hideSection(self, hideState, sectionLabel): if item.itemType == "section" and item.name == sectionLabel: self.__hide_item(model, model.items.index(item), hideState) - @QtCore.pyqtSlot(int, result=QtCore.QVariant) + @QtCore.Slot(int, result="QVariant") def pluginData(self, index): models = self.data["models"] proxies = self.data["proxies"] @@ -559,7 +561,7 @@ def pluginData(self, index): source_index = source_qindex.row() return self.__item_data(models["item"], source_index) - @QtCore.pyqtSlot(int, result=QtCore.QVariant) + @QtCore.Slot(int, result="QVariant") def instanceData(self, index): models = self.data["models"] proxies = self.data["proxies"] @@ -569,7 +571,7 @@ def instanceData(self, index): source_index = source_qindex.row() return self.__item_data(models["item"], source_index) - @QtCore.pyqtSlot(int) + @QtCore.Slot(int) def togglePlugin(self, index): models = self.data["models"] proxies = self.data["proxies"] @@ -584,7 +586,7 @@ def togglePlugin(self, index): else: self.error.emit("Cannot toggle") - @QtCore.pyqtSlot(str, str, str, str) + @QtCore.Slot(str, str, str, str) def exclude(self, target, operation, role, value): """Exclude a `role` of `value` at `target` @@ -609,7 +611,7 @@ def exclude(self, target, operation, role, value): else: raise TypeError("operation must be either `add` or `remove`") - @QtCore.pyqtSlot() + @QtCore.Slot() def save(self): # Deprecated return @@ -664,6 +666,11 @@ def comment_sync(self, comment): self.host.update(key="comment", value=comment) self.host.emit("commented", comment=comment) + def is_ready(self): + count = self.data["state"]["readyCount"] + util.wait(self.ready, 1000) + return self.data["state"]["readyCount"] == count + 1 + # Event handlers def on_commenting(self, comment): @@ -712,6 +719,9 @@ def on_state_changed(self, state): s.name for s in self.machine.configuration() ) + def on_ready(self): + self.data["state"]["readyCount"] += 1 + def on_finished(self): self.data["models"]["item"].reset_status() @@ -733,12 +743,12 @@ def on_info(self, message): # Slots - @QtCore.pyqtSlot() + @QtCore.Slot() def stop(self): self.data["state"]["is_running"] = False self.stopping.emit() - @QtCore.pyqtSlot() + @QtCore.Slot() def reset(self): """Request that host re-discovers plug-ins and re-processes selectors @@ -885,7 +895,7 @@ def on_reset(): util.defer(self.host.reset, callback=on_reset) - @QtCore.pyqtSlot() + @QtCore.Slot() def publish(self): """Start asynchonous publishing @@ -942,7 +952,7 @@ def on_finished(): util.defer(get_data, callback=on_data_received) - @QtCore.pyqtSlot() + @QtCore.Slot() def validate(self): """Start asynchonous validation @@ -1102,7 +1112,7 @@ def on_finished(message=None): iterator = self.iterator(plugins, context) util.defer(lambda: next(iterator), callback=on_next) - @QtCore.pyqtSlot(int) + @QtCore.Slot(int) def repairPlugin(self, index): """ diff --git a/pyblish_qml/models.py b/pyblish_qml/models.py index 2eaa6d3a..2f4c75b5 100644 --- a/pyblish_qml/models.py +++ b/pyblish_qml/models.py @@ -2,10 +2,9 @@ import time import logging -from PyQt5 import QtCore - from . import util, settings from .vendor import six +from .vendor.Qt5 import QtCore defaults = { @@ -117,7 +116,7 @@ def __new__(cls, name, bases, attrs): if key.startswith("__"): continue - notify = QtCore.pyqtSignal() + notify = QtCore.Signal() def set_data(key, value): def set_data(self, value): @@ -127,8 +126,8 @@ def set_data(self, value): return set_data attrs[key + "Changed"] = notify - attrs[key] = QtCore.pyqtProperty( - type(value) if value is not None else QtCore.QVariant, + attrs[key] = QtCore.Property( + type(value) if value is not None else "QVariant", fget=lambda self, k=key: getattr(self, cls.prefix + k, None), fset=set_data(key, value), notify=notify) @@ -145,7 +144,7 @@ class AbstractItem(QtCore.QObject): """ - __datachanged__ = QtCore.pyqtSignal(QtCore.QObject) + __datachanged__ = QtCore.Signal(QtCore.QObject) def __str__(self): return self.name @@ -199,7 +198,7 @@ def __init__(self, parent=None): super(AbstractModel, self).__init__(parent) self.items = util.ItemList(key="id") - @QtCore.pyqtSlot(int, result=QtCore.QObject) + @QtCore.Slot(int, result=QtCore.QObject) def item(self, index): return self.items[index] @@ -247,7 +246,7 @@ def data(self, index, role=QtCore.Qt.DisplayRole): except Exception: pass - return QtCore.QVariant() + return "QVariant" def roleNames(self): return { @@ -309,7 +308,7 @@ def reorder(self, context): self.endResetModel() - @QtCore.pyqtSlot(QtCore.QVariant) + @QtCore.Slot("QVariant") def add_plugin(self, plugin): """Append `plugin` to model @@ -381,7 +380,7 @@ def add_plugin(self, plugin): item = self.add_item(item) self.plugins.append(item) - @QtCore.pyqtSlot(QtCore.QVariant) + @QtCore.Slot("QVariant") def add_instance(self, instance): """Append `instance` to model @@ -445,7 +444,7 @@ def add_section(self, name): return item - @QtCore.pyqtSlot(QtCore.QVariant) + @QtCore.Slot("QVariant") def add_context(self, context, label=None): """Append `context` to model @@ -604,7 +603,7 @@ def reset(self): class ResultModel(AbstractModel): - added = QtCore.pyqtSignal() + added = QtCore.Signal() def add_item(self, item): item_ = defaults["result"].copy() @@ -744,21 +743,21 @@ def __init__(self, source, excludes=None, includes=None, parent=None): self.setFilterCaseSensitivity(QtCore.Qt.CaseInsensitive) - @QtCore.pyqtSlot(int, result=QtCore.QObject) + @QtCore.Slot(int, result=QtCore.QObject) def item(self, index): index = self.index(index, 0, QtCore.QModelIndex()) index = self.mapToSource(index) model = self.sourceModel() return model.items[index.row()] - @QtCore.pyqtSlot(str, result=QtCore.QObject) + @QtCore.Slot(str, result=QtCore.QObject) def itemByName(self, name): model = self.sourceModel() for item in model.items: if name == item.name: return item - @QtCore.pyqtSlot(str, str) + @QtCore.Slot(str, str) def add_exclusion(self, role, value): """Exclude item if `role` equals `value` @@ -770,7 +769,7 @@ def add_exclusion(self, role, value): self._add_rule(self.excludes, role, value) - @QtCore.pyqtSlot(str, str) + @QtCore.Slot(str, str) def remove_exclusion(self, role, value=None): """Remove exclusion rule @@ -795,11 +794,11 @@ def set_exclusion(self, rules): self._set_rules(self.excludes, rules) - @QtCore.pyqtSlot() + @QtCore.Slot() def clear_exclusion(self): self._clear_group(self.excludes) - @QtCore.pyqtSlot(str, str) + @QtCore.Slot(str, str) def add_inclusion(self, role, value): """Include item if `role` equals `value` @@ -811,7 +810,7 @@ def add_inclusion(self, role, value): self._add_rule(self.includes, role, value) - @QtCore.pyqtSlot(str, str) + @QtCore.Slot(str, str) def remove_inclusion(self, role, value=None): """Remove exclusion rule""" self._remove_rule(self.includes, role, value) @@ -819,7 +818,7 @@ def remove_inclusion(self, role, value=None): def set_inclusion(self, rules): self._set_rules(self.includes, rules) - @QtCore.pyqtSlot() + @QtCore.Slot() def clear_inclusion(self): self._clear_group(self.includes) @@ -885,6 +884,6 @@ def filterAcceptsRow(self, source_row, source_parent): return super(ProxyModel, self).filterAcceptsRow( source_row, source_parent) - @QtCore.pyqtSlot(result=int) + @QtCore.Slot(result=int) def rowCount(self, parent=QtCore.QModelIndex()): return super(ProxyModel, self).rowCount(parent) diff --git a/pyblish_qml/qml/CommentBox.qml b/pyblish_qml/qml/CommentBox.qml index 19bd218a..075d5ff2 100644 --- a/pyblish_qml/qml/CommentBox.qml +++ b/pyblish_qml/qml/CommentBox.qml @@ -63,7 +63,7 @@ Rectangle { visible: parent.length == 0 } - font.family: "Open Sans" + font.family: mainFont.name font.weight: Font.Normal KeyNavigation.priority: KeyNavigation.BeforeItem diff --git a/pyblish_qml/qml/Pyblish/Label.qml b/pyblish_qml/qml/Pyblish/Label.qml index 8849099e..df46d48e 100644 --- a/pyblish_qml/qml/Pyblish/Label.qml +++ b/pyblish_qml/qml/Pyblish/Label.qml @@ -80,7 +80,7 @@ Text { property var fontInfo: fontStyles[style] font.pixelSize: fontInfo.size * sizeMult - font.family: "Open Sans" + font.family: mainFont.name font.weight: { var weight = fontInfo.font diff --git a/pyblish_qml/qml/Pyblish/SpinBox.qml b/pyblish_qml/qml/Pyblish/SpinBox.qml index 8d6ba685..29238458 100644 --- a/pyblish_qml/qml/Pyblish/SpinBox.qml +++ b/pyblish_qml/qml/Pyblish/SpinBox.qml @@ -7,7 +7,7 @@ Control.SpinBox { style: SpinBoxStyle { background: Item {} - font.family: "Open Sans" + font.family: mainFont.name textColor: Theme.dark.textColor selectionColor: Theme.accentColor diff --git a/pyblish_qml/qml/Pyblish/TextField.qml b/pyblish_qml/qml/Pyblish/TextField.qml index ac1b8558..1119bf36 100644 --- a/pyblish_qml/qml/Pyblish/TextField.qml +++ b/pyblish_qml/qml/Pyblish/TextField.qml @@ -78,7 +78,7 @@ TextEdit { property var fontInfo: fontStyles[style] font.pixelSize: fontInfo.size * sizeMult - font.family: "Open Sans" + font.family: mainFont.name font.weight: { var weight = fontInfo.font diff --git a/pyblish_qml/qml/main.qml b/pyblish_qml/qml/main.qml index 56b64241..f1842118 100644 --- a/pyblish_qml/qml/main.qml +++ b/pyblish_qml/qml/main.qml @@ -17,6 +17,11 @@ import QtQuick 2.3 Rectangle { color: Qt.rgba(0.3, 0.3, 0.3) + FontLoader { + id: mainFont + name: "Open Sans" + } + Loader { id: loader anchors.fill: parent diff --git a/pyblish_qml/util.py b/pyblish_qml/util.py index eeb03eb7..ec11beb4 100644 --- a/pyblish_qml/util.py +++ b/pyblish_qml/util.py @@ -4,9 +4,9 @@ import traceback from functools import wraps -from PyQt5 import QtCore from .vendor import six +from .vendor.Qt5 import QtCore _timers = {} _defer_threads = [] @@ -146,7 +146,18 @@ def defer(target, args=None, kwargs=None, callback=None): class _defer(QtCore.QThread): - done = QtCore.pyqtSignal(QtCore.QVariant, arguments=["result"]) + done = QtCore.Signal(object, arguments=["result"]) + # (NOTE) The type `object` is a workaround for `QVaraint` + # + # When using PySide as Qt binding, QVaraint was not able to handle + # custom type properly. + # + # For example, like `pyblish.api.Context` which is a subclass of + # `list`, the signal receiver will get an instance of `list` instead + # of `pyblish.api.Context` when using PySide. But if register it with + # type `object` instead of `QVaraint`, the variable type will stay as + # what it was in both PyQt and PySide. + # def __init__(self, target, args=None, kwargs=None, callback=None): super(_defer, self).__init__() @@ -196,6 +207,30 @@ def schedule(func, time, channel="default"): _jobs[channel] = timer +def wait(signal, timeout=5000): + """Wait until signal received + + Starts an event loop that runs until the given signal is received. + Optionally the event loop can return earlier on a timeout (milliseconds). + + Returns `True` if the signal was emitted at least once in timeout, + otherwise returns `False`. + + """ + loop = QtCore.QEventLoop() + + def on_signal(): + loop.exit(True) + + def on_timeout(): + loop.exit(False) + + signal.connect(on_signal) + QtCore.QTimer.singleShot(timeout, on_timeout) + + return loop.exec_() + + class Timer(object): """Time operations using this context manager @@ -232,10 +267,10 @@ def format_text(text): return result -def pyqtConstantProperty(fget): - return QtCore.pyqtProperty(QtCore.QVariant, - fget=fget, - constant=True) +def qtConstantProperty(fget): + return QtCore.Property("QVariant", + fget=fget, + constant=True) def SlotSentinel(*args): @@ -248,7 +283,7 @@ def SlotSentinel(*args): if len(args) == 0 or isinstance(args[0], types.FunctionType): args = [] - @QtCore.pyqtSlot(*args) + @QtCore.Slot(*args) def slotdecorator(func): @wraps(func) def wrapper(*args, **kwargs): diff --git a/pyblish_qml/vendor/Qt5.py b/pyblish_qml/vendor/Qt5.py new file mode 100644 index 00000000..a24b2572 --- /dev/null +++ b/pyblish_qml/vendor/Qt5.py @@ -0,0 +1,83 @@ +import os +import sys +import types + +__version__ = "0.2.0.b2" + +QT_VERBOSE = bool(os.getenv("QT_VERBOSE")) +QT_PREFERRED_BINDING = os.environ.get("QT_PREFERRED_BINDING") +QtCompat = types.ModuleType("QtCompat") + + +def _log(text): + if QT_VERBOSE: + sys.stdout.write(text + "\n") + + +try: + from PySide2 import ( + QtWidgets, + QtCore, + QtGui, + QtQml, + QtQuick, + QtMultimedia, + QtOpenGL, + ) + + from shiboken2 import wrapInstance, getCppPointer + QtCompat.wrapInstance = wrapInstance + QtCompat.getCppPointer = getCppPointer + + try: + from PySide2 import QtUiTools + QtCompat.loadUi = QtUiTools.QUiLoader + + except ImportError: + _log("QtUiTools not provided.") + + +except ImportError: + try: + from PyQt5 import ( + QtWidgets, + QtCore, + QtGui, + QtQml, + QtQuick, + QtMultimedia, + QtOpenGL, + ) + + QtCore.Signal = QtCore.pyqtSignal + QtCore.Slot = QtCore.pyqtSlot + QtCore.Property = QtCore.pyqtProperty + + from sip import wrapinstance, unwrapinstance + QtCompat.wrapInstance = wrapinstance + QtCompat.getCppPointer = unwrapinstance + + try: + from PyQt5 import uic + QtCompat.loadUi = uic.loadUi + except ImportError: + _log("uic not provided.") + + except ImportError: + + # Used during tests and installers + if QT_PREFERRED_BINDING == "None": + _log("No binding found") + else: + raise + +__all__ = [ + "QtWidgets", + "QtCore", + "QtGui", + "QtQml", + "QtQuick", + "QtMultimedia", + "QtCompat", + "QtOpenGL", +] diff --git a/pyblish_qml/version.py b/pyblish_qml/version.py index b81ab146..170991ed 100644 --- a/pyblish_qml/version.py +++ b/pyblish_qml/version.py @@ -1,7 +1,7 @@ VERSION_MAJOR = 1 -VERSION_MINOR = 10 -VERSION_PATCH = 6 +VERSION_MINOR = 11 +VERSION_PATCH = 0 version_info = (VERSION_MAJOR, VERSION_MINOR, VERSION_PATCH) version = '%i.%i.%i' % version_info