diff --git a/README.md b/README.md index 094f6a32..59d50506 100644 --- a/README.md +++ b/README.md @@ -103,6 +103,11 @@ api.register_gui("pyblish_qml") See [pyblish-maya](https://github.com/pyblish/pyblish-maya#usage) for an example. +**Additional Environment Variables** + +- `PYBLISH_QML_FOSTER=1` Make QML process a real child of parent process, this makes the otherwise external process act like a native window within a host, to appear below inner windows such as the Script Editor in Maya. +- `PYBLISH_QML_MODAL=1` Block interactions to parent process, useful for headless publishing where you expect a process to remain alive for as long as QML is. Without this, Pyblish is at the mercy of the parent process, e.g. `mayapy` which quits at the first sign of EOF. +


diff --git a/pyblish_qml/app.py b/pyblish_qml/app.py index 577dd2f0..76061559 100644 --- a/pyblish_qml/app.py +++ b/pyblish_qml/app.py @@ -25,6 +25,7 @@ class Window(QtQuick.QQuickView): def __init__(self): super(Window, self).__init__(None) + self.app = QtGui.QGuiApplication.instance() self.setTitle(settings.WindowTitle) self.setResizeMode(self.SizeRootObjectToView) @@ -32,17 +33,6 @@ def __init__(self): self.resize(*settings.WindowSize) self.setMinimumSize(QtCore.QSize(430, 300)) - -class NativeVessel(QtGui.QWindow): - """Container window""" - - def __init__(self, app): - super(NativeVessel, self).__init__(None) - self.app = app - - def resizeEvent(self, event): - self.app.resize(self.width(), self.height()) - def event(self, event): """Allow GUI to be closed upon holding Shift""" if event.type() == QtCore.QEvent.Close: @@ -63,6 +53,29 @@ def event(self, event): print("Not ready, hold SHIFT to force an exit") event.ignore() + return super(Window, self).event(event) + + +class NativeVessel(QtGui.QWindow): + """Container window""" + + def __init__(self): + super(NativeVessel, self).__init__(None) + self.app = QtGui.QGuiApplication.instance() + + def resizeEvent(self, event): + self.app.resize(self.width(), self.height()) + + def event(self, event): + # Is required when Foster mode is on. + # Native vessel will receive closeEvent while foster mode is on + # and is the parent of window. + if event.type() == QtCore.QEvent.Close: + self.app.window.event(event) + if event.isAccepted(): + # `app.fostered` is False at this moment. + self.app.quit() + return super(NativeVessel, self).event(event) @@ -74,7 +87,7 @@ class Application(QtGui.QGuiApplication): """ - shown = QtCore.pyqtSignal(QtCore.QVariant, QtCore.QVariant) + shown = QtCore.pyqtSignal(*(QtCore.QVariant,) * 3) hidden = QtCore.pyqtSignal() quitted = QtCore.pyqtSignal() published = QtCore.pyqtSignal() @@ -86,15 +99,17 @@ class Application(QtGui.QGuiApplication): inFocused = QtCore.pyqtSignal() outFocused = QtCore.pyqtSignal() - attached = QtCore.pyqtSignal() + attached = QtCore.pyqtSignal(QtCore.QVariant) detached = QtCore.pyqtSignal() + host_attached = QtCore.pyqtSignal() + host_detached = QtCore.pyqtSignal() def __init__(self, source, targets=[]): super(Application, self).__init__(sys.argv) self.setWindowIcon(QtGui.QIcon(ICON_PATH)) - native_vessel = NativeVessel(self) + native_vessel = NativeVessel() window = Window() window.statusChanged.connect(self.on_status_changed) @@ -110,9 +125,10 @@ def __init__(self, source, targets=[]): context.setContextProperty("app", controller) self.fostered = False + self.foster_fixed = False self.foster_vessel = None - self.vessel = self.native_vessel = native_vessel + self.native_vessel = native_vessel self.window = window self.engine = engine @@ -151,8 +167,20 @@ def register_client(self, port): def deregister_client(self, port): self.clients.pop(port) + def quit(self): + event = None + if self.fostered: + # Foster vessel's closeEvent will trigger "quit" which connected + # to here. + # Forward the event to window. + event = QtCore.QEvent(QtCore.QEvent.Close) + self.window.event(event) + + if event is None or event.isAccepted(): + super(Application, self).quit() + @util.SlotSentinel() - def show(self, client_settings=None, window_id=None): + def show(self, client_settings=None, window_id=None, foster_fixed=False): """Display GUI Once the QML interface has been loaded, use this @@ -177,22 +205,27 @@ def show(self, client_settings=None, window_id=None): "This is a bug.".format(window_id)) self.window.setParent(foster_vessel) - - self.vessel = self.foster_vessel = foster_vessel + self.foster_vessel = foster_vessel + self.foster_fixed = foster_fixed if client_settings: # Apply client-side settings settings.from_dict(client_settings) - self.vessel.setWidth(client_settings["WindowSize"][0]) - self.vessel.setHeight(client_settings["WindowSize"][1]) - self.vessel.setTitle(client_settings["WindowTitle"]) - self.vessel.setFramePosition( - QtCore.QPoint( - client_settings["WindowPosition"][0], - client_settings["WindowPosition"][1] - ) - ) + def first_appearance_setup(vessel): + vessel.setGeometry(client_settings["WindowPosition"][0], + client_settings["WindowPosition"][1], + client_settings["WindowSize"][0], + client_settings["WindowSize"][1]) + vessel.setTitle(client_settings["WindowTitle"]) + + first_appearance_setup(self.native_vessel) + + if self.fostered: + if not self.foster_fixed: + # Return it back to native vessel for first run + self.window.setParent(self.native_vessel) + first_appearance_setup(self.foster_vessel) message = list() message.append("Settings: ") @@ -201,6 +234,9 @@ def show(self, client_settings=None, window_id=None): print("\n".join(message)) + if self.fostered and not self.foster_fixed: + self.native_vessel.show() + self.window.requestActivate() self.window.showNormal() @@ -232,22 +268,22 @@ def hide(self): via a call to `show()` """ - self.vessel.hide() + self.window.hide() def rise(self): """Rise GUI from hidden""" - self.vessel.show() + self.window.show() def inFocus(self): """Set GUI on-top flag""" - if not self.fostered and os.name == "nt": + if not self.fostered: previous_flags = self.window.flags() self.window.setFlags(previous_flags | QtCore.Qt.WindowStaysOnTopHint) def outFocus(self): """Remove GUI on-top flag""" - if not self.fostered and os.name == "nt": + if not self.fostered: previous_flags = self.window.flags() self.window.setFlags(previous_flags ^ QtCore.Qt.WindowStaysOnTopHint) @@ -260,7 +296,7 @@ def resize(self, width, height): self.window.resize(width, height) def _popup(self): - if not self.fostered and os.name == "nt": + if not self.fostered: window = self.window # Work-around for window appearing behind # other windows upon being shown once hidden. @@ -268,16 +304,6 @@ def _popup(self): window.setFlags(previous_flags | QtCore.Qt.WindowStaysOnTopHint) window.setFlags(previous_flags) - def _set_goemetry(self, source, target): - """Set window position and size after parent swap""" - target.setFramePosition(source.framePosition()) - - window_state = source.windowState() - target.setWindowState(window_state) - - if not window_state == QtCore.Qt.WindowMaximized: - target.resize(source.size()) - def detach(self): """Detach QQuickView window from the host @@ -290,26 +316,30 @@ def detach(self): This is the part that detaching from host. """ - if self.foster_vessel is None: + if self.foster_fixed or self.foster_vessel is None: self.controller.detached.emit() return print("Detach window from foster parent...") - self.vessel = self.native_vessel - - self.host.detach() - self.fostered = False - self.vessel.show() - - self.window.setParent(self.vessel) - self._set_goemetry(self.foster_vessel, self.vessel) + self.window.setParent(self.native_vessel) + + # Show dst container + self.native_vessel.show() + self.native_vessel.setGeometry(self.foster_vessel.geometry()) + self.native_vessel.setOpacity(100) + # Hide src container (will wait for host) + host_detached = QtTest.QSignalSpy(self.host_detached) + self.host.detach() + host_detached.wait(300) + # Stay on top + self.window.requestActivate() self._popup() self.controller.detached.emit() - def attach(self): + def attach(self, alert=False): """Attach QQuickView window to the host In foster mode, inorder to prevent window freeze when the host's @@ -321,21 +351,26 @@ def attach(self): This is the part that attaching back to host. """ - if self.foster_vessel is None: + if self.foster_fixed or self.foster_vessel is None: self.controller.attached.emit() + if self.foster_vessel is not None: + self.host.popup(alert) # Send alert return print("Attach window to foster parent...") - self.vessel = self.foster_vessel - - self.host.attach() - - self.native_vessel.hide() self.fostered = True - - self.window.setParent(self.vessel) - self._set_goemetry(self.native_vessel, self.vessel) + self.window.setParent(self.foster_vessel) + + # Show dst container (will wait for host) + host_attached = QtTest.QSignalSpy(self.host_attached) + self.host.attach(self.native_vessel.geometry()) + host_attached.wait(300) + # Hide src container + self.native_vessel.setOpacity(0) # avoid hide window anim + self.native_vessel.hide() + # Stay on top + self.host.popup(alert) self.controller.attached.emit() @@ -381,6 +416,8 @@ def _listen(): "attach": "attached", "detach": "detached", + "host_attach": "host_attached", + "host_detach": "host_detached", }.get(payload["name"]) diff --git a/pyblish_qml/control.py b/pyblish_qml/control.py index 0d428a96..b989ad3f 100644 --- a/pyblish_qml/control.py +++ b/pyblish_qml/control.py @@ -139,8 +139,8 @@ def detach(self): detached = QtTest.QSignalSpy(self.detached) detached.wait(1000) - def attach(self): - signal = json.dumps({"payload": {"name": "attach"}}) + def attach(self, alert=False): + signal = json.dumps({"payload": {"name": "attach", "args": [alert]}}) self.host.channels["parent"].put(signal) attached = QtTest.QSignalSpy(self.attached) attached.wait(1000) @@ -770,6 +770,8 @@ def on_finished(plugins, context): self.host.emit("reset", context=None) + self.attach() + # Hidden sections for section in self.data["models"]["item"].sections: if section.name in settings.HiddenSections: @@ -822,6 +824,8 @@ def on_context(context): def on_reset(): util.async(self.host.context, callback=on_context) + if not self.data["firstRun"]: + self.detach() util.async(self.host.reset, callback=on_reset) @QtCore.pyqtSlot() @@ -864,7 +868,7 @@ def on_data_received(args): self.run(*args, callback=on_finished) def on_finished(): - self.attach() + self.attach(True) self.host.emit("published", context=None) self.detach() @@ -908,7 +912,7 @@ def on_data_received(args): self.run(*args, callback=on_finished) def on_finished(): - self.attach() + self.attach(True) self.host.emit("validated", context=None) self.detach() diff --git a/pyblish_qml/host.py b/pyblish_qml/host.py index 1c62f56d..7ec4e466 100644 --- a/pyblish_qml/host.py +++ b/pyblish_qml/host.py @@ -64,7 +64,7 @@ def install(modal, foster): sys.stdout.write("Already installed, uninstalling..\n") uninstall() - use_threaded_wrapper = foster or not modal + use_threaded_wrapper = not modal install_callbacks() install_host(use_threaded_wrapper) @@ -89,17 +89,29 @@ def _is_headless(): ) -def _fosterable(foster=None): +def _fosterable(foster, modal): if foster is None: # Get foster mode from environment foster = bool(os.environ.get("PYBLISH_QML_FOSTER", False)) if foster: - os.environ["PYBLISH_QML_FOSTER"] = "True" + if modal or _is_headless(): + print("Foster disabled due to Modal is on or in headless mode.") + return False + + print("Foster on.") + return True + else: - os.environ["PYBLISH_QML_FOSTER"] = "" + return False - return foster and not _is_headless() + +def _foster_fixed(foster): + if not foster: + return False + + value = os.environ.get("PYBLISH_QML_FOSTER_FIXED", "").lower() + return value in ("true", "yes", "1") or QtCore.qVersion()[0] == "4" def show(parent=None, targets=[], modal=None, foster=None): @@ -108,6 +120,12 @@ def show(parent=None, targets=[], modal=None, foster=None): Requires install() to have been run first, and a live instance of Pyblish QML in the background. + Arguments: + parent (None, optional): Deprecated + targets (list, optional): Publishing targets + modal (bool, optional): Block interactions to parent + foster (bool, optional): Become a real child of the parent process + """ # Get modal mode from environment @@ -116,7 +134,8 @@ def show(parent=None, targets=[], modal=None, foster=None): is_headless = _is_headless() - foster = _fosterable(foster) + foster = _fosterable(foster, modal) + foster_fixed = _foster_fixed(foster) # Automatically install if not already installed. if not _state.get("installed"): @@ -160,7 +179,8 @@ def on_shown(): server = ipc.server.Server(service, targets=targets, modal=modal, - foster=foster) + foster=foster, + foster_fixed=foster_fixed) except Exception: # If for some reason, the GUI fails to show. traceback.print_exc() @@ -175,6 +195,9 @@ def on_shown(): print("Success. QML server available as " "pyblish_qml.api.current_server()") + # Install eventFilter if not exists one + install_event_filter() + server.listen() return server @@ -274,18 +297,59 @@ def install_host(use_threaded_wrapper): break +SIGNALS_TO_REMOVE_EVENT_FILTER = ( + "pyblishQmlClose", + "pyblishQmlCloseForced", +) + + +def remove_event_filter(): + event_filter = _state.get("eventFilter") + if isinstance(event_filter, QtCore.QObject): + + # (NOTE) Should remove from the QApp instance which originally + # installed to. + # This will not work: + # `QApplication.instance().removeEventFilter(event_filter)` + # + event_filter.parent().removeEventFilter(event_filter) + del _state["eventFilter"] + + for signal in SIGNALS_TO_REMOVE_EVENT_FILTER: + try: + pyblish.api.deregister_callback(signal, remove_event_filter) + except (KeyError, ValueError): + pass + + print("The eventFilter of pyblish-qml has been removed.") + + def install_event_filter(): main_window = _state.get("vesselParent") if main_window is None: - raise Exception("Main window not found, event filter did not " - "install. This is a bug.") + print("Main window not found, event filter did not install.") + return + + event_filter = _state.get("eventFilter") + if isinstance(event_filter, QtCore.QObject): + print("Event filter exists.") + return + else: + event_filter = HostEventFilter(main_window) try: - host_event_filter = HostEventFilter(main_window) - main_window.installEventFilter(host_event_filter) - except Exception: - pass + main_window.installEventFilter(event_filter) + except Exception as e: + print("An error has occurred during event filter's installation.") + print(e) + else: + _state["eventFilter"] = event_filter + + for signal in SIGNALS_TO_REMOVE_EVENT_FILTER: + pyblish.api.register_callback(signal, remove_event_filter) + + print("Event filter has been installed.") def _on_application_quit(): @@ -336,14 +400,13 @@ def eventFilter(self, widget, event): # proxy is None, or does not have the function return False - try: - func() - return True - except IOError: + connected = func() + + if connected is not True: # The running instance has already been closed. - _state.pop("currentServer") + remove_event_filter() - return False + return True def _acquire_host_main_window(app): @@ -413,8 +476,6 @@ def threaded_wrapper(func, *args, **kwargs): for widget in QtWidgets.QApplication.topLevelWidgets() }["MayaWindow"] - install_event_filter() - _set_host_label("Maya") @@ -429,8 +490,6 @@ def _common_setup(host_name, threaded_wrapper, use_threaded_wrapper): app.aboutToQuit.connect(_on_application_quit) _acquire_host_main_window(app) - install_event_filter() - _set_host_label(host_name) diff --git a/pyblish_qml/ipc/client.py b/pyblish_qml/ipc/client.py index 4bfb0ab1..433a1031 100644 --- a/pyblish_qml/ipc/client.py +++ b/pyblish_qml/ipc/client.py @@ -36,8 +36,12 @@ def reset(self): def detach(self): self._dispatch("detach") - def attach(self): - self._dispatch("attach") + def attach(self, qRect): + geometry = [qRect.x(), qRect.y(), qRect.width(), qRect.height()] + self._dispatch("attach", args=geometry) + + def popup(self, alert): + self._dispatch("popup", args=[alert]) def test(self, **vars): """Vars can only be passed as a non-keyword argument""" diff --git a/pyblish_qml/ipc/server.py b/pyblish_qml/ipc/server.py index 975ddf8c..a8c1ed36 100644 --- a/pyblish_qml/ipc/server.py +++ b/pyblish_qml/ipc/server.py @@ -15,6 +15,12 @@ IS_WIN32 = sys.platform == "win32" +SIGNALS_TO_CLOSE_VESSEL = ( + "pyblishQmlClose", + "pyblishQmlCloseForced", +) + + def default_wrapper(func, *args, **kwargs): return func(*args, **kwargs) @@ -38,13 +44,16 @@ def __init__(self, proxy): self._winId = winIdFixed(self.winId()) self.resize(1, 1) - # Modal Mode - self.setModal(proxy.modal) self.proxy = proxy + self.close_lock = True def closeEvent(self, event): - self.proxy.quit() + if self.close_lock: + self.proxy.quit() + event.ignore() + else: + event.accept() def resizeEvent(self, event): self.proxy._dispatch("resize", args=[self.width(), self.height()]) @@ -52,7 +61,7 @@ def resizeEvent(self, event): class MockFosterVessel(object): """We don't create widget without QApp, we mock one""" - _winId = None + _winId = close_lock = None show = hide = close = lambda _: None @@ -60,16 +69,32 @@ class Proxy(object): """Speak to child process and control the vessel (window container)""" def __init__(self, server): + import pyblish.api self.popen = server.popen - self.modal = server.modal self.foster = server.foster + self.foster_fixed = server.foster_fixed self.vessel = FosterVessel(self) if self.foster else MockFosterVessel() self._winId = self.vessel._winId server.proxy = self + def close_vessel(): + self.vessel.close_lock = False + self.vessel.close() + + for signal in SIGNALS_TO_CLOSE_VESSEL: + try: + pyblish.api.deregister_callback(signal, self.close_vessel) + except (KeyError, ValueError): + pass + + self.close_vessel = close_vessel + + for signal in SIGNALS_TO_CLOSE_VESSEL: + pyblish.api.register_callback(signal, self.close_vessel) + self._alive() def show(self, settings=None): @@ -79,13 +104,16 @@ def show(self, settings=None): settings (optional, dict): Client settings """ - self.vessel.show() - return self._dispatch("show", args=[settings or {}, self._winId]) + if self.foster_fixed: + self.vessel.show() + return self._dispatch("show", args=[settings or {}, + self._winId, + self.foster_fixed]) def hide(self): """Hide the GUI""" self._dispatch("hide") - self.vessel.hide() + return self.vessel.hide() def quit(self): """Ask the GUI to quit""" @@ -93,25 +121,39 @@ def quit(self): def rise(self): """Rise GUI from hidden""" - self._dispatch("rise") + self.vessel.show() + return self._dispatch("rise") def inFocus(self): """Set GUI on-top flag""" - self._dispatch("inFocus") + return self._dispatch("inFocus") def outFocus(self): """Remove GUI on-top flag""" - self._dispatch("outFocus") + return self._dispatch("outFocus") def kill(self): """Forcefully destroy the process""" self.popen.kill() def detach(self): + self.vessel.setWindowOpacity(0) # avoid hide window anim self.vessel.hide() + self._dispatch("host_detach") - def attach(self): + def attach(self, x, y, w, h): self.vessel.show() + self.vessel.setGeometry(x, y, w, h) + self.vessel.setWindowOpacity(100) + self._dispatch("host_attach") + + def popup(self, alert): + # No hijack keyboard focus + if not self.foster_fixed: + QtWidgets.QApplication.setActiveWindow(self.vessel) + # Plus alert + if alert: + QtWidgets.QApplication.alert(self.vessel.parent(), 0) def publish(self): return self._dispatch("publish") @@ -168,7 +210,7 @@ def _flush(self, data): self.popen.stdin.flush() except IOError: # subprocess closed - self.vessel.close() + self.close_vessel() else: return True @@ -182,6 +224,10 @@ class Server(object): service (service.Service): Dispatch requests to this service python (str, optional): Absolute path to Python executable pyqt5 (str, optional): Absolute path to PyQt5 + targets (list, optional): Publishing targets, e.g. `ftrack` + modal (bool, optional): Block interactions to parent + foster (bool, optional): GUI become a real child of the parent process + foster_fixed (bool, optional): GUI always remain inside the parent """ @@ -191,7 +237,8 @@ def __init__(self, pyqt5=None, targets=[], modal=False, - foster=False): + foster=False, + foster_fixed=False): super(Server, self).__init__() self.service = service self.listening = False @@ -202,6 +249,7 @@ def __init__(self, self.modal = modal self.foster = foster + self.foster_fixed = foster_fixed # The server may be run within Maya or some other host, # in which case we refer to it as running embedded. @@ -333,10 +381,14 @@ def _listen(): # self.service have no access to proxy object, so # this `if` statement is needed - if func_name in ("detach", "attach"): - getattr(self.proxy, func_name)() + if func_name in ("detach", "attach", "popup"): + getattr(self.proxy, func_name)(*args) result = None + elif func_name in ("emit",): + # Avoid main thread hang + result = getattr(self.service, func_name)(*args) + else: wrapper = _state.get("dispatchWrapper", default_wrapper) @@ -374,7 +426,7 @@ def _listen(): sys.stdout.write(line) if not self.listening: - if self.modal and not self.foster: + if self.modal: _listen() else: thread = threading.Thread(target=_listen) diff --git a/pyblish_qml/version.py b/pyblish_qml/version.py index de3a9db3..082e6304 100644 --- a/pyblish_qml/version.py +++ b/pyblish_qml/version.py @@ -1,7 +1,7 @@ VERSION_MAJOR = 1 VERSION_MINOR = 8 -VERSION_PATCH = 1 +VERSION_PATCH = 2 version_info = (VERSION_MAJOR, VERSION_MINOR, VERSION_PATCH) version = '%i.%i.%i' % version_info