diff --git a/pype/lib.py b/pype/lib.py index 103d5a15f1..7cf4e2f1a5 100644 --- a/pype/lib.py +++ b/pype/lib.py @@ -7,15 +7,19 @@ import json import collections import logging import itertools +import copy import contextlib import subprocess +import getpass import inspect +import acre +import platform from abc import ABCMeta, abstractmethod from avalon import io, pipeline import six import avalon.api -from .api import config +from .api import config, Anatomy log = logging.getLogger(__name__) @@ -1438,3 +1442,207 @@ def get_latest_version(asset_name, subset_name): assert version, "No version found, this is a bug" return version + + +class ApplicationLaunchFailed(Exception): + pass + + +def launch_application(project_name, asset_name, task_name, app_name): + database = get_avalon_database() + project_document = database[project_name].find_one({"type": "project"}) + asset_document = database[project_name].find_one({ + "type": "asset", + "name": asset_name + }) + + asset_doc_parents = asset_document["data"].get("parents") + hierarchy = "/".join(asset_doc_parents) + + app_def = avalon.lib.get_application(app_name) + app_label = app_def.get("ftrack_label", app_def.get("label", app_name)) + + host_name = app_def["application_dir"] + data = { + "project": { + "name": project_document["name"], + "code": project_document["data"].get("code") + }, + "task": task_name, + "asset": asset_name, + "app": host_name, + "hierarchy": hierarchy + } + + try: + anatomy = Anatomy(project_name) + anatomy_filled = anatomy.format(data) + workdir = os.path.normpath(anatomy_filled["work"]["folder"]) + + except Exception as exc: + raise ApplicationLaunchFailed( + "Error in anatomy.format: {}".format(str(exc)) + ) + + try: + os.makedirs(workdir) + except FileExistsError: + pass + + last_workfile_path = None + extensions = avalon.api.HOST_WORKFILE_EXTENSIONS.get(host_name) + if extensions: + # Find last workfile + file_template = anatomy.templates["work"]["file"] + data.update({ + "version": 1, + "user": os.environ.get("PYPE_USERNAME") or getpass.getuser(), + "ext": extensions[0] + }) + + last_workfile_path = avalon.api.last_workfile( + workdir, file_template, data, extensions, True + ) + + # set environments for Avalon + prep_env = copy.deepcopy(os.environ) + prep_env.update({ + "AVALON_PROJECT": project_name, + "AVALON_ASSET": asset_name, + "AVALON_TASK": task_name, + "AVALON_APP": host_name, + "AVALON_APP_NAME": app_name, + "AVALON_HIERARCHY": hierarchy, + "AVALON_WORKDIR": workdir + }) + + start_last_workfile = avalon.api.should_start_last_workfile( + project_name, host_name, task_name + ) + # Store boolean as "0"(False) or "1"(True) + prep_env["AVALON_OPEN_LAST_WORKFILE"] = ( + str(int(bool(start_last_workfile))) + ) + + if ( + start_last_workfile + and last_workfile_path + and os.path.exists(last_workfile_path) + ): + prep_env["AVALON_LAST_WORKFILE"] = last_workfile_path + + prep_env.update(anatomy.roots_obj.root_environments()) + + # collect all the 'environment' attributes from parents + tools_attr = [prep_env["AVALON_APP"], prep_env["AVALON_APP_NAME"]] + tools_env = asset_document["data"].get("tools_env") or [] + tools_attr.extend(tools_env) + + tools_env = acre.get_tools(tools_attr) + env = acre.compute(tools_env) + env = acre.merge(env, current_env=dict(prep_env)) + + # Get path to execute + st_temp_path = os.environ["PYPE_CONFIG"] + os_plat = platform.system().lower() + + # Path to folder with launchers + path = os.path.join(st_temp_path, "launchers", os_plat) + + # Full path to executable launcher + execfile = None + + launch_hook = app_def.get("launch_hook") + if launch_hook: + log.info("launching hook: {}".format(launch_hook)) + ret_val = execute_hook(launch_hook, env=env) + if not ret_val: + raise ApplicationLaunchFailed( + "Hook didn't finish successfully {}".format(app_label) + ) + + if sys.platform == "win32": + for ext in os.environ["PATHEXT"].split(os.pathsep): + fpath = os.path.join(path.strip('"'), app_def["executable"] + ext) + if os.path.isfile(fpath) and os.access(fpath, os.X_OK): + execfile = fpath + break + + # Run SW if was found executable + if execfile is None: + raise ApplicationLaunchFailed( + "We didn't find launcher for {}".format(app_label) + ) + + popen = avalon.lib.launch( + executable=execfile, args=[], environment=env + ) + + elif ( + sys.platform.startswith("linux") + or sys.platform.startswith("darwin") + ): + execfile = os.path.join(path.strip('"'), app_def["executable"]) + # Run SW if was found executable + if execfile is None: + raise ApplicationLaunchFailed( + "We didn't find launcher for {}".format(app_label) + ) + + if not os.path.isfile(execfile): + raise ApplicationLaunchFailed( + "Launcher doesn't exist - {}".format(execfile) + ) + + try: + fp = open(execfile) + except PermissionError as perm_exc: + raise ApplicationLaunchFailed( + "Access denied on launcher {} - {}".format(execfile, perm_exc) + ) + + fp.close() + # check executable permission + if not os.access(execfile, os.X_OK): + raise ApplicationLaunchFailed( + "No executable permission - {}".format(execfile) + ) + + popen = avalon.lib.launch( # noqa: F841 + "/usr/bin/env", args=["bash", execfile], environment=env + ) + return popen + + +class ApplicationAction(avalon.api.Action): + """Default application launcher + + This is a convenience application Action that when "config" refers to a + parsed application `.toml` this can launch the application. + + """ + + config = None + group = None + variant = None + required_session_keys = ( + "AVALON_PROJECT", + "AVALON_ASSET", + "AVALON_TASK" + ) + + def is_compatible(self, session): + for key in self.required_session_keys: + if key not in session: + return False + return True + + def process(self, session, **kwargs): + """Process the full Application action""" + + project_name = session["AVALON_PROJECT"] + asset_name = session["AVALON_ASSET"] + task_name = session["AVALON_TASK"] + return launch_application( + project_name, asset_name, task_name, self.name + ) diff --git a/pype/modules/avalon_apps/avalon_app.py b/pype/modules/avalon_apps/avalon_app.py index d103a84d90..34fbc5c5ae 100644 --- a/pype/modules/avalon_apps/avalon_app.py +++ b/pype/modules/avalon_apps/avalon_app.py @@ -1,10 +1,7 @@ -import os -import argparse -from Qt import QtGui, QtWidgets +from Qt import QtWidgets from avalon.tools import libraryloader from pype.api import Logger -from avalon import io -from launcher import launcher_widget, lib as launcher_lib +from pype.tools.launcher import LauncherWindow, actions class AvalonApps: @@ -12,7 +9,12 @@ class AvalonApps: self.log = Logger().get_logger(__name__) self.main_parent = main_parent self.parent = parent - self.app_launcher = None + + self.app_launcher = LauncherWindow() + + # actions.register_default_actions() + actions.register_config_actions() + actions.register_environment_actions() def process_modules(self, modules): if "RestApiServer" in modules: @@ -32,23 +34,20 @@ class AvalonApps: self.log.warning('Parent menu is not set') return - icon = QtGui.QIcon(launcher_lib.resource("icon", "main.png")) - aShowLauncher = QtWidgets.QAction(icon, "&Launcher", parent_menu) - aLibraryLoader = QtWidgets.QAction("Library", parent_menu) + action_launcher = QtWidgets.QAction("Launcher", parent_menu) + action_library_loader = QtWidgets.QAction( + "Library loader", parent_menu + ) - aShowLauncher.triggered.connect(self.show_launcher) - aLibraryLoader.triggered.connect(self.show_library_loader) + action_launcher.triggered.connect(self.show_launcher) + action_library_loader.triggered.connect(self.show_library_loader) - parent_menu.addAction(aShowLauncher) - parent_menu.addAction(aLibraryLoader) + parent_menu.addAction(action_launcher) + parent_menu.addAction(action_library_loader) def show_launcher(self): # if app_launcher don't exist create it/otherwise only show main window - if self.app_launcher is None: - io.install() - APP_PATH = launcher_lib.resource("qml", "main.qml") - self.app_launcher = launcher_widget.Launcher(APP_PATH) - self.app_launcher.window.show() + self.app_launcher.show() def show_library_loader(self): libraryloader.show( diff --git a/pype/modules/ftrack/lib/ftrack_app_handler.py b/pype/modules/ftrack/lib/ftrack_app_handler.py index 22fd6eeaab..4847464973 100644 --- a/pype/modules/ftrack/lib/ftrack_app_handler.py +++ b/pype/modules/ftrack/lib/ftrack_app_handler.py @@ -1,16 +1,6 @@ -import os -import sys -import copy -import platform -import avalon.lib -import acre -import getpass from pype import lib as pypelib -from pype.api import config, Anatomy +from pype.api import config from .ftrack_action_handler import BaseAction -from avalon.api import ( - last_workfile, HOST_WORKFILE_EXTENSIONS, should_start_last_workfile -) class AppAction(BaseAction): @@ -156,43 +146,21 @@ class AppAction(BaseAction): entity = entities[0] task_name = entity["name"] - - project_name = entity["project"]["full_name"] - - database = pypelib.get_avalon_database() - asset_name = entity["parent"]["name"] - asset_document = database[project_name].find_one({ - "type": "asset", - "name": asset_name - }) - - hierarchy = "" - asset_doc_parents = asset_document["data"].get("parents") - if asset_doc_parents: - hierarchy = os.path.join(*asset_doc_parents) - - application = avalon.lib.get_application(self.identifier) - host_name = application["application_dir"] - data = { - "project": { - "name": entity["project"]["full_name"], - "code": entity["project"]["name"] - }, - "task": task_name, - "asset": asset_name, - "app": host_name, - "hierarchy": hierarchy - } - + project_name = entity["project"]["full_name"] try: - anatomy = Anatomy(project_name) - anatomy_filled = anatomy.format(data) - workdir = os.path.normpath(anatomy_filled["work"]["folder"]) + pypelib.launch_application(project_name, asset_name, task_name) - except Exception as exc: - msg = "Error in anatomy.format: {}".format( - str(exc) + except pypelib.ApplicationLaunchFailed as exc: + self.log.error(str(exc)) + return { + "success": False, + "message": str(exc) + } + + except Exception: + msg = "Unexpected failure of application launch {}".format( + self.label ) self.log.error(msg, exc_info=True) return { @@ -200,160 +168,6 @@ class AppAction(BaseAction): "message": msg } - try: - os.makedirs(workdir) - except FileExistsError: - pass - - last_workfile_path = None - extensions = HOST_WORKFILE_EXTENSIONS.get(host_name) - if extensions: - # Find last workfile - file_template = anatomy.templates["work"]["file"] - data.update({ - "version": 1, - "user": getpass.getuser(), - "ext": extensions[0] - }) - - last_workfile_path = last_workfile( - workdir, file_template, data, extensions, True - ) - - # set environments for Avalon - prep_env = copy.deepcopy(os.environ) - prep_env.update({ - "AVALON_PROJECT": project_name, - "AVALON_ASSET": asset_name, - "AVALON_TASK": task_name, - "AVALON_APP": host_name, - "AVALON_APP_NAME": self.identifier, - "AVALON_HIERARCHY": hierarchy, - "AVALON_WORKDIR": workdir - }) - - start_last_workfile = should_start_last_workfile( - project_name, host_name, task_name - ) - # Store boolean as "0"(False) or "1"(True) - prep_env["AVALON_OPEN_LAST_WORKFILE"] = ( - str(int(bool(start_last_workfile))) - ) - - if ( - start_last_workfile - and last_workfile_path - and os.path.exists(last_workfile_path) - ): - prep_env["AVALON_LAST_WORKFILE"] = last_workfile_path - - prep_env.update(anatomy.roots_obj.root_environments()) - - # collect all parents from the task - parents = [] - for item in entity['link']: - parents.append(session.get(item['type'], item['id'])) - - # collect all the 'environment' attributes from parents - tools_attr = [prep_env["AVALON_APP"], prep_env["AVALON_APP_NAME"]] - tools_env = asset_document["data"].get("tools_env") or [] - tools_attr.extend(tools_env) - - tools_env = acre.get_tools(tools_attr) - env = acre.compute(tools_env) - env = acre.merge(env, current_env=dict(prep_env)) - - # Get path to execute - st_temp_path = os.environ["PYPE_CONFIG"] - os_plat = platform.system().lower() - - # Path to folder with launchers - path = os.path.join(st_temp_path, "launchers", os_plat) - - # Full path to executable launcher - execfile = None - - if application.get("launch_hook"): - hook = application.get("launch_hook") - self.log.info("launching hook: {}".format(hook)) - ret_val = pypelib.execute_hook( - application.get("launch_hook"), env=env) - if not ret_val: - return { - 'success': False, - 'message': "Hook didn't finish successfully {0}" - .format(self.label) - } - - if sys.platform == "win32": - for ext in os.environ["PATHEXT"].split(os.pathsep): - fpath = os.path.join(path.strip('"'), self.executable + ext) - if os.path.isfile(fpath) and os.access(fpath, os.X_OK): - execfile = fpath - break - - # Run SW if was found executable - if execfile is None: - return { - "success": False, - "message": "We didn't find launcher for {0}".format( - self.label - ) - } - - popen = avalon.lib.launch( - executable=execfile, args=[], environment=env - ) - - elif (sys.platform.startswith("linux") - or sys.platform.startswith("darwin")): - execfile = os.path.join(path.strip('"'), self.executable) - if not os.path.isfile(execfile): - msg = "Launcher doesn't exist - {}".format(execfile) - - self.log.error(msg) - return { - "success": False, - "message": msg - } - - try: - fp = open(execfile) - except PermissionError as perm_exc: - msg = "Access denied on launcher {} - {}".format( - execfile, perm_exc - ) - - self.log.exception(msg, exc_info=True) - return { - "success": False, - "message": msg - } - - fp.close() - # check executable permission - if not os.access(execfile, os.X_OK): - msg = "No executable permission - {}".format(execfile) - - self.log.error(msg) - return { - "success": False, - "message": msg - } - - # Run SW if was found executable - if execfile is None: - return { - "success": False, - "message": "We didn't found launcher for {0}".format( - self.label - ) - } - - popen = avalon.lib.launch( # noqa: F841 - "/usr/bin/env", args=["bash", execfile], environment=env - ) - # Change status of task to In progress presets = config.get_presets()["ftrack"]["ftrack_config"] diff --git a/pype/tools/launcher/__init__.py b/pype/tools/launcher/__init__.py new file mode 100644 index 0000000000..109d642e86 --- /dev/null +++ b/pype/tools/launcher/__init__.py @@ -0,0 +1,7 @@ +from .window import LauncherWindow +from . import actions + +__all__ = [ + "LauncherWindow", + "actions" +] diff --git a/pype/tools/launcher/actions.py b/pype/tools/launcher/actions.py new file mode 100644 index 0000000000..80e6f71ae7 --- /dev/null +++ b/pype/tools/launcher/actions.py @@ -0,0 +1,104 @@ +import os +import importlib + +from avalon import api, lib + + +class ProjectManagerAction(api.Action): + name = "projectmanager" + label = "Project Manager" + icon = "gear" + order = 999 # at the end + + def is_compatible(self, session): + return "AVALON_PROJECT" in session + + def process(self, session, **kwargs): + return lib.launch( + executable="python", + args=[ + "-u", "-m", "avalon.tools.projectmanager", + session['AVALON_PROJECT'] + ] + ) + + +class LoaderAction(api.Action): + name = "loader" + label = "Loader" + icon = "cloud-download" + order = 998 + + def is_compatible(self, session): + return "AVALON_PROJECT" in session + + def process(self, session, **kwargs): + return lib.launch( + executable="python", + args=[ + "-u", "-m", "avalon.tools.loader", session['AVALON_PROJECT'] + ] + ) + + +class LoaderLibrary(api.Action): + name = "loader_os" + label = "Library Loader" + icon = "book" + order = 997 # at the end + + def is_compatible(self, session): + return True + + def process(self, session, **kwargs): + return lib.launch( + executable="python", + args=["-u", "-m", "avalon.tools.libraryloader"] + ) + + +def register_default_actions(): + """Register default actions for Launcher""" + api.register_plugin(api.Action, ProjectManagerAction) + api.register_plugin(api.Action, LoaderAction) + api.register_plugin(api.Action, LoaderLibrary) + + +def register_config_actions(): + """Register actions from the configuration for Launcher""" + + module_name = os.environ["AVALON_CONFIG"] + config = importlib.import_module(module_name) + if not hasattr(config, "register_launcher_actions"): + print( + "Current configuration `%s` has no 'register_launcher_actions'" + % config.__name__ + ) + return + + config.register_launcher_actions() + + +def register_environment_actions(): + """Register actions from AVALON_ACTIONS for Launcher.""" + + paths = os.environ.get("AVALON_ACTIONS") + if not paths: + return + + for path in paths.split(os.pathsep): + api.register_plugin_path(api.Action, path) + + # Run "register" if found. + for module in lib.modules_from_path(path): + if "register" not in dir(module): + continue + + try: + module.register() + except Exception as e: + print( + "Register method in {0} failed: {1}".format( + module, str(e) + ) + ) diff --git a/pype/tools/launcher/delegates.py b/pype/tools/launcher/delegates.py new file mode 100644 index 0000000000..95ccde6445 --- /dev/null +++ b/pype/tools/launcher/delegates.py @@ -0,0 +1,50 @@ +from Qt import QtCore, QtWidgets, QtGui + + +class ActionDelegate(QtWidgets.QStyledItemDelegate): + extender_lines = 2 + extender_bg_brush = QtGui.QBrush(QtGui.QColor(100, 100, 100, 160)) + extender_fg = QtGui.QColor(255, 255, 255, 160) + + def __init__(self, group_roles, *args, **kwargs): + super(ActionDelegate, self).__init__(*args, **kwargs) + self.group_roles = group_roles + + def paint(self, painter, option, index): + super(ActionDelegate, self).paint(painter, option, index) + is_group = False + for group_role in self.group_roles: + is_group = index.data(group_role) + if is_group: + break + if not is_group: + return + + extender_width = int(option.decorationSize.width() / 2) + extender_height = int(option.decorationSize.height() / 2) + + exteder_rect = QtCore.QRectF( + option.rect.x() + (option.rect.width() / 10), + option.rect.y() + (option.rect.height() / 10), + extender_width, + extender_height + ) + path = QtGui.QPainterPath() + path.addRoundedRect(exteder_rect, 2, 2) + + painter.fillPath(path, self.extender_bg_brush) + + painter.setPen(self.extender_fg) + painter.drawPath(path) + + divider = (2 * self.extender_lines) + 1 + line_height = extender_height / divider + line_width = extender_width - (extender_width / 5) + pos_x = exteder_rect.x() + extender_width / 10 + pos_y = exteder_rect.y() + line_height + for _ in range(self.extender_lines): + line_rect = QtCore.QRectF( + pos_x, pos_y, line_width, round(line_height) + ) + painter.fillRect(line_rect, self.extender_fg) + pos_y += 2 * line_height diff --git a/pype/tools/launcher/flickcharm.py b/pype/tools/launcher/flickcharm.py new file mode 100644 index 0000000000..a5ea5a79d8 --- /dev/null +++ b/pype/tools/launcher/flickcharm.py @@ -0,0 +1,304 @@ +""" +This based on the flickcharm-python code from: + https://code.google.com/archive/p/flickcharm-python/ + +Which states: + This is a Python (PyQt) port of Ariya Hidayat's elegant FlickCharm + hack which adds kinetic scrolling to any scrollable Qt widget. + + Licensed under GNU GPL version 2 or later. + +It has been altered to fix edge cases where clicks and drags would not +propagate correctly under some conditions. It also allows a small "dead zone" +threshold in which it will still propagate the user pressed click if he or she +travelled only very slightly with the cursor. + +""" + +import copy +from Qt import QtWidgets, QtCore, QtGui + + +class FlickData(object): + Steady = 0 + Pressed = 1 + ManualScroll = 2 + AutoScroll = 3 + Stop = 4 + + def __init__(self): + self.state = FlickData.Steady + self.widget = None + self.pressPos = QtCore.QPoint(0, 0) + self.offset = QtCore.QPoint(0, 0) + self.dragPos = QtCore.QPoint(0, 0) + self.speed = QtCore.QPoint(0, 0) + self.travelled = 0 + self.ignored = [] + + +class FlickCharm(QtCore.QObject): + """Make scrollable widgets flickable. + + For example: + charm = FlickCharm() + charm.activateOn(widget) + + It can `activateOn` multiple widgets with a single FlickCharm instance. + Be aware that the FlickCharm object must be kept around for it not + to get garbage collected and losing the flickable behavior. + + Flick away! + + """ + + def __init__(self, parent=None): + super(FlickCharm, self).__init__(parent=parent) + + self.flickData = {} + self.ticker = QtCore.QBasicTimer() + + # The flick button to use + self.button = QtCore.Qt.LeftButton + + # The time taken per update tick of flicking behavior + self.tick_time = 20 + + # Allow a item click/press directly when AutoScroll is slower than + # this threshold velocity + self.click_in_autoscroll_threshold = 10 + + # Allow an item click/press to propagate as opposed to scrolling + # when the cursor travelled less than this amount of pixels + # Note: back & forth motion increases the value too + self.travel_threshold = 20 + + self.max_speed = 64 # max scroll speed + self.drag = 1 # higher drag will stop autoscroll faster + + def activateOn(self, widget): + viewport = widget.viewport() + viewport.installEventFilter(self) + widget.installEventFilter(self) + self.flickData[viewport] = FlickData() + self.flickData[viewport].widget = widget + self.flickData[viewport].state = FlickData.Steady + + def deactivateFrom(self, widget): + + viewport = widget.viewport() + viewport.removeEventFilter(self) + widget.removeEventFilter(self) + self.flickData.pop(viewport) + + def eventFilter(self, obj, event): + + if not obj.isWidgetType(): + return False + + eventType = event.type() + if eventType != QtCore.QEvent.MouseButtonPress and \ + eventType != QtCore.QEvent.MouseButtonRelease and \ + eventType != QtCore.QEvent.MouseMove: + return False + + if event.modifiers() != QtCore.Qt.NoModifier: + return False + + if obj not in self.flickData: + return False + + data = self.flickData[obj] + found, newIgnored = removeAll(data.ignored, event) + if found: + data.ignored = newIgnored + return False + + if data.state == FlickData.Steady: + if eventType == QtCore.QEvent.MouseButtonPress: + if event.buttons() == self.button: + self._set_press_pos_and_offset(event, data) + data.state = FlickData.Pressed + return True + + elif data.state == FlickData.Pressed: + if eventType == QtCore.QEvent.MouseButtonRelease: + # User didn't actually scroll but clicked in + # the widget. Let the original press and release + # event be evaluated on the Widget + data.state = FlickData.Steady + event1 = QtGui.QMouseEvent(QtCore.QEvent.MouseButtonPress, + data.pressPos, + QtCore.Qt.LeftButton, + QtCore.Qt.LeftButton, + QtCore.Qt.NoModifier) + # Copy the current event + event2 = QtGui.QMouseEvent(QtCore.QEvent.MouseButtonRelease, + event.pos(), + event.button(), + event.buttons(), + event.modifiers()) + data.ignored.append(event1) + data.ignored.append(event2) + QtWidgets.QApplication.postEvent(obj, event1) + QtWidgets.QApplication.postEvent(obj, event2) + return True + elif eventType == QtCore.QEvent.MouseMove: + data.state = FlickData.ManualScroll + data.dragPos = QtGui.QCursor.pos() + if not self.ticker.isActive(): + self.ticker.start(self.tick_time, self) + return True + + elif data.state == FlickData.ManualScroll: + if eventType == QtCore.QEvent.MouseMove: + pos = event.pos() + delta = pos - data.pressPos + data.travelled += delta.manhattanLength() + setScrollOffset(data.widget, data.offset - delta) + return True + elif eventType == QtCore.QEvent.MouseButtonRelease: + + if data.travelled <= self.travel_threshold: + # If the user travelled less than the threshold + # don't go into autoscroll mode but assume the user + # intended to click instead + return self._propagate_click(obj, event, data) + + data.state = FlickData.AutoScroll + return True + + elif data.state == FlickData.AutoScroll: + if eventType == QtCore.QEvent.MouseButtonPress: + + # Allow pressing when auto scroll is already slower than + # the click in autoscroll threshold + velocity = data.speed.manhattanLength() + if velocity <= self.click_in_autoscroll_threshold: + self._set_press_pos_and_offset(event, data) + data.state = FlickData.Pressed + else: + data.state = FlickData.Stop + + data.speed = QtCore.QPoint(0, 0) + return True + elif eventType == QtCore.QEvent.MouseButtonRelease: + data.state = FlickData.Steady + data.speed = QtCore.QPoint(0, 0) + return True + + elif data.state == FlickData.Stop: + if eventType == QtCore.QEvent.MouseButtonRelease: + data.state = FlickData.Steady + + # If the user had a very limited scroll smaller than the + # threshold consider it a regular press and release. + if data.travelled < self.travel_threshold: + return self._propagate_click(obj, event, data) + + return True + elif eventType == QtCore.QEvent.MouseMove: + # Reset the press position and offset to allow us to "continue" + # the scroll from the new point the user clicked and then held + # down to continue scrolling after AutoScroll. + self._set_press_pos_and_offset(event, data) + data.state = FlickData.ManualScroll + + data.dragPos = QtGui.QCursor.pos() + if not self.ticker.isActive(): + self.ticker.start(self.tick_time, self) + return True + + return False + + def _set_press_pos_and_offset(self, event, data): + """Store current event position on Press""" + data.state = FlickData.Pressed + data.pressPos = copy.copy(event.pos()) + data.offset = scrollOffset(data.widget) + data.travelled = 0 + + def _propagate_click(self, obj, event, data): + """Propagate from Pressed state with MouseButtonRelease event. + + Use only on button release in certain states to propagate a click, + for example when the user dragged only a slight distance under the + travel threshold. + + """ + + data.state = FlickData.Pressed + data.pressPos = copy.copy(event.pos()) + data.offset = scrollOffset(data.widget) + data.travelled = 0 + self.eventFilter(obj, event) + return True + + def timerEvent(self, event): + + count = 0 + for data in self.flickData.values(): + if data.state == FlickData.ManualScroll: + count += 1 + cursorPos = QtGui.QCursor.pos() + data.speed = cursorPos - data.dragPos + data.dragPos = cursorPos + elif data.state == FlickData.AutoScroll: + count += 1 + data.speed = deaccelerate(data.speed, + a=self.drag, + maxVal=self.max_speed) + p = scrollOffset(data.widget) + new_p = p - data.speed + setScrollOffset(data.widget, new_p) + + if scrollOffset(data.widget) == p: + # If this scroll resulted in no change on the widget + # we reached the end of the list and set the speed to + # zero. + data.speed = QtCore.QPoint(0, 0) + + if data.speed == QtCore.QPoint(0, 0): + data.state = FlickData.Steady + + if count == 0: + self.ticker.stop() + + super(FlickCharm, self).timerEvent(event) + + +def scrollOffset(widget): + x = widget.horizontalScrollBar().value() + y = widget.verticalScrollBar().value() + return QtCore.QPoint(x, y) + + +def setScrollOffset(widget, p): + widget.horizontalScrollBar().setValue(p.x()) + widget.verticalScrollBar().setValue(p.y()) + + +def deaccelerate(speed, a=1, maxVal=64): + + x = max(min(speed.x(), maxVal), -maxVal) + y = max(min(speed.y(), maxVal), -maxVal) + if x > 0: + x = max(0, x - a) + elif x < 0: + x = min(0, x + a) + if y > 0: + y = max(0, y - a) + elif y < 0: + y = min(0, y + a) + return QtCore.QPoint(x, y) + + +def removeAll(list, val): + found = False + ret = [] + for element in list: + if element == val: + found = True + else: + ret.append(element) + return found, ret diff --git a/pype/tools/launcher/lib.py b/pype/tools/launcher/lib.py new file mode 100644 index 0000000000..a6d6ff6865 --- /dev/null +++ b/pype/tools/launcher/lib.py @@ -0,0 +1,113 @@ +"""Utility script for updating database with configuration files + +Until assets are created entirely in the database, this script +provides a bridge between the file-based project inventory and configuration. + +- Migrating an old project: + $ python -m avalon.inventory --extract --silo-parent=f02_prod + $ python -m avalon.inventory --upload + +- Managing an existing project: + 1. Run `python -m avalon.inventory --load` + 2. Update the .inventory.toml or .config.toml + 3. Run `python -m avalon.inventory --save` + +""" + +import os +from Qt import QtGui +from avalon import lib +from avalon.vendor import qtawesome +from pype.api import resources +from pype.lib import ApplicationAction + +ICON_CACHE = {} +NOT_FOUND = type("NotFound", (object, ), {}) + + +def get_application_actions(project): + """Define dynamic Application classes for project using `.toml` files + + Args: + project (dict): project document from the database + + Returns: + list: list of dictionaries + """ + + apps = [] + for app in project["config"]["apps"]: + try: + app_name = app["name"] + app_definition = lib.get_application(app_name) + except Exception as exc: + print("Unable to load application: %s - %s" % (app['name'], exc)) + continue + + # Get from app definition, if not there from app in project + icon = app_definition.get("icon", app.get("icon", "folder-o")) + color = app_definition.get("color", app.get("color", None)) + order = app_definition.get("order", app.get("order", 0)) + label = app_definition.get("label") or app.get("label") or app_name + label_variant = app_definition.get("label_variant") + group = app_definition.get("group") or app.get("group") + action = type( + "app_{}".format(app_name), + (ApplicationAction,), + { + "name": app_name, + "label": label, + "label_variant": label_variant, + "group": group, + "icon": icon, + "color": color, + "order": order, + "config": app_definition.copy() + } + ) + + apps.append(action) + return apps + + +def get_action_icon(action): + icon_name = action.icon + if not icon_name: + return None + + global ICON_CACHE + + icon = ICON_CACHE.get(icon_name) + if icon is NOT_FOUND: + return None + elif icon: + return icon + + icon_path = resources.get_resource(icon_name) + if os.path.exists(icon_path): + icon = QtGui.QIcon(icon_path) + ICON_CACHE[icon_name] = icon + return icon + + try: + icon_color = getattr(action, "color", None) or "white" + icon = qtawesome.icon( + "fa.{}".format(icon_name), color=icon_color + ) + + except Exception: + ICON_CACHE[icon_name] = NOT_FOUND + print("Can't load icon \"{}\"".format(icon_name)) + + return icon + + +def get_action_label(action): + label = getattr(action, "label", None) + if not label: + return action.name + + label_variant = getattr(action, "label_variant", None) + if not label_variant: + return label + return " ".join([label, label_variant]) diff --git a/pype/tools/launcher/models.py b/pype/tools/launcher/models.py new file mode 100644 index 0000000000..3fb201702e --- /dev/null +++ b/pype/tools/launcher/models.py @@ -0,0 +1,303 @@ +import copy +import logging +import collections + +from . import lib +from Qt import QtCore, QtGui +from avalon.vendor import qtawesome +from avalon import style, api + +log = logging.getLogger(__name__) + + +class TaskModel(QtGui.QStandardItemModel): + """A model listing the tasks combined for a list of assets""" + + def __init__(self, dbcon, parent=None): + super(TaskModel, self).__init__(parent=parent) + self.dbcon = dbcon + + self._num_assets = 0 + + self.default_icon = qtawesome.icon( + "fa.male", color=style.colors.default + ) + self.no_task_icon = qtawesome.icon( + "fa.exclamation-circle", color=style.colors.mid + ) + + self._icons = {} + + self._get_task_icons() + + def _get_task_icons(self): + if not self.dbcon.Session.get("AVALON_PROJECT"): + return + + # Get the project configured icons from database + project = self.dbcon.find_one({"type": "project"}) + for task in project["config"].get("tasks") or []: + icon_name = task.get("icon") + if icon_name: + self._icons[task["name"]] = qtawesome.icon( + "fa.{}".format(icon_name), color=style.colors.default + ) + + def set_assets(self, asset_ids=None, asset_docs=None): + """Set assets to track by their database id + + Arguments: + asset_ids (list): List of asset ids. + asset_docs (list): List of asset entities from MongoDB. + + """ + + if asset_docs is None and asset_ids is not None: + # find assets in db by query + asset_docs = list(self.dbcon.find({ + "type": "asset", + "_id": {"$in": asset_ids} + })) + db_assets_ids = tuple(asset_doc["_id"] for asset_doc in asset_docs) + + # check if all assets were found + not_found = tuple( + str(asset_id) + for asset_id in asset_ids + if asset_id not in db_assets_ids + ) + + assert not not_found, "Assets not found by id: {0}".format( + ", ".join(not_found) + ) + + self.clear() + + if not asset_docs: + return + + task_names = set() + for asset_doc in asset_docs: + asset_tasks = asset_doc.get("data", {}).get("tasks") or set() + task_names.update(asset_tasks) + + self.beginResetModel() + + if not task_names: + item = QtGui.QStandardItem(self.no_task_icon, "No task") + item.setEnabled(False) + self.appendRow(item) + + else: + for task_name in sorted(task_names): + icon = self._icons.get(task_name, self.default_icon) + item = QtGui.QStandardItem(icon, task_name) + self.appendRow(item) + + self.endResetModel() + + def headerData(self, section, orientation, role): + if ( + role == QtCore.Qt.DisplayRole + and orientation == QtCore.Qt.Horizontal + and section == 0 + ): + return "Tasks" + return super(TaskModel, self).headerData(section, orientation, role) + + +class ActionModel(QtGui.QStandardItemModel): + ACTION_ROLE = QtCore.Qt.UserRole + GROUP_ROLE = QtCore.Qt.UserRole + 1 + VARIANT_GROUP_ROLE = QtCore.Qt.UserRole + 2 + + def __init__(self, dbcon, parent=None): + super(ActionModel, self).__init__(parent=parent) + self.dbcon = dbcon + + self._session = {} + self._groups = {} + self.default_icon = qtawesome.icon("fa.cube", color="white") + # Cache of available actions + self._registered_actions = list() + + self.discover() + + def discover(self): + """Set up Actions cache. Run this for each new project.""" + if not self.dbcon.Session.get("AVALON_PROJECT"): + self._registered_actions = list() + return + + # Discover all registered actions + actions = api.discover(api.Action) + + # Get available project actions and the application actions + project_doc = self.dbcon.find_one({"type": "project"}) + app_actions = lib.get_application_actions(project_doc) + actions.extend(app_actions) + + self._registered_actions = actions + + def get_icon(self, action, skip_default=False): + icon = lib.get_action_icon(action) + if not icon and not skip_default: + return self.default_icon + return icon + + def refresh(self): + # Validate actions based on compatibility + self.clear() + + self._groups.clear() + + actions = self.filter_compatible_actions(self._registered_actions) + + self.beginResetModel() + + single_actions = [] + varianted_actions = collections.defaultdict(list) + grouped_actions = collections.defaultdict(list) + for action in actions: + # Groups + group_name = getattr(action, "group", None) + + # Lable variants + label = getattr(action, "label", None) + label_variant = getattr(action, "label_variant", None) + if label_variant and not label: + print(( + "Invalid action \"{}\" has set `label_variant` to \"{}\"" + ", but doesn't have set `label` attribute" + ).format(action.name, label_variant)) + action.label_variant = None + label_variant = None + + if group_name: + grouped_actions[group_name].append(action) + + elif label_variant: + varianted_actions[label].append(action) + else: + single_actions.append(action) + + items_by_order = collections.defaultdict(list) + for label, actions in tuple(varianted_actions.items()): + if len(actions) == 1: + varianted_actions.pop(label) + single_actions.append(actions[0]) + continue + + icon = None + order = None + for action in actions: + if icon is None: + _icon = lib.get_action_icon(action) + if _icon: + icon = _icon + + if order is None or action.order < order: + order = action.order + + item = QtGui.QStandardItem(icon, action.label) + item.setData(actions, self.ACTION_ROLE) + item.setData(True, self.VARIANT_GROUP_ROLE) + items_by_order[order].append(item) + + for action in single_actions: + icon = self.get_icon(action) + item = QtGui.QStandardItem(icon, lib.get_action_label(action)) + item.setData(action, self.ACTION_ROLE) + items_by_order[action.order].append(item) + + for group_name, actions in grouped_actions.items(): + icon = None + order = None + for action in actions: + if order is None or action.order < order: + order = action.order + + if icon is None: + _icon = lib.get_action_icon(action) + if _icon: + icon = _icon + + if icon is None: + icon = self.default_icon + + item = QtGui.QStandardItem(icon, group_name) + item.setData(actions, self.ACTION_ROLE) + item.setData(True, self.GROUP_ROLE) + + items_by_order[order].append(item) + + for order in sorted(items_by_order.keys()): + for item in items_by_order[order]: + self.appendRow(item) + + self.endResetModel() + + def set_session(self, session): + assert isinstance(session, dict) + self._session = copy.deepcopy(session) + self.refresh() + + def filter_compatible_actions(self, actions): + """Collect all actions which are compatible with the environment + + Each compatible action will be translated to a dictionary to ensure + the action can be visualized in the launcher. + + Args: + actions (list): list of classes + + Returns: + list: collection of dictionaries sorted on order int he + """ + + compatible = [] + for action in actions: + if action().is_compatible(self._session): + compatible.append(action) + + # Sort by order and name + return sorted( + compatible, + key=lambda action: (action.order, action.name) + ) + + +class ProjectModel(QtGui.QStandardItemModel): + """List of projects""" + + def __init__(self, dbcon, parent=None): + super(ProjectModel, self).__init__(parent=parent) + + self.dbcon = dbcon + + self.hide_invisible = False + self.project_icon = qtawesome.icon("fa.map", color="white") + + def refresh(self): + self.clear() + self.beginResetModel() + + for project_doc in self.get_projects(): + item = QtGui.QStandardItem(self.project_icon, project_doc["name"]) + self.appendRow(item) + + self.endResetModel() + + def get_projects(self): + project_docs = [] + for project_doc in sorted( + self.dbcon.projects(), key=lambda x: x["name"] + ): + if ( + self.hide_invisible + and not project_doc["data"].get("visible", True) + ): + continue + project_docs.append(project_doc) + + return project_docs diff --git a/pype/tools/launcher/widgets.py b/pype/tools/launcher/widgets.py new file mode 100644 index 0000000000..7ab0a3f8ea --- /dev/null +++ b/pype/tools/launcher/widgets.py @@ -0,0 +1,442 @@ +import copy +import collections +from Qt import QtWidgets, QtCore, QtGui +from avalon.vendor import qtawesome + +from .delegates import ActionDelegate +from . import lib +from .models import TaskModel, ActionModel, ProjectModel +from .flickcharm import FlickCharm + + +class ProjectBar(QtWidgets.QWidget): + project_changed = QtCore.Signal(int) + + def __init__(self, dbcon, parent=None): + super(ProjectBar, self).__init__(parent) + + self.dbcon = dbcon + + self.model = ProjectModel(self.dbcon) + self.model.hide_invisible = True + + self.project_combobox = QtWidgets.QComboBox() + self.project_combobox.setModel(self.model) + self.project_combobox.setRootModelIndex(QtCore.QModelIndex()) + + layout = QtWidgets.QHBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.addWidget(self.project_combobox) + + self.setSizePolicy( + QtWidgets.QSizePolicy.MinimumExpanding, + QtWidgets.QSizePolicy.Maximum + ) + + # Initialize + self.refresh() + + # Signals + self.project_combobox.currentIndexChanged.connect(self.project_changed) + + # Set current project by default if it's set. + project_name = self.dbcon.Session.get("AVALON_PROJECT") + if project_name: + self.set_project(project_name) + + def get_current_project(self): + return self.project_combobox.currentText() + + def set_project(self, project_name): + index = self.project_combobox.findText(project_name) + if index >= 0: + self.project_combobox.setCurrentIndex(index) + + def refresh(self): + prev_project_name = self.get_current_project() + + # Refresh without signals + self.project_combobox.blockSignals(True) + + self.model.refresh() + self.set_project(prev_project_name) + + self.project_combobox.blockSignals(False) + + self.project_changed.emit(self.project_combobox.currentIndex()) + + +class ActionBar(QtWidgets.QWidget): + """Launcher interface""" + + action_clicked = QtCore.Signal(object) + + def __init__(self, dbcon, parent=None): + super(ActionBar, self).__init__(parent) + + self.dbcon = dbcon + + layout = QtWidgets.QHBoxLayout(self) + layout.setContentsMargins(8, 0, 8, 0) + + view = QtWidgets.QListView(self) + view.setProperty("mode", "icon") + view.setObjectName("IconView") + view.setViewMode(QtWidgets.QListView.IconMode) + view.setResizeMode(QtWidgets.QListView.Adjust) + view.setSelectionMode(QtWidgets.QListView.NoSelection) + view.setWrapping(True) + view.setGridSize(QtCore.QSize(70, 75)) + view.setIconSize(QtCore.QSize(30, 30)) + view.setSpacing(0) + view.setWordWrap(True) + + model = ActionModel(self.dbcon, self) + view.setModel(model) + + # TODO better group delegate + delegate = ActionDelegate( + [model.GROUP_ROLE, model.VARIANT_GROUP_ROLE], + self + ) + view.setItemDelegate(delegate) + + layout.addWidget(view) + + self.model = model + self.view = view + + # Make view flickable + flick = FlickCharm(parent=view) + flick.activateOn(view) + + self.set_row_height(1) + + view.clicked.connect(self.on_clicked) + + def set_row_height(self, rows): + self.setMinimumHeight(rows * 75) + + def on_clicked(self, index): + if not index.isValid(): + return + + is_group = index.data(self.model.GROUP_ROLE) + is_variant_group = index.data(self.model.VARIANT_GROUP_ROLE) + if not is_group and not is_variant_group: + action = index.data(self.model.ACTION_ROLE) + self.action_clicked.emit(action) + return + + actions = index.data(self.model.ACTION_ROLE) + + menu = QtWidgets.QMenu(self) + actions_mapping = {} + + if is_variant_group: + for action in actions: + menu_action = QtWidgets.QAction( + lib.get_action_label(action) + ) + menu.addAction(menu_action) + actions_mapping[menu_action] = action + else: + by_variant_label = collections.defaultdict(list) + orders = [] + for action in actions: + # Lable variants + label = getattr(action, "label", None) + label_variant = getattr(action, "label_variant", None) + if label_variant and not label: + label_variant = None + + if not label_variant: + orders.append(action) + continue + + if label not in orders: + orders.append(label) + by_variant_label[label].append(action) + + for action_item in orders: + actions = by_variant_label.get(action_item) + if not actions: + action = action_item + elif len(actions) == 1: + action = actions[0] + else: + action = None + + if action: + menu_action = QtWidgets.QAction( + lib.get_action_label(action) + ) + menu.addAction(menu_action) + actions_mapping[menu_action] = action + continue + + sub_menu = QtWidgets.QMenu(label, menu) + for action in actions: + menu_action = QtWidgets.QAction( + lib.get_action_label(action) + ) + sub_menu.addAction(menu_action) + actions_mapping[menu_action] = action + + menu.addMenu(sub_menu) + + result = menu.exec_(QtGui.QCursor.pos()) + if result: + action = actions_mapping[result] + self.action_clicked.emit(action) + + +class TasksWidget(QtWidgets.QWidget): + """Widget showing active Tasks""" + + task_changed = QtCore.Signal() + selection_mode = ( + QtCore.QItemSelectionModel.Select | QtCore.QItemSelectionModel.Rows + ) + + def __init__(self, dbcon, parent=None): + super(TasksWidget, self).__init__(parent) + + self.dbcon = dbcon + + view = QtWidgets.QTreeView(self) + view.setIndentation(0) + model = TaskModel(self.dbcon) + view.setModel(model) + + layout = QtWidgets.QVBoxLayout(self) + layout.addWidget(view) + + view.selectionModel().selectionChanged.connect(self.task_changed) + + self.model = model + self.view = view + + self._last_selected_task = None + + def set_asset(self, asset_id): + if asset_id is None: + # Asset deselected + self.model.set_assets() + return + + # Try and preserve the last selected task and reselect it + # after switching assets. If there's no currently selected + # asset keep whatever the "last selected" was prior to it. + current = self.get_current_task() + if current: + self._last_selected_task = current + + self.model.set_assets([asset_id]) + + if self._last_selected_task: + self.select_task(self._last_selected_task) + + # Force a task changed emit. + self.task_changed.emit() + + def select_task(self, task_name): + """Select a task by name. + + If the task does not exist in the current model then selection is only + cleared. + + Args: + task (str): Name of the task to select. + + """ + + # Clear selection + self.view.selectionModel().clearSelection() + + # Select the task + for row in range(self.model.rowCount()): + index = self.model.index(row, 0) + _task_name = index.data(QtCore.Qt.DisplayRole) + if _task_name == task_name: + self.view.selectionModel().select(index, self.selection_mode) + # Set the currently active index + self.view.setCurrentIndex(index) + break + + def get_current_task(self): + """Return name of task at current index (selected) + + Returns: + str: Name of the current task. + + """ + index = self.view.currentIndex() + if self.view.selectionModel().isSelected(index): + return index.data(QtCore.Qt.DisplayRole) + + +class ActionHistory(QtWidgets.QPushButton): + trigger_history = QtCore.Signal(tuple) + + def __init__(self, parent=None): + super(ActionHistory, self).__init__(parent=parent) + + self.max_history = 15 + + self.setFixedWidth(25) + self.setFixedHeight(25) + + self.setIcon(qtawesome.icon("fa.history", color="#CCCCCC")) + self.setIconSize(QtCore.QSize(15, 15)) + + self._history = [] + self.clicked.connect(self.show_history) + + def show_history(self): + # Show history popup + if not self._history: + return + + widget = QtWidgets.QListWidget() + widget.setSelectionMode(widget.NoSelection) + widget.setStyleSheet(""" + * { + font-family: "Courier New"; + } + """) + + largest_label_num_chars = 0 + largest_action_label = max(len(x[0].label) for x in self._history) + action_session_role = QtCore.Qt.UserRole + 1 + + for action, session in reversed(self._history): + project = session.get("AVALON_PROJECT") + asset = session.get("AVALON_ASSET") + task = session.get("AVALON_TASK") + breadcrumb = " > ".join(x for x in [project, asset, task] if x) + + m = "{{action:{0}}} | {{breadcrumb}}".format(largest_action_label) + label = m.format(action=action.label, breadcrumb=breadcrumb) + + icon = lib.get_action_icon(action) + item = QtWidgets.QListWidgetItem(icon, label) + item.setData(action_session_role, (action, session)) + + largest_label_num_chars = max(largest_label_num_chars, len(label)) + + widget.addItem(item) + + # Show history + dialog = QtWidgets.QDialog(parent=self) + dialog.setWindowTitle("Action History") + dialog.setWindowFlags( + QtCore.Qt.FramelessWindowHint | QtCore.Qt.Popup + ) + dialog.setSizePolicy( + QtWidgets.QSizePolicy.Ignored, + QtWidgets.QSizePolicy.Ignored + ) + + layout = QtWidgets.QVBoxLayout(dialog) + layout.setContentsMargins(0, 0, 0, 0) + layout.addWidget(widget) + + def on_clicked(index): + data = index.data(action_session_role) + self.trigger_history.emit(data) + dialog.close() + + widget.clicked.connect(on_clicked) + + # padding + icon + text + width = 40 + (largest_label_num_chars * 7) + entry_height = 21 + height = entry_height * len(self._history) + + point = QtGui.QCursor().pos() + dialog.setGeometry( + point.x() - width, + point.y() - height, + width, + height + ) + dialog.exec_() + + self.widget_popup = widget + + def add_action(self, action, session): + key = (action, copy.deepcopy(session)) + + # Remove entry if already exists + if key in self._history: + self._history.remove(key) + + self._history.append(key) + + # Slice the end of the list if we exceed the max history + if len(self._history) > self.max_history: + self._history = self._history[-self.max_history:] + + def clear_history(self): + self._history.clear() + + +class SlidePageWidget(QtWidgets.QStackedWidget): + """Stacked widget that nicely slides between its pages""" + + directions = { + "left": QtCore.QPoint(-1, 0), + "right": QtCore.QPoint(1, 0), + "up": QtCore.QPoint(0, 1), + "down": QtCore.QPoint(0, -1) + } + + def slide_view(self, index, direction="right"): + if self.currentIndex() == index: + return + + offset_direction = self.directions.get(direction) + if offset_direction is None: + print("BUG: invalid slide direction: {}".format(direction)) + return + + width = self.frameRect().width() + height = self.frameRect().height() + offset = QtCore.QPoint( + offset_direction.x() * width, + offset_direction.y() * height + ) + + new_page = self.widget(index) + new_page.setGeometry(0, 0, width, height) + curr_pos = new_page.pos() + new_page.move(curr_pos + offset) + new_page.show() + new_page.raise_() + + current_page = self.currentWidget() + + b_pos = QtCore.QByteArray(b"pos") + + anim_old = QtCore.QPropertyAnimation(current_page, b_pos, self) + anim_old.setDuration(250) + anim_old.setStartValue(curr_pos) + anim_old.setEndValue(curr_pos - offset) + anim_old.setEasingCurve(QtCore.QEasingCurve.OutQuad) + + anim_new = QtCore.QPropertyAnimation(new_page, b_pos, self) + anim_new.setDuration(250) + anim_new.setStartValue(curr_pos + offset) + anim_new.setEndValue(curr_pos) + anim_new.setEasingCurve(QtCore.QEasingCurve.OutQuad) + + anim_group = QtCore.QParallelAnimationGroup(self) + anim_group.addAnimation(anim_old) + anim_group.addAnimation(anim_new) + + def slide_finished(): + self.setCurrentWidget(new_page) + + anim_group.finished.connect(slide_finished) + anim_group.start() diff --git a/pype/tools/launcher/window.py b/pype/tools/launcher/window.py new file mode 100644 index 0000000000..13b4abee6e --- /dev/null +++ b/pype/tools/launcher/window.py @@ -0,0 +1,467 @@ +import copy +import logging + +from Qt import QtWidgets, QtCore, QtGui +from avalon import style + +from pype.modules.ftrack.lib.io_nonsingleton import DbConnector +from pype.api import resources + +from avalon.tools import lib as tools_lib +from avalon.tools.widgets import AssetWidget +from avalon.vendor import qtawesome +from .models import ProjectModel +from .widgets import ( + ProjectBar, ActionBar, TasksWidget, ActionHistory, SlidePageWidget +) + +from .flickcharm import FlickCharm + + +class IconListView(QtWidgets.QListView): + """Styled ListView that allows to toggle between icon and list mode. + + Toggling between the two modes is done by Right Mouse Click. + + """ + + IconMode = 0 + ListMode = 1 + + def __init__(self, parent=None, mode=ListMode): + super(IconListView, self).__init__(parent=parent) + + # Workaround for scrolling being super slow or fast when + # toggling between the two visual modes + self.setVerticalScrollMode(self.ScrollPerPixel) + self.setObjectName("IconView") + + self._mode = None + self.set_mode(mode) + + def set_mode(self, mode): + if mode == self._mode: + return + + self._mode = mode + + if mode == self.IconMode: + self.setViewMode(QtWidgets.QListView.IconMode) + self.setResizeMode(QtWidgets.QListView.Adjust) + self.setWrapping(True) + self.setWordWrap(True) + self.setGridSize(QtCore.QSize(151, 90)) + self.setIconSize(QtCore.QSize(50, 50)) + self.setSpacing(0) + self.setAlternatingRowColors(False) + + self.setProperty("mode", "icon") + self.style().polish(self) + + self.verticalScrollBar().setSingleStep(30) + + elif self.ListMode: + self.setProperty("mode", "list") + self.style().polish(self) + + self.setViewMode(QtWidgets.QListView.ListMode) + self.setResizeMode(QtWidgets.QListView.Adjust) + self.setWrapping(False) + self.setWordWrap(False) + self.setIconSize(QtCore.QSize(20, 20)) + self.setGridSize(QtCore.QSize(100, 25)) + self.setSpacing(0) + self.setAlternatingRowColors(False) + + self.verticalScrollBar().setSingleStep(33.33) + + def mousePressEvent(self, event): + if event.button() == QtCore.Qt.RightButton: + self.set_mode(int(not self._mode)) + return super(IconListView, self).mousePressEvent(event) + + +class ProjectsPanel(QtWidgets.QWidget): + """Projects Page""" + + project_clicked = QtCore.Signal(str) + + def __init__(self, dbcon, parent=None): + super(ProjectsPanel, self).__init__(parent=parent) + + layout = QtWidgets.QVBoxLayout(self) + + self.dbcon = dbcon + self.dbcon.install() + + view = IconListView(parent=self) + view.setSelectionMode(QtWidgets.QListView.NoSelection) + flick = FlickCharm(parent=self) + flick.activateOn(view) + model = ProjectModel(self.dbcon) + model.hide_invisible = True + model.refresh() + view.setModel(model) + + layout.addWidget(view) + + view.clicked.connect(self.on_clicked) + + self.model = model + self.view = view + + def on_clicked(self, index): + if index.isValid(): + project_name = index.data(QtCore.Qt.DisplayRole) + self.project_clicked.emit(project_name) + + +class AssetsPanel(QtWidgets.QWidget): + """Assets page""" + back_clicked = QtCore.Signal() + + def __init__(self, dbcon, parent=None): + super(AssetsPanel, self).__init__(parent=parent) + + self.dbcon = dbcon + + # project bar + project_bar_widget = QtWidgets.QWidget(self) + + layout = QtWidgets.QHBoxLayout(project_bar_widget) + layout.setSpacing(4) + + btn_back_icon = qtawesome.icon("fa.angle-left", color="white") + btn_back = QtWidgets.QPushButton(project_bar_widget) + btn_back.setIcon(btn_back_icon) + btn_back.setFixedWidth(23) + btn_back.setFixedHeight(23) + + project_bar = ProjectBar(self.dbcon, project_bar_widget) + + layout.addWidget(btn_back) + layout.addWidget(project_bar) + + # assets + assets_proxy_widgets = QtWidgets.QWidget(self) + assets_proxy_widgets.setContentsMargins(0, 0, 0, 0) + assets_layout = QtWidgets.QVBoxLayout(assets_proxy_widgets) + assets_widget = AssetWidget( + dbcon=self.dbcon, parent=assets_proxy_widgets + ) + + # Make assets view flickable + flick = FlickCharm(parent=self) + flick.activateOn(assets_widget.view) + assets_widget.view.setVerticalScrollMode( + assets_widget.view.ScrollPerPixel + ) + assets_layout.addWidget(assets_widget) + + # tasks + tasks_widget = TasksWidget(self.dbcon, self) + body = QtWidgets.QSplitter() + body.setContentsMargins(0, 0, 0, 0) + body.setSizePolicy( + QtWidgets.QSizePolicy.Expanding, + QtWidgets.QSizePolicy.Expanding + ) + body.setOrientation(QtCore.Qt.Horizontal) + body.addWidget(assets_proxy_widgets) + body.addWidget(tasks_widget) + body.setStretchFactor(0, 100) + body.setStretchFactor(1, 65) + + # main layout + layout = QtWidgets.QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(0) + layout.addWidget(project_bar_widget) + layout.addWidget(body) + + self.project_bar = project_bar + self.assets_widget = assets_widget + self.tasks_widget = tasks_widget + + # signals + project_bar.project_changed.connect(self.on_project_changed) + assets_widget.selection_changed.connect(self.on_asset_changed) + btn_back.clicked.connect(self.back_clicked) + + # Force initial refresh for the assets since we might not be + # trigging a Project switch if we click the project that was set + # prior to launching the Launcher + # todo: remove this behavior when AVALON_PROJECT is not required + assets_widget.refresh() + + def set_project(self, project): + before = self.project_bar.get_current_project() + self.project_bar.set_project(project) + if project == before: + # Force a refresh on the assets if the project hasn't changed + self.assets_widget.refresh() + + def on_project_changed(self): + project_name = self.project_bar.get_current_project() + self.dbcon.Session["AVALON_PROJECT"] = project_name + self.assets_widget.refresh() + + # Force asset change callback to ensure tasks are correctly reset + tools_lib.schedule(self.on_asset_changed, 0.05, channel="assets") + + def on_asset_changed(self): + """Callback on asset selection changed + + This updates the task view. + + """ + + print("Asset changed..") + + asset_doc = self.assets_widget.get_active_asset_document() + if asset_doc: + self.tasks_widget.set_asset(asset_doc["_id"]) + else: + self.tasks_widget.set_asset(None) + + def get_current_session(self): + asset_doc = self.assets_widget.get_active_asset_document() + session = copy.deepcopy(self.dbcon.Session) + + # Clear some values that we are about to collect if available + session.pop("AVALON_SILO", None) + session.pop("AVALON_ASSET", None) + session.pop("AVALON_TASK", None) + + if asset_doc: + session["AVALON_ASSET"] = asset_doc["name"] + task_name = self.tasks_widget.get_current_task() + if task_name: + session["AVALON_TASK"] = task_name + + return session + + +class LauncherWindow(QtWidgets.QDialog): + """Launcher interface""" + + def __init__(self, parent=None): + super(LauncherWindow, self).__init__(parent) + + self.log = logging.getLogger( + ".".join([__name__, self.__class__.__name__]) + ) + self.dbcon = DbConnector() + + self.setWindowTitle("Launcher") + self.setFocusPolicy(QtCore.Qt.StrongFocus) + self.setAttribute(QtCore.Qt.WA_DeleteOnClose, False) + + icon = QtGui.QIcon(resources.pype_icon_filepath()) + self.setWindowIcon(icon) + self.setStyleSheet(style.load_stylesheet()) + + # Allow minimize + self.setWindowFlags( + self.windowFlags() | QtCore.Qt.WindowMinimizeButtonHint + ) + + project_panel = ProjectsPanel(self.dbcon) + asset_panel = AssetsPanel(self.dbcon) + + page_slider = SlidePageWidget() + page_slider.addWidget(project_panel) + page_slider.addWidget(asset_panel) + + # actions + actions_bar = ActionBar(self.dbcon, self) + + # statusbar + statusbar = QtWidgets.QWidget() + layout = QtWidgets.QHBoxLayout(statusbar) + + message_label = QtWidgets.QLabel() + message_label.setFixedHeight(15) + + action_history = ActionHistory() + action_history.setStatusTip("Show Action History") + + layout.addWidget(message_label) + layout.addWidget(action_history) + + # Vertically split Pages and Actions + body = QtWidgets.QSplitter() + body.setContentsMargins(0, 0, 0, 0) + body.setSizePolicy( + QtWidgets.QSizePolicy.Expanding, + QtWidgets.QSizePolicy.Expanding + ) + body.setOrientation(QtCore.Qt.Vertical) + body.addWidget(page_slider) + body.addWidget(actions_bar) + + # Set useful default sizes and set stretch + # for the pages so that is the only one that + # stretches on UI resize. + body.setStretchFactor(0, 10) + body.setSizes([580, 160]) + + layout = QtWidgets.QVBoxLayout(self) + layout.addWidget(body) + layout.addWidget(statusbar) + layout.setSpacing(0) + layout.setContentsMargins(0, 0, 0, 0) + + self.message_label = message_label + self.project_panel = project_panel + self.asset_panel = asset_panel + self.actions_bar = actions_bar + self.action_history = action_history + self.page_slider = page_slider + self._page = 0 + + # signals + actions_bar.action_clicked.connect(self.on_action_clicked) + action_history.trigger_history.connect(self.on_history_action) + project_panel.project_clicked.connect(self.on_project_clicked) + asset_panel.back_clicked.connect(self.on_back_clicked) + + # Add some signals to propagate from the asset panel + for signal in ( + asset_panel.project_bar.project_changed, + asset_panel.assets_widget.selection_changed, + asset_panel.tasks_widget.task_changed + ): + signal.connect(self.on_session_changed) + + # todo: Simplify this callback connection + asset_panel.project_bar.project_changed.connect( + self.on_project_changed + ) + + self.resize(520, 740) + + def set_page(self, page): + current = self.page_slider.currentIndex() + if current == page and self._page == page: + return + + direction = "right" if page > current else "left" + self._page = page + self.page_slider.slide_view(page, direction=direction) + + def refresh(self): + self.asset_panel.assets_widget.refresh() + self.refresh_actions() + + def echo(self, message): + self.message_label.setText(str(message)) + QtCore.QTimer.singleShot(5000, lambda: self.message_label.setText("")) + self.log.debug(message) + + def on_project_changed(self): + project_name = self.asset_panel.project_bar.get_current_project() + self.dbcon.Session["AVALON_PROJECT"] = project_name + + # Update the Action plug-ins available for the current project + self.actions_bar.model.discover() + + def on_session_changed(self): + self.refresh_actions() + + def refresh_actions(self, delay=1): + tools_lib.schedule(self.on_refresh_actions, delay) + + def on_project_clicked(self, project_name): + self.dbcon.Session["AVALON_PROJECT"] = project_name + # Refresh projects + self.asset_panel.project_bar.refresh() + self.asset_panel.set_project(project_name) + self.set_page(1) + self.refresh_actions() + + def on_back_clicked(self): + self.set_page(0) + self.project_panel.model.refresh() # Refresh projects + self.refresh_actions() + + def on_refresh_actions(self): + session = self.get_current_session() + self.actions_bar.model.set_session(session) + self.actions_bar.model.refresh() + + def on_action_clicked(self, action): + self.echo("Running action: {}".format(action.name)) + self.run_action(action) + + def on_history_action(self, history_data): + action, session = history_data + app = QtWidgets.QApplication.instance() + modifiers = app.keyboardModifiers() + + is_control_down = QtCore.Qt.ControlModifier & modifiers + if is_control_down: + # Revert to that "session" location + self.set_session(session) + else: + # User is holding control, rerun the action + self.run_action(action, session=session) + + def get_current_session(self): + if self._page == 1: + # Assets page + return self.asset_panel.get_current_session() + + session = copy.deepcopy(self.dbcon.Session) + + # Remove some potential invalid session values + # that we know are not set when not browsing in + # a project. + session.pop("AVALON_PROJECT", None) + session.pop("AVALON_ASSET", None) + session.pop("AVALON_SILO", None) + session.pop("AVALON_TASK", None) + + return session + + def run_action(self, action, session=None): + if session is None: + session = self.get_current_session() + + # Add to history + self.action_history.add_action(action, session) + + # Process the Action + try: + action().process(session) + except Exception as exc: + self.log.warning("Action launch failed.", exc_info=True) + self.echo("Failed: {}".format(str(exc))) + + def set_session(self, session): + project_name = session.get("AVALON_PROJECT") + silo = session.get("AVALON_SILO") + asset_name = session.get("AVALON_ASSET") + task_name = session.get("AVALON_TASK") + + if project_name: + # Force the "in project" view. + self.page_slider.slide_view(1, direction="right") + index = self.asset_panel.project_bar.project_combobox.findText( + project_name + ) + if index >= 0: + self.asset_panel.project_bar.project_combobox.setCurrentIndex( + index + ) + + if silo: + self.asset_panel.assets_widget.set_silo(silo) + + if asset_name: + self.asset_panel.assets_widget.select_assets([asset_name]) + + if task_name: + # requires a forced refresh first + self.asset_panel.on_asset_changed() + self.asset_panel.tasks_widget.select_task(task_name) diff --git a/pype/tools/tray/__main__.py b/pype/tools/tray/__main__.py index d0006c0afe..94d5461dc4 100644 --- a/pype/tools/tray/__main__.py +++ b/pype/tools/tray/__main__.py @@ -1,4 +1,12 @@ +import os import sys import pype_tray -sys.exit(pype_tray.PypeTrayApplication().exec_()) +app = pype_tray.PypeTrayApplication() +if os.name == "nt": + import ctypes + ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID( + u"pype_tray" + ) + +sys.exit(app.exec_())