From 0a7ee3b638a16b1fa6a53b15a13ca4ea8f1ba10c Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Thu, 27 May 2021 19:16:06 +0200 Subject: [PATCH] Console to system tray - refactored and moved implementation to OpenPype --- openpype/hooks/pre_with_windows_shell.py | 2 +- openpype/tools/tray_app/app.py | 297 +++++++++++++++++++++++ 2 files changed, 298 insertions(+), 1 deletion(-) create mode 100644 openpype/tools/tray_app/app.py diff --git a/openpype/hooks/pre_with_windows_shell.py b/openpype/hooks/pre_with_windows_shell.py index 720b285ad4..7044bd12b2 100644 --- a/openpype/hooks/pre_with_windows_shell.py +++ b/openpype/hooks/pre_with_windows_shell.py @@ -15,7 +15,7 @@ class LaunchWithWindowsShell(PreLaunchHook): # Should be as last hook because must change launch arguments to string order = 1000 app_groups = ["nuke", "nukex", "hiero", "nukestudio", - "photoshop", "aftereffects", "harmony"] + "aftereffects", "harmony"] platforms = ["windows"] def execute(self): diff --git a/openpype/tools/tray_app/app.py b/openpype/tools/tray_app/app.py new file mode 100644 index 0000000000..37765cfb57 --- /dev/null +++ b/openpype/tools/tray_app/app.py @@ -0,0 +1,297 @@ +import sys +import re +import platform +import collections +import queue +from io import StringIO + +from avalon import style +from openpype import resources + +from Qt import QtWidgets, QtGui, QtCore + +class TrayApp(QtWidgets.QSystemTrayIcon): + """Application showing console for non python hosts instead of cmd""" + callback_queue = None + + sdict = { + r">>> ": + ' >>> ', + r"!!!(?!\sCRI|\sERR)": + ' !!! ', + r"\-\-\- ": + ' --- ', + r"\*\*\*(?!\sWRN)": + ' *** ', + r"\*\*\* WRN": + ' *** WRN', + r" \- ": + ' - ', + r"\[ ": + '[', + r"\]": + ']', + r"{": + '{', + r"}": + r"}", + r"\(": + '(', + r"\)": + r")", + r"^\.\.\. ": + ' ... ', + r"!!! ERR: ": + ' !!! ERR: ', + r"!!! CRI: ": + ' !!! CRI: ', + r"(?i)failed": + ' FAILED ', + r"(?i)error": + ' ERROR ' + } + + def __init__(self, host, parent=None): + super(TrayApp, self).__init__(parent) + self.host = host + + self.initialized = False + self.websocket_server = None + self.timer = None + self.subprocess_args = None + self.initializing = False + self.tray = False + self.launch_method = None + self.timer = None + + self.original_stdout_write = None + self.original_stderr_write = None + self.new_text = collections.deque() + + self.icons = self._select_icons(self.host) + self.status_texts = self._prepare_status_texts(self.host) + + timer = QtCore.QTimer() + timer.timeout.connect(self.on_timer) + timer.setInterval(200) + timer.start() + + self.timer = timer + + self.redirect_stds() + + menu = QtWidgets.QMenu() + menu.setStyleSheet(style.load_stylesheet()) + # not working yet + # + # restart_server_action = QtWidgets.QAction("Restart communication", + # self) + # restart_server_action.triggered.connect(self.restart_server) + # menu.addAction(restart_server_action) + + # Add Exit action to menu + exit_action = QtWidgets.QAction("Exit", self) + exit_action.triggered.connect(self.exit) + menu.addAction(exit_action) + + self.menu = menu + + self.dialog = ConsoleDialog(self.new_text) + + # Catch activate event for left click if not on MacOS + # - MacOS has this ability by design so menu would be doubled + if platform.system().lower() != "darwin": + self.activated.connect(self.on_systray_activated) + + self.change_status("initializing") + self.setContextMenu(self.menu) + self.show() + + def on_timer(self): + """Called periodically to initialize and run function on main thread""" + self.dialog.append_text(self.new_text) + if not self.initialized: + if self.initializing: + return + TrayApp.callback_queue = queue.Queue() + self.initializing = True + + self.launch_method(*self.subprocess_args) + self.initialized = True + self.initializing = False + self.change_status("ready") + elif TrayApp.callback_queue: + try: + callback = TrayApp.callback_queue.get(block=False) + callback() + except queue.Empty: + pass + else: + if self.process.poll() is not None: + # Wait on Photoshop to close before closing the websocket serv. + self.process.wait() + self.websocket_server.stop() + self.timer.stop() + self.change_status("error") + + @classmethod + def execute_in_main_thread(cls, func_to_call_from_main_thread): + """Put function to the queue to be picked by 'on_timer'""" + if not cls.callback_queue: + cls.callback_queue = queue.Queue() + cls.callback_queue.put(func_to_call_from_main_thread) + + def on_systray_activated(self, reason): + if reason == QtWidgets.QSystemTrayIcon.Context: + position = QtGui.QCursor().pos() + self.menu.popup(position) + else: + self.open_console() + + @classmethod + def restart_server(self): + if TrayApp.websocket_server: + TrayApp.websocket_server.stop_server(restart=True) + + def open_console(self): + self.dialog.show() + + def exit(self): + """ Exit whole application. + + - Icon won't stay in tray after exit. + """ + TrayApp.process.kill() + self.hide() + QtCore.QCoreApplication.exit() + + def redirect_stds(self): + """Redirects standard out and error to own functions""" + if sys.stdout: + self.original_stdout_write = sys.stdout.write + else: + sys.stdout = StringIO() + + if sys.stderr: + self.original_stderr_write = sys.stderr.write + else: + sys.stderr = StringIO() + + sys.stdout.write = self.my_stdout_write + sys.stderr.write = self.my_stderr_write + + def my_stdout_write(self, text): + """Appends outputted text to queue, keep writing to original stdout""" + if self.original_stdout_write is not None: + self.original_stdout_write(text) + self.new_text.append(text) + + def my_stderr_write(self, text): + """Appends outputted text to queue, keep writing to original stderr""" + if self.original_stderr_write is not None: + self.original_stderr_write(text) + self.new_text.append(text) + + def _prepare_status_texts(self, host_name): + """Status text used as a tooltip""" + status_texts = { + 'initializing': "Starting communication with {}".format(host_name), + 'ready': "Communicating with {}".format(host_name), + 'error': "Error!" + } + + return status_texts + + def _select_icons(self, host_name): + """Use different icons per state and host_name""" + # use host_name + icons = { + 'initializing': QtGui.QIcon( + resources.get_resource("icons", "circle_orange.png") + ), + 'ready': QtGui.QIcon( + resources.get_resource("icons", "circle_green.png") + ), + 'error': QtGui.QIcon( + resources.get_resource("icons", "circle_red.png") + ) + } + + return icons + + + @staticmethod + def _multiple_replace(text, adict): + """Replace multiple tokens defined in dict. + + Find and replace all occurances of strings defined in dict is + supplied string. + + Args: + text (str): string to be searched + adict (dict): dictionary with `{'search': 'replace'}` + + Returns: + str: string with replaced tokens + + """ + for r, v in adict.items(): + text = re.sub(r, v, text) + + return text + + @staticmethod + def color(message): + message = TrayApp._multiple_replace(message, TrayApp.sdict) + + return message + + def change_status(self, status): + """Updates tooltip and icon with new status""" + self._change_tooltip(status) + self._change_icon(status) + + def _change_tooltip(self, status): + status = self.status_texts.get(status) + if not status: + raise ValueError("Unknown state") + + self.setToolTip(status) + + def _change_icon(self, state): + icon = self.icons.get(state) + if not icon: + raise ValueError("Unknown state") + + self.setIcon(icon) + + +class ConsoleDialog(QtWidgets.QDialog): + """Qt dialog to show stdout instead of unwieldy cmd window""" + WIDTH = 720 + HEIGHT = 450 + + def __init__(self, text, parent=None): + super(ConsoleDialog, self).__init__(parent) + layout = QtWidgets.QHBoxLayout(parent) + + plain_text = QtWidgets.QPlainTextEdit(self) + plain_text.setReadOnly(True) + plain_text.resize(self.WIDTH, self.HEIGHT) + while text: + plain_text.appendPlainText(text.popleft().strip()) + + layout.addWidget(plain_text) + + self.setWindowTitle("Console output") + + self.plain_text = plain_text + + self.setStyleSheet(style.load_stylesheet()) + + self.resize(self.WIDTH, self.HEIGHT) + + def append_text(self, new_text): + while new_text: + self.plain_text.appendHtml( + TrayApp.color(new_text.popleft().strip()))