# Copyright (c) 2013 Shotgun Software Inc. # # CONFIDENTIAL AND PROPRIETARY # # This work is provided "AS IS" and subject to the Shotgun Pipeline Toolkit # Source Code License included in this distribution package. See LICENSE. # By accessing, using, copying or modifying this work you indicate your # agreement to the Shotgun Pipeline Toolkit Source Code License. All rights # not expressly granted therein are reserved by Shotgun Software Inc. """A Krita engine for Tank. https://en.wikipedia.org/wiki/Krita_(software) """ import inspect import logging import os import sys import time import traceback import krita import tank import tank.platform.framework from tank.log import LogManager from tank.platform import Engine from tank.util import is_linux, is_macos, is_windows from tank.util.pyside2_patcher import PySide2Patcher __author__ = "Diego Garcia Huerta" __contact__ = "https://www.linkedin.com/in/diegogh/" ENGINE_NAME = "tk-krita" APPLICATION_NAME = "Krita" # environment variable that control if to show the compatibility warning dialog # when Krita software version is above the tested one. SHOW_COMP_DLG = "SGTK_COMPATIBILITY_DIALOG_SHOWN" # this is the absolute minimum Krita version for the engine to work. Actually # the one the engine was developed originally under, so change it at your # own risk if needed. MIN_COMPATIBILITY_VERSION = 4.0 # this is a place to put our persistent variables between different documents # opened if not hasattr(krita, "shotgun"): krita.shotgun = lambda: None # Although the engine has logging already, this logger is needed for logging # where an engine may not be present. logger = LogManager.get_logger(__name__) # logging functionality def show_error(msg): from PyQt5.QtWidgets import QMessageBox batch_mode = krita.Krita.instance().batchmode() if not batch_mode: QMessageBox.critical(None, "Shotgun Error | %s engine" % APPLICATION_NAME, msg) else: display_error(msg) def show_warning(msg): from PyQt5.QtWidgets import QMessageBox batch_mode = krita.Krita.instance().batchmode() if not batch_mode: QMessageBox.warning(None, "Shotgun Warning | %s engine" % APPLICATION_NAME, msg) else: display_warning(msg) def show_info(msg): from PyQt5.QtWidgets import QMessageBox batch_mode = False if not batch_mode: QMessageBox.information(None, "Shotgun Info | %s engine" % APPLICATION_NAME, msg) else: display_info(msg) def display_error(msg): krita.qCritical(msg) t = time.asctime(time.localtime()) message = "%s - Shotgun Error | %s engine | %s " % (t, APPLICATION_NAME, msg) print(message) def display_warning(msg): krita.qWarning(msg) t = time.asctime(time.localtime()) message = "%s - Shotgun Warning | %s engine | %s " % (t, APPLICATION_NAME, msg) print(message) def display_info(msg): # Krita 4.0.0 did not have qInfo yet, so use debug instead if hasattr(krita, "qInfo"): krita.qInfo(msg) else: krita.qDebug(msg) t = time.asctime(time.localtime()) message = "%s - Shotgun Information | %s engine | %s " % (t, APPLICATION_NAME, msg) print(message) def display_debug(msg): if os.environ.get("TK_DEBUG") == "1": krita.qDebug(msg) t = time.asctime(time.localtime()) message = "%s - Shotgun Debug | %s engine | %s " % (t, APPLICATION_NAME, msg) print(message) # methods to support the state when the engine cannot start up # for example if a non-tank file is loaded in Krita we load the project # context if exists, so we give a chance to the user to at least # do the basics operations. def refresh_engine(): """ refresh the current engine """ logger.debug("Refreshing the engine") engine = tank.platform.current_engine() if not engine: # If we don't have an engine for some reason then we don't have # anything to do. logger.debug( "%s Refresh_engine | No currently initialized engine found; " "aborting the refresh of the engine\n" % APPLICATION_NAME ) return _fix_tk_multi_pythonconsole(logger) active_doc_path = None active_doc = krita.Krita.instance().activeDocument() if active_doc: # determine the tk instance and context to use: active_doc_path = active_doc.fileName() if not active_doc_path: logger.debug("File has not been saved yet, aborting the refresh of the engine.") return # make sure path is normalized active_doc_path = os.path.abspath(active_doc_path) # we are going to try to figure out the context based on the # active document current_context = tank.platform.current_engine().context ctx = current_context # this file could be in another project altogether, so create a new # API instance. try: # and construct the new context for this path: tk = tank.sgtk_from_path(active_doc_path) logger.debug("Extracted sgtk instance: '%r' from path: '%r'", tk, active_doc_path) except tank.TankError: # could not detect context from path, will use the project context # for menus if it exists message = ( "Shotgun %s Engine could not detect the context\n" "from the active document. Shotgun menus will be \n" "stay in the current context '%s' " "\n" % (APPLICATION_NAME, ctx) ) display_warning(message) return ctx = tk.context_from_path(active_doc_path, current_context) logger.debug( "Given the path: '%s' the following context was extracted: '%r'", active_doc_path, ctx, ) # default to project context in worse case scenario if not ctx: project_name = engine.context.project.get("name") ctx = tk.context_from_entity_dictionary(engine.context.project) logger.debug( ( "Could not extract a context from the current active project " "path, so we revert to the current project '%r' context: '%r'" ), project_name, ctx, ) # Only change if the context is different if ctx != current_context: try: engine.change_context(ctx) except tank.TankError: message = ( "Shotgun %s Engine could not change context\n" "to '%r'. Shotgun menu will be disabled!.\n" "\n" % (APPLICATION_NAME, ctx) ) display_warning(message) engine.create_shotgun_menu(disabled=True) # TBR: DGH290420 # This is an interesting one. It is the only way I found I could fix the # python console. Other ideas are welcomed. I could have gone the deeper # route introspecting engine.apps but this ultimately felt simpler. # the main issue is that PyQt5 behaves differently when returning from a # keyPressEvent. While in PySide(2) the accepted behaviour is to return True # or False to indicate that we want to propagate the event, in PyQt5 seems # that a simple return indicates no propagation, whereas if we want to propagate # the event we should simply pass it on to our parent class. I could be wrong # in this as a general PyQt5 rule, but that is what I experienced. def _fix_tk_multi_pythonconsole(logger): PythonTabWidget = None for module_name in sys.modules.keys(): if "app.console" in module_name: module = sys.modules[module_name] if hasattr(module, "PythonTabWidget"): PythonTabWidget = module.PythonTabWidget if PythonTabWidget: try: def keyPressEvent(self, event): """ Adds support for tab creation and navigation via hotkeys. """ if bool(module.QtCore.Qt.ControlModifier & event.modifiers()): # Ctrl+T to add a new tab if event.key() == module.QtCore.Qt.Key_T: self.add_tab() return # Ctrl+Shift+[ or Ctrl+Shift+] to navigate tabs if bool(module.QtCore.Qt.ShiftModifier & event.modifiers()): if event.key() in [module.QtCore.Qt.Key_BraceLeft]: self.goto_tab(-1) elif event.key() in [module.QtCore.Qt.Key_BraceRight]: self.goto_tab(1) return super(PythonTabWidget, self).keyPressEvent(event) PythonTabWidget.keyPressEvent = keyPressEvent logger.debug( "Applied tk-krita fix to tk-multi-python console. Class:%s" % PythonTabWidget ) except Exception: logger.warning( "Could not apply tk-krita fix to multi python console. Class: %s" % PythonTabWidget ) class PyQt5Patcher(PySide2Patcher): """ Patches PyQt5 so it can be API compatible with PySide 1. .. code-block:: python from PyQt5 import QtGui, QtCore, QtWidgets import PyQt5 PyQt5Patcher.patch(QtCore, QtGui, QtWidgets, PyQt5) """ # Flag that will be set at the module level so that if an engine is reloaded # the PySide 2 API won't be monkey patched twice. # Note: not sure where this is in use in SGTK, but wanted to make sure # nothing breaks _TOOLKIT_COMPATIBLE = "__toolkit_compatible" @classmethod def patch(cls, QtCore, QtGui, QtWidgets, PyQt5): """ Patches QtCore, QtGui and QtWidgets :param QtCore: The QtCore module. :param QtGui: The QtGui module. :param QtWidgets: The QtWidgets module. :param PyQt5: The PyQt5 module. """ # Add this version info otherwise it breaks since tk_core v0.19.9 # PySide2Patcher is now checking the version of PySide2 in a way # that PyQt5 does not like: __version_info__ is not defined in PyQt5 version = list(map(int, QtCore.PYQT_VERSION_STR.split("."))) PyQt5.__version_info__ = version QtCore, QtGui = PySide2Patcher.patch(QtCore, QtGui, QtWidgets, PyQt5) def SIGNAL(arg): """ This is a trick to fix the fact that old style signals are not longer supported in pyQt5 """ return arg.replace("()", "") class QLabel(QtGui.QLabel): """ Unfortunately in some cases sgtk sets the pixmap as None to remove the icon. This behaviour is not supported in PyQt5 and requires an empty instance of QPixmap. """ def setPixmap(self, pixmap): if pixmap is None: pixmap = QtGui.QPixmap() return super(QLabel, self).setPixmap(pixmap) class QPixmap(QtGui.QPixmap): """ The following method is obsolete in PyQt5 so we have to provide a backwards compatible solution. https://doc.qt.io/qt-5/qpixmap-obsolete.html#grabWindow """ def grabWindow(self, window, x=0, y=0, width=-1, height=-1): screen = QtGui.QApplication.primaryScreen() return screen.grabWindow(window, x=x, y=y, width=width, height=height) class QAction(QtGui.QAction): """ From the docs: https://www.riverbankcomputing.com/static/Docs/PyQt5/incompatibilities.html#qt-signals-with-default-arguments # noqa: B950 Explanation: https://stackoverflow.com/questions/44371451/python-pyqt-qt-qmenu-qaction-syntax # noqa: B950 A lot of cases in tk apps where QAction triggered signal is connected with `triggered[()].connect` which in PyQt5 is a problem because triggered is an overloaded signal with two signatures, triggered = QtCore.pyqtSignal(bool) triggered = QtCore.pyqtSignal() If you wanted to use the second overload, you had to use the `triggered[()]` approach to avoid the extra boolean attribute to trip you in the callback function. The issue is that in PyQt5.3+ this has changed and is no longer allowed as only the first overloaded function is implemented and always called with the extra boolean value. To avoid this normally we would have to decorate our slots with the decorator: @QtCore.pyqtSlot but changing the tk apps is out of the scope of this engine. To fix this we implement a new signal and rewire the connections so it is available once more for tk apps to be happy. """ triggered_ = QtCore.pyqtSignal([bool], []) def __init__(self, *args, **kwargs): super(QAction, self).__init__(*args, **kwargs) super(QAction, self).triggered.connect( lambda checked: self.triggered_[()] ) super(QAction, self).triggered.connect(self.triggered_[bool]) self.triggered = self.triggered_ self.triggered.connect(self._onTriggered) def _onTriggered(self, checked=False): self.triggered_[()].emit() class QAbstractButton(QtGui.QAbstractButton): """ See QAction above for explanation """ clicked_ = QtCore.pyqtSignal([bool], []) triggered_ = QtCore.pyqtSignal([bool], []) def __init__(self, *args, **kwargs): super(QAbstractButton, self).__init__(*args, **kwargs) super(QAbstractButton, self).clicked.connect( lambda checked: self.clicked_[()] ) super(QAbstractButton, self).clicked.connect(self.clicked_[bool]) self.clicked = self.clicked_ self.clicked.connect(self._onClicked) super(QAction, self).triggered.connect( lambda checked: self.triggered_[()] ) super(QAction, self).triggered.connect(self.triggered_[bool]) self.triggered = self.triggered_ self.triggered.connect(self._onTriggered) def _onClicked(self, checked=False): self.clicked_[()].emit() class QObject(QtCore.QObject): """ QObject no longer has got the connect method in PyQt5 so we have to reinvent it here... https://doc.bccnsoft.com/docs/PyQt5/pyqt4_differences.html#old-style-signals-and-slots """ def connect( sender, # noqa: B902 signal, method, connection_type=QtCore.Qt.AutoConnection, ): if hasattr(sender, signal): getattr(sender, signal).connect(method, connection_type) class QCheckBox(QtGui.QCheckBox): """ PyQt5 no longer allows anything but an QIcon as an argument. In some cases sgtk is passing a pixmap, so we need to intercept the call to convert the pixmap to an actual QIcon. """ def setIcon(self, icon): return super(QCheckBox, self).setIcon(QtGui.QIcon(icon)) class QTabWidget(QtGui.QTabWidget): """ For whatever reason pyQt5 is returning the name of the Tab including the key accelerator, the & that indicates what key is the shortcut. This is tripping dialog.py in tk-multi-loaders2 """ def tabText(self, index): return super(QTabWidget, self).tabText(index).replace("&", "") class QPyTextObject(QtCore.QObject, QtGui.QTextObjectInterface): """ PyQt4 implements the QPyTextObject as a workaround for the inability to define a Python class that is sub-classed from more than one Qt class. QPyTextObject is not implemented in PyQt5 https://doc.bccnsoft.com/docs/PyQt5/pyqt4_differences.html#qpytextobject """ pass class QStandardItem(QtGui.QStandardItem): """ PyQt5 no longer allows anything but an QIcon as an argument. In some cases sgtk is passing a pixmap, so we need to intercept the call to convert the pixmap to an actual QIcon. """ def setIcon(self, icon): icon = QtGui.QIcon(icon) return super(QStandardItem, self).setIcon(icon) class QTreeWidgetItem(QtGui.QTreeWidgetItem): """ PyQt5 no longer allows anything but an QIcon as an argument. In some cases sgtk is passing a pixmap, so we need to intercept the call to convert the pixmap to an actual QIcon. """ def setIcon(self, column, icon): icon = QtGui.QIcon(icon) return super(QTreeWidgetItem, self).setIcon(column, icon) class QTreeWidgetItemIterator(QtGui.QTreeWidgetItemIterator): """ This fixes the iteration over QTreeWidgetItems. It seems that it is no longer iterable, so we create our own. """ def __iter__(self): value = self.value() while value: yield self self += 1 value = self.value() class QColor(QtGui.QColor): """ Adds missing toTuple method to PyQt5 QColor class. """ def toTuple(self): if self.spec() == QtGui.QColor.Rgb: r, g, b, a = self.getRgb() return (r, g, b, a) elif self.spec() == QtGui.QColor.Hsv: h, s, v, a = self.getHsv() return (h, s, v, a) elif self.spec() == QtGui.QColor.Cmyk: c, m, y, k, a = self.getCmyk() return (c, m, y, k, a) elif self.spec() == QtGui.QColor.Hsl: h, s, l, a = self.getHsl() return (h, s, l, a) return tuple() # hot patch the library to make it work with pyside code QtCore.SIGNAL = SIGNAL QtCore.Signal = QtCore.pyqtSignal QtCore.Slot = QtCore.pyqtSlot QtCore.Property = QtCore.pyqtProperty QtCore.__version__ = QtCore.PYQT_VERSION_STR # widgets and class fixes QtGui.QLabel = QLabel QtGui.QPixmap = QPixmap QtGui.QAction = QAction QtCore.QObject = QObject QtGui.QCheckBox = QCheckBox QtGui.QTabWidget = QTabWidget QtGui.QStandardItem = QStandardItem QtGui.QPyTextObject = QPyTextObject QtGui.QTreeWidgetItem = QTreeWidgetItem QtGui.QTreeWidgetItemIterator = QTreeWidgetItemIterator QtGui.QColor = QColor return QtCore, QtGui class KritaEngine(Engine): """ Toolkit engine for Krita. """ def __init__(self, *args, **kwargs): """ Engine Constructor """ # Add instance variables before calling our base class # __init__() because the initialization may need those # variables. self._dock_widgets = [] tank.platform.Engine.__init__(self, *args, **kwargs) def _define_qt_base(self): """ This will be called at initialization time and will allow a user to control various aspects of how QT is being used by Toolkit. The method should return a dictionary with a number of specific keys, outlined below. * qt_core - the QtCore module to use * qt_gui - the QtGui module to use * dialog_base - base class for to use for Toolkit's dialog factory :returns: dict """ if not self.has_ui: return {} # Proxy class used when QT does not exist on the system. # this will raise an exception when any QT code tries to use it class QTProxy(object): def __getattr__(self, name): raise tank.TankError( "Looks like you are trying to run an App that uses a QT " "based UI, however the engine could not find a " "PyQt installation!" ) base = {"qt_core": QTProxy(), "qt_gui": QTProxy(), "dialog_base": None} try: from PyQt5 import QtCore, QtGui, QtWidgets import PyQt5 except ImportError as e: self.log_warning( "Error setting up PyQt. PyQt based UI support will not be available: %s" % e ) self.log_debug(traceback.format_exc()) return base QtCore, QtGui = PyQt5Patcher.patch(QtCore, QtGui, QtWidgets, PyQt5) base["qt_core"] = QtCore base["qt_gui"] = QtGui base["dialog_base"] = QtWidgets.QDialog logger.debug( "Successfully initialized PyQt '{0}' located in {1}.".format( QtCore.PYQT_VERSION_STR, PyQt5.__file__ ) ) return base def has_qt5(self): return True @property def context_change_allowed(self): """ Whether the engine allows a context change without the need for a restart. """ return True @property def host_info(self): """ :returns: A dictionary with information about the application hosting this engine. The returned dictionary is of the following form on success: { "name": "Krita", "version": "4.2.8", } The returned dictionary is of following form on an error preventing the version identification. { "name": "Krita", "version: "unknown" } """ host_info = {"name": APPLICATION_NAME, "version": "unknown"} try: krita_ver = krita.Krita.instance().version() host_info["version"] = krita_ver except Exception: # Fallback to 'Krita' initialized above pass return host_info def _on_active_doc_timer(self): """ Refresh the engine if the current document has changed since the last time we checked. """ active_doc = krita.Krita.instance().activeDocument() if self.active_doc != active_doc: self.active_doc = active_doc refresh_engine() def pre_app_init(self): """ Runs after the engine is set up but before any apps have been initialized. """ from tank.platform.qt import QtCore # unicode characters returned by the shotgun api need to be converted # to display correctly in all of the app windows # tell QT to interpret C strings as utf-8 utf8 = QtCore.QTextCodec.codecForName("utf-8") QtCore.QTextCodec.setCodecForCStrings(utf8) self.logger.debug("set utf-8 codec for widget text") # We use a timer instead of the notifier API as the API does not # inform us when the user changes views, only when they are created # cloned, or closed. # Since the restart of the engine every time a view is chosen is an # expensive operation, we will offer this functionality as am option # inside the context menu. self.active_doc = None self.active_doc_timer = QtCore.QTimer() self.active_doc_timer.timeout.connect(self._on_active_doc_timer) def init_engine(self): """ Initializes the Krita engine. """ self.logger.debug("%s: Initializing...", self) # check that we are running a supported OS if not any([is_windows(), is_linux(), is_macos()]): raise tank.TankError( "The current platform is not supported!" " Supported platforms " "are Mac, Linux 64 and Windows 64." ) # check that we are running an ok version of Krita krita_build_version = krita.Krita.instance().version() krita_ver = float(".".join(krita_build_version.split(".")[:2])) if krita_ver < MIN_COMPATIBILITY_VERSION: msg = ( "Shotgun integration is not compatible with %s versions older than %s" % ( APPLICATION_NAME, MIN_COMPATIBILITY_VERSION, ) ) show_error(msg) raise tank.TankError(msg) if krita_ver > MIN_COMPATIBILITY_VERSION: # show a warning that this version of Krita isn't yet fully tested # with Shotgun: msg = ( "The Shotgun Pipeline Toolkit has not yet been fully " "tested with %s %s. " "You can continue to use Toolkit but you may experience " "bugs or instability." "\n\n" % (APPLICATION_NAME, krita_ver) ) # determine if we should show the compatibility warning dialog: show_warning_dlg = self.has_ui and SHOW_COMP_DLG not in os.environ if show_warning_dlg: # make sure we only show it once per session os.environ[SHOW_COMP_DLG] = "1" # split off the major version number - accommodate complex # version strings and decimals: major_version_number_str = krita_build_version.split(".")[0] if major_version_number_str and major_version_number_str.isdigit(): # check against the compatibility_dialog_min_version # setting min_ver = self.get_setting("compatibility_dialog_min_version") if int(major_version_number_str) < min_ver: show_warning_dlg = False if show_warning_dlg: # Note, title is padded to try to ensure dialog isn't insanely # narrow! show_info(msg) # always log the warning to the script editor: self.logger.warning(msg) # In the case of Windows, we have the possibility of locking up if # we allow the PySide shim to import QtWebEngineWidgets. # We can stop that happening here by setting the following # environment variable. # Note that prior PyQt5 v5.12 this module existed, after that it has # been separated and would not cause any issues. Since it is no # harm if the module is not there, we leave it just in case older # versions of Krita were using previous versions of PyQt # https://www.riverbankcomputing.com/software/pyqtwebengine/intro if is_windows(): self.logger.debug( "This application on Windows can deadlock if QtWebEngineWidgets " "is imported. Setting " "SHOTGUN_SKIP_QTWEBENGINEWIDGETS_IMPORT=1..." ) os.environ["SHOTGUN_SKIP_QTWEBENGINEWIDGETS_IMPORT"] = "1" # check that we can load the GUI libraries self._init_pyside() # default menu name is Shotgun but this can be overriden # in the configuration to be Sgtk in case of conflicts self._menu_name = "Shotgun" if self.get_setting("use_sgtk_as_menu_name", False): self._menu_name = "Sgtk" def __get_active_document_context_switch(self): """ Returns the status of the automatic context switch. """ if not hasattr(krita.shotgun, "active_document_context_switch"): krita.shotgun.active_document_context_switch = self.get_setting( "active_document_context_switch", False ) return krita.shotgun.active_document_context_switch def __set_active_document_context_switch(self, value): """ Sets the status of the automatic context switch. """ krita.shotgun.active_document_context_switch = value self.log_info("set_active_document_context_switch: %s" % value) if not value: self.active_doc_timer.stop() else: self.active_doc_timer.start(1000) active_document_context_switch = property( __get_active_document_context_switch, __set_active_document_context_switch ) def toggle_active_document_context_switch(self): """ Toggles the automatic switch context when the view is changed. If the filename of the view is different than the current one, we restart the engine with a new context if different than the current. """ self.active_document_context_switch = not self.active_document_context_switch return self.active_document_context_switch def create_shotgun_menu(self, disabled=False): """ Creates the main shotgun menu in Krita. Note that this only creates the menu, not the child actions :return: bool """ # only create the shotgun menu if not in batch mode and menu doesn't # already exist if self.has_ui: # create our menu handler tk_krita = self.import_module("tk_krita") if tk_krita.can_create_menu(): self.logger.debug("Creating shotgun menu...") self._menu_generator = tk_krita.MenuGenerator(self, self._menu_name) self._menu_generator.create_menu(disabled=disabled) else: self.logger.debug("Waiting for menu to be created...") from sgtk.platform.qt import QtCore QtCore.QTimer.singleShot(200, self.create_shotgun_menu) return True return False def post_app_init(self): """ Called when all apps have initialized """ tank.platform.engine.set_current_engine(self) # create the shotgun menu self.create_shotgun_menu() # let's close the windows created by the engine before exiting the # application from sgtk.platform.qt import QtGui app = QtGui.QApplication.instance() app.aboutToQuit.connect(self.destroy_engine) # apply a fix to multi python console if loaded pythonconsole_app = self.apps.get("tk-multi-pythonconsole") if pythonconsole_app: _fix_tk_multi_pythonconsole(self.logger) # Run a series of app instance commands at startup. self._run_app_instance_commands() def post_context_change(self, old_context, new_context): """ Runs after a context change. The Krita event watching will be stopped and new callbacks registered containing the new context information. :param old_context: The context being changed away from. :param new_context: The new context being changed to. """ # apply a fix to multi python console if loaded pythonconsole_app = self.apps.get("tk-multi-pythonconsole") if pythonconsole_app: _fix_tk_multi_pythonconsole(self.logger) if self.get_setting("automatic_context_switch", True): # finally create the menu with the new context if needed if old_context != new_context: self.create_shotgun_menu() def _run_app_instance_commands(self): """ Runs the series of app instance commands listed in the 'run_at_startup' setting of the environment configuration YAML file. """ # Build a dictionary mapping app instance names to dictionaries of # commands they registered with the engine. app_instance_commands = {} for (cmd_name, value) in self.commands.items(): app_instance = value["properties"].get("app") if app_instance: # Add entry 'command name: command function' to the command # dictionary of this app instance. cmd_dict = app_instance_commands.setdefault( app_instance.instance_name, {} ) cmd_dict[cmd_name] = value["callback"] # Run the series of app instance commands listed in the # 'run_at_startup' setting. for app_setting_dict in self.get_setting("run_at_startup", []): app_instance_name = app_setting_dict["app_instance"] # Menu name of the command to run or '' to run all commands of the # given app instance. setting_cmd_name = app_setting_dict["name"] # Retrieve the command dictionary of the given app instance. cmd_dict = app_instance_commands.get(app_instance_name) if cmd_dict is None: self.logger.warning( "%s configuration setting 'run_at_startup' requests app" " '%s' that is not installed.", self.name, app_instance_name, ) else: if not setting_cmd_name: # Run all commands of the given app instance. for (cmd_name, command_function) in cmd_dict.items(): msg = ( "%s startup running app '%s' command '%s'.", self.name, app_instance_name, cmd_name, ) self.logger.debug(msg) command_function() else: # Run the command whose name is listed in the # 'run_at_startup' setting. command_function = cmd_dict.get(setting_cmd_name) if command_function: msg = ( "%s startup running app '%s' command '%s'.", self.name, app_instance_name, setting_cmd_name, ) self.logger.debug(msg) command_function() else: known_commands = ", ".join("'%s'" % name for name in cmd_dict) self.logger.warning( "%s configuration setting 'run_at_startup' " "requests app '%s' unknown command '%s'. " "Known commands: %s", self.name, app_instance_name, setting_cmd_name, known_commands, ) def destroy_engine(self): """ Let's close the windows created by the engine before exiting the application """ self.logger.debug("%s: Destroying...", self) self.close_windows() def _init_pyside(self): """ Checks if we can load PyQt5 in this application """ # import QtWidgets first or we are in trouble try: import PyQt5.QtWidgets except Exception as e: traceback.print_exc() self.logger.error( "PyQt5 could not be imported! Apps using UI" " will not operate correctly!" "Error reported: %s", e, ) def _get_dialog_parent(self): """ Get the QWidget parent for all dialogs created through show_dialog & show_modal. """ import PyQt5.QtWidgets from PyQt5.QtWidgets import QApplication app = QApplication.instance() for widget in app.topLevelWidgets(): if isinstance(widget, PyQt5.QtWidgets.QMainWindow): return widget def show_panel(self, panel_id, title, bundle, widget_class, *args, **kwargs): """ Docks an app widget in a Krita Docket, (conveniently borrowed from the tk-3dsmax engine) :param panel_id: Unique identifier for the panel, as obtained by register_panel(). :param title: The title of the panel :param bundle: The app, engine or framework object that is associated with this window :param widget_class: The class of the UI to be constructed. This must derive from QWidget. Additional parameters specified will be passed through to the widget_class constructor. :returns: the created widget_class instance """ from sgtk.platform.qt import QtGui, QtCore dock_widget_id = "sgtk_dock_widget_" + panel_id main_window = self._get_dialog_parent() dock_widget = main_window.findChild(QtGui.QDockWidget, dock_widget_id) if dock_widget is None: # The dock widget wrapper cannot be found in the main window's # children list so that means it has not been created yet, so create it. widget_instance = widget_class(*args, **kwargs) widget_instance.setParent(self._get_dialog_parent()) widget_instance.setObjectName(panel_id) class DockWidget(QtGui.QDockWidget): """ Widget used for docking app panels that ensures the widget is closed when the dock is closed """ closed = QtCore.pyqtSignal(QtCore.QObject) def closeEvent(self, event): widget = self.widget() if widget: widget.close() self.closed.emit(self) dock_widget = DockWidget(title, parent=main_window) dock_widget.setObjectName(dock_widget_id) dock_widget.setWidget(widget_instance) # Add a callback to remove the dock_widget from the list of open # panels and delete it dock_widget.closed.connect(self._remove_dock_widget) # Remember the dock widget, so we can delete it later. self._dock_widgets.append(dock_widget) else: # The dock widget wrapper already exists, so just get the # shotgun panel from it. widget_instance = dock_widget.widget() # apply external style sheet self._apply_external_stylesheet(bundle, widget_instance) if not main_window.restoreDockWidget(dock_widget): # The dock widget cannot be restored from the main window's state, # so dock it to the right dock area and make it float by default. main_window.addDockWidget(QtCore.Qt.RightDockWidgetArea, dock_widget) dock_widget.setFloating(True) dock_widget.show() return widget_instance def _remove_dock_widget(self, dock_widget): """ Removes a docked widget (panel) opened by the engine """ self._get_dialog_parent().removeDockWidget(dock_widget) self._dock_widgets.remove(dock_widget) dock_widget.deleteLater() @property def has_ui(self): """ Detect and return if Krita is running in batch mode """ batch_mode = krita.Krita.instance().batchmode() return not batch_mode def _emit_log_message(self, handler, record): """ Called by the engine to log messages in Krita script editor. All log messages from the toolkit logging namespace will be passed to this method. :param handler: Log handler that this message was dispatched from. Its default format is "[levelname basename] message". :type handler: :class:`~python.logging.LogHandler` :param record: Standard python logging record. :type record: :class:`~python.logging.LogRecord` """ # Give a standard format to the message: # Shotgun <basename>: <message> # where "basename" is the leaf part of the logging record name, # for example "tk-multi-shotgunpanel" or "qt_importer". if record.levelno < logging.INFO: formatter = logging.Formatter("Debug: Shotgun %(basename)s: %(message)s") else: formatter = logging.Formatter("Shotgun %(basename)s: %(message)s") msg = formatter.format(record) # Select Krita display function to use according to the logging # record level. if record.levelno >= logging.ERROR: fct = display_error elif record.levelno >= logging.WARNING: fct = display_warning elif record.levelno >= logging.INFO: fct = display_info else: fct = display_debug # Display the message in Krita script editor in a thread safe manner. self.async_execute_in_main_thread(fct, msg) def close_windows(self): """ Closes the various windows (dialogs, panels, etc.) opened by the engine. """ self.logger.debug("Closing all engine dialogs...") # Make a copy of the list of Tank dialogs that have been created by the # engine and are still opened since the original list will be updated # when each dialog is closed. opened_dialog_list = self.created_qt_dialogs[:] # Loop through the list of opened Tank dialogs. for dialog in opened_dialog_list: dialog_window_title = dialog.windowTitle() try: # Close the dialog and let its close callback remove it from # the original dialog list. dialog.close() except Exception as exception: traceback.print_exc() self.logger.error( "Cannot close dialog %s: %s", dialog_window_title, exception ) # Close all dock widgets previously added. for dock_widget in self._dock_widgets[:]: dock_widget.close()