ayon-core/openpype/tools/launcher/widgets.py
2023-01-16 11:19:24 +01:00

566 lines
18 KiB
Python

import copy
import time
import collections
from qtpy import QtWidgets, QtCore, QtGui
import qtawesome
from openpype.tools.flickcharm import FlickCharm
from openpype.tools.utils.assets_widget import SingleSelectAssetsWidget
from openpype.tools.utils.tasks_widget import TasksWidget
from .delegates import ActionDelegate
from . import lib
from .models import (
ActionModel,
ProjectModel,
LauncherAssetsModel,
AssetRecursiveSortFilterModel,
LauncherTaskModel,
LauncherTasksProxyModel
)
from .actions import ApplicationAction
from .constants import (
ACTION_ROLE,
GROUP_ROLE,
VARIANT_GROUP_ROLE,
ACTION_ID_ROLE,
ANIMATION_START_ROLE,
ANIMATION_STATE_ROLE,
ANIMATION_LEN,
FORCE_NOT_OPEN_WORKFILE_ROLE
)
class ProjectBar(QtWidgets.QWidget):
def __init__(self, launcher_model, parent=None):
super(ProjectBar, self).__init__(parent)
project_combobox = QtWidgets.QComboBox(self)
# Change delegate so stylysheets are applied
project_delegate = QtWidgets.QStyledItemDelegate(project_combobox)
project_combobox.setItemDelegate(project_delegate)
model = ProjectModel(launcher_model)
project_combobox.setModel(model)
project_combobox.setRootModelIndex(QtCore.QModelIndex())
layout = QtWidgets.QHBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
layout.addWidget(project_combobox)
self.setSizePolicy(
QtWidgets.QSizePolicy.MinimumExpanding,
QtWidgets.QSizePolicy.Maximum
)
self._launcher_model = launcher_model
self.project_delegate = project_delegate
self.project_combobox = project_combobox
self._model = model
# Signals
self.project_combobox.currentIndexChanged.connect(self.on_index_change)
launcher_model.project_changed.connect(self._on_project_change)
# Set current project by default if it's set.
project_name = launcher_model.project_name
if project_name:
self.set_project(project_name)
def _on_project_change(self, project_name):
if self.get_current_project() == project_name:
return
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:
# Try refresh combobox model
self._launcher_model.refresh_projects()
index = self.project_combobox.findText(project_name)
if index >= 0:
self.project_combobox.setCurrentIndex(index)
def on_index_change(self, idx):
if not self.isVisible():
return
project_name = self.get_current_project()
self._launcher_model.set_project_name(project_name)
class LauncherTaskWidget(TasksWidget):
def __init__(self, launcher_model, *args, **kwargs):
self._launcher_model = launcher_model
super(LauncherTaskWidget, self).__init__(*args, **kwargs)
def _create_source_model(self):
return LauncherTaskModel(self._launcher_model, self._dbcon)
def _create_proxy_model(self, source_model):
proxy = LauncherTasksProxyModel(self._launcher_model)
proxy.setSourceModel(source_model)
return proxy
class LauncherAssetsWidget(SingleSelectAssetsWidget):
def __init__(self, launcher_model, *args, **kwargs):
self._launcher_model = launcher_model
super(LauncherAssetsWidget, self).__init__(*args, **kwargs)
launcher_model.assets_refresh_started.connect(self._on_refresh_start)
self.set_current_asset_btn_visibility(False)
def _on_refresh_start(self):
self._set_loading_state(loading=True, empty=True)
self.refresh_triggered.emit()
@property
def refreshing(self):
return self._model.refreshing
def refresh(self):
self._launcher_model.refresh_assets(force=True)
def stop_refresh(self):
raise ValueError("bug stop_refresh called")
def _refresh_model(self, clear=False):
raise ValueError("bug _refresh_model called")
def _create_source_model(self):
model = LauncherAssetsModel(self._launcher_model, self.dbcon)
model.refreshed.connect(self._on_model_refresh)
return model
def _create_proxy_model(self, source_model):
proxy = AssetRecursiveSortFilterModel(self._launcher_model)
proxy.setSourceModel(source_model)
proxy.setFilterCaseSensitivity(QtCore.Qt.CaseInsensitive)
proxy.setSortCaseSensitivity(QtCore.Qt.CaseInsensitive)
return proxy
def _on_model_refresh(self, has_item):
self._proxy.sort(0)
self._set_loading_state(loading=False, empty=not has_item)
self.refreshed.emit()
def _on_filter_text_change(self, new_text):
self._launcher_model.set_asset_name_filter(new_text)
class ActionBar(QtWidgets.QWidget):
"""Launcher interface"""
action_clicked = QtCore.Signal(object)
def __init__(self, launcher_model, dbcon, parent=None):
super(ActionBar, self).__init__(parent)
self._launcher_model = launcher_model
self.dbcon = dbcon
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.setContextMenuPolicy(QtCore.Qt.CustomContextMenu)
view.setEditTriggers(QtWidgets.QAbstractItemView.NoEditTriggers)
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(
[GROUP_ROLE, VARIANT_GROUP_ROLE],
self
)
view.setItemDelegate(delegate)
layout = QtWidgets.QHBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
layout.addWidget(view)
self.model = model
self.view = view
self._animated_items = set()
animation_timer = QtCore.QTimer()
animation_timer.setInterval(50)
animation_timer.timeout.connect(self._on_animation)
self._animation_timer = animation_timer
# Make view flickable
flick = FlickCharm(parent=view)
flick.activateOn(view)
self.set_row_height(1)
launcher_model.projects_refreshed.connect(self._on_projects_refresh)
view.clicked.connect(self.on_clicked)
view.customContextMenuRequested.connect(self.on_context_menu)
self._context_menu = None
self._discover_on_menu = False
def discover_actions(self):
if self._context_menu is not None:
self._discover_on_menu = True
return
if self._animation_timer.isActive():
self._animation_timer.stop()
self.model.discover()
def filter_actions(self):
if self._animation_timer.isActive():
self._animation_timer.stop()
self.model.filter_actions()
def set_row_height(self, rows):
self.setMinimumHeight(rows * 75)
def _on_projects_refresh(self):
self.discover_actions()
def _on_animation(self):
time_now = time.time()
for action_id in tuple(self._animated_items):
item = self.model.items_by_id.get(action_id)
if not item:
self._animated_items.remove(action_id)
continue
start_time = item.data(ANIMATION_START_ROLE)
if (time_now - start_time) > ANIMATION_LEN:
item.setData(0, ANIMATION_STATE_ROLE)
self._animated_items.remove(action_id)
if not self._animated_items:
self._animation_timer.stop()
self.update()
def _start_animation(self, index):
# Offset refresh timout
self._launcher_model.start_refresh_timer()
action_id = index.data(ACTION_ID_ROLE)
item = self.model.items_by_id.get(action_id)
if item:
item.setData(time.time(), ANIMATION_START_ROLE)
item.setData(1, ANIMATION_STATE_ROLE)
self._animated_items.add(action_id)
self._animation_timer.start()
def on_context_menu(self, point):
"""Creates menu to force skip opening last workfile."""
index = self.view.indexAt(point)
if not index.isValid():
return
action_item = index.data(ACTION_ROLE)
if not self.model.is_application_action(action_item):
return
menu = QtWidgets.QMenu(self.view)
checkbox = QtWidgets.QCheckBox("Skip opening last workfile.",
menu)
if index.data(FORCE_NOT_OPEN_WORKFILE_ROLE):
checkbox.setChecked(True)
action_id = index.data(ACTION_ID_ROLE)
checkbox.stateChanged.connect(
lambda: self.on_checkbox_changed(checkbox.isChecked(),
action_id))
action = QtWidgets.QWidgetAction(menu)
action.setDefaultWidget(checkbox)
menu.addAction(action)
self._context_menu = menu
global_point = self.mapToGlobal(point)
menu.exec_(global_point)
self._context_menu = None
if self._discover_on_menu:
self._discover_on_menu = False
self.discover_actions()
def on_checkbox_changed(self, is_checked, action_id):
self.model.update_force_not_open_workfile_settings(is_checked,
action_id)
self.view.update()
if self._context_menu is not None:
self._context_menu.close()
def on_clicked(self, index):
if not index or not index.isValid():
return
is_group = index.data(GROUP_ROLE)
is_variant_group = index.data(VARIANT_GROUP_ROLE)
force_not_open_workfile = index.data(FORCE_NOT_OPEN_WORKFILE_ROLE)
if not is_group and not is_variant_group:
action = index.data(ACTION_ROLE)
# Change data of application action
if issubclass(action, ApplicationAction):
if force_not_open_workfile:
action.data["start_last_workfile"] = False
else:
action.data.pop("start_last_workfile", None)
self._start_animation(index)
self.action_clicked.emit(action)
return
# Offset refresh timout
self._launcher_model.start_refresh_timer()
actions = index.data(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:
# Label 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 not result:
return
action = actions_mapping[result]
if issubclass(action, ApplicationAction):
if force_not_open_workfile:
action.data["start_last_workfile"] = False
else:
action.data.pop("start_last_workfile", None)
self._start_animation(index)
self.action_clicked.emit(action)
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(QtWidgets.QAbstractItemView.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()