diff --git a/libs/framework-core/source/ftrack_framework_core/widget/widget.py b/libs/framework-core/source/ftrack_framework_core/widget/widget.py index 2016c5591a..fef638ed8e 100644 --- a/libs/framework-core/source/ftrack_framework_core/widget/widget.py +++ b/libs/framework-core/source/ftrack_framework_core/widget/widget.py @@ -92,6 +92,16 @@ def set_plugin_option(self, name, value): ''' self.plugin_options = {name: value} + def remove_plugin_option(self, name): + ''' + Updates the *name* option of the current plugin with the given *value* + ''' + try: + self._options.pop(name) + self.on_set_plugin_option(self._options) + except: + pass + def validate(self): '''Re implement this method to add validation to the widget''' return None diff --git a/libs/qt-style/release_notes.md b/libs/qt-style/release_notes.md index 087ae05bcb..0d7a27019c 100644 --- a/libs/qt-style/release_notes.md +++ b/libs/qt-style/release_notes.md @@ -1,5 +1,9 @@ # ftrack Framework QT Style library release Notes +## upcoming + +* [changed] LineEdit; Support label property. + ## v3.0.0 2024-09-19 diff --git a/libs/qt/release_notes.md b/libs/qt/release_notes.md index 5851f1112e..33b6b2386b 100644 --- a/libs/qt/release_notes.md +++ b/libs/qt/release_notes.md @@ -1,5 +1,14 @@ # ftrack QT library release Notes + +## upcoming + +* [new] MenuButton; Button supporting menu if multiple action items provided. +* [changed] Improved AccordionWidget and AccordionHeaderWidget to use EditableLabelWidget and possibility to add a remove button. Emits bin_clicked, title_changed, title_edited signals. +* [changed] StatusMaterialIconWidget clickable. +* [new] Editable label widget. + + ## v3.0.0 2024-09-19 diff --git a/libs/qt/source/ftrack_qt/widgets/accordion/accordion_widget.py b/libs/qt/source/ftrack_qt/widgets/accordion/accordion_widget.py index 8b27bc00c4..490d3fa2bf 100644 --- a/libs/qt/source/ftrack_qt/widgets/accordion/accordion_widget.py +++ b/libs/qt/source/ftrack_qt/widgets/accordion/accordion_widget.py @@ -17,15 +17,23 @@ class AccordionBaseWidget(QtWidgets.QFrame): doubleClicked = QtCore.Signal( object ) # Emitted when accordion is double clicked + bin_clicked = QtCore.Signal(object) # Emitted when bin icon is clicked show_options_overlay = QtCore.Signal(object) hide_options_overlay = QtCore.Signal() enabled_changed = QtCore.Signal(object) + title_changed = QtCore.Signal(object) + title_edited = QtCore.Signal(object) @property def title(self): '''Return the title text shown in header by default''' return self._title + @property + def editable_title(self): + '''Return the title text shown in header by default''' + return self._editable_title + @property def selectable(self): '''Return the current selection mode''' @@ -41,6 +49,16 @@ def show_checkbox(self): '''Return True if accordion is checkable with a checkbox''' return self._show_checkbox + @property + def show_settings(self): + '''Return True if accordion is checkable with a checkbox''' + return self._show_settings + + @property + def show_status(self): + '''Return True if accordion is checkable with a checkbox''' + return self._show_status + @property def collapsed(self): '''Return True if accordion is collapsed - content hidden (default)''' @@ -68,16 +86,31 @@ def checked(self): '''(Checkable) Return True if checked''' return self._checked + @property + def removable(self): + ''' + Return True if accordion is removable - can be deleted by user + ''' + return self._removable + + @property + def previous_title(self): + return self._header_widget.previous_title + def __init__( self, selectable=False, show_checkbox=False, + show_settings=True, + show_status=True, checkable=False, title=None, + editable_title=False, selected=False, checked=True, collapsable=True, collapsed=True, + removable=False, parent=None, ): ''' @@ -102,8 +135,12 @@ def __init__( self._selectable = selectable self._checkable = checkable self._show_checkbox = show_checkbox + self._show_settings = show_settings + self._show_status = show_status self._title = title + self._editable_title = editable_title self._collapsable = collapsable + self._removable = removable self._selected = selected self._checked = checked @@ -140,11 +177,15 @@ def build(self): # Create Header self._header_widget = AccordionHeaderWidget( title=self.title, + editable_title=self.editable_title, checkable=self.checkable, checked=self.checked, show_checkbox=self.show_checkbox, + show_settings=self.show_settings, + show_status=self.show_status, collapsable=self.collapsable, collapsed=self.collapsed, + removable=self.removable, ) # Add header to main widget @@ -170,12 +211,15 @@ def post_build(self): self._header_widget.arrow_clicked.connect( self._on_header_arrow_clicked ) + self._header_widget.bin_clicked.connect(self._on_header_bin_clicked) self._header_widget.show_options_overlay.connect( self._on_show_options_overlay_callback ) self._header_widget.hide_options_overlay.connect( self._on_hide_options_overlay_callback ) + self._header_widget.title_changed.connect(self._on_title_changed) + self._header_widget.title_edited.connect(self._on_title_edited) self._content_widget.setVisible(not self._collapsed) self._content_widget.setEnabled(self.checked) @@ -185,6 +229,12 @@ def _on_show_options_overlay_callback(self, widget): def _on_hide_options_overlay_callback(self): self.hide_options_overlay.emit() + def _on_title_changed(self, title): + self.title_changed.emit(title) + + def _on_title_edited(self, title): + self.title_edited.emit(title) + def add_option_widget(self, widget, section_name): self._header_widget.add_option_widget(widget, section_name) @@ -234,6 +284,10 @@ def _on_header_arrow_clicked(self, event): # This is the way to collapse self.toggle_collapsed() + def _on_header_bin_clicked(self, event): + '''Callback on header arrow user click''' + self.bin_clicked.emit(event) + def toggle_collapsed(self): '''Toggle the accordion collapsed state''' self._collapsed = not self._collapsed @@ -274,6 +328,10 @@ def update_accordion(self): 'selected' if self._selected else 'transparent', ) + def set_title(self, new_title): + self._title = new_title + self._header_widget.set_title(self.title) + def teardown(self): '''Teardown the header widget - properly cleanup the options overlay''' self._header_widget.teardown() diff --git a/libs/qt/source/ftrack_qt/widgets/buttons/__init__.py b/libs/qt/source/ftrack_qt/widgets/buttons/__init__.py index 53152c6e91..b79ce4ef36 100644 --- a/libs/qt/source/ftrack_qt/widgets/buttons/__init__.py +++ b/libs/qt/source/ftrack_qt/widgets/buttons/__init__.py @@ -4,3 +4,4 @@ ProgressStatusButtonWidget, ProgressPhaseButtonWidget, ) +from ftrack_qt.widgets.buttons.menu_button import MenuButton diff --git a/libs/qt/source/ftrack_qt/widgets/buttons/menu_button.py b/libs/qt/source/ftrack_qt/widgets/buttons/menu_button.py new file mode 100644 index 0000000000..4b79645d23 --- /dev/null +++ b/libs/qt/source/ftrack_qt/widgets/buttons/menu_button.py @@ -0,0 +1,75 @@ +# :coding: utf-8 +# :copyright: Copyright (c) 2024 ftrack + +from functools import partial + +try: + from PySide6 import QtWidgets, QtCore +except ImportError: + from PySide2 import QtWidgets, QtCore + + +class MenuButton(QtWidgets.QWidget): + item_clicked = QtCore.Signal(object) + + @property + def button_widget(self): + return self._button_widget + + @property + def menu_items(self): + return self._menu_items + + def __init__(self, button_widget, menu_items=None, parent=None): + super(MenuButton, self).__init__(parent) + + self._button_widget = button_widget + if not menu_items: + menu_items = [] + self._menu_items = menu_items + + self.pre_build() + self.build() + self.post_build() + + def pre_build(self): + layout = QtWidgets.QVBoxLayout() + self.setLayout(layout) + layout.setContentsMargins(0, 0, 0, 0) + + def build(self): + self.layout().addWidget(self.button_widget) + + def post_build(self): + self.button_widget.clicked.connect(self.show_menu) + + def add_item(self, item_data, label): + self.menu_items.append( + { + 'data': item_data, + 'label': label, + } + ) + + def show_menu(self): + if len(self.menu_items) == 1: + return self.menu_item_selected(self.menu_items[0]) + # Create a QMenu + menu = QtWidgets.QMenu(self.button_widget) + + # Add menu items + for _item in self.menu_items: + action = menu.addAction(_item['label']) + # Connect each action to a slot (optional) + action.triggered.connect(partial(self.menu_item_selected, _item)) + + # Show the menu below the button + menu.exec_( + self.button_widget.mapToGlobal( + QtCore.QPoint(0, self.button_widget.height()) + ) + ) + + def menu_item_selected(self, item): + # Handle the selected menu item (this is a placeholder for your custom logic) + self.item_clicked.emit(item['data']) diff --git a/libs/qt/source/ftrack_qt/widgets/headers/accordion_header_widget.py b/libs/qt/source/ftrack_qt/widgets/headers/accordion_header_widget.py index 44881d7cf9..e64174bd68 100644 --- a/libs/qt/source/ftrack_qt/widgets/headers/accordion_header_widget.py +++ b/libs/qt/source/ftrack_qt/widgets/headers/accordion_header_widget.py @@ -13,6 +13,7 @@ ) from ftrack_qt.widgets.lines import LineWidget from ftrack_qt.widgets.buttons import OptionsButton +from ftrack_qt.widgets.labels import EditableLabel class AccordionHeaderWidget(QtWidgets.QFrame): @@ -21,14 +22,23 @@ class AccordionHeaderWidget(QtWidgets.QFrame): clicked = QtCore.Signal(object) # User header click arrow_clicked = QtCore.Signal(object) # User header click + bin_clicked = QtCore.Signal(object) # User header click checkbox_status_changed = QtCore.Signal(object) show_options_overlay = QtCore.Signal(object) hide_options_overlay = QtCore.Signal() + title_changed = QtCore.Signal(object) + title_edited = QtCore.Signal(object) @property def title(self): + if self._title_label: + return self._title_label.text() return self._title + @property + def editable_title(self): + return self._editable_title + @property def checkable(self): return self._checkable @@ -41,6 +51,14 @@ def checked(self): def show_checkbox(self): return self._show_checkbox + @property + def show_settings(self): + return self._show_settings + + @property + def show_status(self): + return self._show_status + @property def collapsable(self): return self._collapsable @@ -49,18 +67,30 @@ def collapsable(self): def collapsed(self): return self._collapsed + @property + def removable(self): + return self._removable + @property def options_button(self): return self._options_button + @property + def previous_title(self): + return self._title_label.previous_text + def __init__( self, title=None, + editable_title=False, checkable=False, checked=True, show_checkbox=False, + show_settings=False, + show_status=False, collapsable=True, collapsed=True, + removable=False, parent=None, ): ''' @@ -72,11 +102,15 @@ def __init__( super(AccordionHeaderWidget, self).__init__(parent=parent) self._title = title + self._editable_title = editable_title self._checkable = checkable self._checked = checked self._show_checkbox = show_checkbox + self._show_settings = show_settings + self._show_status = show_status self._collapsable = collapsable self._collapsed = collapsed + self._removable = removable self._checkbox = None self._title_label = None @@ -85,6 +119,7 @@ def __init__( self._status = None self._options_button = None self._status_icon = None + self._bin = None self.pre_build() self.build() @@ -104,7 +139,9 @@ def build(self): self._checkbox.setVisible(self.show_checkbox) # Create title - self._title_label = QtWidgets.QLabel(self.title or '') + self._title_label = EditableLabel( + text=self.title, editable=self.editable_title + ) if not self.title: self._title_label.hide() @@ -125,31 +162,42 @@ def build(self): self.title, MaterialIcon('settings', color='gray') ) self._options_button.setProperty('borderless', True) + self._options_button.setVisible(self._show_settings) content_layout.addWidget(LineWidget(horizontal=True)) # add status icon self._status_icon = StatusMaterialIconWidget('check') + self._status_icon.setVisible(self._show_status) # Create Arrow self._arrow = ArrowMaterialIconWidget(None) self.update_arrow_icon(self.collapsed) self._arrow.setVisible(self.collapsable) + if self.removable: + # Create Bin + self._bin = StatusMaterialIconWidget(name='delete_outline') + self.layout().addWidget(self._checkbox) self.layout().addWidget(self._title_label) self.layout().addWidget(self._header_content_widget, 10) self.layout().addWidget(self._options_button) self.layout().addWidget(self._status_icon) self.layout().addWidget(self._arrow) + if self._bin: + self.layout().addWidget(self._bin) def post_build(self): self._checkbox.stateChanged.connect(self._on_checkbox_status_changed) self._arrow.clicked.connect(self._on_arrow_clicked) + if self._bin: + self._bin.clicked.connect(self._on_bin_clicked) self._options_button.show_overlay_signal.connect( self.on_show_options_callback ) self._options_button.hide_overlay_signal.connect( self.on_hide_options_callback ) + self._title_label.editingFinished.connect(self._on_title_edited) def on_show_options_callback(self, widget): self.show_options_overlay.emit(widget) @@ -169,6 +217,9 @@ def _on_checkbox_status_changed(self): def _on_arrow_clicked(self, event): self.arrow_clicked.emit(event) + def _on_bin_clicked(self, event): + self.bin_clicked.emit(event) + def update_arrow_icon(self, collapsed): '''Update the arrow icon based on collapse state''' if collapsed: @@ -187,6 +238,25 @@ def set_status(self, status, message): '''Set status message within header, to be implemented by child''' pass + def _on_title_changed(self): + ''' + Emit signal when title is changed + ''' + self.title_changed.emit(self._title_label.text()) + + def _on_title_edited(self): + ''' + Emit signal when title is changed + ''' + self.title_edited.emit(self._title_label.text()) + + def set_title(self, new_title): + ''' + Set the title of the header to *new_title* + ''' + self._title_label.setText(new_title) + self._on_title_changed() + def teardown(self): '''Teardown the options button - properly cleanup the options overlay''' self._options_button.teardown() diff --git a/libs/qt/source/ftrack_qt/widgets/icons/status_icon.py b/libs/qt/source/ftrack_qt/widgets/icons/status_icon.py index 50a49dc83e..ec4b4d6d52 100644 --- a/libs/qt/source/ftrack_qt/widgets/icons/status_icon.py +++ b/libs/qt/source/ftrack_qt/widgets/icons/status_icon.py @@ -18,6 +18,8 @@ class StatusMaterialIconWidget(QtWidgets.QWidget): '''Material icon widget, support status > icon encoding''' + clicked = QtCore.Signal(object) + @property def icon(self): '''Return the material icon''' @@ -81,3 +83,7 @@ def set_status(self, status, size=16): icon_name, variant=variant, color='#{}'.format(color), size=size ) return color + + def mousePressEvent(self, event): + self.clicked.emit(event) + return super(StatusMaterialIconWidget, self).mousePressEvent(event) diff --git a/libs/qt/source/ftrack_qt/widgets/labels/__init__.py b/libs/qt/source/ftrack_qt/widgets/labels/__init__.py new file mode 100644 index 0000000000..ac14df64b9 --- /dev/null +++ b/libs/qt/source/ftrack_qt/widgets/labels/__init__.py @@ -0,0 +1 @@ +from ftrack_qt.widgets.labels.editable_label import EditableLabel diff --git a/libs/qt/source/ftrack_qt/widgets/labels/editable_label.py b/libs/qt/source/ftrack_qt/widgets/labels/editable_label.py new file mode 100644 index 0000000000..bd1be53c6b --- /dev/null +++ b/libs/qt/source/ftrack_qt/widgets/labels/editable_label.py @@ -0,0 +1,46 @@ +# :coding: utf-8 +# :copyright: Copyright (c) 2024 ftrack + +try: + from PySide6 import QtWidgets, QtCore +except ImportError: + from PySide2 import QtWidgets, QtCore + + +class EditableLabel(QtWidgets.QLineEdit): + '''Editable label widget.''' + + @property + def previous_text(self): + '''Return previous text.''' + return self._previous_text + + @property + def editable(self): + '''Return editable state.''' + return self._editable + + def __init__(self, text=None, editable=True, parent=None): + super(EditableLabel, self).__init__(parent) + self._previous_text = self.text() + self._editable = editable + self.setReadOnly(True) + + # Set the title line edit to be a label + self.setProperty('label', True) + self.setText(text or '') + + self.editingFinished.connect(self.on_editing_finished) + + def setText(self, event): + super(EditableLabel, self).setText(event) + + def mouseDoubleClickEvent(self, event): + if self._editable: + if self.isReadOnly(): + self._previous_text = self.text() + self.setReadOnly(False) + super(EditableLabel, self).mouseDoubleClickEvent(event) + + def on_editing_finished(self): + self.setReadOnly(True) diff --git a/projects/framework-common-extensions/dialogs/generic_publisher_dialog.py b/projects/framework-common-extensions/dialogs/generic_publisher_dialog.py new file mode 100644 index 0000000000..5173c9be9c --- /dev/null +++ b/projects/framework-common-extensions/dialogs/generic_publisher_dialog.py @@ -0,0 +1,549 @@ +# :coding: utf-8 +# :copyright: Copyright (c) 2024 ftrack +import copy +import os +from functools import partial + +import clique + +try: + from PySide6 import QtWidgets, QtCore +except ImportError: + from PySide2 import QtWidgets, QtCore + +import ftrack_constants as constants +from ftrack_utils.framework.config.tool import get_plugins, get_groups +from ftrack_framework_qt.dialogs import BaseContextDialog +from ftrack_qt.widgets.accordion import AccordionBaseWidget +from ftrack_qt.widgets.progress import ProgressWidget +from ftrack_qt.utils.widget import build_progress_data +from ftrack_qt.utils.decorators import invoke_in_qt_main_thread +from ftrack_qt.widgets.buttons import CircularButton +from ftrack_qt.widgets.buttons import MenuButton + + +class GenericPublisherDialog(BaseContextDialog): + '''Default Framework Publisher dialog''' + + name = 'framework_generic_publisher_dialog' + tool_config_type_filter = ['publisher'] + ui_type = 'qt' + run_button_title = 'PUBLISH' + + @property + def modified_tool_config(self): + '''Return the modified tool config''' + return self._modified_tool_config + + def __init__( + self, + event_manager, + client_id, + connect_methods_callback, + connect_setter_property_callback, + connect_getter_property_callback, + dialog_options, + parent=None, + ): + ''' + Initialize Mixin class publisher dialog. It will load the qt dialog and + mix it with the framework dialog. + *event_manager*: instance of + :class:`~ftrack_framework_core.event.EventManager` + *client_id*: Id of the client that initializes the current dialog + *connect_methods_callback*: Client callback method for the dialog to be + able to execute client methods. + *connect_setter_property_callback*: Client callback property setter for + the dialog to be able to read client properties. + *connect_getter_property_callback*: Client callback property getter for + the dialog to be able to write client properties. + *dialog_options*: Dictionary of arguments passed on to configure the + current dialog. + ''' + self._scroll_area = None + self._scroll_area_widget = None + self._progress_widget = None + self._modified_tool_config = None + self._save_preset_button = None + + super(GenericPublisherDialog, self).__init__( + event_manager, + client_id, + connect_methods_callback, + connect_setter_property_callback, + connect_getter_property_callback, + dialog_options, + parent=parent, + ) + self.setWindowTitle('ftrack Publisher') + + def pre_build_ui(self): + # Make sure to remove self._scroll_area in case of reload + if self._scroll_area: + self._scroll_area.deleteLater() + # Create scroll area to add all the widgets + self._scroll_area = QtWidgets.QScrollArea() + self._scroll_area.setStyle(QtWidgets.QStyleFactory.create("plastique")) + self._scroll_area.setWidgetResizable(True) + self._scroll_area.setHorizontalScrollBarPolicy( + QtCore.Qt.ScrollBarAlwaysOff + ) + + index = self.main_layout.indexOf(self.tool_widget) + run_index = self.main_layout.indexOf(self.run_button) + if index != -1: # Ensure the widget is found in the layout + # Remove the old widget from layout + self.main_layout.takeAt(index) + # Insert the new widget at the same position + self.main_layout.insertWidget(index, self._scroll_area) + elif run_index != -1: + # In case tool_widget is not already parented make sure to add scroll + # area above the run button. + self.main_layout.insertWidget((run_index - 1), self._scroll_area) + else: # otherwise set it at the end + self.main_layout.addWidget(self._scroll_area) + + self._scroll_area.setWidget(self.tool_widget) + + def build_ui(self): + # Select the desired tool_config + + self.tool_config = None + tool_config_message = None + if self.filtered_tool_configs.get("publisher"): + if len(self.tool_config_names or []) != 1: + tool_config_message = ( + 'One(1) tool config name must be supplied to publisher!' + ) + else: + tool_config_name = self.tool_config_names[0] + for tool_config in self.filtered_tool_configs["publisher"]: + if ( + tool_config.get('name', '').lower() + == tool_config_name.lower() + ): + self.logger.debug( + f'Using tool config {tool_config_name}' + ) + if self.tool_config != tool_config: + try: + self.tool_config = tool_config + except Exception as error: + tool_config_message = error + break + if not self._progress_widget: + self._progress_widget = ProgressWidget( + 'publish', build_progress_data(tool_config) + ) + self.header.set_widget( + self._progress_widget.status_widget + ) + self.overlay_layout.addWidget( + self._progress_widget.overlay_widget + ) + break + if not self.tool_config and not tool_config_message: + tool_config_message = ( + f'Could not find tool config: "{tool_config_name}"' + ) + else: + tool_config_message = 'No publisher tool configs available!' + + if not self.tool_config: + self.logger.warning(tool_config_message) + label_widget = QtWidgets.QLabel(f'{tool_config_message}') + label_widget.setStyleSheet( + "font-style: italic; font-weight: bold;" + ) + self.tool_widget.layout().addWidget(label_widget) + self.run_button.setEnabled(False) + return + + # Create a new tool_config + self._original_tool_config = self.tool_config + self._modified_tool_config = copy.deepcopy(self._original_tool_config) + # Make sure we generate new references + self.registry.create_unic_references(self._modified_tool_config) + self.tool_config = self._modified_tool_config + + # Sync the tool_config with the host + self.sync_tool_config(self.tool_config) + + # Build context widgets + context_plugins = get_plugins( + self.tool_config, filters={'tags': ['context']} + ) + for context_plugin in context_plugins: + if not context_plugin.get('ui'): + continue + context_widget = self.init_framework_widget(context_plugin) + self.tool_widget.layout().addWidget(context_widget) + + # Build component widgets + # Build top label layout + components_layout = QtWidgets.QHBoxLayout() + components_label = QtWidgets.QLabel('Components') + self._save_preset_button = QtWidgets.QPushButton('Save Preset') + components_layout.addWidget(components_label) + components_layout.addStretch() + components_layout.addWidget(self._save_preset_button) + self.tool_widget.layout().addLayout(components_layout) + + component_groups = get_groups( + self.tool_config, filters={'tags': ['component']} + ) + + generic_groups = get_groups( + self._original_tool_config, filters={'tags': ['generic']} + ) + self._accordion_widgets_registry = [] + for _group in component_groups: + group_accordion_widget = self.add_accordion_group(_group) + self.tool_widget.layout().addWidget(group_accordion_widget) + + circular_add_button = CircularButton('add', variant='outlined') + self.menu_button = MenuButton(circular_add_button) + self.tool_widget.layout().addWidget(self.menu_button) + for _generic_group in generic_groups: + self.menu_button.add_item( + item_data=_generic_group, + label=_generic_group.get('options').get( + 'button_label', 'Add Component' + ), + ) + self.menu_button.item_clicked.connect(self._on_add_component_callback) + + spacer = QtWidgets.QSpacerItem( + 1, + 1, + QtWidgets.QSizePolicy.Minimum, + QtWidgets.QSizePolicy.Expanding, + ) + self.tool_widget.layout().addItem(spacer) + + def _get_latest_component_index(self, idx=1): + # Get the number of items in the layout + count = self.tool_widget.layout().count() + if count > 0: + # Get the last item + item = self.tool_widget.layout().itemAt(count - idx) + if item: + if item.widget() in self._accordion_widgets_registry: + # Return the widget associated with the item + return self.tool_widget.layout().indexOf(item.widget()) + else: + return self._get_latest_component_index(idx + 1) + return 0 + + def _on_add_component_callback(self, _generic_group): + # Create a new unic group + new_group = copy.deepcopy(_generic_group) + # Make sure that we don't duplicate component names + available_name = self.get_available_component_name( + new_group.get('options').get('component') + ) + new_group['options']['component'] = available_name + + # generate references for the new group + self.registry.create_unic_references(new_group, skip_root=True) + + group_accordion_widget = self.add_accordion_group(new_group) + + # Insert the new group before the add button + latest_idx = self._get_latest_component_index() + self.tool_widget.layout().insertWidget( + latest_idx + 1, group_accordion_widget + ) + + # Insert the new group into the correct position in the tool_config + self.insert_group_in_tool_config(new_group, group_accordion_widget) + + # Sync the tool_config with the host + self.sync_tool_config(self.tool_config) + + self._progress_widget.set_data(build_progress_data(self.tool_config)) + + def insert_group_in_tool_config(self, new_group, group_accordion_widget): + ''' + Insert the new group in the tool config in the right position. + ''' + current_idx = self._accordion_widgets_registry.index( + group_accordion_widget + ) + if current_idx > 0: + previous_widget = self._accordion_widgets_registry[current_idx - 1] + previous_group = get_groups( + self.tool_config, + filters={ + 'tags': ['component'], + 'options': {'component': previous_widget.title}, + }, + ) + if previous_group: + previous_group = previous_group[0] + previous_group_idx = self.tool_config['engine'].index( + previous_group + ) + self.tool_config['engine'].insert( + previous_group_idx + 1, new_group + ) + else: + self.tool_config['engine'].append(new_group) + else: + self.tool_config['engine'].insert(0, new_group) + + def get_available_component_name(self, name, skip_widget=None): + def increment_name(name): + if '_' in name and name.rsplit('_', 1)[-1].isdigit(): + base, num = name.rsplit('_', 1) + return f'{base}_{int(num) + 1}' + else: + return f'{name}_1' + + for widget in self._accordion_widgets_registry: + if widget != skip_widget: + if widget.title == name: + return self.get_available_component_name( + increment_name(name), skip_widget + ) + return name + + def add_accordion_group(self, group): + group_accordion_widget = AccordionBaseWidget( + selectable=False, + show_checkbox=True, + checkable=group.get('optional', False), + title=group.get('options').get('component'), + editable_title=group.get('options', {}).get( + 'editable_component_name', False + ), + selected=False, + checked=group.get('enabled', True), + collapsable=True, + collapsed=True, + removable=group.get('options', {}).get('removable', False), + ) + collectors = get_plugins(group, filters={'tags': ['collector']}) + self.add_collector_widgets(collectors, group_accordion_widget, group) + validators = get_plugins(group, filters={'tags': ['validator']}) + self.add_validator_widgets(validators, group_accordion_widget, group) + exporters = get_plugins(group, filters={'tags': ['exporter']}) + self.add_exporter_widgets(exporters, group_accordion_widget, group) + group_accordion_widget.hide_options_overlay.connect( + self.show_main_widget + ) + group_accordion_widget.show_options_overlay.connect( + self.show_options_widget + ) + group_accordion_widget.title_edited.connect( + self._on_component_name_edited_callback + ) + group_accordion_widget.bin_clicked.connect( + self._on_component_removed_callback + ) + group_accordion_widget.enabled_changed.connect( + partial(self._on_enable_component_changed_callback, group) + ) + + self._accordion_widgets_registry.append(group_accordion_widget) + return group_accordion_widget + + def add_collector_widgets( + self, collectors, accordion_widget, group_config=None + ): + for plugin_config in collectors: + if not plugin_config.get('ui'): + continue + widget = self.init_framework_widget(plugin_config, group_config) + accordion_widget.add_widget(widget) + if hasattr(widget, 'path_changed'): + if group_config.get('options', {}).get( + 'editable_component_name', False + ): + widget.path_changed.connect( + partial( + self._on_path_changed_callback, accordion_widget + ) + ) + + def add_validator_widgets( + self, validators, accordion_widget, group_config=None + ): + for plugin_config in validators: + if not plugin_config.get('ui'): + continue + widget = self.init_framework_widget(plugin_config, group_config) + accordion_widget.add_option_widget( + widget, section_name='Validators' + ) + + def add_exporter_widgets( + self, exporters, accordion_widget, group_config=None + ): + for plugin_config in exporters: + if not plugin_config.get('ui'): + continue + widget = self.init_framework_widget(plugin_config, group_config) + accordion_widget.add_option_widget( + widget, section_name='Exporters' + ) + + def post_build_ui(self): + if self._progress_widget: + self._progress_widget.hide_overlay_signal.connect( + self.show_main_widget + ) + self._progress_widget.show_overlay_signal.connect( + self.show_overlay_widget + ) + self._save_preset_button.clicked.connect( + self._on_save_preset_button_clicked + ) + + def _on_path_changed_callback(self, accordion_widget, new_name): + ''' + Callback to update the component name when the path is changed. + ''' + file_extension = None + try: + collection = clique.parse(new_name) + if collection: + file_extension = collection.tail + except Exception as error: + self.logger.debug( + f"{new_name} is not a clique collection. Error {error}" + ) + + if not file_extension: + file_extension = os.path.splitext(new_name)[1] or os.path.basename( + new_name + ) + + file_extension = file_extension.lstrip('.') + + extension = self.get_available_component_name(file_extension) + + group = get_groups( + self.tool_config, + filters={ + 'tags': ['component'], + 'options': {'component': accordion_widget.title}, + }, + )[0] + group['options']['component'] = extension + + accordion_widget.set_title(extension) + + # Sync the tool_config with the host + self.sync_tool_config(self.tool_config) + + def _on_component_name_edited_callback(self, new_name): + new_name = self.get_available_component_name( + new_name, skip_widget=self.sender() + ) + if self.sender().previous_title: + group = get_groups( + self.tool_config, + filters={ + 'tags': ['component'], + 'options': {'component': self.sender().previous_title}, + }, + )[0] + group['options']['component'] = new_name + + self.sender().set_title(new_name) + + # Sync the tool_config with the host + self.sync_tool_config(self.tool_config) + + def _on_component_removed_callback(self, event): + # Remove the group from the tool_config + group = get_groups( + self.tool_config, + filters={ + 'tags': ['component'], + 'options': {'component': self.sender().title}, + }, + ) + if group: + group = group[0] + self.tool_config['engine'].remove(group) + # Remove the widget from the registry + self._accordion_widgets_registry.remove(self.sender()) + # Remove the widget from the layout + self.sender().teardown() + self.sender().deleteLater() + + # Sync the tool_config with the host + self.sync_tool_config(self.tool_config) + + self._progress_widget.set_data(build_progress_data(self.tool_config)) + + def show_options_widget(self, widget): + '''Sets the given *widget* as the index 2 of the stacked widget and + remove the previous one if it exists''' + if self._stacked_widget.widget(2): + self._stacked_widget.removeWidget(self._stacked_widget.widget(2)) + self._stacked_widget.addWidget(widget) + self._stacked_widget.setCurrentIndex(2) + + def _on_enable_component_changed_callback(self, group_config, enabled): + '''Callback for when the component is enabled/disabled''' + self.set_tool_config_option( + {'enabled': enabled}, group_config['reference'] + ) + group_config['enabled'] = enabled + # Sync the tool_config with the host + self.sync_tool_config(self.tool_config) + + self._progress_widget.set_data(build_progress_data(self.tool_config)) + + def _on_run_button_clicked(self): + '''(Override) Drive the progress widget''' + self.show_overlay_widget() + self._progress_widget.run() + super(GenericPublisherDialog, self)._on_run_button_clicked() + # TODO: This will not work in remote mode (async mode) as plugin events + # will arrive after this point of execution. + if self._progress_widget.status == constants.status.SUCCESS_STATUS: + self.clean_ui() + self.pre_build_ui() + self.build_ui() + self.post_build_ui() + # TODO: there is an error here showing the overlay widget because is not repainting all the widegts that has been parented to the self.layout() in the pre_build_ui build_ui or post_build_ui methods. + + @invoke_in_qt_main_thread + def plugin_run_callback(self, log_item): + '''(Override) Pass framework log item to the progress widget''' + self._progress_widget.update_phase_status( + log_item.reference, + log_item.status, + log_message=log_item.message, + time=log_item.execution_time, + ) + + def _on_save_preset_button_clicked(self): + '''Callback to save the current tool config as a preset''' + # Open a save dialog to get the destination + destination = QtWidgets.QFileDialog.getSaveFileName( + self, 'Save Tool Config Preset', '', 'YAML (*.yaml)' + )[0] + args = { + 'tool_config': self.tool_config, + 'destination': destination, + } + self.client_method_connection( + 'save_tool_config_in_destination', arguments=args + ) + + def closeEvent(self, event): + '''(Override) Close the context and progress widgets''' + if self._context_selector: + self._context_selector.teardown() + if self._progress_widget: + self._progress_widget.teardown() + self._progress_widget.deleteLater() + if self._accordion_widgets_registry: + for accordion in self._accordion_widgets_registry: + accordion.teardown() + super(GenericPublisherDialog, self).closeEvent(event) diff --git a/projects/framework-common-extensions/dialogs/simpler_generic_publisher_dialog.py b/projects/framework-common-extensions/dialogs/simpler_generic_publisher_dialog.py new file mode 100644 index 0000000000..e4d50a7c75 --- /dev/null +++ b/projects/framework-common-extensions/dialogs/simpler_generic_publisher_dialog.py @@ -0,0 +1,248 @@ +# :coding: utf-8 +# :copyright: Copyright (c) 2024 ftrack +import copy +import os +from functools import partial + +import clique + +try: + from PySide6 import QtWidgets, QtCore +except ImportError: + from PySide2 import QtWidgets, QtCore + +import ftrack_constants as constants +from ftrack_utils.framework.config.tool import get_plugins, get_groups +from ftrack_framework_qt.dialogs import BaseContextDialog +from ftrack_qt.widgets.progress import ProgressWidget +from ftrack_qt.utils.widget import build_progress_data +from ftrack_qt.utils.decorators import invoke_in_qt_main_thread + + +class SimplerGenericPublisherDialog(BaseContextDialog): + '''Default Framework Publisher dialog''' + + name = 'simpler_framework_generic_publisher_dialog' + tool_config_type_filter = ['publisher'] + ui_type = 'qt' + run_button_title = 'PUBLISH' + + def __init__( + self, + event_manager, + client_id, + connect_methods_callback, + connect_setter_property_callback, + connect_getter_property_callback, + dialog_options, + parent=None, + ): + ''' + Initialize Mixin class publisher dialog. It will load the qt dialog and + mix it with the framework dialog. + *event_manager*: instance of + :class:`~ftrack_framework_core.event.EventManager` + *client_id*: Id of the client that initializes the current dialog + *connect_methods_callback*: Client callback method for the dialog to be + able to execute client methods. + *connect_setter_property_callback*: Client callback property setter for + the dialog to be able to read client properties. + *connect_getter_property_callback*: Client callback property getter for + the dialog to be able to write client properties. + *dialog_options*: Dictionary of arguments passed on to configure the + current dialog. + ''' + self._scroll_area = None + self._scroll_area_widget = None + self._progress_widget = None + self._modified_tool_config = None + self._save_preset_button = None + + super(SimplerGenericPublisherDialog, self).__init__( + event_manager, + client_id, + connect_methods_callback, + connect_setter_property_callback, + connect_getter_property_callback, + dialog_options, + parent=parent, + ) + self.setWindowTitle('ftrack Publisher') + + def pre_build_ui(self): + # Make sure to remove self._scroll_area in case of reload + if self._scroll_area: + self._scroll_area.deleteLater() + # Create scroll area to add all the widgets + self._scroll_area = QtWidgets.QScrollArea() + self._scroll_area.setStyle(QtWidgets.QStyleFactory.create("plastique")) + self._scroll_area.setWidgetResizable(True) + self._scroll_area.setHorizontalScrollBarPolicy( + QtCore.Qt.ScrollBarAlwaysOff + ) + + index = self.main_layout.indexOf(self.tool_widget) + run_index = self.main_layout.indexOf(self.run_button) + if index != -1: # Ensure the widget is found in the layout + # Remove the old widget from layout + self.main_layout.takeAt(index) + # Insert the new widget at the same position + self.main_layout.insertWidget(index, self._scroll_area) + elif run_index != -1: + # In case tool_widget is not already parented make sure to add scroll + # area above the run button. + self.main_layout.insertWidget((run_index - 1), self._scroll_area) + else: # otherwise set it at the end + self.main_layout.addWidget(self._scroll_area) + + self._scroll_area.setWidget(self.tool_widget) + + def build_ui(self): + # Select the desired tool_config + + self.tool_config = None + tool_config_message = None + if self.filtered_tool_configs.get("publisher"): + if len(self.tool_config_names or []) != 1: + tool_config_message = ( + 'One(1) tool config name must be supplied to publisher!' + ) + else: + tool_config_name = self.tool_config_names[0] + for tool_config in self.filtered_tool_configs["publisher"]: + if ( + tool_config.get('name', '').lower() + == tool_config_name.lower() + ): + self.logger.debug( + f'Using tool config {tool_config_name}' + ) + if self.tool_config != tool_config: + try: + self.tool_config = tool_config + except Exception as error: + tool_config_message = error + break + if not self._progress_widget: + self._progress_widget = ProgressWidget( + 'publish', build_progress_data(tool_config) + ) + self.header.set_widget( + self._progress_widget.status_widget + ) + self.overlay_layout.addWidget( + self._progress_widget.overlay_widget + ) + break + if not self.tool_config and not tool_config_message: + tool_config_message = ( + f'Could not find tool config: "{tool_config_name}"' + ) + else: + tool_config_message = 'No publisher tool configs available!' + + if not self.tool_config: + self.logger.warning(tool_config_message) + label_widget = QtWidgets.QLabel(f'{tool_config_message}') + label_widget.setStyleSheet( + "font-style: italic; font-weight: bold;" + ) + self.tool_widget.layout().addWidget(label_widget) + self.run_button.setEnabled(False) + return + + # Build context widgets + context_plugins = get_plugins( + self.tool_config, filters={'tags': ['context']} + ) + for context_plugin in context_plugins: + if not context_plugin.get('ui'): + continue + context_widget = self.init_framework_widget(context_plugin) + self.tool_widget.layout().addWidget(context_widget) + + # Build component widgets + # Build top label layout + components_layout = QtWidgets.QHBoxLayout() + components_label = QtWidgets.QLabel('Components') + self._save_preset_button = QtWidgets.QPushButton('Save Preset') + components_layout.addWidget(components_label) + components_layout.addStretch() + components_layout.addWidget(self._save_preset_button) + self.tool_widget.layout().addLayout(components_layout) + + collector_plugins = get_plugins( + self.tool_config, filters={'tags': ['collector']} + ) + for collector_plugin in collector_plugins: + if not collector_plugin.get('ui'): + continue + collector_widget = self.init_framework_widget(collector_plugin) + self.tool_widget.layout().addWidget(collector_widget) + + spacer = QtWidgets.QSpacerItem( + 1, + 1, + QtWidgets.QSizePolicy.Minimum, + QtWidgets.QSizePolicy.Expanding, + ) + self.tool_widget.layout().addItem(spacer) + + def post_build_ui(self): + if self._progress_widget: + self._progress_widget.hide_overlay_signal.connect( + self.show_main_widget + ) + self._progress_widget.show_overlay_signal.connect( + self.show_overlay_widget + ) + self._save_preset_button.clicked.connect( + self._on_save_preset_button_clicked + ) + + def _on_run_button_clicked(self): + '''(Override) Drive the progress widget''' + self.show_overlay_widget() + self._progress_widget.run() + super(SimplerGenericPublisherDialog, self)._on_run_button_clicked() + # TODO: This will not work in remote mode (async mode) as plugin events + # will arrive after this point of execution. + if self._progress_widget.status == constants.status.SUCCESS_STATUS: + self.clean_ui() + self.pre_build_ui() + self.build_ui() + self.post_build_ui() + # TODO: there is an error here showing the overlay widget because is not repainting all the widegts that has been parented to the self.layout() in the pre_build_ui build_ui or post_build_ui methods. + + @invoke_in_qt_main_thread + def plugin_run_callback(self, log_item): + '''(Override) Pass framework log item to the progress widget''' + self._progress_widget.update_phase_status( + log_item.reference, + log_item.status, + log_message=log_item.message, + time=log_item.execution_time, + ) + + def _on_save_preset_button_clicked(self): + '''Callback to save the current tool config as a preset''' + # Open a save dialog to get the destination + destination = QtWidgets.QFileDialog.getSaveFileName( + self, 'Save Tool Config Preset', '', 'YAML (*.yaml)' + )[0] + args = { + 'tool_config': self.tool_config, + 'destination': destination, + } + self.client_method_connection( + 'save_tool_config_in_destination', arguments=args + ) + + def closeEvent(self, event): + '''(Override) Close the context and progress widgets''' + if self._context_selector: + self._context_selector.teardown() + if self._progress_widget: + self._progress_widget.teardown() + self._progress_widget.deleteLater() + super(SimplerGenericPublisherDialog, self).closeEvent(event) diff --git a/projects/framework-common-extensions/plugins/generic_collector.py b/projects/framework-common-extensions/plugins/generic_collector.py new file mode 100644 index 0000000000..775efcc3db --- /dev/null +++ b/projects/framework-common-extensions/plugins/generic_collector.py @@ -0,0 +1,44 @@ +# :coding: utf-8 +# :copyright: Copyright (c) 2024 ftrack + +import os + +from ftrack_framework_core.plugin import BasePlugin +from ftrack_framework_core.exceptions.plugin import PluginExecutionError + + +class GenericCollectorPlugin(BasePlugin): + name = 'generic_collector' + + def ui_hook(self, payload): + ''' ''' + mock_items = [ + 'item1', + 'item2', + ] + result = { + 'widget': payload.get('widget'), + 'items': mock_items, + } + return result + + def run(self, store): + ''' + Get folder_path and file_name from the :obj:`self.options` + and store the collected_file in the given *store*. + ''' + components = self.options.get('components', {}) + for component_name, component_value in components.items(): + file_path = component_value.get('file_path') + if not file_path: + message = ( + "Please provide file_path in component options. \n " + "options: {}".format(self.options) + ) + raise PluginExecutionError(message) + self.logger.debug(f"Collected file_path: {file_path}.") + if store.get('components') is None: + store['components'] = {} + if store['components'].get(component_name) is None: + store['components'][component_name] = {} + store['components'][component_name]['collected_path'] = file_path diff --git a/projects/framework-common-extensions/plugins/publish_to_ftrack.py b/projects/framework-common-extensions/plugins/publish_to_ftrack.py index f500db9d04..8b02aa487d 100644 --- a/projects/framework-common-extensions/plugins/publish_to_ftrack.py +++ b/projects/framework-common-extensions/plugins/publish_to_ftrack.py @@ -103,12 +103,25 @@ def run(self, store): ), ) else: + component_dictionary = store['components'][component_name] + # Try to get first the exported path if doesn't exists, pick the collected_path + component_path = component_dictionary.get( + 'exported_path', + component_dictionary.get('collected_path'), + ) + # Raise error if no exported or collected path passed. + if not component_path: + message = ( + f"Please provide exported_path or collected_path in " + f"component options. \n options: {component_dictionary}" + ) + error = True + break + self._create_component( asset_version_object, component_name, - store['components'][component_name].get( - 'exported_path' - ), + component_path, ) store['components'][component_name][ 'published_to_ftrack' diff --git a/projects/framework-common-extensions/release_notes.md b/projects/framework-common-extensions/release_notes.md index 1ec000897b..967b4388b8 100644 --- a/projects/framework-common-extensions/release_notes.md +++ b/projects/framework-common-extensions/release_notes.md @@ -2,6 +2,7 @@ ## Upcoming +* [new] Generic publisher dialog added with a generic tool-config. * [fixed] StandardPublisherDialog; Fix bug were _accordion_widgets_registry wasn't initialized if no tool_config available. * [fixed] StandardOpenerDialog, StandardPublisherDialog; Fix bug were dialog creation crashed if not tool configs. Also disabled run button. * [changed] RenameFileExporterPlugin; Accept folder as destination. diff --git a/projects/framework-common-extensions/tool-configs/standalone-generic-publisher.yaml b/projects/framework-common-extensions/tool-configs/standalone-generic-publisher.yaml new file mode 100644 index 0000000000..499b8cf681 --- /dev/null +++ b/projects/framework-common-extensions/tool-configs/standalone-generic-publisher.yaml @@ -0,0 +1,22 @@ +type: tool_config +name: standalone-generic-publisher +config_type: publisher +engine: + - type: plugin + tags: + - context + plugin: store_asset_context + options: + asset_type_name: script + ui: publisher_asset_version_selector + + - type: plugin + tags: + - collector + plugin: generic_collector + options: + editable_component_name: True + + ui: generic_accordion_collector + + - publish_to_ftrack diff --git a/projects/framework-common-extensions/widgets/generic_accordion_collector.py b/projects/framework-common-extensions/widgets/generic_accordion_collector.py new file mode 100644 index 0000000000..97b6443d5b --- /dev/null +++ b/projects/framework-common-extensions/widgets/generic_accordion_collector.py @@ -0,0 +1,371 @@ +# :coding: utf-8 +# :copyright: Copyright (c) 2024 ftrack +import os.path + +try: + from PySide6 import QtWidgets, QtCore +except ImportError: + from PySide2 import QtWidgets, QtCore + +from functools import partial +import clique + +from ftrack_framework_qt.widgets import BaseWidget + +from ftrack_qt.widgets.accordion import AccordionBaseWidget +from ftrack_qt.widgets.buttons import CircularButton +from ftrack_qt.widgets.buttons import MenuButton + +# TODO: Remove this from qt widgets as it needs to be loaded from the registry +from ftrack_qt.widgets.browsers import FileBrowser + +from ftrack_qt.utils.decorators import invoke_in_qt_main_thread + + +class GenericObjectCollectorWidget(QtWidgets.QWidget): + '''Main class to represent a context widget on a publish process.''' + + items_changed = QtCore.Signal(object) + collect_items = QtCore.Signal() + + def __init__( + self, + parent=None, + ): + '''initialise PublishContextWidget with *parent*, *session*, *data*, + *name*, *description*, *options* and *context* + ''' + super(GenericObjectCollectorWidget, self).__init__(parent) + self._object_list = None + self._add_button = None + self._remove_button = None + self.pre_build_ui() + self.build_ui() + self.post_build_ui() + + def pre_build_ui(self): + layout = QtWidgets.QVBoxLayout() + layout.setContentsMargins(0, 0, 0, 0) + layout.setAlignment(QtCore.Qt.AlignTop) + self.setLayout(layout) + self.setSizePolicy( + QtWidgets.QSizePolicy.Preferred, QtWidgets.QSizePolicy.Fixed + ) + + def build_ui(self): + '''build function widgets.''' + + self._object_list = QtWidgets.QListWidget() + + buttons_layout = QtWidgets.QHBoxLayout() + self._add_button = QtWidgets.QPushButton('Add') + self._remove_button = QtWidgets.QPushButton('Remove') + + self.layout().addWidget(self._object_list) + buttons_layout.addWidget(self._add_button) + buttons_layout.addWidget(self._remove_button) + self.layout().addLayout(buttons_layout) + + def post_build_ui(self): + '''hook events''' + self._add_button.clicked.connect(self._on_add_object_callback) + self._remove_button.clicked.connect(self._on_remove_object_callback) + + def _on_add_object_callback(self): + self.collect_items.emit() + + def _on_remove_object_callback(self): + selected_items = self._object_list.selectedItems() + for item in selected_items: + self._object_list.takeItem(self._object_list.row(item)) + self.items_changed.emit(self._get_items()) + + def _get_items(self): + items = [] + for index in range(0, self._object_list.count()): + items.append(self._object_list.item(index)) + return items + + def add_items(self, items): + self._object_list.addItems(items) + self.items_changed.emit(self._get_items()) + + def remove_items(self, items, emit_signal=True): + for item in items: + self._object_list.takeItem(self._object_list.row(item)) + if emit_signal: + self.items_changed.emit(self._get_items()) + + +class GenericAccordionCollectorWidget(BaseWidget): + '''Main class to represent a context widget on a publish process.''' + + name = 'generic_accordion_collector' + ui_type = 'qt' + + path_changed = QtCore.Signal(object) + + def get_available_widgets(self): + return [FileBrowser, GenericObjectCollectorWidget] + + def __init__( + self, + event_manager, + client_id, + context_id, + plugin_config, + group_config, + on_set_plugin_option, + on_run_ui_hook, + parent=None, + ): + '''initialise PublishContextWidget with *parent*, *session*, *data*, + *name*, *description*, *options* and *context* + ''' + self._accordion_widgets_registry = [] + self._circular_add_button = None + self._menu_button = None + + super(GenericAccordionCollectorWidget, self).__init__( + event_manager, + client_id, + context_id, + plugin_config, + group_config, + on_set_plugin_option, + on_run_ui_hook, + parent, + ) + + def pre_build_ui(self): + layout = QtWidgets.QVBoxLayout() + layout.setContentsMargins(0, 0, 0, 0) + layout.setAlignment(QtCore.Qt.AlignTop) + self.setLayout(layout) + self.setSizePolicy( + QtWidgets.QSizePolicy.Preferred, QtWidgets.QSizePolicy.Fixed + ) + + def build_ui(self): + '''build function widgets.''' + available_widgets = self.get_available_widgets() + + self._on_add_component_callback( + collector_widget_class=available_widgets[0] + ) + + self._circular_add_button = CircularButton('add', variant='outlined') + self._menu_button = MenuButton(self._circular_add_button) + for collector_widget in available_widgets: + self._menu_button.add_item( + item_data=collector_widget, + label=collector_widget.__name__, + ) + + self.layout().addWidget(self._menu_button) + + def _on_add_component_callback(self, collector_widget_class): + editable_component_name = self.plugin_config.get('options', {}).get( + 'editable_component_name', True + ) + # TODO: initialize accordion without exporters button and without validators btton + accordion_widget = AccordionBaseWidget( + selectable=False, + show_checkbox=False, + show_settings=False, + show_status=False, + checkable=False, + title="", + editable_title=editable_component_name, + selected=False, + checked=True, + collapsable=True, + collapsed=True, + removable=True, + ) + + accordion_widget.title_edited.connect( + self._on_component_name_edited_callback + ) + accordion_widget.bin_clicked.connect( + self._on_component_removed_callback + ) + + # TODO: Could be good to have the registry in the widgets as well, + # so we could query the available widgets in there. + # But for now we will hardcode it. + if str(collector_widget_class) not in str( + self.get_available_widgets() + ): + self.logger.warning( + f"Collector widget not supported: {str(collector_widget_class)}" + ) + QMessage_box = QtWidgets.QMessageBox() + QMessage_box.setText( + f"Collector widget not supported: {str(collector_widget_class)}" + ) + QMessage_box.exec_() + return + widget_instance = collector_widget_class() + accordion_widget.add_widget(widget_instance) + if hasattr(widget_instance, 'path_changed'): + if editable_component_name: + widget_instance.path_changed.connect( + partial(self._on_path_changed_callback, accordion_widget) + ) + if hasattr(widget_instance, 'items_changed'): + widget_instance.items_changed.connect( + partial(self._on_items_changed_callback, accordion_widget) + ) + if hasattr(widget_instance, 'collect_items'): + widget_instance.collect_items.connect( + self._on_collect_items_callback + ) + self._accordion_widgets_registry.append(accordion_widget) + latest_idx = self._get_latest_component_index() + self.layout().insertWidget(latest_idx + 1, accordion_widget) + + def post_build_ui(self): + '''hook events''' + self._menu_button.item_clicked.connect(self._on_add_component_callback) + + def _get_latest_component_index(self, idx=1): + # Get the number of items in the layout + count = self.layout().count() + if count > 0: + # Get the last item + item = self.layout().itemAt(count - idx) + if item: + if item.widget() in self._accordion_widgets_registry: + # Return the widget associated with the item + return self.layout().indexOf(item.widget()) + else: + return self._get_latest_component_index(idx + 1) + return -1 + + def _on_component_name_edited_callback(self, new_name): + new_name = self.get_available_component_name( + new_name, skip_widget=self.sender() + ) + if self.sender().previous_title: + current_components = self.plugin_options.get('components', {}) + component_options = current_components.get( + self.sender().previous_title + ) + if current_components.get(self.sender().previous_title): + current_components.pop(self.sender().previous_title) + current_components.update({new_name: component_options}) + self.set_plugin_option('components', current_components) + + self.sender().set_title(new_name) + + def _on_component_removed_callback(self): + current_components = self.plugin_options.get('components', {}) + current_components.pop(self.sender().title) + # Remove the widget from the registry + self._accordion_widgets_registry.remove(self.sender()) + # Remove the widget from the layout + self.sender().teardown() + self.sender().deleteLater() + + def _on_path_changed_callback(self, accordion_widget, file_path): + ''' + Callback to update the component name when the path is changed. + Updates the option dictionary with provided *asset_name* when + asset_changed of asset_selector event is triggered + ''' + current_components = self.plugin_options.get('components', {}) + if current_components.get(accordion_widget.title): + current_components.pop(accordion_widget.title) + if not file_path: + return + + file_extension = None + try: + collection = clique.parse(file_path) + if collection: + file_extension = collection.tail + except Exception as error: + self.logger.debug( + f"{file_path} is not a clique collection. Error {error}" + ) + + if not file_extension: + file_extension = os.path.splitext(file_path)[ + 1 + ] or os.path.basename(file_path) + + file_extension = file_extension.lstrip('.') + + extension = self.get_available_component_name(file_extension) + + accordion_widget.set_title(extension) + + option_value = { + 'file_path': file_path, + } + + current_components.update({accordion_widget.title: option_value}) + self.set_plugin_option('components', current_components) + + # self.path_changed.emit(file_path) + + def _on_items_changed_callback(self, accordion_widget, items_list): + ''' + Callback to update the component name when the path is changed. + Updates the option dictionary with provided *asset_name* when + asset_changed of asset_selector event is triggered + ''' + current_components = self.plugin_options.get('components', {}) + if current_components.get(accordion_widget.title): + current_components.pop(accordion_widget.title) + if not items_list: + return + + component_name = accordion_widget.title + if not component_name or component_name == '': + self.logger.warning( + f"No component name has been set. Please set a component name before continue." + ) + QMessage_box = QtWidgets.QMessageBox() + QMessage_box.setText( + f"Please set a component name before collecting objects" + ) + QMessage_box.exec_() + self.sender().remove_items(items_list, emit_signal=False) + return + + option_value = { + 'items_list': [item.text() for item in items_list], + } + + current_components.update({accordion_widget.title: option_value}) + self.set_plugin_option('components', current_components) + + def _on_collect_items_callback(self): + payload = {'widget': self.sender()} + self.run_ui_hook(payload) + + @invoke_in_qt_main_thread + def ui_hook_callback(self, ui_hook_result): + '''Handle the result of the UI hook.''' + super(GenericAccordionCollectorWidget, self).ui_hook_callback( + ui_hook_result + ) + ui_hook_result.get("widget").add_items(ui_hook_result.get("items")) + + def get_available_component_name(self, name, skip_widget=None): + def increment_name(name): + if '_' in name and name.rsplit('_', 1)[-1].isdigit(): + base, num = name.rsplit('_', 1) + return f'{base}_{int(num) + 1}' + else: + return f'{name}_1' + + for widget in self._accordion_widgets_registry: + if widget != skip_widget: + if widget.title == name: + return self.get_available_component_name( + increment_name(name), skip_widget + ) + return name diff --git a/resource/style/sass/widget/_lineedit.scss b/resource/style/sass/widget/_lineedit.scss index b55824ee35..1577152ec3 100644 --- a/resource/style/sass/widget/_lineedit.scss +++ b/resource/style/sass/widget/_lineedit.scss @@ -7,4 +7,9 @@ QLineEdit { QLineEdit:focus { border: 1px solid $color-primary; +} + +QLineEdit[label='true']:read-only { + border: none; + background: transparent; } \ No newline at end of file