diff --git a/pype/tools/launcher/__init__.py b/pype/tools/launcher/__init__.py new file mode 100644 index 0000000000..3b88ebe984 --- /dev/null +++ b/pype/tools/launcher/__init__.py @@ -0,0 +1,10 @@ + +from .app import ( + show, + cli +) + +__all__ = [ + "show", + "cli", +] diff --git a/pype/tools/launcher/__main__.py b/pype/tools/launcher/__main__.py new file mode 100644 index 0000000000..50642c46cd --- /dev/null +++ b/pype/tools/launcher/__main__.py @@ -0,0 +1,5 @@ +from app import cli + +if __name__ == '__main__': + import sys + sys.exit(cli(sys.argv[1:])) diff --git a/pype/tools/launcher/actions.py b/pype/tools/launcher/actions.py new file mode 100644 index 0000000000..2a2e2ab0f0 --- /dev/null +++ b/pype/tools/launcher/actions.py @@ -0,0 +1,95 @@ +import os +import importlib + +from avalon import api, lib + + +class ProjectManagerAction(api.Action): + name = "projectmanager" + label = "Project Manager" + icon = "gear" + group = "Test" + 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 # at the end + group = "Test" + + 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.cbloader", + 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/app.py b/pype/tools/launcher/app.py new file mode 100644 index 0000000000..8bce705fc9 --- /dev/null +++ b/pype/tools/launcher/app.py @@ -0,0 +1,717 @@ +import sys +import copy + +from avalon.vendor.Qt import QtWidgets, QtCore, QtGui +from avalon import io, api, style + +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 + +module = sys.modules[__name__] +module.window = None + + +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._mode = 0 + self.set_mode(mode) + + def set_mode(self, 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.setStyleSheet(""" + QListView { + font-size: 11px; + border: 0px; + padding: 0px; + margin: 0px; + + } + + QListView::item { + margin-top: 6px; + /* Won't work without borders set */ + border: 0px; + } + + /* For icon only */ + QListView::icon { + top: 3px; + } + """) + + self.verticalScrollBar().setSingleStep(30) + + elif self.ListMode: + self.setStyleSheet("") # clear stylesheet + 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) + + self._mode = mode + + 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, parent=None): + super(ProjectsPanel, self).__init__(parent=parent) + + layout = QtWidgets.QVBoxLayout(self) + + io.install() + view = IconListView(parent=self) + view.setSelectionMode(QtWidgets.QListView.NoSelection) + flick = FlickCharm(parent=self) + flick.activateOn(view) + model = ProjectModel() + 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 = index.data(QtCore.Qt.DisplayRole) + self.project_clicked.emit(project) + + +class AssetsPanel(QtWidgets.QWidget): + """Assets page""" + + back_clicked = QtCore.Signal() + + def __init__(self, parent=None): + super(AssetsPanel, self).__init__(parent=parent) + + # project bar + project_bar = QtWidgets.QWidget() + layout = QtWidgets.QHBoxLayout(project_bar) + layout.setSpacing(4) + back = QtWidgets.QPushButton("<") + back.setFixedWidth(25) + back.setFixedHeight(23) + projects = ProjectBar() + projects.layout().setContentsMargins(0, 0, 0, 0) + layout.addWidget(back) + layout.addWidget(projects) + + # assets + _assets_widgets = QtWidgets.QWidget() + _assets_widgets.setContentsMargins(0, 0, 0, 0) + assets_layout = QtWidgets.QVBoxLayout(_assets_widgets) + assets_widgets = AssetWidget() + + # Make assets view flickable + flick = FlickCharm(parent=self) + flick.activateOn(assets_widgets.view) + assets_widgets.view.setVerticalScrollMode( + assets_widgets.view.ScrollPerPixel + ) + assets_layout.addWidget(assets_widgets) + + # tasks + tasks_widgets = TasksWidget() + 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_widgets) + body.addWidget(tasks_widgets) + 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) + layout.addWidget(body) + + self.data = { + "model": { + "projects": projects, + "assets": assets_widgets, + "tasks": tasks_widgets + }, + } + + # signals + projects.project_changed.connect(self.on_project_changed) + assets_widgets.selection_changed.connect(self.asset_changed) + 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_widgets.refresh() + + def set_project(self, project): + + projects = self.data["model"]["projects"] + + before = projects.get_current_project() + projects.set_project(project) + if project == before: + # Force a refresh on the assets if the project hasn't changed + self.data["model"]["assets"].refresh() + + def asset_changed(self): + tools_lib.schedule(self.on_asset_changed, 0.05, + channel="assets") + + def on_project_changed(self): + + project = self.data["model"]["projects"].get_current_project() + + api.Session["AVALON_PROJECT"] = project + self.data["model"]["assets"].refresh() + + # Force asset change callback to ensure tasks are correctly reset + self.asset_changed() + + def on_asset_changed(self): + """Callback on asset selection changed + + This updates the task view. + + """ + + print("Asset changed..") + + tasks = self.data["model"]["tasks"] + assets = self.data["model"]["assets"] + + asset = assets.get_active_asset_document() + if asset: + tasks.set_asset(asset["_id"]) + else: + tasks.set_asset(None) + + def _get_current_session(self): + + tasks = self.data["model"]["tasks"] + assets = self.data["model"]["assets"] + + asset = assets.get_active_asset_document() + session = copy.deepcopy(api.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: + session["AVALON_ASSET"] = asset["name"] + + silo = asset.get("silo") + if silo: + session["AVALON_SILO"] = silo + + task = tasks.get_current_task() + if task: + session["AVALON_TASK"] = task + + return session + + +class Window(QtWidgets.QDialog): + """Launcher interface""" + + def __init__(self, parent=None): + super(Window, self).__init__(parent) + + self.setWindowTitle("Launcher") + self.setFocusPolicy(QtCore.Qt.StrongFocus) + self.setAttribute(QtCore.Qt.WA_DeleteOnClose) + + # Allow minimize + self.setWindowFlags( + self.windowFlags() | QtCore.Qt.WindowMinimizeButtonHint + ) + + project_panel = ProjectsPanel() + asset_panel = AssetsPanel() + + pages = SlidePageWidget() + pages.addWidget(project_panel) + pages.addWidget(asset_panel) + + # actions + actions = ActionBar() + + # statusbar + statusbar = QtWidgets.QWidget() + message = QtWidgets.QLabel() + message.setFixedHeight(15) + action_history = ActionHistory() + action_history.setStatusTip("Show Action History") + layout = QtWidgets.QHBoxLayout(statusbar) + layout.addWidget(message) + 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(pages) + body.addWidget(actions) + + # 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.data = { + "label": { + "message": message, + }, + "pages": { + "project": project_panel, + "asset": asset_panel + }, + "model": { + "actions": actions, + "action_history": action_history + }, + } + + self.pages = pages + self._page = 0 + + # signals + actions.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.data["model"]["projects"].project_changed, + asset_panel.data["model"]["assets"].selection_changed, + asset_panel.data["model"]["tasks"].task_changed + ]: + signal.connect(self.on_session_changed) + + # todo: Simplify this callback connection + asset_panel.data["model"]["projects"].project_changed.connect( + self.on_project_changed + ) + + self.resize(520, 740) + + def set_page(self, page): + + current = self.pages.currentIndex() + if current == page and self._page == page: + return + + direction = "right" if page > current else "left" + self._page = page + self.pages.slide_view(page, direction=direction) + + def refresh(self): + asset = self.data["pages"]["asset"] + asset.data["model"]["assets"].refresh() + self.refresh_actions() + + def echo(self, message): + widget = self.data["label"]["message"] + widget.setText(str(message)) + + QtCore.QTimer.singleShot(5000, lambda: widget.setText("")) + + print(message) + + def on_project_changed(self): + project_name = self.data["pages"]["asset"].data["model"]["projects"].get_current_project() + io.Session["AVALON_PROJECT"] = project_name + + # Update the Action plug-ins available for the current project + actions_model = self.data["model"]["actions"].model + actions_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): + io.Session["AVALON_PROJECT"] = project + asset_panel = self.data["pages"]["asset"] + asset_panel.data["model"]["projects"].refresh() # Refresh projects + asset_panel.set_project(project) + self.set_page(1) + self.refresh_actions() + + def on_back_clicked(self): + + self.set_page(0) + self.data["pages"]["project"].model.refresh() # Refresh projects + self.refresh_actions() + + def on_refresh_actions(self): + session = self.get_current_session() + + actions = self.data["model"]["actions"] + actions.model.set_session(session) + actions.model.refresh() + + def on_action_clicked(self, action): + self.echo("Running action: %s" % 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: + # User is holding control, rerun the action + self.run_action(action, session=session) + else: + # Revert to that "session" location + self.set_session(session) + + def get_current_session(self): + + index = self._page + if index == 1: + # Assets page + return self.data["pages"]["asset"]._get_current_session() + + else: + session = copy.deepcopy(api.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 + history = self.data["model"]["action_history"] + history.add_action(action, session) + + # Process the Action + action().process(session) + + def set_session(self, session): + + panel = self.data["pages"]["asset"] + + project = session.get("AVALON_PROJECT") + silo = session.get("AVALON_SILO") + asset = session.get("AVALON_ASSET") + task = session.get("AVALON_TASK") + + if project: + + # Force the "in project" view. + self.pages.slide_view(1, direction="right") + + projects = panel.data["model"]["projects"] + index = projects.view.findText(project) + if index >= 0: + projects.view.setCurrentIndex(index) + + if silo: + panel.data["model"]["assets"].set_silo(silo) + + if asset: + panel.data["model"]["assets"].select_assets([asset]) + + if task: + panel.on_asset_changed() # requires a forced refresh first + panel.data["model"]["tasks"].select_task(task) + + +class Application(QtWidgets.QApplication): + + def __init__(self, *args): + super(Application, self).__init__(*args) + + # Set app icon + icon_path = tools_lib.resource("icons", "png", "avalon-logo-16.png") + icon = QtGui.QIcon(icon_path) + + self.setWindowIcon(icon) + + # Toggles + self.toggles = {"autoHide": False} + + # Timers + keep_visible = QtCore.QTimer(self) + keep_visible.setInterval(100) + keep_visible.setSingleShot(True) + + timers = {"keepVisible": keep_visible} + + tray = QtWidgets.QSystemTrayIcon(icon) + tray.setToolTip("Avalon Launcher") + + # Signals + tray.activated.connect(self.on_tray_activated) + self.aboutToQuit.connect(self.on_quit) + + menu = self.build_menu() + tray.setContextMenu(menu) + tray.show() + + tray.showMessage("Avalon", "Launcher started.") + + # Don't close the app when we close the log window. + # self.setQuitOnLastWindowClosed(False) + + self.focusChanged.connect(self.on_focus_changed) + + window = Window() + window.setAttribute(QtCore.Qt.WA_DeleteOnClose, False) + + self.timers = timers + self._tray = tray + self._window = window + + # geometry = self.calculate_window_geometry(window) + # window.setGeometry(geometry) + + def show(self): + """Show the primary GUI + + This also activates the window and deals with platform-differences. + + """ + + self._window.show() + self._window.raise_() + self._window.activateWindow() + + self.timers["keepVisible"].start() + + def on_tray_activated(self, reason): + if self._window.isVisible(): + self._window.hide() + + elif reason == QtWidgets.QSystemTrayIcon.Trigger: + self.show() + + def on_focus_changed(self, old, new): + """Respond to window losing focus""" + window = new + keep_visible = self.timers["keepVisible"].isActive() + self._window.hide() if (self.toggles["autoHide"] and + not window and + not keep_visible) else None + + def on_autohide_changed(self, auto_hide): + """Respond to changes to auto-hide + + Auto-hide is changed in the UI and determines whether or not + the UI hides upon losing focus. + + """ + + self.toggles["autoHide"] = auto_hide + self.echo("Hiding when losing focus" if auto_hide else "Stays visible") + + def on_quit(self): + """Respond to the application quitting""" + self._tray.hide() + + def build_menu(self): + """Build the right-mouse context menu for the tray icon""" + menu = QtWidgets.QMenu() + + icon = qtawesome.icon("fa.eye", color=style.colors.default) + open = QtWidgets.QAction(icon, "Open", self) + open.triggered.connect(self.show) + + def toggle(): + self.on_autohide_changed(not self.toggles['autoHide']) + + keep_open = QtWidgets.QAction("Keep open", self) + keep_open.setCheckable(True) + keep_open.setChecked(not self.toggles['autoHide']) + keep_open.triggered.connect(toggle) + + quit = QtWidgets.QAction("Quit", self) + quit.triggered.connect(self.quit) + + menu.setStyleSheet(""" + QMenu { + padding: 0px; + margin: 0px; + } + """) + + for action in [open, keep_open, quit]: + menu.addAction(action) + + return menu + + def calculate_window_geometry(self, window): + """Respond to status changes + + On creation, align window with where the tray icon is + located. For example, if the tray icon is in the upper + right corner of the screen, then this is where the + window is supposed to appear. + + Arguments: + status (int): Provided by Qt, the status flag of + loading the input file. + + """ + + tray_x = self._tray.geometry().x() + tray_y = self._tray.geometry().y() + + width = window.width() + width = max(width, window.minimumWidth()) + + height = window.height() + height = max(height, window.sizeHint().height()) + + desktop_geometry = QtWidgets.QDesktopWidget().availableGeometry() + screen_geometry = window.geometry() + + screen_width = screen_geometry.width() + screen_height = screen_geometry.height() + + # Calculate width and height of system tray + systray_width = screen_geometry.width() - desktop_geometry.width() + systray_height = screen_geometry.height() - desktop_geometry.height() + + padding = 10 + + x = screen_width - width + y = screen_height - height + + if tray_x < (screen_width / 2): + x = 0 + systray_width + padding + else: + x -= systray_width + padding + + if tray_y < (screen_height / 2): + y = 0 + systray_height + padding + else: + y -= systray_height + padding + + return QtCore.QRect(x, y, width, height) + + +def show(root=None, debug=False, parent=None): + """Display Loader GUI + + Arguments: + debug (bool, optional): Run loader in debug-mode, + defaults to False + parent (QtCore.QObject, optional): When provided parent the interface + to this QObject. + + """ + + app = Application(sys.argv) + app.setStyleSheet(style.load_stylesheet()) + + # Show the window on launch + app.show() + + app.exec_() + + +def cli(args): + import argparse + parser = argparse.ArgumentParser() + #parser.add_argument("project") + + args = parser.parse_args(args) + #project = args.project + + import launcher.actions as actions + print("Registering default actions..") + actions.register_default_actions() + print("Registering config actions..") + actions.register_config_actions() + print("Registering environment actions..") + actions.register_environment_actions() + io.install() + + #api.Session["AVALON_PROJECT"] = project + + import traceback + sys.excepthook = lambda typ, val, tb: traceback.print_last() + + show() diff --git a/pype/tools/launcher/flickcharm.py b/pype/tools/launcher/flickcharm.py new file mode 100644 index 0000000000..b4dd69be6c --- /dev/null +++ b/pype/tools/launcher/flickcharm.py @@ -0,0 +1,305 @@ +""" +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 +import sys +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..8cd117074c --- /dev/null +++ b/pype/tools/launcher/lib.py @@ -0,0 +1,67 @@ +"""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` + +""" + +from avalon import io, lib, pipeline + + +def list_project_tasks(): + """List the project task types available in the current project""" + project = io.find_one({"type": "project"}) + return [task["name"] for task in project["config"]["tasks"]] + + +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.get("label") or app_definition.get("label") or app["name"] + group = app.get("group") or app_definition.get("group") + + action = type( + "app_{}".format(app_name), + (pipeline.Application,), + { + "name": app_name, + "label": label, + "group": group, + "icon": icon, + "color": color, + "order": order, + "config": app_definition.copy() + } + ) + + apps.append(action) + return apps diff --git a/pype/tools/launcher/models.py b/pype/tools/launcher/models.py new file mode 100644 index 0000000000..17c28c19b3 --- /dev/null +++ b/pype/tools/launcher/models.py @@ -0,0 +1,292 @@ +import os +import copy +import logging +import collections + +from . import lib +from Qt import QtCore, QtGui +from avalon.vendor import qtawesome +from avalon import io, style, api + + +log = logging.getLogger(__name__) + +icons_dir = "C:/Users/iLLiCiT/Desktop/Prace/pype-setup/repos/pype/pype/resources/app_icons" + + +class TaskModel(QtGui.QStandardItemModel): + """A model listing the tasks combined for a list of assets""" + + def __init__(self, parent=None): + super(TaskModel, self).__init__(parent=parent) + 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 io.Session.get("AVALON_PROJECT") is None: + return + + # Get the project configured icons from database + project = io.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(io.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 = collections.Counter() + for asset_doc in asset_docs: + asset_tasks = asset_doc.get("data", {}).get("tasks", []) + 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, count in sorted(task_names.items()): + 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 + + def __init__(self, parent=None): + super(ActionModel, self).__init__(parent=parent) + self._icon_cache = {} + self._group_icon_cache = {} + 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 io.Session.get("AVALON_PROJECT") is None: + self._registered_actions = list() + return + + # Discover all registered actions + actions = api.discover(api.Action) + + # Get available project actions and the application actions + project_doc = io.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_name = action.icon + if not icon_name: + if skip_default: + return None + return self.default_icon + + icon = self._icon_cache.get(icon_name) + if icon: + return icon + + icon = self.default_icon + icon_path = os.path.join(icons_dir, icon_name) + if os.path.exists(icon_path): + icon = QtGui.QIcon(icon_path) + self._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: + print("Can't load icon \"{}\"".format(icon_name)) + + self._icon_cache[icon_name] = 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 = [] + grouped_actions = collections.defaultdict(list) + for action in actions: + group_name = getattr(action, "group", None) + if not group_name: + single_actions.append(action) + else: + grouped_actions[group_name].append(action) + + for group_name, actions in tuple(grouped_actions.items()): + if len(actions) == 1: + grouped_actions.pop(group_name) + single_actions.append(actions[0]) + + items_by_order = collections.defaultdict(list) + for action in single_actions: + icon = self.get_icon(action) + item = QtGui.QStandardItem( + icon, str(action.label or action.name) + ) + 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 = self.get_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, parent=None): + super(ProjectModel, self).__init__(parent=parent) + + 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(io.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..c48376ae91 --- /dev/null +++ b/pype/tools/launcher/widgets.py @@ -0,0 +1,419 @@ +import copy +from Qt import QtWidgets, QtCore, QtGui +from avalon.vendor import qtawesome +from avalon import api + +from .models import TaskModel, ActionModel, ProjectModel +from .flickcharm import FlickCharm + + +class ProjectBar(QtWidgets.QWidget): + project_changed = QtCore.Signal(int) + + def __init__(self, parent=None): + super(ProjectBar, self).__init__(parent) + + layout = QtWidgets.QHBoxLayout(self) + + self.model = ProjectModel() + self.model.hide_invisible = True + + self.view = QtWidgets.QComboBox() + self.view.setModel(self.model) + self.view.setRootModelIndex(QtCore.QModelIndex()) + + layout.addWidget(self.view) + + self.setSizePolicy( + QtWidgets.QSizePolicy.MinimumExpanding, + QtWidgets.QSizePolicy.Maximum + ) + + # Initialize + self.refresh() + + # Signals + self.view.currentIndexChanged.connect(self.project_changed) + + # Set current project by default if it's set. + project_name = api.Session.get("AVALON_PROJECT") + if project_name: + self.set_project(project_name) + + def get_current_project(self): + return self.view.currentText() + + def set_project(self, project_name): + index = self.view.findText(project_name) + if index >= 0: + self.view.setCurrentIndex(index) + + def refresh(self): + prev_project_name = self.get_current_project() + + # Refresh without signals + self.view.blockSignals(True) + self.model.refresh() + + self.set_project(prev_project_name) + + self.view.blockSignals(False) + + self.project_changed.emit(self.view.currentIndex()) + + +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_role, *args, **kwargs): + super(ActionDelegate, self).__init__(*args, **kwargs) + self.group_role = group_role + + def paint(self, painter, option, index): + super(ActionDelegate, self).paint(painter, option, index) + is_group = index.data(self.group_role) + 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 + + +class ActionBar(QtWidgets.QWidget): + """Launcher interface""" + + action_clicked = QtCore.Signal(object) + + def __init__(self, parent=None): + super(ActionBar, self).__init__(parent) + + layout = QtWidgets.QHBoxLayout(self) + layout.setContentsMargins(8, 0, 8, 0) + + view = QtWidgets.QListView(self) + view.setObjectName("ActionView") + 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) + view.setModel(model) + + delegate = ActionDelegate(model.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 index.isValid(): + is_group = action = index.data(self.model.GROUP_ROLE) + if not is_group: + action = index.data(self.model.ACTION_ROLE) + self.action_clicked.emit(action) + return + + menu = QtWidgets.QMenu(self) + actions = index.data(self.model.ACTION_ROLE) + actions_mapping = {} + for action in actions: + menu_action = QtWidgets.QAction(action.label or action.name) + menu.addAction(menu_action) + actions_mapping[menu_action] = action + + 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): + super(TasksWidget, self).__init__() + + view = QtWidgets.QTreeView() + view.setIndentation(0) + model = TaskModel() + 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 + + point = QtGui.QCursor().pos() + + 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_name = action.icon + color = action.color or "white" + icon = qtawesome.icon("fa.%s" % icon_name, color=color) + 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 + width = 40 + (largest_label_num_chars * 7) # padding + icon + text + entry_height = 21 + height = entry_height * len(self._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) + + 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 + try: + index = self._history.index(key) + self._history.pop(index) + except ValueError: + pass + + 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[:] = [] + + +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 = self.directions.get(direction) + assert offset is not None, "invalid slide direction: %s" % (direction,) + + width = self.frameRect().width() + height = self.frameRect().height() + offset = QtCore.QPoint(offset.x() * width, offset.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()