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