From d312101573e6505fa462390ab3da29aa441c3bcf Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 27 May 2025 18:29:12 +0200 Subject: [PATCH] use tooltip widget to show subactions --- client/ayon_core/style/style.css | 5 + .../tools/launcher/ui/actions_widget.py | 364 +++++++++++++----- 2 files changed, 267 insertions(+), 102 deletions(-) diff --git a/client/ayon_core/style/style.css b/client/ayon_core/style/style.css index 70ab552f75..d75837a656 100644 --- a/client/ayon_core/style/style.css +++ b/client/ayon_core/style/style.css @@ -829,6 +829,11 @@ HintedLineEditButton { } /* Launcher specific stylesheets */ +ActionMenuToolTip { + border: 1px solid #555555; + background: {color:bg-inputs}; +} + ActionVariantWidget { background: transparent; } diff --git a/client/ayon_core/tools/launcher/ui/actions_widget.py b/client/ayon_core/tools/launcher/ui/actions_widget.py index d6338bbb73..071982e3fa 100644 --- a/client/ayon_core/tools/launcher/ui/actions_widget.py +++ b/client/ayon_core/tools/launcher/ui/actions_widget.py @@ -304,13 +304,219 @@ class ActionsQtModel(QtGui.QStandardItemModel): self.refresh() +class ActionMenuToolTip(QtWidgets.QFrame): + def __init__(self, parent): + super().__init__(parent) + + self.setWindowFlags(QtCore.Qt.ToolTip) + self.setAttribute(QtCore.Qt.WA_ShowWithoutActivating, True) + self.setAutoFillBackground(True) + self.setBackgroundRole(QtGui.QPalette.Base) + + # Update size on show + show_timer = QtCore.QTimer() + show_timer.setSingleShot(True) + show_timer.setInterval(5) + + # Close widget if is not updated by event + close_timer = QtCore.QTimer() + close_timer.setSingleShot(True) + close_timer.setInterval(100) + + update_state_timer = QtCore.QTimer() + update_state_timer.setInterval(500) + + main_layout = QtWidgets.QVBoxLayout(self) + main_layout.setContentsMargins(0, 0, 0, 0) + main_layout.setSpacing(0) + + close_timer.timeout.connect(self.close) + show_timer.timeout.connect(self._on_show_timer) + update_state_timer.timeout.connect(self._on_update_state) + + self._main_layout = main_layout + self._show_timer = show_timer + self._close_timer = close_timer + self._update_state_timer = update_state_timer + + self._showed = False + self._mouse_entered = False + self._view_hovered = False + self._current_id = None + self._view = None + self._last_pos = QtCore.QPoint(0, 0) + self._widgets_by_id = {} + + def showEvent(self, event): + self._showed = True + self._update_state_timer.start() + super().showEvent(event) + + def closeEvent(self, event): + self._showed = False + self._update_state_timer.stop() + self._mouse_entered = False + super().closeEvent(event) + + def enterEvent(self, event): + self._mouse_entered = True + self._close_timer.stop() + super().leaveEvent(event) + + def leaveEvent(self, event): + self._mouse_entered = False + super().leaveEvent(event) + if not self._view_hovered: + self._close_timer.start() + + def mouse_entered_view(self): + self._view_hovered = True + + def mouse_left_view(self): + self._view_hovered = False + if not self._mouse_entered: + self._close_timer.start() + + def show_on_event(self, action_id, action_items, view, event): + self._close_timer.stop() + + self._view_hovered = True + + is_current = action_id == self._current_id + if not is_current: + self._current_id = action_id + self._view = view + self._update_items(view, action_items) + + # Nothing to show + if not self._widgets_by_id: + if self._showed: + self.close() + return + + # Make sure is visible + update_position = not is_current + if not self._showed: + update_position = True + self.show() + + self._last_pos = QtCore.QPoint(event.globalPos()) + if not update_position: + # Only resize if is current + self.resize(self.sizeHint()) + else: + # Set geometry to position + # - first make sure widget changes from '_update_items' + # are recalculated + app = QtWidgets.QApplication.instance() + app.processEvents() + self._on_update_state() + + self.raise_() + self._show_timer.start() + + def _on_show_timer(self): + size = self.sizeHint() + self.resize(size) + + def _on_update_state(self): + if not self._view_hovered: + return + size = self.sizeHint() + pos = self._last_pos + offset = 4 + self.setGeometry( + pos.x() + offset, pos.y() + offset, + size.width(), size.height() + ) + + def _update_items(self, view, action_items): + """Update items in the tooltip.""" + # This method can be used to update the content of the tooltip + # with new icon, text and settings button visibility. + + remove_ids = set(self._widgets_by_id.keys()) + new_ids = set() + widgets = [] + + any_has_settings = False + prepared_items = [] + for idx, action_item in enumerate(action_items): + has_settings = bool(action_item.config_fields) + if has_settings: + any_has_settings = True + prepared_items.append((idx, action_item, has_settings)) + + if any_has_settings or len(action_items) > 1: + for idx, action_item, has_settings in prepared_items: + widget = self._widgets_by_id.get(action_item.identifier) + icon = get_qt_icon(action_item.icon) + label = action_item.full_label + if widget is None: + widget = ActionVariantWidget( + action_item.identifier, label, has_settings, self + ) + widget.settings_requested.connect( + view.settings_requested + ) + new_ids.add(action_item.identifier) + self._widgets_by_id[action_item.identifier] = widget + else: + remove_ids.discard(action_item.identifier) + widgets.append((idx, widget)) + + for action_id in remove_ids: + widget = self._widgets_by_id.pop(action_id) + widget.setVisible(False) + self._main_layout.removeWidget(widget) + widget.deleteLater() + + for idx, widget in widgets: + self._main_layout.insertWidget(idx, widget, 0) + + class ActionDelegate(QtWidgets.QStyledItemDelegate): _cached_extender = {} def __init__(self, *args, **kwargs): - super(ActionDelegate, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) self._anim_start_color = QtGui.QColor(178, 255, 246) self._anim_end_color = QtGui.QColor(5, 44, 50) + self._tooltip_widget = None + + def helpEvent(self, event, view, option, index): + if not index.isValid(): + if self._tooltip_widget is not None: + self._tooltip_widget.close() + return False + + action_id = index.data(ACTION_ID_ROLE) + model = index.model() + source_model = model.sourceModel() + if index.data(ACTION_IS_GROUP_ROLE): + action_items = source_model.get_group_items(action_id) + else: + action_items = [source_model.get_action_item_by_id(action_id)] + if self._tooltip_widget is None: + self._tooltip_widget = ActionMenuToolTip(view) + + self._tooltip_widget.show_on_event( + action_id, action_items, view, event + ) + event.setAccepted(True) + return True + + def close_tooltip(self): + if self._tooltip_widget is not None: + self._tooltip_widget.close() + + def mouse_entered_view(self): + if self._tooltip_widget is not None: + self._tooltip_widget.mouse_entered_view() + + def mouse_left_view(self): + if self._tooltip_widget is not None: + self._tooltip_widget.mouse_left_view() def _draw_animation(self, painter, option, index): grid_size = option.widget.gridSize() @@ -430,29 +636,51 @@ class ActionsProxyModel(QtCore.QSortFilterProxyModel): return True +class ActionsView(QtWidgets.QListView): + settings_requested = QtCore.Signal(str) + + def __init__(self, parent): + super().__init__(parent) + self.setProperty("mode", "icon") + self.setObjectName("IconView") + self.setViewMode(QtWidgets.QListView.IconMode) + self.setResizeMode(QtWidgets.QListView.Adjust) + self.setSelectionMode(QtWidgets.QListView.NoSelection) + self.setContextMenuPolicy(QtCore.Qt.CustomContextMenu) + self.setEditTriggers(QtWidgets.QAbstractItemView.NoEditTriggers) + self.setWrapping(True) + self.setGridSize(QtCore.QSize(70, 75)) + self.setIconSize(QtCore.QSize(30, 30)) + self.setSpacing(0) + self.setWordWrap(True) + self.setToolTipDuration(150) + + delegate = ActionDelegate(self) + self.setItemDelegate(delegate) + + # Make view flickable + flick = FlickCharm(parent=self) + flick.activateOn(self) + + self._flick = flick + self._delegate = delegate + + def enterEvent(self, event): + super().enterEvent(event) + self._delegate.mouse_entered_view() + + def leaveEvent(self, event): + super().leaveEvent(event) + self._delegate.mouse_left_view() + + class ActionsWidget(QtWidgets.QWidget): def __init__(self, controller, parent): - super(ActionsWidget, self).__init__(parent) + super().__init__(parent) self._controller = controller - 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) - - # Make view flickable - flick = FlickCharm(parent=view) - flick.activateOn(view) + view = ActionsView(self) model = ActionsQtModel(controller) @@ -460,9 +688,6 @@ class ActionsWidget(QtWidgets.QWidget): proxy_model.setSourceModel(model) view.setModel(proxy_model) - delegate = ActionDelegate(self) - view.setItemDelegate(delegate) - layout = QtWidgets.QHBoxLayout(self) layout.setContentsMargins(0, 0, 0, 0) layout.addWidget(view) @@ -472,13 +697,12 @@ class ActionsWidget(QtWidgets.QWidget): animation_timer.timeout.connect(self._on_animation) view.clicked.connect(self._on_clicked) - view.customContextMenuRequested.connect(self._on_context_menu) + view.settings_requested.connect(self._show_config_dialog) model.refreshed.connect(self._on_model_refresh) self._animated_items = set() self._animation_timer = animation_timer - self._flick = flick self._view = view self._model = model self._proxy_model = proxy_model @@ -572,37 +796,27 @@ class ActionsWidget(QtWidgets.QWidget): return is_group = index.data(ACTION_IS_GROUP_ROLE) + # TODO define and store what is default action for a group action_id = index.data(ACTION_ID_ROLE) project_name = self._model.get_selected_project_name() folder_id = self._model.get_selected_folder_id() task_id = self._model.get_selected_task_id() - if is_group: - action_item = self._show_menu_on_group(action_id) - if action_item is None: - return - - action_id = action_item.identifier - action_label = action_item.full_label - action_type = action_item.action_type - addon_name = action_item.addon_name - addon_version = action_item.addon_version - else: - action_label = index.data(QtCore.Qt.DisplayRole) - action_type = index.data(ACTION_TYPE_ROLE) - addon_name = index.data(ACTION_ADDON_NAME_ROLE) - addon_version = index.data(ACTION_ADDON_VERSION_ROLE) + action_type = index.data(ACTION_TYPE_ROLE) if action_type == "webaction": + action_item = self._model.get_action_item_by_id(action_id) context = WebactionContext( action_id, project_name, folder_id, task_id, - addon_name, - addon_version + action_item.addon_name, + action_item.add_version + ) + self._controller.trigger_webaction( + context, action_item.full_label ) - self._controller.trigger_webaction(context, action_label) else: self._controller.trigger_action( action_id, project_name, folder_id, task_id @@ -610,57 +824,12 @@ class ActionsWidget(QtWidgets.QWidget): self._start_animation(index) - def _show_menu_on_group(self, action_id): - action_items = self._model.get_group_items(action_id) - - menu = QtWidgets.QMenu(self) - actions_mapping = {} - - def on_settings_clicked(identifier): - # Close menu - menu.close() - - # Show config dialog - self._show_config_dialog(identifier, False) - - for action_item in action_items: - menu_action = ActionVariantAction( - action_item.identifier, - action_item.full_label, - bool(action_item.config_fields), - menu, - ) - menu_action.settings_requested.connect(on_settings_clicked) - menu.addAction(menu_action) - actions_mapping[menu_action] = action_item - - result = menu.exec_(QtGui.QCursor.pos()) - if not result: - return None - - return actions_mapping[result] - - 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_id = index.data(ACTION_ID_ROLE) - if not action_id: - return - is_group = index.data(ACTION_IS_GROUP_ROLE) - self._show_config_dialog(action_id, is_group) - - def _show_config_dialog(self, action_id, is_group): - item = self._model.get_item_by_id(action_id) + def _show_config_dialog(self, action_id): + action_item = self._model.get_action_item_by_id(action_id) config_fields = self._model.get_action_config_fields(action_id) if not config_fields: return - addon_name = item.data(ACTION_ADDON_NAME_ROLE) - addon_version = item.data(ACTION_ADDON_VERSION_ROLE) - project_name = self._model.get_selected_project_name() folder_id = self._model.get_selected_folder_id() task_id = self._model.get_selected_task_id() @@ -669,8 +838,8 @@ class ActionsWidget(QtWidgets.QWidget): project_name=project_name, folder_id=folder_id, task_id=task_id, - addon_name=addon_name, - addon_version=addon_version, + addon_name=action_item.addon_name, + addon_version=action_item.addon_version, ) values = self._controller.get_action_config_values(context) @@ -682,17 +851,8 @@ class ActionsWidget(QtWidgets.QWidget): ) dialog.set_values(values) result = dialog.exec_() - if result != QtWidgets.QDialog.Accepted: - return - new_values = dialog.get_values() - if is_group: - action_items = self._model.get_group_items(action_id) - action_ids = [item.identifier for item in action_items] - else: - action_ids = [action_id] - - for action_id in action_ids: - context.identifier = action_id + if result == QtWidgets.QDialog.Accepted: + new_values = dialog.get_values() self._controller.set_action_config_values(context, new_values) def _create_attrs_dialog(