From 8c42aaa245195b8fb4afa30bfb38c1ad699be980 Mon Sep 17 00:00:00 2001 From: Lluis Ftrack Date: Wed, 7 Feb 2024 09:43:18 +0100 Subject: [PATCH 01/20] remove framework-widget --- libs/framework-widget/release_notes.md | 6 ------ 1 file changed, 6 deletions(-) delete mode 100644 libs/framework-widget/release_notes.md diff --git a/libs/framework-widget/release_notes.md b/libs/framework-widget/release_notes.md deleted file mode 100644 index e4d4f08c11..0000000000 --- a/libs/framework-widget/release_notes.md +++ /dev/null @@ -1,6 +0,0 @@ -# ftrack Framework Widget library release Notes - -## Upcoming -YYYY-mm-dd - -* [new] Initial release. From cbd2c2c4bece4a85a20a8a6ab393b0580e476b1b Mon Sep 17 00:00:00 2001 From: Lluis Ftrack Date: Fri, 16 Feb 2024 10:16:08 +0100 Subject: [PATCH 02/20] update date on publisher widget and timetracker widget --- projects/connect-publisher-widget/release_notes.md | 2 +- projects/connect-timetracker-widget/release_notes.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/projects/connect-publisher-widget/release_notes.md b/projects/connect-publisher-widget/release_notes.md index 95af9f7206..2bb839567a 100644 --- a/projects/connect-publisher-widget/release_notes.md +++ b/projects/connect-publisher-widget/release_notes.md @@ -1,7 +1,7 @@ # ftrack Connect Publisher widget release Notes ## v24.2.0 -2024-02-13 +2024-02-16 * [changed] Ported from ftrack-connect-publisher-widget repo and aligned with Connect 3 * [changed] Use poetry as build system. diff --git a/projects/connect-timetracker-widget/release_notes.md b/projects/connect-timetracker-widget/release_notes.md index f978e0d5b8..58140d1c3d 100644 --- a/projects/connect-timetracker-widget/release_notes.md +++ b/projects/connect-timetracker-widget/release_notes.md @@ -1,7 +1,7 @@ # ftrack Connect Timetracker widget release Notes ## v24.2.0 -2024-02-14 +2024-02-16 * [fix] Fixed bug with wrong plugin name reported back with debug information to Connect about dialog. * [changed] Ported from ftrack-connect-timetracker-widget repo and aligned with Connect 3 From 8810d6f7760d40f6f6b5036ec97d45b738a79717 Mon Sep 17 00:00:00 2001 From: Lluis Ftrack Date: Wed, 15 May 2024 13:02:35 +0200 Subject: [PATCH 03/20] WIP ui renaming working --- .../widgets/accordion/accordion_widget.py | 4 + .../headers/accordion_header_widget.py | 4 + .../dialogs/multi_publisher_dialog.py | 315 ++++++++++++++++++ .../plugins/generic_file_exporter.py | 31 ++ .../standalone-file-publisher.yaml | 35 ++ .../widgets/file_browser_collector.py | 3 + .../widgets/generic_flie_export_options.py | 106 ++++++ 7 files changed, 498 insertions(+) create mode 100644 projects/framework-common-extensions/dialogs/multi_publisher_dialog.py create mode 100644 projects/framework-common-extensions/plugins/generic_file_exporter.py create mode 100644 projects/framework-common-extensions/widgets/generic_flie_export_options.py 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 7085dea690..35a88fbb53 100644 --- a/libs/qt/source/ftrack_qt/widgets/accordion/accordion_widget.py +++ b/libs/qt/source/ftrack_qt/widgets/accordion/accordion_widget.py @@ -272,6 +272,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/headers/accordion_header_widget.py b/libs/qt/source/ftrack_qt/widgets/headers/accordion_header_widget.py index 44881d7cf9..03ba3c2118 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 @@ -187,6 +187,10 @@ def set_status(self, status, message): '''Set status message within header, to be implemented by child''' pass + def set_title(self, new_title): + self._title = new_title + self._title_label.setText(self.title) + def teardown(self): '''Teardown the options button - properly cleanup the options overlay''' self._options_button.teardown() diff --git a/projects/framework-common-extensions/dialogs/multi_publisher_dialog.py b/projects/framework-common-extensions/dialogs/multi_publisher_dialog.py new file mode 100644 index 0000000000..92f6949273 --- /dev/null +++ b/projects/framework-common-extensions/dialogs/multi_publisher_dialog.py @@ -0,0 +1,315 @@ +# :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 + +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 + + +class MultiPublisherDialog(BaseContextDialog): + '''Default Framework Publisher dialog''' + + name = 'framework_multi_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 + + super(MultiPublisherDialog, 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) + 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 + + self.tool_widget.layout().addWidget(QtWidgets.QLabel('Components')) + + component_groups = get_groups( + self.tool_config, filters={'tags': ['component']} + ) + + multi_groups = get_groups( + self.tool_config, filters={'tags': ['component', 'multi']} + ) + self._accordion_widgets_registry = [] + for _group in component_groups: + group_accordion_widget = self.add_acordion_group(_group) + self.tool_widget.layout().addWidget(group_accordion_widget) + for _multi_group in multi_groups: + add_button = QtWidgets.QPushButton("add component") + self.tool_widget.layout().addWidget(add_button) + add_button.clicked.connect( + partial( + self._on_add_component_callback, _multi_group, add_button + ) + ) + + spacer = QtWidgets.QSpacerItem( + 1, + 1, + QtWidgets.QSizePolicy.Minimum, + QtWidgets.QSizePolicy.Expanding, + ) + self.tool_widget.layout().addItem(spacer) + + def _on_add_component_callback(self, _multi_group, add_button): + group_accordion_widget = self.add_acordion_group(_multi_group) + add_button_idx = self.tool_widget.layout().indexOf(add_button) + self.tool_widget.layout().insertWidget( + add_button_idx, group_accordion_widget + ) + + def add_acordion_group(self, group): + group_accordion_widget = AccordionBaseWidget( + selectable=False, + show_checkbox=True, + checkable=group.get('optional', False), + title=group.get('options').get('component'), + selected=False, + checked=group.get('enabled', True), + collapsable=True, + collapsed=True, + ) + 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 + ) + 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) + # TODO: if the accordion widget is multi and rename is set to auto then connect the widget.path_cahnged to the on_rename_component_callback + + 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' + ) + if hasattr(widget, 'rename_component'): + widget.rename_component.connect( + partial( + self._on_rename_component_callback, accordion_widget + ) + ) + + def post_build_ui(self): + self._progress_widget.hide_overlay_signal.connect( + self.show_main_widget + ) + self._progress_widget.show_overlay_signal.connect( + self.show_overlay_widget + ) + + def _on_rename_component_callback(self, accordion_widget, new_name): + # TODO: always check if name already exists, and in that case we rename adding number + accordion_widget.set_title(new_name) + + 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_run_button_clicked(self): + '''(Override) Drive the progress widget''' + self.show_overlay_widget() + self._progress_widget.run() + super(MultiPublisherDialog, 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 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(MultiPublisherDialog, self).closeEvent(event) diff --git a/projects/framework-common-extensions/plugins/generic_file_exporter.py b/projects/framework-common-extensions/plugins/generic_file_exporter.py new file mode 100644 index 0000000000..efb0d8c7c6 --- /dev/null +++ b/projects/framework-common-extensions/plugins/generic_file_exporter.py @@ -0,0 +1,31 @@ +# :coding: utf-8 +# :copyright: Copyright (c) 2024 ftrack +import os.path +import shutil + +from ftrack_framework_core.plugin import BasePlugin + + +class GenericFileExporterPlugin(BasePlugin): + name = 'generic_file_exporter' + + def rename(self, origin_path, destination_path): + ''' + Rename the given *origin_path* to *destination_path* + ''' + return shutil.copy(origin_path, os.path.expanduser(destination_path)) + + def run(self, store): + ''' + Expects collected_path in the key of the given *store* + and an export_destination from the :obj:`self.options`. + ''' + component_name = self.options.get('component') + + collected_path = store['components'][component_name]['collected_path'] + export_destination = self.options['export_destination'] + + store['components'][component_name]['exported_path'] = self.rename( + collected_path, export_destination + ) + self.logger.debug(f"Copied {collected_path} to {export_destination}.") diff --git a/projects/framework-common-extensions/tool-configs/standalone-file-publisher.yaml b/projects/framework-common-extensions/tool-configs/standalone-file-publisher.yaml index c2e4a78093..59fba0de75 100644 --- a/projects/framework-common-extensions/tool-configs/standalone-file-publisher.yaml +++ b/projects/framework-common-extensions/tool-configs/standalone-file-publisher.yaml @@ -42,6 +42,41 @@ engine: options: export_destination: "~/Desktop/myPublishedFile.png" + # Add a generic component. + - type: group + tags: + - component + - multi + options: + component: generic + #renameable: true + optional: True # by default so is no need + #enabled: True by default so is no need + plugins: + - type: plugin + plugin: store_component + - type: plugin + tags: + - collector + plugin: file_collector + ui: file_browser_collector + options: + folder_path: null + file_name: null + - type: plugin + tags: + - validator + plugin: file_exists_validator + ui: validator_label + - type: plugin + tags: + - exporter + plugin: generic_file_exporter + ui: generic_file_exporter_options + options: + rename: auto # True means user choose or a string is a fixed not renamable with the name should be allowed + export_destination: "~/Desktop/myPublishedFile.png" + # Common validator check all exported paths exists. - type: plugin tags: diff --git a/projects/framework-common-extensions/widgets/file_browser_collector.py b/projects/framework-common-extensions/widgets/file_browser_collector.py index 528d923af7..009ffdd1bc 100644 --- a/projects/framework-common-extensions/widgets/file_browser_collector.py +++ b/projects/framework-common-extensions/widgets/file_browser_collector.py @@ -18,6 +18,8 @@ class FileBrowserWidget(BaseWidget): name = 'file_browser_collector' ui_type = 'qt' + path_changed = QtCore.Signal(object) + def __init__( self, event_manager, @@ -71,3 +73,4 @@ def _on_path_changed(self, file_path): return self.set_plugin_option('folder_path', os.path.dirname(file_path)) self.set_plugin_option('file_name', os.path.basename(file_path)) + self.path_changed.emit(file_path) diff --git a/projects/framework-common-extensions/widgets/generic_flie_export_options.py b/projects/framework-common-extensions/widgets/generic_flie_export_options.py new file mode 100644 index 0000000000..1818bd460c --- /dev/null +++ b/projects/framework-common-extensions/widgets/generic_flie_export_options.py @@ -0,0 +1,106 @@ +# :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 + +from ftrack_framework_qt.widgets import BaseWidget + + +class GenericFileExportOptionsWidget(BaseWidget): + '''Main class to represent a file publish export options widget on a publish process.''' + + name = 'generic_file_exporter_options' + ui_type = 'qt' + + rename_component = QtCore.Signal(object) + + def __init__( + self, + event_manager, + client_id, + context_id, + plugin_config, + group_config, + on_set_plugin_option, + on_run_ui_hook, + parent=None, + ): + '''initialise FileExportOptionsWidget with *parent*, *session*, *data*, + *name*, *description*, *options* and *context* + ''' + + super(GenericFileExportOptionsWidget, 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.''' + # Create options: + for option, value in self.plugin_config.get('options').items(): + h_layout = QtWidgets.QHBoxLayout() + option_widget = QtWidgets.QLabel(option) + value_widget = None + editable = True + if option == 'rename': + if value == 'auto': + editable = False + elif value == True: + editable = True + value = '' + else: + editable = False + + if type(value) == str: + value_widget = QtWidgets.QLineEdit(value) + value_widget.setAttribute(QtCore.Qt.WA_MacShowFocusRect, False) + value_widget.setReadOnly(not editable) + value_widget.setEnabled(editable) + value_widget.textChanged.connect( + partial(self._on_option_changed, option) + ) + elif type(value) == bool: + # TODO: implement + pass + elif type(value) == list: + # TODO: implement + pass + elif type(value) == dict: + # TODO: implement + pass + else: + value_widget = QtWidgets.QLineEdit(value) + value_widget.setAttribute(QtCore.Qt.WA_MacShowFocusRect, False) + + h_layout.addWidget(option_widget) + h_layout.addWidget(value_widget) + self.layout().addLayout(h_layout) + + def post_build_ui(self): + '''hook events''' + pass + + def _on_option_changed(self, option, value): + self.set_plugin_option(option, value) + if option == 'rename': + self.rename_component.emit(value) From 36257ad026618f77c47bc03f74b07b2a3507c785 Mon Sep 17 00:00:00 2001 From: Lluis Ftrack Date: Thu, 27 Jun 2024 12:39:15 +0200 Subject: [PATCH 04/20] WIP --- .../source/ftrack_qt/utils/widget/__init__.py | 2 +- .../widgets/accordion/accordion_widget.py | 8 ++ .../headers/accordion_header_widget.py | 14 +++- .../dialogs/multi_publisher_dialog.py | 29 ++++++- .../plugins/generic_file_exporter.py | 82 +++++++++++++++++++ .../standalone-file-publisher.yaml | 5 +- .../widgets/file_browser_collector.py | 1 + .../widgets/generic_flie_export_options.py | 15 ++-- resource/style/sass/widget/_lineedit.scss | 5 ++ 9 files changed, 147 insertions(+), 14 deletions(-) diff --git a/libs/qt/source/ftrack_qt/utils/widget/__init__.py b/libs/qt/source/ftrack_qt/utils/widget/__init__.py index 6adbdae2d9..5fb2c03dfd 100644 --- a/libs/qt/source/ftrack_qt/utils/widget/__init__.py +++ b/libs/qt/source/ftrack_qt/utils/widget/__init__.py @@ -126,7 +126,7 @@ def build_progress_data(tool_config): tags = plugin_config.get('tags') or [] for group in plugin_config.get('parents') or []: if 'options' in group: - tags.extend(list(group['options'].values())) + tags.extend(list(str(group['options'].values()))) if 'tags' in group: tags.extend(group['tags']) phase_data['tags'] = reversed(tags) 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 35a88fbb53..cb7e78c045 100644 --- a/libs/qt/source/ftrack_qt/widgets/accordion/accordion_widget.py +++ b/libs/qt/source/ftrack_qt/widgets/accordion/accordion_widget.py @@ -25,6 +25,11 @@ 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''' @@ -73,6 +78,7 @@ def __init__( show_checkbox=False, checkable=False, title=None, + editable_title=False, selected=False, checked=True, collapsable=True, @@ -102,6 +108,7 @@ def __init__( self._checkable = checkable self._show_checkbox = show_checkbox self._title = title + self._editable_title = editable_title self._collapsable = collapsable self._selected = selected @@ -139,6 +146,7 @@ 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, 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 03ba3c2118..4983d87c44 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 @@ -29,6 +29,10 @@ class AccordionHeaderWidget(QtWidgets.QFrame): def title(self): return self._title + @property + def editable_title(self): + return self._editable_title + @property def checkable(self): return self._checkable @@ -56,6 +60,7 @@ def options_button(self): def __init__( self, title=None, + editable_title=False, checkable=False, checked=True, show_checkbox=False, @@ -72,6 +77,7 @@ 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 @@ -104,7 +110,13 @@ def build(self): self._checkbox.setVisible(self.show_checkbox) # Create title - self._title_label = QtWidgets.QLabel(self.title or '') + # TODO: we should be able to double click in order to edit the label in + # case the editable is true. (So it will look better) + self._title_label = QtWidgets.QLineEdit() + self._title_label.setText(self.title or '') + # Set the title line edit to be a label + self._title_label.setProperty('label', True) + self._title_label.setReadOnly(not self.editable_title) if not self.title: self._title_label.hide() diff --git a/projects/framework-common-extensions/dialogs/multi_publisher_dialog.py b/projects/framework-common-extensions/dialogs/multi_publisher_dialog.py index 92f6949273..e320f95505 100644 --- a/projects/framework-common-extensions/dialogs/multi_publisher_dialog.py +++ b/projects/framework-common-extensions/dialogs/multi_publisher_dialog.py @@ -1,5 +1,7 @@ # :coding: utf-8 # :copyright: Copyright (c) 2024 ftrack + +import os from functools import partial try: @@ -164,14 +166,19 @@ def build_ui(self): ) multi_groups = get_groups( - self.tool_config, filters={'tags': ['component', 'multi']} + self.tool_config, filters={'tags': ['multi']} ) self._accordion_widgets_registry = [] for _group in component_groups: group_accordion_widget = self.add_acordion_group(_group) self.tool_widget.layout().addWidget(group_accordion_widget) + for _multi_group in multi_groups: - add_button = QtWidgets.QPushButton("add component") + add_button = QtWidgets.QPushButton( + _multi_group.get('options').get( + 'button_label', 'Add Component' + ) + ) self.tool_widget.layout().addWidget(add_button) add_button.clicked.connect( partial( @@ -200,6 +207,9 @@ def add_acordion_group(self, group): 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, @@ -228,7 +238,13 @@ def add_collector_widgets( continue widget = self.init_framework_widget(plugin_config, group_config) accordion_widget.add_widget(widget) - # TODO: if the accordion widget is multi and rename is set to auto then connect the widget.path_cahnged to the on_rename_component_callback + if hasattr(widget, 'path_changed'): + if group_config.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 @@ -266,10 +282,17 @@ def post_build_ui(self): self.show_overlay_widget ) + def _on_path_changed_callback(self, accordion_widget, new_name): + # TODO: always check if name already exists, and in that case we rename adding number + extension = new_name.split('.')[-1] or os.path.basename(new_name) + self._on_rename_component_callback(accordion_widget, extension) + def _on_rename_component_callback(self, accordion_widget, new_name): # TODO: always check if name already exists, and in that case we rename adding number accordion_widget.set_title(new_name) + # TODO replace the tag component generic to the new name + 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''' diff --git a/projects/framework-common-extensions/plugins/generic_file_exporter.py b/projects/framework-common-extensions/plugins/generic_file_exporter.py index efb0d8c7c6..6a06f28f56 100644 --- a/projects/framework-common-extensions/plugins/generic_file_exporter.py +++ b/projects/framework-common-extensions/plugins/generic_file_exporter.py @@ -9,6 +9,88 @@ class GenericFileExporterPlugin(BasePlugin): name = 'generic_file_exporter' + def ui_hook(self, payload): + ''' + if payload['context_type'] is 'asset': return all Assets on the given + payload['context_id'] with asset type payload['asset_type_name'] + + if payload['context_type'] is 'asset_version': return all + AssetVersion entities available on the given + payload['asset_id'] on task payload['context_id'] + ''' + + context_id = payload['context_id'] + component_name = payload['component'] + # Determine if we have a task or not + context = self.session.get('Context', context_id) + # If it's a fake asset, context will be None so return empty list. + if not context: + return [] + + asset_type_entity = self.session.query( + 'select name from AssetType where short is "{}"'.format( + payload['asset_type_name'] + ) + ).one() + + if context.entity_type == 'Task' and not payload.get('show_all'): + asset_versions = self.session.query( + 'select asset.name, asset_id, id, date, version, ' + 'is_latest_version, thumbnail_url, user.first_name, ' + 'user.last_name, date, components.name from AssetVersion where ' + 'task_id is {} and asset.type.id is {} and components.name is {}'.format( + context_id, asset_type_entity['id'], component_name + ) + ).all() + elif context.entity_type == 'Task' and payload.get('show_all'): + asset_versions = self.session.query( + 'select asset.name, asset_id, id, date, version, ' + 'is_latest_version, thumbnail_url, user.first_name, ' + 'user.last_name, date, components.name from AssetVersion where ' + 'asset.parent.children.id is {} and asset.type.id is {} and components.name is {}'.format( + context_id, asset_type_entity['id'], component_name + ) + ).all() + else: + asset_versions = self.session.query( + 'select asset.name, asset_id, id, date, version, ' + 'is_latest_version, thumbnail_url, user.first_name, ' + 'user.last_name, date, components.name from AssetVersion where ' + 'parent.id is {} and asset.type.id is {} and components.name is {}'.format( + context_id, asset_type_entity['id'], component_name + ) + ).all() + + result = {} + with self.session.auto_populating(False): + result['assets'] = {} + for asset_version in asset_versions: + if asset_version['asset_id'] not in list( + result['assets'].keys() + ): + result['assets'][asset_version['asset_id']] = { + 'name': asset_version['asset']['name'], + 'versions': [], + 'server_url': self.session.server_url, + } + + result['assets'][asset_version['asset_id']]['versions'].append( + { + 'id': asset_version['id'], + 'date': asset_version['date'].strftime( + '%y-%m-%d %H:%M' + ), + 'version': asset_version['version'], + 'is_latest_version': asset_version[ + 'is_latest_version' + ], + 'thumbnail': asset_version['thumbnail_url']['url'], + 'user_first_name': asset_version['user']['first_name'], + 'user_last_name': asset_version['user']['last_name'], + } + ) + return result + def rename(self, origin_path, destination_path): ''' Rename the given *origin_path* to *destination_path* diff --git a/projects/framework-common-extensions/tool-configs/standalone-file-publisher.yaml b/projects/framework-common-extensions/tool-configs/standalone-file-publisher.yaml index 59fba0de75..0b6834d607 100644 --- a/projects/framework-common-extensions/tool-configs/standalone-file-publisher.yaml +++ b/projects/framework-common-extensions/tool-configs/standalone-file-publisher.yaml @@ -49,7 +49,8 @@ engine: - multi options: component: generic - #renameable: true + button_label: Add component + editable_component_name: True optional: True # by default so is no need #enabled: True by default so is no need plugins: @@ -74,7 +75,7 @@ engine: plugin: generic_file_exporter ui: generic_file_exporter_options options: - rename: auto # True means user choose or a string is a fixed not renamable with the name should be allowed + rename_component: auto # True means user choose or a string is a fixed not renamable with the name should be allowed export_destination: "~/Desktop/myPublishedFile.png" # Common validator check all exported paths exists. diff --git a/projects/framework-common-extensions/widgets/file_browser_collector.py b/projects/framework-common-extensions/widgets/file_browser_collector.py index 009ffdd1bc..11441a74f4 100644 --- a/projects/framework-common-extensions/widgets/file_browser_collector.py +++ b/projects/framework-common-extensions/widgets/file_browser_collector.py @@ -73,4 +73,5 @@ def _on_path_changed(self, file_path): return self.set_plugin_option('folder_path', os.path.dirname(file_path)) self.set_plugin_option('file_name', os.path.basename(file_path)) + # if self.group_config.get('editable_component_name', False): self.path_changed.emit(file_path) diff --git a/projects/framework-common-extensions/widgets/generic_flie_export_options.py b/projects/framework-common-extensions/widgets/generic_flie_export_options.py index 1818bd460c..ca1508af65 100644 --- a/projects/framework-common-extensions/widgets/generic_flie_export_options.py +++ b/projects/framework-common-extensions/widgets/generic_flie_export_options.py @@ -62,17 +62,16 @@ def build_ui(self): option_widget = QtWidgets.QLabel(option) value_widget = None editable = True - if option == 'rename': + if option == 'rename_component': if value == 'auto': editable = False - elif value == True: - editable = True - value = '' - else: + elif value == False: editable = False + else: + editable = True if type(value) == str: - value_widget = QtWidgets.QLineEdit(value) + value_widget = QtWidgets.QLineEdit(str(value)) value_widget.setAttribute(QtCore.Qt.WA_MacShowFocusRect, False) value_widget.setReadOnly(not editable) value_widget.setEnabled(editable) @@ -102,5 +101,7 @@ def post_build_ui(self): def _on_option_changed(self, option, value): self.set_plugin_option(option, value) - if option == 'rename': + if option == 'rename_component': + if value == 'auto': + self.rename_component.emit(False) self.rename_component.emit(value) 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 From dcc343f7b7d2c93cc5f67ce30f3bab4d65f2f599 Mon Sep 17 00:00:00 2001 From: Lluis Ftrack Date: Thu, 27 Jun 2024 16:22:02 +0200 Subject: [PATCH 05/20] WIP rename component name working --- .../resource/sass/widget/_scrollarea.scss | 11 ++ libs/framework-core/release_notes.md | 5 + .../ftrack_framework_core/client/__init__.py | 20 +++- .../ftrack_framework_core/widget/dialog.py | 12 +- libs/qt/release_notes.md | 5 + .../widgets/accordion/accordion_widget.py | 5 + .../headers/accordion_header_widget.py | 26 ++-- .../ftrack_qt/widgets/labels/__init__.py | 1 + .../widgets/labels/editable_label.py | 35 ++++++ .../dialogs/multi_publisher_dialog.py | 31 +++-- .../plugins/generic_file_exporter.py | 113 ------------------ .../standalone-file-publisher.yaml | 5 +- .../widgets/file_browser_collector.py | 1 - .../widgets/generic_flie_export_options.py | 107 ----------------- 14 files changed, 123 insertions(+), 254 deletions(-) create mode 100644 apps/connect/resource/sass/widget/_scrollarea.scss create mode 100644 libs/qt/source/ftrack_qt/widgets/labels/__init__.py create mode 100644 libs/qt/source/ftrack_qt/widgets/labels/editable_label.py delete mode 100644 projects/framework-common-extensions/plugins/generic_file_exporter.py delete mode 100644 projects/framework-common-extensions/widgets/generic_flie_export_options.py diff --git a/apps/connect/resource/sass/widget/_scrollarea.scss b/apps/connect/resource/sass/widget/_scrollarea.scss new file mode 100644 index 0000000000..a1067ba378 --- /dev/null +++ b/apps/connect/resource/sass/widget/_scrollarea.scss @@ -0,0 +1,11 @@ +/** Scroll area */ + +QScrollArea { + border: none; + background-color: transparent; + padding: 0px; +} + +QScrollArea QWidget { + background-color: transparent; +} diff --git a/libs/framework-core/release_notes.md b/libs/framework-core/release_notes.md index 5395c427f4..9e2162cd35 100644 --- a/libs/framework-core/release_notes.md +++ b/libs/framework-core/release_notes.md @@ -1,5 +1,10 @@ # ftrack Framework Core library release Notes +## upcoming + +* [new] Add set options to the tool config from the client and dialog. + + ## v2.4.0 2024-06-04 diff --git a/libs/framework-core/source/ftrack_framework_core/client/__init__.py b/libs/framework-core/source/ftrack_framework_core/client/__init__.py index 242ce72592..4e2be40b8f 100644 --- a/libs/framework-core/source/ftrack_framework_core/client/__init__.py +++ b/libs/framework-core/source/ftrack_framework_core/client/__init__.py @@ -500,16 +500,24 @@ def _connect_getter_property_callback(self, property_name): return self.__getattribute__(property_name) def set_config_options( - self, tool_config_reference, plugin_config_reference, plugin_options + self, tool_config_reference, plugin_config_reference=None, options=None ): - if not isinstance(plugin_options, dict): + if not options: + options = dict() + # TODO_ mayabe we should rename this one to make sure this is just for plugins + if not isinstance(options, dict): raise Exception( "plugin_options should be a dictionary. " - "Current given type: {}".format(plugin_options) + "Current given type: {}".format(options) ) - self._tool_config_options[tool_config_reference][ - plugin_config_reference - ] = plugin_options + if not plugin_config_reference: + self._tool_config_options[tool_config_reference][ + 'options' + ] = options + else: + self._tool_config_options[tool_config_reference][ + plugin_config_reference + ] = options def run_ui_hook( self, tool_config_reference, plugin_config_reference, payload diff --git a/libs/framework-core/source/ftrack_framework_core/widget/dialog.py b/libs/framework-core/source/ftrack_framework_core/widget/dialog.py index 3b5e4de884..95309114ec 100644 --- a/libs/framework-core/source/ftrack_framework_core/widget/dialog.py +++ b/libs/framework-core/source/ftrack_framework_core/widget/dialog.py @@ -464,11 +464,15 @@ def _on_set_plugin_option_callback(self, plugin_reference, options): arguments = { "tool_config_reference": self.tool_config['reference'], "plugin_config_reference": plugin_reference, - "plugin_options": options, + "options": options, } - self.client_method_connection( - 'set_config_options', arguments=arguments - ) + self.set_option_callback(arguments) + + def set_option_callback(self, args): + ''' + Pass the given *args* to the client set_config_options method. + ''' + self.client_method_connection('set_config_options', arguments=args) def _on_run_ui_hook_callback(self, plugin_reference, payload): arguments = { diff --git a/libs/qt/release_notes.md b/libs/qt/release_notes.md index 9f0f729f99..29c1868cb5 100644 --- a/libs/qt/release_notes.md +++ b/libs/qt/release_notes.md @@ -1,5 +1,10 @@ # ftrack QT library release Notes +## upcoming + +* [new] Editable label widget. + + ## v2.2.2 2024-06-26 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 cb7e78c045..408629b11c 100644 --- a/libs/qt/source/ftrack_qt/widgets/accordion/accordion_widget.py +++ b/libs/qt/source/ftrack_qt/widgets/accordion/accordion_widget.py @@ -19,6 +19,7 @@ class AccordionBaseWidget(QtWidgets.QFrame): ) # Emitted when accordion is double clicked show_options_overlay = QtCore.Signal(object) hide_options_overlay = QtCore.Signal() + title_changed = QtCore.Signal(object) @property def title(self): @@ -183,6 +184,7 @@ def post_build(self): self._header_widget.hide_options_overlay.connect( self._on_hide_options_overlay_callback ) + self._header_widget.title_changed.connect(self._on_title_changed) self._content_widget.setVisible(not self._collapsed) self._content_widget.setEnabled(self.checked) @@ -192,6 +194,9 @@ 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 add_option_widget(self, widget, section_name): self._header_widget.add_option_widget(widget, section_name) 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 4983d87c44..46783ddc95 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): @@ -24,9 +25,12 @@ class AccordionHeaderWidget(QtWidgets.QFrame): checkbox_status_changed = QtCore.Signal(object) show_options_overlay = QtCore.Signal(object) hide_options_overlay = QtCore.Signal() + title_changed = QtCore.Signal(object) @property def title(self): + if self._title_label: + return self._title_label.text() return self._title @property @@ -112,11 +116,9 @@ def build(self): # Create title # TODO: we should be able to double click in order to edit the label in # case the editable is true. (So it will look better) - self._title_label = QtWidgets.QLineEdit() - self._title_label.setText(self.title or '') - # Set the title line edit to be a label - self._title_label.setProperty('label', True) - self._title_label.setReadOnly(not self.editable_title) + self._title_label = EditableLabel( + text=self.title, editable=self.editable_title + ) if not self.title: self._title_label.hide() @@ -162,6 +164,7 @@ def post_build(self): self._options_button.hide_overlay_signal.connect( self.on_hide_options_callback ) + self._title_label.editingFinished.connect(self._on_title_changed) def on_show_options_callback(self, widget): self.show_options_overlay.emit(widget) @@ -199,9 +202,18 @@ 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 set_title(self, new_title): - self._title = new_title - self._title_label.setText(self.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''' 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..1f36b4bb78 --- /dev/null +++ b/libs/qt/source/ftrack_qt/widgets/labels/editable_label.py @@ -0,0 +1,35 @@ +# :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 editable(self): + '''Return editable state.''' + return self._editable + + def __init__(self, text=None, editable=True, parent=None): + super(EditableLabel, self).__init__(parent) + 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 mouseDoubleClickEvent(self, event): + if self._editable: + if self.isReadOnly(): + self.setReadOnly(False) + + def on_editing_finished(self): + self.setReadOnly(True) diff --git a/projects/framework-common-extensions/dialogs/multi_publisher_dialog.py b/projects/framework-common-extensions/dialogs/multi_publisher_dialog.py index e320f95505..eb2cedc0f1 100644 --- a/projects/framework-common-extensions/dialogs/multi_publisher_dialog.py +++ b/projects/framework-common-extensions/dialogs/multi_publisher_dialog.py @@ -227,6 +227,9 @@ def add_acordion_group(self, group): group_accordion_widget.show_options_overlay.connect( self.show_options_widget ) + group_accordion_widget.title_changed.connect( + self._on_component_name_changed + ) self._accordion_widgets_registry.append(group_accordion_widget) return group_accordion_widget @@ -239,7 +242,9 @@ def add_collector_widgets( widget = self.init_framework_widget(plugin_config, group_config) accordion_widget.add_widget(widget) if hasattr(widget, 'path_changed'): - if group_config.get('editable_component_name', False): + if group_config.get('options', {}).get( + 'editable_component_name', False + ): widget.path_changed.connect( partial( self._on_path_changed_callback, accordion_widget @@ -267,12 +272,6 @@ def add_exporter_widgets( accordion_widget.add_option_widget( widget, section_name='Exporters' ) - if hasattr(widget, 'rename_component'): - widget.rename_component.connect( - partial( - self._on_rename_component_callback, accordion_widget - ) - ) def post_build_ui(self): self._progress_widget.hide_overlay_signal.connect( @@ -283,15 +282,21 @@ def post_build_ui(self): ) def _on_path_changed_callback(self, accordion_widget, new_name): - # TODO: always check if name already exists, and in that case we rename adding number + ''' + Callback to update the component name when the path is changed. + ''' extension = new_name.split('.')[-1] or os.path.basename(new_name) - self._on_rename_component_callback(accordion_widget, extension) + accordion_widget.set_title(extension) - def _on_rename_component_callback(self, accordion_widget, new_name): - # TODO: always check if name already exists, and in that case we rename adding number - accordion_widget.set_title(new_name) + def _on_component_name_changed(self, new_name): + self.set_tool_config_option('component', new_name) - # TODO replace the tag component generic to the new name + def set_tool_config_option(self, key, value): + arguments = { + "tool_config_reference": self.tool_config['reference'], + "options": {key: value}, + } + self.set_option_callback(arguments) def show_options_widget(self, widget): '''Sets the given *widget* as the index 2 of the stacked widget and diff --git a/projects/framework-common-extensions/plugins/generic_file_exporter.py b/projects/framework-common-extensions/plugins/generic_file_exporter.py deleted file mode 100644 index 6a06f28f56..0000000000 --- a/projects/framework-common-extensions/plugins/generic_file_exporter.py +++ /dev/null @@ -1,113 +0,0 @@ -# :coding: utf-8 -# :copyright: Copyright (c) 2024 ftrack -import os.path -import shutil - -from ftrack_framework_core.plugin import BasePlugin - - -class GenericFileExporterPlugin(BasePlugin): - name = 'generic_file_exporter' - - def ui_hook(self, payload): - ''' - if payload['context_type'] is 'asset': return all Assets on the given - payload['context_id'] with asset type payload['asset_type_name'] - - if payload['context_type'] is 'asset_version': return all - AssetVersion entities available on the given - payload['asset_id'] on task payload['context_id'] - ''' - - context_id = payload['context_id'] - component_name = payload['component'] - # Determine if we have a task or not - context = self.session.get('Context', context_id) - # If it's a fake asset, context will be None so return empty list. - if not context: - return [] - - asset_type_entity = self.session.query( - 'select name from AssetType where short is "{}"'.format( - payload['asset_type_name'] - ) - ).one() - - if context.entity_type == 'Task' and not payload.get('show_all'): - asset_versions = self.session.query( - 'select asset.name, asset_id, id, date, version, ' - 'is_latest_version, thumbnail_url, user.first_name, ' - 'user.last_name, date, components.name from AssetVersion where ' - 'task_id is {} and asset.type.id is {} and components.name is {}'.format( - context_id, asset_type_entity['id'], component_name - ) - ).all() - elif context.entity_type == 'Task' and payload.get('show_all'): - asset_versions = self.session.query( - 'select asset.name, asset_id, id, date, version, ' - 'is_latest_version, thumbnail_url, user.first_name, ' - 'user.last_name, date, components.name from AssetVersion where ' - 'asset.parent.children.id is {} and asset.type.id is {} and components.name is {}'.format( - context_id, asset_type_entity['id'], component_name - ) - ).all() - else: - asset_versions = self.session.query( - 'select asset.name, asset_id, id, date, version, ' - 'is_latest_version, thumbnail_url, user.first_name, ' - 'user.last_name, date, components.name from AssetVersion where ' - 'parent.id is {} and asset.type.id is {} and components.name is {}'.format( - context_id, asset_type_entity['id'], component_name - ) - ).all() - - result = {} - with self.session.auto_populating(False): - result['assets'] = {} - for asset_version in asset_versions: - if asset_version['asset_id'] not in list( - result['assets'].keys() - ): - result['assets'][asset_version['asset_id']] = { - 'name': asset_version['asset']['name'], - 'versions': [], - 'server_url': self.session.server_url, - } - - result['assets'][asset_version['asset_id']]['versions'].append( - { - 'id': asset_version['id'], - 'date': asset_version['date'].strftime( - '%y-%m-%d %H:%M' - ), - 'version': asset_version['version'], - 'is_latest_version': asset_version[ - 'is_latest_version' - ], - 'thumbnail': asset_version['thumbnail_url']['url'], - 'user_first_name': asset_version['user']['first_name'], - 'user_last_name': asset_version['user']['last_name'], - } - ) - return result - - def rename(self, origin_path, destination_path): - ''' - Rename the given *origin_path* to *destination_path* - ''' - return shutil.copy(origin_path, os.path.expanduser(destination_path)) - - def run(self, store): - ''' - Expects collected_path in the key of the given *store* - and an export_destination from the :obj:`self.options`. - ''' - component_name = self.options.get('component') - - collected_path = store['components'][component_name]['collected_path'] - export_destination = self.options['export_destination'] - - store['components'][component_name]['exported_path'] = self.rename( - collected_path, export_destination - ) - self.logger.debug(f"Copied {collected_path} to {export_destination}.") diff --git a/projects/framework-common-extensions/tool-configs/standalone-file-publisher.yaml b/projects/framework-common-extensions/tool-configs/standalone-file-publisher.yaml index 0b6834d607..f3609a7aee 100644 --- a/projects/framework-common-extensions/tool-configs/standalone-file-publisher.yaml +++ b/projects/framework-common-extensions/tool-configs/standalone-file-publisher.yaml @@ -72,10 +72,9 @@ engine: - type: plugin tags: - exporter - plugin: generic_file_exporter - ui: generic_file_exporter_options + plugin: rename_file_exporter + ui: file_exporter_options options: - rename_component: auto # True means user choose or a string is a fixed not renamable with the name should be allowed export_destination: "~/Desktop/myPublishedFile.png" # Common validator check all exported paths exists. diff --git a/projects/framework-common-extensions/widgets/file_browser_collector.py b/projects/framework-common-extensions/widgets/file_browser_collector.py index 11441a74f4..009ffdd1bc 100644 --- a/projects/framework-common-extensions/widgets/file_browser_collector.py +++ b/projects/framework-common-extensions/widgets/file_browser_collector.py @@ -73,5 +73,4 @@ def _on_path_changed(self, file_path): return self.set_plugin_option('folder_path', os.path.dirname(file_path)) self.set_plugin_option('file_name', os.path.basename(file_path)) - # if self.group_config.get('editable_component_name', False): self.path_changed.emit(file_path) diff --git a/projects/framework-common-extensions/widgets/generic_flie_export_options.py b/projects/framework-common-extensions/widgets/generic_flie_export_options.py deleted file mode 100644 index ca1508af65..0000000000 --- a/projects/framework-common-extensions/widgets/generic_flie_export_options.py +++ /dev/null @@ -1,107 +0,0 @@ -# :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 - -from ftrack_framework_qt.widgets import BaseWidget - - -class GenericFileExportOptionsWidget(BaseWidget): - '''Main class to represent a file publish export options widget on a publish process.''' - - name = 'generic_file_exporter_options' - ui_type = 'qt' - - rename_component = QtCore.Signal(object) - - def __init__( - self, - event_manager, - client_id, - context_id, - plugin_config, - group_config, - on_set_plugin_option, - on_run_ui_hook, - parent=None, - ): - '''initialise FileExportOptionsWidget with *parent*, *session*, *data*, - *name*, *description*, *options* and *context* - ''' - - super(GenericFileExportOptionsWidget, 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.''' - # Create options: - for option, value in self.plugin_config.get('options').items(): - h_layout = QtWidgets.QHBoxLayout() - option_widget = QtWidgets.QLabel(option) - value_widget = None - editable = True - if option == 'rename_component': - if value == 'auto': - editable = False - elif value == False: - editable = False - else: - editable = True - - if type(value) == str: - value_widget = QtWidgets.QLineEdit(str(value)) - value_widget.setAttribute(QtCore.Qt.WA_MacShowFocusRect, False) - value_widget.setReadOnly(not editable) - value_widget.setEnabled(editable) - value_widget.textChanged.connect( - partial(self._on_option_changed, option) - ) - elif type(value) == bool: - # TODO: implement - pass - elif type(value) == list: - # TODO: implement - pass - elif type(value) == dict: - # TODO: implement - pass - else: - value_widget = QtWidgets.QLineEdit(value) - value_widget.setAttribute(QtCore.Qt.WA_MacShowFocusRect, False) - - h_layout.addWidget(option_widget) - h_layout.addWidget(value_widget) - self.layout().addLayout(h_layout) - - def post_build_ui(self): - '''hook events''' - pass - - def _on_option_changed(self, option, value): - self.set_plugin_option(option, value) - if option == 'rename_component': - if value == 'auto': - self.rename_component.emit(False) - self.rename_component.emit(value) From 8282e03bdee040f509e46b1053506f90c53bf04a Mon Sep 17 00:00:00 2001 From: Lluis Ftrack Date: Fri, 28 Jun 2024 09:41:29 +0200 Subject: [PATCH 06/20] WIP --- .../standalone-file-publisher.yaml | 38 ++++++++++++++++++- 1 file changed, 37 insertions(+), 1 deletion(-) diff --git a/projects/framework-common-extensions/tool-configs/standalone-file-publisher.yaml b/projects/framework-common-extensions/tool-configs/standalone-file-publisher.yaml index f3609a7aee..2ceee5c1eb 100644 --- a/projects/framework-common-extensions/tool-configs/standalone-file-publisher.yaml +++ b/projects/framework-common-extensions/tool-configs/standalone-file-publisher.yaml @@ -41,7 +41,8 @@ engine: ui: file_exporter_options options: export_destination: "~/Desktop/myPublishedFile.png" - + # Plugin to check that there is no duplicated components + - unic_components_validator # Add a generic component. - type: group tags: @@ -77,6 +78,41 @@ engine: options: export_destination: "~/Desktop/myPublishedFile.png" + # Add a static name generic component. + - type: group + tags: + - component + - multi + options: + component: generic_static + button_label: Add static component + editable_component_name: False + optional: True # by default so is no need + #enabled: True by default so is no need + plugins: + - type: plugin + plugin: store_component + - type: plugin + tags: + - collector + plugin: file_collector + ui: file_browser_collector + options: + folder_path: null + file_name: null + - type: plugin + tags: + - validator + plugin: file_exists_validator + ui: validator_label + - type: plugin + tags: + - exporter + plugin: rename_file_exporter + ui: file_exporter_options + options: + export_destination: "~/Desktop/myPublishedFile.png" + # Common validator check all exported paths exists. - type: plugin tags: From 7f262847f62b1afc90a9de6526284217c4493105 Mon Sep 17 00:00:00 2001 From: Lluis Ftrack Date: Fri, 28 Jun 2024 10:33:18 +0200 Subject: [PATCH 07/20] WIP --- .../dialogs/multi_publisher_dialog.py | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/projects/framework-common-extensions/dialogs/multi_publisher_dialog.py b/projects/framework-common-extensions/dialogs/multi_publisher_dialog.py index eb2cedc0f1..4a3944549b 100644 --- a/projects/framework-common-extensions/dialogs/multi_publisher_dialog.py +++ b/projects/framework-common-extensions/dialogs/multi_publisher_dialog.py @@ -170,7 +170,7 @@ def build_ui(self): ) self._accordion_widgets_registry = [] for _group in component_groups: - group_accordion_widget = self.add_acordion_group(_group) + group_accordion_widget = self.add_accordion_group(_group) self.tool_widget.layout().addWidget(group_accordion_widget) for _multi_group in multi_groups: @@ -195,13 +195,23 @@ def build_ui(self): self.tool_widget.layout().addItem(spacer) def _on_add_component_callback(self, _multi_group, add_button): - group_accordion_widget = self.add_acordion_group(_multi_group) + group_accordion_widget = self.add_accordion_group(_multi_group) add_button_idx = self.tool_widget.layout().indexOf(add_button) self.tool_widget.layout().insertWidget( add_button_idx, group_accordion_widget ) - def add_acordion_group(self, group): + def add_accordion_group(self, group): + # TODO: we have to check if there is any group already created, maybe with a different reference + # component_name = group.get('options').get('component'), + # component_groups = get_groups( + # self.tool_config, filters={'tags': ['component']} + # ) + # for _group in component_groups: + # if group.get("reference") == _group.get("reference"): + # continue + # if _group.get('options').get('component') == component_name: + # # get all groups with the same component name and increase the latest number if there are others group_accordion_widget = AccordionBaseWidget( selectable=False, show_checkbox=True, From e145597dff14e1aa377c9b05d0c935654991c6a2 Mon Sep 17 00:00:00 2001 From: Lluis Ftrack Date: Tue, 2 Jul 2024 13:37:28 +0200 Subject: [PATCH 08/20] WIP sync with Host --- libs/constants/release_notes.md | 5 + .../framework/event/__init__.py | 3 + libs/framework-core/release_notes.md | 1 + .../ftrack_framework_core/client/__init__.py | 37 ++++++ .../ftrack_framework_core/event/__init__.py | 29 +++++ .../ftrack_framework_core/host/__init__.py | 80 +++++++++++++ .../registry/__init__.py | 77 +++++++++--- .../ftrack_framework_core/widget/dialog.py | 10 ++ .../widgets/labels/editable_label.py | 6 + .../ftrack_utils/framework/config/tool.py | 4 + .../dialogs/multi_publisher_dialog.py | 113 ++++++++++++++---- .../standalone-file-publisher.yaml | 3 +- 12 files changed, 331 insertions(+), 37 deletions(-) diff --git a/libs/constants/release_notes.md b/libs/constants/release_notes.md index 95433d3a3e..ba26c44543 100644 --- a/libs/constants/release_notes.md +++ b/libs/constants/release_notes.md @@ -1,5 +1,10 @@ # ftrack Constants library release Notes +## Upcoming + +* [new] Add HOST_SYNC_TOOL_CONFIG_TOPIC constant. + + ## v2.0.0 2024-02-12 diff --git a/libs/constants/source/ftrack_constants/framework/event/__init__.py b/libs/constants/source/ftrack_constants/framework/event/__init__.py index 6e9115b7dd..4deb37af56 100644 --- a/libs/constants/source/ftrack_constants/framework/event/__init__.py +++ b/libs/constants/source/ftrack_constants/framework/event/__init__.py @@ -60,6 +60,9 @@ # Client wants to verify the plugins are registered in host HOST_VERIFY_PLUGINS_TOPIC = '{}.host.verify.plugins'.format(_BASE_) +# Client wants to verify the plugins are registered in host +HOST_SYNC_TOOL_CONFIG_TOPIC = '{}.host.sync.tool_config'.format(_BASE_) + # Remote integration<>Python communication; Connection and alive check DISCOVER_REMOTE_INTEGRATION_TOPIC = "{}.discover.remote.integration".format( _BASE_ diff --git a/libs/framework-core/release_notes.md b/libs/framework-core/release_notes.md index 9e2162cd35..33f4ef7847 100644 --- a/libs/framework-core/release_notes.md +++ b/libs/framework-core/release_notes.md @@ -3,6 +3,7 @@ ## upcoming * [new] Add set options to the tool config from the client and dialog. +* [new] Add host_sync_tool_config on event manager to sync tool config from the client to the host. ## v2.4.0 diff --git a/libs/framework-core/source/ftrack_framework_core/client/__init__.py b/libs/framework-core/source/ftrack_framework_core/client/__init__.py index 4e2be40b8f..3a0a4df227 100644 --- a/libs/framework-core/source/ftrack_framework_core/client/__init__.py +++ b/libs/framework-core/source/ftrack_framework_core/client/__init__.py @@ -519,6 +519,43 @@ def set_config_options( plugin_config_reference ] = options + def sync_tool_config(self, tool_config): + self.event_manager.publish.host_sync_tool_config( + self.host_id, + tool_config, + ) + + # def augment_tool_config(self, tool_config_reference, section=None, new_item=None): + # ''' + # Augment the given *tool_config* with the given *new_item* in the given *section* + # ''' + # self.event_manager.publish.host_augment_tool_config( + # self.host_id, + # tool_config_reference, + # section, + # new_item, + # ) + # + # + # + # + # if not new_item: + # return + # self.registry.augment_tool_config( + # self.tool_configs[tool_config_reference], + # section=section, + # new_item=new_item + # ) + # + # def remove_from_tool_config_engine(self, tool_config_reference, plugin_config_reference): + # ''' + # Remove the given *plugin_config_reference* from the tool config with the given + # *tool_config_reference* + # ''' + # if not plugin_config_reference: + # return + # self.tool_configs[tool_config_reference].pop(plugin_config_reference) + def run_ui_hook( self, tool_config_reference, plugin_config_reference, payload ): diff --git a/libs/framework-core/source/ftrack_framework_core/event/__init__.py b/libs/framework-core/source/ftrack_framework_core/event/__init__.py index 9bc1572344..94fd2699cd 100644 --- a/libs/framework-core/source/ftrack_framework_core/event/__init__.py +++ b/libs/framework-core/source/ftrack_framework_core/event/__init__.py @@ -371,6 +371,25 @@ def host_verify_plugins( event_topic = constants.event.HOST_VERIFY_PLUGINS_TOPIC return self._publish_event(event_topic, data, callback, mode) + def host_sync_tool_config( + self, + host_id, + tool_config, + callback=None, + mode=constants.event.LOCAL_EVENT_MODE, + ): + ''' + Publish an event with topic + :const:`~ftrack_framework_core.constants.event.HOST_VERIFY_PLUGINS_TOPIC` + ''' + data = { + 'host_id': host_id, + 'tool_config': tool_config, + } + + event_topic = constants.event.HOST_SYNC_TOOL_CONFIG_TOPIC + return self._publish_event(event_topic, data, callback, mode) + class Subscribe(object): '''Class with all the events subscribed by the framework''' @@ -504,3 +523,13 @@ def host_verify_plugins(self, host_id, callback=None): constants.event.HOST_VERIFY_PLUGINS_TOPIC, host_id ) return self._subscribe_event(event_topic, callback) + + def host_sync_tool_config(self, host_id, callback=None): + ''' + Subscribe to an event with topic + :const:`~ftrack_framework_core.constants.event.HOST_SYNC_TOOL_CONFIG_TOPIC` + ''' + event_topic = '{} and data.host_id={}'.format( + constants.event.HOST_SYNC_TOOL_CONFIG_TOPIC, host_id + ) + return self._subscribe_event(event_topic, callback) diff --git a/libs/framework-core/source/ftrack_framework_core/host/__init__.py b/libs/framework-core/source/ftrack_framework_core/host/__init__.py index c20eafa43f..d5268b09e3 100644 --- a/libs/framework-core/source/ftrack_framework_core/host/__init__.py +++ b/libs/framework-core/source/ftrack_framework_core/host/__init__.py @@ -202,6 +202,11 @@ def _subscribe_events(self): self.id, self._verify_plugins_callback ) + # Subscribe to sync tool config from the client + self.event_manager.subscribe.host_sync_tool_config( + self.id, self._sync_tool_config_callback + ) + def _client_context_change_callback(self, event): '''Callback when the client has changed context''' context_id = event['data']['context_id'] @@ -291,6 +296,7 @@ def run_ui_hook_callback(self, event, session=None): client_options = event['data']['client_options'] payload = event['data']['payload'] + # TODO: we should be able to replace this to: tool_config = self.registry.get_one(extension_type='tool_config', reference=tool_config_reference) for typed_configs in self.tool_configs.values(): tool_config = None for _tool_config in typed_configs: @@ -378,3 +384,77 @@ def verify_plugins(self, plugin_names): f'correct extensions path: {unregistered_plugins}' ) return unregistered_plugins + + # def augment_tool_config_callback(self, event): + # ''' + # Runs the data with the defined engine type of the given *event* + # + # Returns result of the engine run. + # + # *event* : Published from the client host connection at + # :meth:`~ftrack_framework_core.client.HostConnection.run` + # ''' + # + # tool_config_reference = event['data']['tool_config_reference'] + # section = event['data']['section'] + # new_item = event['data']['new_item'] + # + # if not new_item: + # return + # + # tool_config = self.registry.get_one(extension_type='tool_config', reference=tool_config_reference) + # + # self.registry.augment_tool_config( + # tool_config=tool_config, + # section=section, + # new_item=new_item + # ) + # # TODO: we need now to sync the tool config to the host connection. + # + # # Need to unsubscribe to make sure we subscribe again with the new + # # context + # self.event_manager.unsubscribe(self._discover_host_subscribe_id) + # # Reply to discover_host_callback to clients to pass the host information, so we make sure that if a new connection is made, it receives the correct tool_config + # discover_host_callback_reply = partial( + # provide_host_information, + # self.id, + # self.context_id, + # self.tool_configs, + # ) + # self._discover_host_subscribe_id = ( + # self.event_manager.subscribe.discover_host( + # callback=discover_host_callback_reply + # ) + # ) + # self.event_manager.publish.host_context_changed( + # self.id, self.context_id + # ) + + def _sync_tool_config_callback(self, event): + ''' + Runs the data with the defined engine type of the given *event* + + Returns result of the engine run. + + *event* : Published from the client host connection at + :meth:`~ftrack_framework_core.client.HostConnection.run` + ''' + + tool_config = event['data']['tool_config'] + + registered_tool_config = self.registry.get_one( + extension_type='tool_config', reference=tool_config['reference'] + ) + + if not registered_tool_config: + self.registry.add( + tool_config['type'], + name=tool_config['name'], + extension=tool_config, + path="Memory", + create_reference=False, + ) + else: + registered_tool_config = tool_config + + return registered_tool_config diff --git a/libs/framework-core/source/ftrack_framework_core/registry/__init__.py b/libs/framework-core/source/ftrack_framework_core/registry/__init__.py index 1733dddec6..a0c7249e5f 100644 --- a/libs/framework-core/source/ftrack_framework_core/registry/__init__.py +++ b/libs/framework-core/source/ftrack_framework_core/registry/__init__.py @@ -124,13 +124,15 @@ def scan_extensions(self, paths, extension_types=None): for extension in unique_extensions: self.add(**extension) - def add(self, extension_type, name, extension, path): + def add( + self, extension_type, name, extension, path, create_reference=True + ): ''' Add the given *extension_type* with *name*, *extension* and *path to the registry ''' - if extension_type == 'tool_config': - self.augment_tool_config(extension) + if extension_type == 'tool_config' and create_reference: + self.create_unic_references(extension, skip_root=False) # We use extension_type and not type to not interfere with python # build in type self.__registry[extension_type].append( @@ -141,12 +143,12 @@ def add(self, extension_type, name, extension, path): } ) - def _get(self, extensions, name, extension, path): + def _get(self, extensions, name, extension, path, reference=None): ''' Check given *extensions* list to match given *name*, *extension* and *path* if neither provided, return all available extensions ''' - if not any([name, extension, path]): + if not any([name, extension, path, reference]): return extensions found_extensions = [] for _extension in extensions: @@ -156,10 +158,21 @@ def _get(self, extensions, name, extension, path): continue if path and _extension['path'] != path: continue + if reference and isinstance(_extension['extension'], dict): + if _extension['extension'].get('type') == 'tool_config': + if _extension['extension'].get('reference') != reference: + continue found_extensions.append(_extension) return found_extensions - def get(self, name=None, extension=None, path=None, extension_type=None): + def get( + self, + name=None, + extension=None, + path=None, + extension_type=None, + reference=None, + ): ''' Return given matching *name*, *extension*, *path* or *extension_type*. If nothing provided, return all available extensions. @@ -168,13 +181,25 @@ def get(self, name=None, extension=None, path=None, extension_type=None): if extension_type: extensions = self.registry.get(extension_type) found_extensions.extend( - self._get(extensions, name, extension, path) + self._get( + extensions=extensions, + name=name, + extension=extension, + path=path, + reference=reference, + ) ) else: for extension_type in list(self.registry.keys()): extensions = self.registry.get(extension_type) found_extensions.extend( - self._get(extensions, name, extension, path) + self._get( + extensions=extensions, + name=name, + extension=extension, + path=path, + reference=reference, + ) ) return found_extensions @@ -186,30 +211,54 @@ def get_one(self, *args, **kwargs): ''' matching_extensions = self.get(*args, **kwargs) if len(matching_extensions) == 0: - kwargs_string = ''.join([('%s=%s' % x) for x in kwargs.items()]) - raise Exception( + kwargs_string = ', '.join([('%s=%s' % x) for x in kwargs.items()]) + self.logger.warning( "Extension not found. Arguments: {}".format( (''.join(args), kwargs_string) ) ) + return None if len(matching_extensions) > 1: - kwargs_string = ''.join([('%s=%s' % x) for x in kwargs.items()]) - raise Exception( + kwargs_string = ', '.join([('%s=%s' % x) for x in kwargs.items()]) + self.logger.warning( "Multiple matching extensions found.Arguments: {}".format( (''.join(args), kwargs_string) ) ) + return None return matching_extensions[0] - def augment_tool_config(self, tool_config): + def augment_tool_config(self, tool_config, section=None, new_item=None): + ''' + Augment the given *tool_config* with the given *new_item* in the given *section* + ''' + if not new_item: + return + # Make sure the new item has unic references + self.create_unic_references(new_item) + # Section will usually be engine + if section: + if section in tool_config: + tool_config[section].append(new_item) + else: + tool_config[section] = [new_item] + else: + tool_config.append(new_item) + + def create_unic_references(self, tool_config, skip_root=False): ''' Augment the given *tool_config* to add a reference id to it and each plugin and group ''' - tool_config['reference'] = uuid.uuid4().hex + if not skip_root: + tool_config['reference'] = uuid.uuid4().hex if 'engine' in tool_config: self._recursive_create_reference(tool_config['engine']) + else: + # if doesn't contain engine, we assume that is a portion of the tool + # config so we augment that portion + self._recursive_create_reference([tool_config]) return tool_config def _recursive_create_reference(self, tool_config_engine_portion): diff --git a/libs/framework-core/source/ftrack_framework_core/widget/dialog.py b/libs/framework-core/source/ftrack_framework_core/widget/dialog.py index 95309114ec..c53fe2a348 100644 --- a/libs/framework-core/source/ftrack_framework_core/widget/dialog.py +++ b/libs/framework-core/source/ftrack_framework_core/widget/dialog.py @@ -468,6 +468,16 @@ def _on_set_plugin_option_callback(self, plugin_reference, options): } self.set_option_callback(arguments) + def set_tool_config_option(self, name, value): + ''' + Set the given name and value as options for the current tool config. + ''' + arguments = { + "tool_config_reference": self.tool_config['reference'], + "options": {name: value}, + } + self.set_option_callback(arguments) + def set_option_callback(self, args): ''' Pass the given *args* to the client set_config_options method. diff --git a/libs/qt/source/ftrack_qt/widgets/labels/editable_label.py b/libs/qt/source/ftrack_qt/widgets/labels/editable_label.py index 1f36b4bb78..0abd9821bb 100644 --- a/libs/qt/source/ftrack_qt/widgets/labels/editable_label.py +++ b/libs/qt/source/ftrack_qt/widgets/labels/editable_label.py @@ -10,6 +10,8 @@ class EditableLabel(QtWidgets.QLineEdit): '''Editable label widget.''' + forbidden_keys = ['a'] + @property def editable(self): '''Return editable state.''' @@ -32,4 +34,8 @@ def mouseDoubleClickEvent(self, event): self.setReadOnly(False) def on_editing_finished(self): + if self.forbidden_keys: + for key in self.forbidden_keys: + if key in self.text(): + self.setText(self.text().replace(key, 'b')) self.setReadOnly(True) diff --git a/libs/utils/source/ftrack_utils/framework/config/tool.py b/libs/utils/source/ftrack_utils/framework/config/tool.py index ca904ba8f2..a9282f0315 100644 --- a/libs/utils/source/ftrack_utils/framework/config/tool.py +++ b/libs/utils/source/ftrack_utils/framework/config/tool.py @@ -123,6 +123,10 @@ def get_groups(tool_config, filters=None, top_level_only=True): if isinstance(obj.get(k), list): if not any(x in obj[k] for x in v): candidate = False + elif isinstance(obj.get(k), dict): + for key, value in v.items(): + if obj[k].get(key) != value: + candidate = False elif isinstance(obj.get(k), str): if obj[k] != v: candidate = False diff --git a/projects/framework-common-extensions/dialogs/multi_publisher_dialog.py b/projects/framework-common-extensions/dialogs/multi_publisher_dialog.py index 4a3944549b..766a0bef10 100644 --- a/projects/framework-common-extensions/dialogs/multi_publisher_dialog.py +++ b/projects/framework-common-extensions/dialogs/multi_publisher_dialog.py @@ -1,6 +1,6 @@ # :coding: utf-8 # :copyright: Copyright (c) 2024 ftrack - +import copy import os from functools import partial @@ -26,6 +26,11 @@ class MultiPublisherDialog(BaseContextDialog): 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, @@ -54,6 +59,7 @@ def __init__( self._scroll_area = None self._scroll_area_widget = None self._progress_widget = None + self._modified_tool_config = None super(MultiPublisherDialog, self).__init__( event_manager, @@ -193,25 +199,96 @@ def build_ui(self): QtWidgets.QSizePolicy.Expanding, ) self.tool_widget.layout().addItem(spacer) + # Create a new tool_config + self._modified_tool_config = copy.deepcopy(self.tool_config) + # Make sure we generate new references + self.registry.create_unic_references(self._modified_tool_config) + print("ya") def _on_add_component_callback(self, _multi_group, add_button): - group_accordion_widget = self.add_accordion_group(_multi_group) + # Create a new unic group + new_group = copy.deepcopy(_multi_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 + group_accordion_widget = self.add_accordion_group(new_group) + # Insert the new group before the add button add_button_idx = self.tool_widget.layout().indexOf(add_button) self.tool_widget.layout().insertWidget( add_button_idx, group_accordion_widget ) + # generate references for the new group + self.registry.create_unic_references(new_group, skip_root=True) + + # 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 + args = { + 'tool_config': self._modified_tool_config, + } + self.client_method_connection('sync_tool_config', arguments=args) + + # # Add the new item into the tool config + # args = { + # 'tool_config_reference': self.tool_config['reference'], + # 'section': 'engine', + # 'new_item': _multi_group, + # } + # self.client_method_connection('augment_tool_config', arguments=args) + + 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._modified_tool_config, + filters={ + 'tags': ['component'], + 'options': {'component': previous_widget.title}, + }, + ) + if previous_group: + previous_group = previous_group[0] + previous_group_idx = self._modified_tool_config[ + 'engine' + ].index(previous_group) + self._modified_tool_config['engine'].insert( + previous_group_idx + 1, new_group + ) + else: + self._modified_tool_config['engine'].append(new_group) + else: + self._modified_tool_config['engine'].insert(0, new_group) + + def get_available_component_name(self, name): + 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' + + matching_components = get_groups( + self._modified_tool_config, + filters={'tags': ['component'], 'options': {'component': name}}, + ) + if matching_components: + for widget in self._accordion_widgets_registry: + if widget.title == name: + return self.get_available_component_name( + increment_name(name) + ) + return name def add_accordion_group(self, group): - # TODO: we have to check if there is any group already created, maybe with a different reference - # component_name = group.get('options').get('component'), - # component_groups = get_groups( - # self.tool_config, filters={'tags': ['component']} - # ) - # for _group in component_groups: - # if group.get("reference") == _group.get("reference"): - # continue - # if _group.get('options').get('component') == component_name: - # # get all groups with the same component name and increase the latest number if there are others group_accordion_widget = AccordionBaseWidget( selectable=False, show_checkbox=True, @@ -238,7 +315,7 @@ def add_accordion_group(self, group): self.show_options_widget ) group_accordion_widget.title_changed.connect( - self._on_component_name_changed + self._on_component_name_changed_callback ) self._accordion_widgets_registry.append(group_accordion_widget) return group_accordion_widget @@ -296,18 +373,12 @@ def _on_path_changed_callback(self, accordion_widget, new_name): Callback to update the component name when the path is changed. ''' extension = new_name.split('.')[-1] or os.path.basename(new_name) + extension = self.get_available_component_name(extension) accordion_widget.set_title(extension) - def _on_component_name_changed(self, new_name): + def _on_component_name_changed_callback(self, new_name): self.set_tool_config_option('component', new_name) - def set_tool_config_option(self, key, value): - arguments = { - "tool_config_reference": self.tool_config['reference'], - "options": {key: value}, - } - self.set_option_callback(arguments) - 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''' diff --git a/projects/framework-common-extensions/tool-configs/standalone-file-publisher.yaml b/projects/framework-common-extensions/tool-configs/standalone-file-publisher.yaml index 2ceee5c1eb..2473ef2cbe 100644 --- a/projects/framework-common-extensions/tool-configs/standalone-file-publisher.yaml +++ b/projects/framework-common-extensions/tool-configs/standalone-file-publisher.yaml @@ -41,8 +41,7 @@ engine: ui: file_exporter_options options: export_destination: "~/Desktop/myPublishedFile.png" - # Plugin to check that there is no duplicated components - - unic_components_validator + # Add a generic component. - type: group tags: From 04cebdbb04bb503e04cf5c1bdc4916a81b4ae5f8 Mon Sep 17 00:00:00 2001 From: Lluis Ftrack Date: Tue, 2 Jul 2024 13:56:13 +0200 Subject: [PATCH 09/20] WIP --- .../ftrack_qt/widgets/labels/editable_label.py | 6 ------ .../dialogs/multi_publisher_dialog.py | 16 ++++++++++------ 2 files changed, 10 insertions(+), 12 deletions(-) diff --git a/libs/qt/source/ftrack_qt/widgets/labels/editable_label.py b/libs/qt/source/ftrack_qt/widgets/labels/editable_label.py index 0abd9821bb..1f36b4bb78 100644 --- a/libs/qt/source/ftrack_qt/widgets/labels/editable_label.py +++ b/libs/qt/source/ftrack_qt/widgets/labels/editable_label.py @@ -10,8 +10,6 @@ class EditableLabel(QtWidgets.QLineEdit): '''Editable label widget.''' - forbidden_keys = ['a'] - @property def editable(self): '''Return editable state.''' @@ -34,8 +32,4 @@ def mouseDoubleClickEvent(self, event): self.setReadOnly(False) def on_editing_finished(self): - if self.forbidden_keys: - for key in self.forbidden_keys: - if key in self.text(): - self.setText(self.text().replace(key, 'b')) self.setReadOnly(True) diff --git a/projects/framework-common-extensions/dialogs/multi_publisher_dialog.py b/projects/framework-common-extensions/dialogs/multi_publisher_dialog.py index 766a0bef10..5accd3ae69 100644 --- a/projects/framework-common-extensions/dialogs/multi_publisher_dialog.py +++ b/projects/framework-common-extensions/dialogs/multi_publisher_dialog.py @@ -203,7 +203,6 @@ def build_ui(self): self._modified_tool_config = copy.deepcopy(self.tool_config) # Make sure we generate new references self.registry.create_unic_references(self._modified_tool_config) - print("ya") def _on_add_component_callback(self, _multi_group, add_button): # Create a new unic group @@ -268,7 +267,7 @@ def insert_group_in_tool_config(self, new_group, group_accordion_widget): else: self._modified_tool_config['engine'].insert(0, new_group) - def get_available_component_name(self, name): + 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) @@ -282,10 +281,11 @@ def increment_name(name): ) if matching_components: for widget in self._accordion_widgets_registry: - if widget.title == name: - return self.get_available_component_name( - increment_name(name) - ) + 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): @@ -377,6 +377,10 @@ def _on_path_changed_callback(self, accordion_widget, new_name): accordion_widget.set_title(extension) def _on_component_name_changed_callback(self, new_name): + new_name = self.get_available_component_name( + new_name, skip_widget=self.sender() + ) + self.sender().set_title(new_name) self.set_tool_config_option('component', new_name) def show_options_widget(self, widget): From 7a899b8a6f8e97d2f678d430890ed4c8f9b8b3fb Mon Sep 17 00:00:00 2001 From: Lluis Ftrack Date: Wed, 3 Jul 2024 09:43:42 +0200 Subject: [PATCH 10/20] make sure unic names is working --- .../widgets/accordion/accordion_widget.py | 5 ++++ .../headers/accordion_header_widget.py | 9 ++++++- .../dialogs/multi_publisher_dialog.py | 26 +++++++++---------- 3 files changed, 26 insertions(+), 14 deletions(-) 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 408629b11c..e1e1acec97 100644 --- a/libs/qt/source/ftrack_qt/widgets/accordion/accordion_widget.py +++ b/libs/qt/source/ftrack_qt/widgets/accordion/accordion_widget.py @@ -20,6 +20,7 @@ class AccordionBaseWidget(QtWidgets.QFrame): 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): @@ -185,6 +186,7 @@ def post_build(self): 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) @@ -197,6 +199,9 @@ def _on_hide_options_overlay_callback(self): 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) 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 46783ddc95..b017e25071 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 @@ -26,6 +26,7 @@ class AccordionHeaderWidget(QtWidgets.QFrame): 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): @@ -164,7 +165,7 @@ def post_build(self): self._options_button.hide_overlay_signal.connect( self.on_hide_options_callback ) - self._title_label.editingFinished.connect(self._on_title_changed) + self._title_label.editingFinished.connect(self._on_title_edited) def on_show_options_callback(self, widget): self.show_options_overlay.emit(widget) @@ -208,6 +209,12 @@ def _on_title_changed(self): ''' 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* diff --git a/projects/framework-common-extensions/dialogs/multi_publisher_dialog.py b/projects/framework-common-extensions/dialogs/multi_publisher_dialog.py index 5accd3ae69..1e1d390298 100644 --- a/projects/framework-common-extensions/dialogs/multi_publisher_dialog.py +++ b/projects/framework-common-extensions/dialogs/multi_publisher_dialog.py @@ -275,17 +275,12 @@ def increment_name(name): else: return f'{name}_1' - matching_components = get_groups( - self._modified_tool_config, - filters={'tags': ['component'], 'options': {'component': name}}, - ) - if matching_components: - 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 - ) + 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): @@ -315,7 +310,10 @@ def add_accordion_group(self, group): self.show_options_widget ) group_accordion_widget.title_changed.connect( - self._on_component_name_changed_callback + self._on_title_changed_callback + ) + group_accordion_widget.title_edited.connect( + self._on_component_name_edited_callback ) self._accordion_widgets_registry.append(group_accordion_widget) return group_accordion_widget @@ -376,11 +374,13 @@ def _on_path_changed_callback(self, accordion_widget, new_name): extension = self.get_available_component_name(extension) accordion_widget.set_title(extension) - def _on_component_name_changed_callback(self, new_name): + def _on_component_name_edited_callback(self, new_name): new_name = self.get_available_component_name( new_name, skip_widget=self.sender() ) self.sender().set_title(new_name) + + def _on_title_changed_callback(self, new_name): self.set_tool_config_option('component', new_name) def show_options_widget(self, widget): From 74fc4e1f56ea41821c2fcd2faaab28571473f886 Mon Sep 17 00:00:00 2001 From: Lluis Ftrack Date: Wed, 3 Jul 2024 12:33:02 +0200 Subject: [PATCH 11/20] WIP add remove and save preset working, Publish not working --- .../ftrack_framework_core/client/__init__.py | 37 ++------ .../ftrack_framework_core/widget/dialog.py | 6 ++ libs/qt/release_notes.md | 1 + .../widgets/accordion/accordion_widget.py | 20 ++++ .../headers/accordion_header_widget.py | 23 +++++ .../ftrack_qt/widgets/icons/status_icon.py | 6 ++ .../widgets/labels/editable_label.py | 11 +++ .../dialogs/multi_publisher_dialog.py | 91 ++++++++++++++++--- .../standalone-file-publisher.yaml | 2 + 9 files changed, 152 insertions(+), 45 deletions(-) diff --git a/libs/framework-core/source/ftrack_framework_core/client/__init__.py b/libs/framework-core/source/ftrack_framework_core/client/__init__.py index 3a0a4df227..271ea3f10b 100644 --- a/libs/framework-core/source/ftrack_framework_core/client/__init__.py +++ b/libs/framework-core/source/ftrack_framework_core/client/__init__.py @@ -5,6 +5,7 @@ import logging import uuid from collections import defaultdict +import yaml from six import string_types @@ -525,36 +526,12 @@ def sync_tool_config(self, tool_config): tool_config, ) - # def augment_tool_config(self, tool_config_reference, section=None, new_item=None): - # ''' - # Augment the given *tool_config* with the given *new_item* in the given *section* - # ''' - # self.event_manager.publish.host_augment_tool_config( - # self.host_id, - # tool_config_reference, - # section, - # new_item, - # ) - # - # - # - # - # if not new_item: - # return - # self.registry.augment_tool_config( - # self.tool_configs[tool_config_reference], - # section=section, - # new_item=new_item - # ) - # - # def remove_from_tool_config_engine(self, tool_config_reference, plugin_config_reference): - # ''' - # Remove the given *plugin_config_reference* from the tool config with the given - # *tool_config_reference* - # ''' - # if not plugin_config_reference: - # return - # self.tool_configs[tool_config_reference].pop(plugin_config_reference) + def save_tool_config_in_destination(self, tool_config, destination): + ''' + Save the given *tool_config* in the given *destination*. + ''' + with open(destination, 'w') as file: + yaml.dump(tool_config, file) def run_ui_hook( self, tool_config_reference, plugin_config_reference, payload diff --git a/libs/framework-core/source/ftrack_framework_core/widget/dialog.py b/libs/framework-core/source/ftrack_framework_core/widget/dialog.py index c53fe2a348..2460730780 100644 --- a/libs/framework-core/source/ftrack_framework_core/widget/dialog.py +++ b/libs/framework-core/source/ftrack_framework_core/widget/dialog.py @@ -413,6 +413,12 @@ def run_tool_config(self, tool_config_reference): arguments = {"tool_config_reference": tool_config_reference} self.client_method_connection('run_tool_config', arguments=arguments) + def sync_tool_config(self, tool_config): + args = { + 'tool_config': tool_config, + } + self.client_method_connection('sync_tool_config', arguments=args) + def _on_client_notify_ui_log_item_added_callback(self, event): ''' Client notify dialog that a new log item has been added. diff --git a/libs/qt/release_notes.md b/libs/qt/release_notes.md index 29c1868cb5..38946929ad 100644 --- a/libs/qt/release_notes.md +++ b/libs/qt/release_notes.md @@ -2,6 +2,7 @@ ## upcoming +* [changed] StatusMaterialIconWidget clickable. * [new] Editable label widget. 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 e1e1acec97..57d3da34d6 100644 --- a/libs/qt/source/ftrack_qt/widgets/accordion/accordion_widget.py +++ b/libs/qt/source/ftrack_qt/widgets/accordion/accordion_widget.py @@ -17,6 +17,7 @@ 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() title_changed = QtCore.Signal(object) @@ -74,6 +75,17 @@ 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, @@ -85,6 +97,7 @@ def __init__( checked=True, collapsable=True, collapsed=True, + removable=False, parent=None, ): ''' @@ -112,6 +125,7 @@ def __init__( self._title = title self._editable_title = editable_title self._collapsable = collapsable + self._removable = removable self._selected = selected self._checked = checked @@ -154,6 +168,7 @@ def build(self): show_checkbox=self.show_checkbox, collapsable=self.collapsable, collapsed=self.collapsed, + removable=self.removable, ) # Add header to main widget @@ -179,6 +194,7 @@ 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 ) @@ -250,6 +266,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 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 b017e25071..c2672693c0 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 @@ -22,6 +22,7 @@ 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() @@ -58,10 +59,18 @@ 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, @@ -71,6 +80,7 @@ def __init__( show_checkbox=False, collapsable=True, collapsed=True, + removable=False, parent=None, ): ''' @@ -88,6 +98,7 @@ def __init__( self._show_checkbox = show_checkbox self._collapsable = collapsable self._collapsed = collapsed + self._removable = removable self._checkbox = None self._title_label = None @@ -96,6 +107,7 @@ def __init__( self._status = None self._options_button = None self._status_icon = None + self._bin = None self.pre_build() self.build() @@ -149,16 +161,24 @@ def build(self): 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 ) @@ -185,6 +205,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: 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/editable_label.py b/libs/qt/source/ftrack_qt/widgets/labels/editable_label.py index 1f36b4bb78..bd1be53c6b 100644 --- a/libs/qt/source/ftrack_qt/widgets/labels/editable_label.py +++ b/libs/qt/source/ftrack_qt/widgets/labels/editable_label.py @@ -10,6 +10,11 @@ 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.''' @@ -17,6 +22,7 @@ def editable(self): 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) @@ -26,10 +32,15 @@ def __init__(self, text=None, editable=True, parent=None): 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/multi_publisher_dialog.py b/projects/framework-common-extensions/dialogs/multi_publisher_dialog.py index 1e1d390298..664144be9f 100644 --- a/projects/framework-common-extensions/dialogs/multi_publisher_dialog.py +++ b/projects/framework-common-extensions/dialogs/multi_publisher_dialog.py @@ -60,6 +60,7 @@ def __init__( self._scroll_area_widget = None self._progress_widget = None self._modified_tool_config = None + self._save_preset_button = None super(MultiPublisherDialog, self).__init__( event_manager, @@ -164,8 +165,14 @@ def build_ui(self): self.tool_widget.layout().addWidget(context_widget) # Build component widgets - - self.tool_widget.layout().addWidget(QtWidgets.QLabel('Components')) + # 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']} @@ -230,14 +237,6 @@ def _on_add_component_callback(self, _multi_group, add_button): } self.client_method_connection('sync_tool_config', arguments=args) - # # Add the new item into the tool config - # args = { - # 'tool_config_reference': self.tool_config['reference'], - # 'section': 'engine', - # 'new_item': _multi_group, - # } - # self.client_method_connection('augment_tool_config', arguments=args) - def insert_group_in_tool_config(self, new_group, group_accordion_widget): ''' Insert the new group in the tool config in the right position. @@ -296,6 +295,7 @@ def add_accordion_group(self, group): 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) @@ -309,12 +309,12 @@ def add_accordion_group(self, group): group_accordion_widget.show_options_overlay.connect( self.show_options_widget ) - group_accordion_widget.title_changed.connect( - self._on_title_changed_callback - ) group_accordion_widget.title_edited.connect( self._on_component_name_edited_callback ) + group_accordion_widget.bin_clicked.connect( + self._on_component_removed_callback + ) self._accordion_widgets_registry.append(group_accordion_widget) return group_accordion_widget @@ -365,6 +365,9 @@ def post_build_ui(self): 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): ''' @@ -372,16 +375,60 @@ def _on_path_changed_callback(self, accordion_widget, new_name): ''' extension = new_name.split('.')[-1] or os.path.basename(new_name) extension = self.get_available_component_name(extension) + + group = get_groups( + self._modified_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._modified_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._modified_tool_config, + filters={ + 'tags': ['component'], + 'options': {'component': self.sender().previous_title}, + }, + )[0] + group['options']['component'] = new_name + self.sender().set_title(new_name) - def _on_title_changed_callback(self, new_name): - self.set_tool_config_option('component', new_name) + # Sync the tool_config with the host + self.sync_tool_config(self._modified_tool_config) + + def _on_component_removed_callback(self, event): + # Remove the group from the tool_config + group = get_groups( + self._modified_tool_config, + filters={ + 'tags': ['component'], + 'options': {'component': self.sender().title}, + }, + ) + if group: + group = group[0] + self._modified_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._modified_tool_config) def show_options_widget(self, widget): '''Sets the given *widget* as the index 2 of the stacked widget and @@ -415,6 +462,20 @@ def plugin_run_callback(self, log_item): 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._modified_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: diff --git a/projects/framework-common-extensions/tool-configs/standalone-file-publisher.yaml b/projects/framework-common-extensions/tool-configs/standalone-file-publisher.yaml index 2473ef2cbe..ba4ad36119 100644 --- a/projects/framework-common-extensions/tool-configs/standalone-file-publisher.yaml +++ b/projects/framework-common-extensions/tool-configs/standalone-file-publisher.yaml @@ -51,6 +51,7 @@ engine: component: generic button_label: Add component editable_component_name: True + removable: True optional: True # by default so is no need #enabled: True by default so is no need plugins: @@ -86,6 +87,7 @@ engine: component: generic_static button_label: Add static component editable_component_name: False + removable: False optional: True # by default so is no need #enabled: True by default so is no need plugins: From 952af17a6228b7749e28cf57ac01c0073e155eae Mon Sep 17 00:00:00 2001 From: Lluis Ftrack Date: Wed, 3 Jul 2024 13:22:18 +0200 Subject: [PATCH 12/20] WIP on publishing --- .../dialogs/multi_publisher_dialog.py | 57 +++++++++++-------- 1 file changed, 34 insertions(+), 23 deletions(-) diff --git a/projects/framework-common-extensions/dialogs/multi_publisher_dialog.py b/projects/framework-common-extensions/dialogs/multi_publisher_dialog.py index 664144be9f..d9cbd02f2a 100644 --- a/projects/framework-common-extensions/dialogs/multi_publisher_dialog.py +++ b/projects/framework-common-extensions/dialogs/multi_publisher_dialog.py @@ -154,6 +154,18 @@ def build_ui(self): self.tool_widget.layout().addWidget(label_widget) 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 + args = { + 'tool_config': self.tool_config, + } + self.client_method_connection('sync_tool_config', arguments=args) + # Build context widgets context_plugins = get_plugins( self.tool_config, filters={'tags': ['context']} @@ -179,7 +191,7 @@ def build_ui(self): ) multi_groups = get_groups( - self.tool_config, filters={'tags': ['multi']} + self._original_tool_config, filters={'tags': ['multi']} ) self._accordion_widgets_registry = [] for _group in component_groups: @@ -206,10 +218,6 @@ def build_ui(self): QtWidgets.QSizePolicy.Expanding, ) self.tool_widget.layout().addItem(spacer) - # Create a new tool_config - self._modified_tool_config = copy.deepcopy(self.tool_config) - # Make sure we generate new references - self.registry.create_unic_references(self._modified_tool_config) def _on_add_component_callback(self, _multi_group, add_button): # Create a new unic group @@ -219,21 +227,24 @@ def _on_add_component_callback(self, _multi_group, add_button): 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 add_button_idx = self.tool_widget.layout().indexOf(add_button) self.tool_widget.layout().insertWidget( add_button_idx, group_accordion_widget ) - # generate references for the new group - self.registry.create_unic_references(new_group, skip_root=True) # 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 args = { - 'tool_config': self._modified_tool_config, + 'tool_config': self.tool_config, } self.client_method_connection('sync_tool_config', arguments=args) @@ -247,7 +258,7 @@ def insert_group_in_tool_config(self, new_group, group_accordion_widget): if current_idx > 0: previous_widget = self._accordion_widgets_registry[current_idx - 1] previous_group = get_groups( - self._modified_tool_config, + self.tool_config, filters={ 'tags': ['component'], 'options': {'component': previous_widget.title}, @@ -255,16 +266,16 @@ def insert_group_in_tool_config(self, new_group, group_accordion_widget): ) if previous_group: previous_group = previous_group[0] - previous_group_idx = self._modified_tool_config[ - 'engine' - ].index(previous_group) - self._modified_tool_config['engine'].insert( + previous_group_idx = self.tool_config['engine'].index( + previous_group + ) + self.tool_config['engine'].insert( previous_group_idx + 1, new_group ) else: - self._modified_tool_config['engine'].append(new_group) + self.tool_config['engine'].append(new_group) else: - self._modified_tool_config['engine'].insert(0, new_group) + self.tool_config['engine'].insert(0, new_group) def get_available_component_name(self, name, skip_widget=None): def increment_name(name): @@ -377,7 +388,7 @@ def _on_path_changed_callback(self, accordion_widget, new_name): extension = self.get_available_component_name(extension) group = get_groups( - self._modified_tool_config, + self.tool_config, filters={ 'tags': ['component'], 'options': {'component': accordion_widget.title}, @@ -388,7 +399,7 @@ def _on_path_changed_callback(self, accordion_widget, new_name): accordion_widget.set_title(extension) # Sync the tool_config with the host - self.sync_tool_config(self._modified_tool_config) + self.sync_tool_config(self.tool_config) def _on_component_name_edited_callback(self, new_name): new_name = self.get_available_component_name( @@ -396,7 +407,7 @@ def _on_component_name_edited_callback(self, new_name): ) if self.sender().previous_title: group = get_groups( - self._modified_tool_config, + self.tool_config, filters={ 'tags': ['component'], 'options': {'component': self.sender().previous_title}, @@ -407,12 +418,12 @@ def _on_component_name_edited_callback(self, new_name): self.sender().set_title(new_name) # Sync the tool_config with the host - self.sync_tool_config(self._modified_tool_config) + 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._modified_tool_config, + self.tool_config, filters={ 'tags': ['component'], 'options': {'component': self.sender().title}, @@ -420,7 +431,7 @@ def _on_component_removed_callback(self, event): ) if group: group = group[0] - self._modified_tool_config['engine'].remove(group) + 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 @@ -428,7 +439,7 @@ def _on_component_removed_callback(self, event): self.sender().deleteLater() # Sync the tool_config with the host - self.sync_tool_config(self._modified_tool_config) + self.sync_tool_config(self.tool_config) def show_options_widget(self, widget): '''Sets the given *widget* as the index 2 of the stacked widget and @@ -469,7 +480,7 @@ def _on_save_preset_button_clicked(self): self, 'Save Tool Config Preset', '', 'YAML (*.yaml)' )[0] args = { - 'tool_config': self._modified_tool_config, + 'tool_config': self.tool_config, 'destination': destination, } self.client_method_connection( From 14568ac70b0d6c6363278496f18eeb364d7740d6 Mon Sep 17 00:00:00 2001 From: Lluis Ftrack Date: Thu, 4 Jul 2024 13:23:50 +0200 Subject: [PATCH 13/20] cleanup and add release notes --- libs/framework-core/release_notes.md | 4 +- .../ftrack_framework_core/client/__init__.py | 6 + .../ftrack_framework_core/host/__init__.py | 45 ------- .../registry/__init__.py | 17 --- libs/qt-style/release_notes.md | 4 + libs/qt/release_notes.md | 2 + .../headers/accordion_header_widget.py | 2 - libs/utils/release_notes.md | 4 + .../dialogs/multi_publisher_dialog.py | 2 + .../release_notes.md | 2 + .../tool-configs/multi-publisher.yaml | 126 ++++++++++++++++++ .../standalone-file-publisher.yaml | 72 ---------- 12 files changed, 149 insertions(+), 137 deletions(-) create mode 100644 projects/framework-common-extensions/tool-configs/multi-publisher.yaml diff --git a/libs/framework-core/release_notes.md b/libs/framework-core/release_notes.md index 33f4ef7847..15f29de53e 100644 --- a/libs/framework-core/release_notes.md +++ b/libs/framework-core/release_notes.md @@ -2,8 +2,10 @@ ## upcoming +* [change] Registry; support get tool config extensions by reference. +* [new] Add method save_tool_config_in_destination in Client; Save given tool config as yaml file in specific destination. * [new] Add set options to the tool config from the client and dialog. -* [new] Add host_sync_tool_config on event manager to sync tool config from the client to the host. +* [new] Add host_sync_tool_config on event manager to sync tool config from the client to the host.; add sync_tool_config method in the client.; _sync_tool_config_callback method added on Host. ## v2.4.0 diff --git a/libs/framework-core/source/ftrack_framework_core/client/__init__.py b/libs/framework-core/source/ftrack_framework_core/client/__init__.py index 271ea3f10b..989cbf90bb 100644 --- a/libs/framework-core/source/ftrack_framework_core/client/__init__.py +++ b/libs/framework-core/source/ftrack_framework_core/client/__init__.py @@ -503,6 +503,9 @@ def _connect_getter_property_callback(self, property_name): def set_config_options( self, tool_config_reference, plugin_config_reference=None, options=None ): + ''' + Set the given *options* to the given *tool_config_reference* or to the given *plugin_config_reference* if provided. + ''' if not options: options = dict() # TODO_ mayabe we should rename this one to make sure this is just for plugins @@ -521,6 +524,9 @@ def set_config_options( ] = options def sync_tool_config(self, tool_config): + ''' + Sync the given *tool_config* with the host. + ''' self.event_manager.publish.host_sync_tool_config( self.host_id, tool_config, diff --git a/libs/framework-core/source/ftrack_framework_core/host/__init__.py b/libs/framework-core/source/ftrack_framework_core/host/__init__.py index d5268b09e3..2d01d5072b 100644 --- a/libs/framework-core/source/ftrack_framework_core/host/__init__.py +++ b/libs/framework-core/source/ftrack_framework_core/host/__init__.py @@ -385,51 +385,6 @@ def verify_plugins(self, plugin_names): ) return unregistered_plugins - # def augment_tool_config_callback(self, event): - # ''' - # Runs the data with the defined engine type of the given *event* - # - # Returns result of the engine run. - # - # *event* : Published from the client host connection at - # :meth:`~ftrack_framework_core.client.HostConnection.run` - # ''' - # - # tool_config_reference = event['data']['tool_config_reference'] - # section = event['data']['section'] - # new_item = event['data']['new_item'] - # - # if not new_item: - # return - # - # tool_config = self.registry.get_one(extension_type='tool_config', reference=tool_config_reference) - # - # self.registry.augment_tool_config( - # tool_config=tool_config, - # section=section, - # new_item=new_item - # ) - # # TODO: we need now to sync the tool config to the host connection. - # - # # Need to unsubscribe to make sure we subscribe again with the new - # # context - # self.event_manager.unsubscribe(self._discover_host_subscribe_id) - # # Reply to discover_host_callback to clients to pass the host information, so we make sure that if a new connection is made, it receives the correct tool_config - # discover_host_callback_reply = partial( - # provide_host_information, - # self.id, - # self.context_id, - # self.tool_configs, - # ) - # self._discover_host_subscribe_id = ( - # self.event_manager.subscribe.discover_host( - # callback=discover_host_callback_reply - # ) - # ) - # self.event_manager.publish.host_context_changed( - # self.id, self.context_id - # ) - def _sync_tool_config_callback(self, event): ''' Runs the data with the defined engine type of the given *event* diff --git a/libs/framework-core/source/ftrack_framework_core/registry/__init__.py b/libs/framework-core/source/ftrack_framework_core/registry/__init__.py index a0c7249e5f..e390a856ea 100644 --- a/libs/framework-core/source/ftrack_framework_core/registry/__init__.py +++ b/libs/framework-core/source/ftrack_framework_core/registry/__init__.py @@ -229,23 +229,6 @@ def get_one(self, *args, **kwargs): return None return matching_extensions[0] - def augment_tool_config(self, tool_config, section=None, new_item=None): - ''' - Augment the given *tool_config* with the given *new_item* in the given *section* - ''' - if not new_item: - return - # Make sure the new item has unic references - self.create_unic_references(new_item) - # Section will usually be engine - if section: - if section in tool_config: - tool_config[section].append(new_item) - else: - tool_config[section] = [new_item] - else: - tool_config.append(new_item) - def create_unic_references(self, tool_config, skip_root=False): ''' Augment the given *tool_config* to add a reference id to it diff --git a/libs/qt-style/release_notes.md b/libs/qt-style/release_notes.md index 6e7ab94aff..841d0ede0c 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. + ## v2.2.1 2024-05-02 diff --git a/libs/qt/release_notes.md b/libs/qt/release_notes.md index 38946929ad..c23b47291d 100644 --- a/libs/qt/release_notes.md +++ b/libs/qt/release_notes.md @@ -2,6 +2,8 @@ ## upcoming +* [changed] Improved AccordionWidget and AcordionHeaderWidget to use EditableLabelWidget and possibility to add a remove button. Emits bin_clicked, title_changed, title_edited signals. +* [fix] BuildProgressData utility, convert options to string. * [changed] StatusMaterialIconWidget clickable. * [new] Editable label widget. 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 c2672693c0..2082f5a4cd 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 @@ -127,8 +127,6 @@ def build(self): self._checkbox.setVisible(self.show_checkbox) # Create title - # TODO: we should be able to double click in order to edit the label in - # case the editable is true. (So it will look better) self._title_label = EditableLabel( text=self.title, editable=self.editable_title ) diff --git a/libs/utils/release_notes.md b/libs/utils/release_notes.md index b2b14141f2..1fefe7e882 100644 --- a/libs/utils/release_notes.md +++ b/libs/utils/release_notes.md @@ -1,5 +1,9 @@ # ftrack Utils library release Notes +## upcoming + +* [changed] framework tool configs; get_groups method dictionary support on filters argument.. + ## v2.3.0 2024-06-04 diff --git a/projects/framework-common-extensions/dialogs/multi_publisher_dialog.py b/projects/framework-common-extensions/dialogs/multi_publisher_dialog.py index d9cbd02f2a..deba20ec1d 100644 --- a/projects/framework-common-extensions/dialogs/multi_publisher_dialog.py +++ b/projects/framework-common-extensions/dialogs/multi_publisher_dialog.py @@ -248,6 +248,8 @@ def _on_add_component_callback(self, _multi_group, add_button): } self.client_method_connection('sync_tool_config', arguments=args) + 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. diff --git a/projects/framework-common-extensions/release_notes.md b/projects/framework-common-extensions/release_notes.md index d7e5abd7fd..336e16584f 100644 --- a/projects/framework-common-extensions/release_notes.md +++ b/projects/framework-common-extensions/release_notes.md @@ -2,6 +2,8 @@ ## Upcoming +* [changed] FileBrowserCollectorWidget emit signal when path changed. +* [new] Generic publisher dialog added with a generic tool-config. * [new] Exported paths validator to support image sequences. * [new] PySide6 support. * [new] PySide2 support. diff --git a/projects/framework-common-extensions/tool-configs/multi-publisher.yaml b/projects/framework-common-extensions/tool-configs/multi-publisher.yaml new file mode 100644 index 0000000000..d28595bbe6 --- /dev/null +++ b/projects/framework-common-extensions/tool-configs/multi-publisher.yaml @@ -0,0 +1,126 @@ +type: tool_config +name: multi-publisher +config_type: publisher +engine: + - type: plugin + tags: + - context + plugin: store_asset_context + options: + asset_type_name: script + ui: publisher_asset_version_selector + + # Export the file component. + - type: group + tags: + - component + options: + component: file + optional: True # by default so is no need + #enabled: True by default so is no need + plugins: + - type: plugin + plugin: store_component + - type: plugin + tags: + - collector + plugin: file_collector + ui: file_browser_collector + options: + folder_path: null + file_name: null + - type: plugin + tags: + - validator + plugin: file_exists_validator + ui: validator_label + - type: plugin + tags: + - exporter + plugin: rename_file_exporter + ui: file_exporter_options + options: + export_destination: "~/Desktop/myPublishedFile.png" + + # Add a generic component. + - type: group + tags: + - component + - multi + options: + component: generic + button_label: Add component + editable_component_name: True + removable: True + optional: True # by default so is no need + #enabled: True by default so is no need + plugins: + - type: plugin + plugin: store_component + - type: plugin + tags: + - collector + plugin: file_collector + ui: file_browser_collector + options: + folder_path: null + file_name: null + - type: plugin + tags: + - validator + plugin: file_exists_validator + ui: validator_label + - type: plugin + tags: + - exporter + plugin: rename_file_exporter + ui: file_exporter_options + options: + export_destination: "~/Desktop/myPublishedFile.png" + + # Add a static name generic component. + - type: group + tags: + - component + - multi + options: + component: generic_static + button_label: Add static component + editable_component_name: False + removable: False + optional: True # by default so is no need + #enabled: True by default so is no need + plugins: + - type: plugin + plugin: store_component + - type: plugin + tags: + - collector + plugin: file_collector + ui: file_browser_collector + options: + folder_path: null + file_name: null + - type: plugin + tags: + - validator + plugin: file_exists_validator + ui: validator_label + - type: plugin + tags: + - exporter + plugin: rename_file_exporter + ui: file_exporter_options + options: + export_destination: "~/Desktop/myPublishedFile.png" + + # Common validator check all exported paths exists. + - type: plugin + tags: + - validator + plugin: exported_paths_validator + ui: validator_label + + + # Publish to ftrack. + - publish_to_ftrack diff --git a/projects/framework-common-extensions/tool-configs/standalone-file-publisher.yaml b/projects/framework-common-extensions/tool-configs/standalone-file-publisher.yaml index ba4ad36119..c2e4a78093 100644 --- a/projects/framework-common-extensions/tool-configs/standalone-file-publisher.yaml +++ b/projects/framework-common-extensions/tool-configs/standalone-file-publisher.yaml @@ -42,78 +42,6 @@ engine: options: export_destination: "~/Desktop/myPublishedFile.png" - # Add a generic component. - - type: group - tags: - - component - - multi - options: - component: generic - button_label: Add component - editable_component_name: True - removable: True - optional: True # by default so is no need - #enabled: True by default so is no need - plugins: - - type: plugin - plugin: store_component - - type: plugin - tags: - - collector - plugin: file_collector - ui: file_browser_collector - options: - folder_path: null - file_name: null - - type: plugin - tags: - - validator - plugin: file_exists_validator - ui: validator_label - - type: plugin - tags: - - exporter - plugin: rename_file_exporter - ui: file_exporter_options - options: - export_destination: "~/Desktop/myPublishedFile.png" - - # Add a static name generic component. - - type: group - tags: - - component - - multi - options: - component: generic_static - button_label: Add static component - editable_component_name: False - removable: False - optional: True # by default so is no need - #enabled: True by default so is no need - plugins: - - type: plugin - plugin: store_component - - type: plugin - tags: - - collector - plugin: file_collector - ui: file_browser_collector - options: - folder_path: null - file_name: null - - type: plugin - tags: - - validator - plugin: file_exists_validator - ui: validator_label - - type: plugin - tags: - - exporter - plugin: rename_file_exporter - ui: file_exporter_options - options: - export_destination: "~/Desktop/myPublishedFile.png" - # Common validator check all exported paths exists. - type: plugin tags: From 449fa98cff71df5c095a6b76a77adff1f4d4aac3 Mon Sep 17 00:00:00 2001 From: Lluis Ftrack Date: Mon, 8 Jul 2024 09:30:11 +0200 Subject: [PATCH 14/20] rename multi to generic --- ..._dialog.py => generic_publisher_dialog.py} | 24 +++++++++---------- ...-publisher.yaml => generic-publisher.yaml} | 6 ++--- 2 files changed, 15 insertions(+), 15 deletions(-) rename projects/framework-common-extensions/dialogs/{multi_publisher_dialog.py => generic_publisher_dialog.py} (96%) rename projects/framework-common-extensions/tool-configs/{multi-publisher.yaml => generic-publisher.yaml} (98%) diff --git a/projects/framework-common-extensions/dialogs/multi_publisher_dialog.py b/projects/framework-common-extensions/dialogs/generic_publisher_dialog.py similarity index 96% rename from projects/framework-common-extensions/dialogs/multi_publisher_dialog.py rename to projects/framework-common-extensions/dialogs/generic_publisher_dialog.py index deba20ec1d..ee444e08b0 100644 --- a/projects/framework-common-extensions/dialogs/multi_publisher_dialog.py +++ b/projects/framework-common-extensions/dialogs/generic_publisher_dialog.py @@ -18,10 +18,10 @@ from ftrack_qt.utils.decorators import invoke_in_qt_main_thread -class MultiPublisherDialog(BaseContextDialog): +class GenericPublisherDialog(BaseContextDialog): '''Default Framework Publisher dialog''' - name = 'framework_multi_publisher_dialog' + name = 'framework_generic_publisher_dialog' tool_config_type_filter = ['publisher'] ui_type = 'qt' run_button_title = 'PUBLISH' @@ -62,7 +62,7 @@ def __init__( self._modified_tool_config = None self._save_preset_button = None - super(MultiPublisherDialog, self).__init__( + super(GenericPublisherDialog, self).__init__( event_manager, client_id, connect_methods_callback, @@ -190,24 +190,24 @@ def build_ui(self): self.tool_config, filters={'tags': ['component']} ) - multi_groups = get_groups( - self._original_tool_config, filters={'tags': ['multi']} + 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) - for _multi_group in multi_groups: + for _generic_group in generic_groups: add_button = QtWidgets.QPushButton( - _multi_group.get('options').get( + _generic_group.get('options').get( 'button_label', 'Add Component' ) ) self.tool_widget.layout().addWidget(add_button) add_button.clicked.connect( partial( - self._on_add_component_callback, _multi_group, add_button + self._on_add_component_callback, _generic_group, add_button ) ) @@ -219,9 +219,9 @@ def build_ui(self): ) self.tool_widget.layout().addItem(spacer) - def _on_add_component_callback(self, _multi_group, add_button): + def _on_add_component_callback(self, _generic_group, add_button): # Create a new unic group - new_group = copy.deepcopy(_multi_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') @@ -455,7 +455,7 @@ def _on_run_button_clicked(self): '''(Override) Drive the progress widget''' self.show_overlay_widget() self._progress_widget.run() - super(MultiPublisherDialog, self)._on_run_button_clicked() + 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: @@ -499,4 +499,4 @@ def closeEvent(self, event): if self._accordion_widgets_registry: for accordion in self._accordion_widgets_registry: accordion.teardown() - super(MultiPublisherDialog, self).closeEvent(event) + super(GenericPublisherDialog, self).closeEvent(event) diff --git a/projects/framework-common-extensions/tool-configs/multi-publisher.yaml b/projects/framework-common-extensions/tool-configs/generic-publisher.yaml similarity index 98% rename from projects/framework-common-extensions/tool-configs/multi-publisher.yaml rename to projects/framework-common-extensions/tool-configs/generic-publisher.yaml index d28595bbe6..82400195dc 100644 --- a/projects/framework-common-extensions/tool-configs/multi-publisher.yaml +++ b/projects/framework-common-extensions/tool-configs/generic-publisher.yaml @@ -1,5 +1,5 @@ type: tool_config -name: multi-publisher +name: generic-publisher config_type: publisher engine: - type: plugin @@ -46,7 +46,7 @@ engine: - type: group tags: - component - - multi + - generic options: component: generic button_label: Add component @@ -82,7 +82,7 @@ engine: - type: group tags: - component - - multi + - generic options: component: generic_static button_label: Add static component From 6f4ea5436d18b37b7816913ec12bfd40c5f386f3 Mon Sep 17 00:00:00 2001 From: Lluis Ftrack Date: Mon, 8 Jul 2024 14:08:02 +0200 Subject: [PATCH 15/20] Changed add buttons --- libs/qt/pyproject.toml | 1 + libs/qt/release_notes.md | 5 ++ .../widgets/browsers/file_browser.py | 30 ++++++-- .../ftrack_qt/widgets/buttons/__init__.py | 1 + .../ftrack_qt/widgets/buttons/menu_button.py | 75 +++++++++++++++++++ .../ftrack_qt/widgets/dialogs/__init__.py | 1 + .../ftrack_qt/widgets/dialogs/file_dialog.py | 19 ++--- .../widgets/dialogs/radio_button_dialog.py | 66 ++++++++++++++++ .../dialogs/generic_publisher_dialog.py | 39 +++++++--- 9 files changed, 211 insertions(+), 26 deletions(-) create mode 100644 libs/qt/source/ftrack_qt/widgets/buttons/menu_button.py create mode 100644 libs/qt/source/ftrack_qt/widgets/dialogs/radio_button_dialog.py diff --git a/libs/qt/pyproject.toml b/libs/qt/pyproject.toml index 634494e526..d82a78fa42 100644 --- a/libs/qt/pyproject.toml +++ b/libs/qt/pyproject.toml @@ -26,6 +26,7 @@ build-backend = "poetry.core.masonry.api" python = ">= 3.7, < 3.12" PySide2 = { version = "^5.13.2", optional = true } PySide6 = { version = "^6.5", optional = true } +clique = "1.6.1" #ftrack ftrack-constants = { version = "^2.0.0", optional = true } ftrack-utils = { version = "^2.0.0", optional = true } diff --git a/libs/qt/release_notes.md b/libs/qt/release_notes.md index c23b47291d..53015b7167 100644 --- a/libs/qt/release_notes.md +++ b/libs/qt/release_notes.md @@ -2,6 +2,11 @@ ## upcoming +* [new] MenuButton; Button supporting menu if multiple action items provided. +* [changed] FileDialog; support selecting multiple files. +* [changed] FileBrowser; support collection of multiple files. +* [changed] Python clique 1.6.1 library as dependency. +* [new] RadioButtonDialog; Dialog to return an item from a list of items represented by radio buttons. * [changed] Improved AccordionWidget and AcordionHeaderWidget to use EditableLabelWidget and possibility to add a remove button. Emits bin_clicked, title_changed, title_edited signals. * [fix] BuildProgressData utility, convert options to string. * [changed] StatusMaterialIconWidget clickable. diff --git a/libs/qt/source/ftrack_qt/widgets/browsers/file_browser.py b/libs/qt/source/ftrack_qt/widgets/browsers/file_browser.py index 2e4bad4f1f..0c6c65e5a0 100644 --- a/libs/qt/source/ftrack_qt/widgets/browsers/file_browser.py +++ b/libs/qt/source/ftrack_qt/widgets/browsers/file_browser.py @@ -2,6 +2,7 @@ # :copyright: Copyright (c) 2024 ftrack from pathlib import Path +import clique try: from PySide6 import QtWidgets, QtCore @@ -9,6 +10,7 @@ from PySide2 import QtWidgets, QtCore from ftrack_qt.widgets.dialogs import FileDialog +from ftrack_qt.widgets.dialogs import RadioButtonDialog class FileBrowser(QtWidgets.QWidget): @@ -65,8 +67,26 @@ def set_tool_tip(self, tooltip_text): def _browse_button_clicked(self): '''Browse button clicked signal''' start_dir = self._path_le.text() or Path.home() - choosen_file_path = FileDialog( - start_dir=str(start_dir), dialog_filter="*" - ) - self.set_path(choosen_file_path.path) - self.path_changed.emit(choosen_file_path.path) + file_dialog = FileDialog(start_dir=str(start_dir), dialog_filter="*") + selected_path = None + if len(file_dialog.paths) > 1: + selected_path = self.process_files_with_clique(file_dialog.paths) + else: + selected_path = file_dialog.paths[0] + self.set_path(selected_path) + self.path_changed.emit(selected_path) + + def process_files_with_clique(self, file_paths): + '''Process the selected files with the clique library''' + collections, remainder = clique.assemble(file_paths) + collections_list = [collection.format() for collection in collections] + collections_list.extend(remainder) + if len(collections_list) > 1: + radio_button_dialog = RadioButtonDialog(collections_list) + if radio_button_dialog.exec_(): + selected_item = radio_button_dialog.selected_item() + return selected_item + else: + return None + else: + return collections_list[0] 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/dialogs/__init__.py b/libs/qt/source/ftrack_qt/widgets/dialogs/__init__.py index 9559bd9c30..8a8a32ce71 100644 --- a/libs/qt/source/ftrack_qt/widgets/dialogs/__init__.py +++ b/libs/qt/source/ftrack_qt/widgets/dialogs/__init__.py @@ -5,3 +5,4 @@ ScrollToolConfigsDialog, ) from ftrack_qt.widgets.dialogs.tab_dialog import TabDialog +from ftrack_qt.widgets.dialogs.radio_button_dialog import RadioButtonDialog diff --git a/libs/qt/source/ftrack_qt/widgets/dialogs/file_dialog.py b/libs/qt/source/ftrack_qt/widgets/dialogs/file_dialog.py index 5d5ef2a0ee..fad3c5e4ee 100644 --- a/libs/qt/source/ftrack_qt/widgets/dialogs/file_dialog.py +++ b/libs/qt/source/ftrack_qt/widgets/dialogs/file_dialog.py @@ -14,28 +14,29 @@ class FileDialog(QtWidgets.QFileDialog): caption = 'Choose file' @property - def path(self): + def paths(self): '''Return selected path in the file dialog''' - return self._path + return self._paths def __init__(self, start_dir, dialog_filter, parent=None): ''' Initialize File dialog ''' super(FileDialog, self).__init__(parent=parent) - self._path = None + self._paths = [] ( - file_path, + file_paths, unused_selected_filter, - ) = self.getOpenFileName( + ) = self.getOpenFileNames( caption=self.caption, dir=start_dir, filter=dialog_filter ) - if not file_path: + if not file_paths: return - self.proces_path(file_path) + self.proces_path(file_paths) - def proces_path(self, file_path): + def proces_path(self, file_paths): '''Process returned path of the file dialog''' - self._path = os.path.normpath(file_path) + for path in file_paths: + self._paths.append(os.path.normpath(path)) diff --git a/libs/qt/source/ftrack_qt/widgets/dialogs/radio_button_dialog.py b/libs/qt/source/ftrack_qt/widgets/dialogs/radio_button_dialog.py new file mode 100644 index 0000000000..17aecbbe07 --- /dev/null +++ b/libs/qt/source/ftrack_qt/widgets/dialogs/radio_button_dialog.py @@ -0,0 +1,66 @@ +# :coding: utf-8 +# :copyright: Copyright (c) 2024 ftrack + +try: + from PySide6 import QtWidgets, QtCore +except ImportError: + from PySide2 import QtWidgets, QtCore + + +class RadioButtonDialog(QtWidgets.QDialog): + '''Dialog to select item from multiple items found.''' + + @property + def items(self): + '''Return items of the dialog''' + return self._items + + def __init__(self, items, parent=None): + ''' + Initialize dialog + ''' + super(RadioButtonDialog, self).__init__(parent=parent) + + self.setWindowTitle('Select Item') + + self._items = items + + self.pre_build() + self.build() + self.post_build() + + def pre_build(self): + self.layout = QtWidgets.QVBoxLayout(self) + self.setLayout(self.layout) + + def build(self): + '''Build widgets''' + + message = QtWidgets.QLabel('Multiple items found, please select one: ') + self.layout.addWidget(message) + + self.radio_buttons = [] + self.button_group = QtWidgets.QButtonGroup(self) + + for item in self.items: + radio_button = QtWidgets.QRadioButton(item) + self.radio_buttons.append(radio_button) + self.button_group.addButton(radio_button) + self.layout.addWidget(radio_button) + + self.ok_button = QtWidgets.QPushButton('OK') + + self.button_layout = QtWidgets.QHBoxLayout() + self.button_layout.addStretch() + self.button_layout.addWidget(self.ok_button) + + self.layout.addLayout(self.button_layout) + + def post_build(self): + self.ok_button.clicked.connect(self.accept) + + def selected_item(self): + for radio_button in self.radio_buttons: + if radio_button.isChecked(): + return radio_button.text() + return None diff --git a/projects/framework-common-extensions/dialogs/generic_publisher_dialog.py b/projects/framework-common-extensions/dialogs/generic_publisher_dialog.py index ee444e08b0..db6f69fb9d 100644 --- a/projects/framework-common-extensions/dialogs/generic_publisher_dialog.py +++ b/projects/framework-common-extensions/dialogs/generic_publisher_dialog.py @@ -16,6 +16,8 @@ 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): @@ -198,18 +200,17 @@ def build_ui(self): 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: - add_button = QtWidgets.QPushButton( - _generic_group.get('options').get( + self.menu_button.add_item( + item_data=_generic_group, + label=_generic_group.get('options').get( 'button_label', 'Add Component' - ) - ) - self.tool_widget.layout().addWidget(add_button) - add_button.clicked.connect( - partial( - self._on_add_component_callback, _generic_group, add_button - ) + ), ) + self.menu_button.item_clicked.connect(self._on_add_component_callback) spacer = QtWidgets.QSpacerItem( 1, @@ -219,7 +220,21 @@ def build_ui(self): ) self.tool_widget.layout().addItem(spacer) - def _on_add_component_callback(self, _generic_group, add_button): + 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 @@ -234,9 +249,9 @@ def _on_add_component_callback(self, _generic_group, add_button): group_accordion_widget = self.add_accordion_group(new_group) # Insert the new group before the add button - add_button_idx = self.tool_widget.layout().indexOf(add_button) + latest_idx = self._get_latest_component_index() self.tool_widget.layout().insertWidget( - add_button_idx, group_accordion_widget + latest_idx + 1, group_accordion_widget ) # Insert the new group into the correct position in the tool_config From c1f7642dcc1c5357943a5635c1d8f3a1031dc9f9 Mon Sep 17 00:00:00 2001 From: Lluis Ftrack Date: Tue, 9 Jul 2024 11:57:32 +0200 Subject: [PATCH 16/20] Publisher working with sequences and temporal directories --- libs/utils/release_notes.md | 1 + .../source/ftrack_utils/paths/__init__.py | 38 +++++++---- .../dialogs/generic_publisher_dialog.py | 22 ++++++- .../plugins/exported_path_validator.py | 61 ++++------------- .../plugins/file_exists_validator.py | 14 +++- .../plugins/rename_file_exporter.py | 66 +++++++++++++++++-- .../release_notes.md | 2 + .../requirements.txt | 1 + .../tool-configs/generic-publisher.yaml | 2 - .../widgets/flie_export_options.py | 2 +- 10 files changed, 136 insertions(+), 73 deletions(-) diff --git a/libs/utils/release_notes.md b/libs/utils/release_notes.md index 1fefe7e882..fbddc743bb 100644 --- a/libs/utils/release_notes.md +++ b/libs/utils/release_notes.md @@ -2,6 +2,7 @@ ## upcoming +* [changed] get_temp_path; Support temp directories. * [changed] framework tool configs; get_groups method dictionary support on filters argument.. ## v2.3.0 diff --git a/libs/utils/source/ftrack_utils/paths/__init__.py b/libs/utils/source/ftrack_utils/paths/__init__.py index 8dcc6f375e..3af75567f7 100644 --- a/libs/utils/source/ftrack_utils/paths/__init__.py +++ b/libs/utils/source/ftrack_utils/paths/__init__.py @@ -37,22 +37,34 @@ def find_image_sequence(file_path): return None -def get_temp_path(filename_extension=None): +def get_temp_path(filename_extension=None, is_directory=False): '''Calculate and return a Connect temporary path, appending *filename_extension* if supplied.''' - result = os.path.join( - tempfile.gettempdir(), - 'ftrack-connect', - 'ftrack', - '{}{}'.format( - os.path.basename(tempfile.NamedTemporaryFile().name), - f'.{filename_extension.split(".")[-1]}' - if filename_extension - else '', - ), + base_temp_dir = os.path.join( + tempfile.gettempdir(), 'ftrack-connect', 'ftrack' ) - if not os.path.exists(os.path.dirname(result)): - os.makedirs(os.path.dirname(result)) + + # Ensure the base temporary directory exists + if not os.path.exists(base_temp_dir): + os.makedirs(base_temp_dir) + + if is_directory: + # Create a temporary directory + result = tempfile.mkdtemp(dir=base_temp_dir) + else: + # Create a temporary file and get its path + temp_file = tempfile.NamedTemporaryFile( + delete=False, dir=base_temp_dir + ) + result = temp_file.name + + # If a filename extension is provided, append it to the file name + if filename_extension: + result_with_extension = ( + f'{result}.{filename_extension.split(".")[-1]}' + ) + os.rename(result, result_with_extension) + result = result_with_extension return result diff --git a/projects/framework-common-extensions/dialogs/generic_publisher_dialog.py b/projects/framework-common-extensions/dialogs/generic_publisher_dialog.py index db6f69fb9d..5a567261b4 100644 --- a/projects/framework-common-extensions/dialogs/generic_publisher_dialog.py +++ b/projects/framework-common-extensions/dialogs/generic_publisher_dialog.py @@ -4,6 +4,8 @@ import os from functools import partial +import clique + try: from PySide6 import QtWidgets, QtCore except ImportError: @@ -401,8 +403,24 @@ def _on_path_changed_callback(self, accordion_widget, new_name): ''' Callback to update the component name when the path is changed. ''' - extension = new_name.split('.')[-1] or os.path.basename(new_name) - extension = self.get_available_component_name(extension) + 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, diff --git a/projects/framework-common-extensions/plugins/exported_path_validator.py b/projects/framework-common-extensions/plugins/exported_path_validator.py index f16390b38f..8afe1da5e9 100644 --- a/projects/framework-common-extensions/plugins/exported_path_validator.py +++ b/projects/framework-common-extensions/plugins/exported_path_validator.py @@ -2,7 +2,8 @@ # :copyright: Copyright (c) 2024 ftrack import os -import re + +import clique from ftrack_framework_core.plugin import BasePlugin from ftrack_framework_core.exceptions.plugin import PluginValidationError @@ -11,48 +12,10 @@ class ExportedPathsValidatorPlugin(BasePlugin): ''' Plugin to validate if exported_paths of all components in the store exists. - - Expected format: folder/image.%d.jpg [1-35] ''' name = 'exported_paths_validator' - def check_image_sequence(self, path): - '''Check if the image sequence pointed out by *path* exists.''' - directory, basename = os.path.split(path) - - p_pos = basename.find('%') - d_pos = basename.find('d', p_pos) - exp = basename[p_pos : d_pos + 1] - - pad = 0 - if d_pos > p_pos + 2: - pad = int(basename[p_pos + 1 : d_pos]) - - ws_pos = basename.rfind(' ') - dash_pos = basename.find('-', ws_pos) - - prefix = basename[:p_pos] - suffix = basename[d_pos + 1 : ws_pos] - - start = int(basename[ws_pos + 2 : dash_pos]) - end = int(basename[dash_pos + 1 : -1]) - - self.logger.debug( - f'Looking for frames {start}>{end} in directory {directory} starting ' - f'with {prefix}, ending with {suffix} (padding: {pad})' - ) - - for frame in range(start, end + 1): - filename = f'{prefix}{exp % frame}{suffix}' - test_path = os.path.join(directory, filename) - if not os.path.exists(test_path): - raise PluginValidationError( - f'Image sequence member {frame} not ' - f'found @ "{test_path}"!' - ) - self.logger.debug(f'Frame {frame} verified: {filename}') - def run(self, store): ''' Run the validation process. @@ -63,14 +26,16 @@ def run(self, store): ) if not exported_path: continue - # Check if image sequence - having "%d" or padded "%NNd" in the path - if re.findall(r"%(\d{1,2}d|d)", exported_path): - # Check that all frames exist and - # TODO: use a 3rd party library here (not clique as it is not maintained) - self.check_image_sequence(exported_path) - else: - if not os.path.exists(exported_path): + if not os.path.exists(exported_path): + try: + collection = clique.parse(exported_path) + for file_path in collection: + if not os.path.exists(file_path): + raise PluginValidationError( + message=f'File {file_path} does not exist.' + ) + except Exception as error: raise PluginValidationError( - message=f"The file {exported_path} doesn't exists" + message=f'File {exported_path} does not exist, and is not a valid collection, error: {error}' ) - self.logger.debug(f"Exported path {exported_path} exists.") + self.logger.debug(f"Exported path {exported_path} exists.") diff --git a/projects/framework-common-extensions/plugins/file_exists_validator.py b/projects/framework-common-extensions/plugins/file_exists_validator.py index ec11caebb1..488809f73c 100644 --- a/projects/framework-common-extensions/plugins/file_exists_validator.py +++ b/projects/framework-common-extensions/plugins/file_exists_validator.py @@ -6,6 +6,8 @@ from ftrack_framework_core.plugin import BasePlugin from ftrack_framework_core.exceptions.plugin import PluginValidationError +import clique + class FileExistsValidatorPlugin(BasePlugin): name = 'file_exists_validator' @@ -15,7 +17,17 @@ def validate(self, file_path): Return True if given *file_path* exists, False If not. ''' if not os.path.exists(file_path): - raise PluginValidationError(message='File does not exist.') + try: + collection = clique.parse(file_path) + for file_path in collection: + if not os.path.exists(file_path): + raise PluginValidationError( + message=f'File {file_path} does not exist.' + ) + except Exception as error: + raise PluginValidationError( + message=f'File {file_path} does not exist, and is not a valid collection, error: {error}' + ) self.logger.debug(f"{file_path} Exists.") return True diff --git a/projects/framework-common-extensions/plugins/rename_file_exporter.py b/projects/framework-common-extensions/plugins/rename_file_exporter.py index c3dbcedb86..80a50b2d23 100644 --- a/projects/framework-common-extensions/plugins/rename_file_exporter.py +++ b/projects/framework-common-extensions/plugins/rename_file_exporter.py @@ -3,17 +3,47 @@ import os.path import shutil +import clique + from ftrack_framework_core.plugin import BasePlugin +from ftrack_utils.paths import get_temp_path class RenameExporterPlugin(BasePlugin): name = 'rename_file_exporter' + def check_collection(self, path): + ''' + Check if the given *path* is a collection. + ''' + try: + collection = clique.parse(path) + except Exception as error: + self.logger.debug(f"{path} is not a collection.") + return False + self.logger.debug(f"{path} is a collection.") + return collection + def rename(self, origin_path, destination_path): ''' Rename the given *origin_path* to *destination_path* ''' - return shutil.copy(origin_path, os.path.expanduser(destination_path)) + # Pick file extension from origin_path + no_extension_path, extension_format = os.path.splitext(origin_path) + # Pick file base name from origin_path + base_name = os.path.basename(no_extension_path) + + if not destination_path: + destination_path = get_temp_path( + filename_extension=extension_format + ) + + if os.path.isdir(destination_path): + destination_path = os.path.join( + destination_path, base_name + extension_format + ) + + return shutil.copy(origin_path, destination_path) def run(self, store): ''' @@ -23,9 +53,33 @@ def run(self, store): component_name = self.options.get('component') collected_path = store['components'][component_name]['collected_path'] - export_destination = self.options['export_destination'] + export_destination = self.options.get('export_destination') + if export_destination: + export_destination = os.path.expanduser(export_destination) + + collection = self.check_collection(collected_path) - store['components'][component_name]['exported_path'] = self.rename( - collected_path, export_destination - ) - self.logger.debug(f"Copied {collected_path} to {export_destination}.") + if collection: + new_location = [] + for file_path in collection: + if export_destination and not os.path.isdir( + export_destination + ): + export_destination = os.path.dirname(export_destination) + if not export_destination: + export_destination = get_temp_path(is_directory=True) + new_location.append(self.rename(file_path, export_destination)) + self.logger.debug( + f"Copied {collected_path} to {export_destination}." + ) + collections, remainder = clique.assemble(new_location) + store['components'][component_name]['exported_path'] = collections[ + 0 + ].format() + else: + store['components'][component_name]['exported_path'] = self.rename( + collected_path, export_destination + ) + self.logger.debug( + f"Copied {collected_path} to {export_destination}." + ) diff --git a/projects/framework-common-extensions/release_notes.md b/projects/framework-common-extensions/release_notes.md index 336e16584f..bf507b8d0c 100644 --- a/projects/framework-common-extensions/release_notes.md +++ b/projects/framework-common-extensions/release_notes.md @@ -2,6 +2,8 @@ ## Upcoming +* [changed] RenameFileExporterPlugin; Accept folder as destination. +* [required] Add clique 1.6.1 as dependency. * [changed] FileBrowserCollectorWidget emit signal when path changed. * [new] Generic publisher dialog added with a generic tool-config. * [new] Exported paths validator to support image sequences. diff --git a/projects/framework-common-extensions/requirements.txt b/projects/framework-common-extensions/requirements.txt index 45934f0c01..1a70499aa2 100644 --- a/projects/framework-common-extensions/requirements.txt +++ b/projects/framework-common-extensions/requirements.txt @@ -3,3 +3,4 @@ ftrack_utils == "^2.0.0" ftrack_qt == "^2.0.0" ftrack-constants == "^2.0.0" ftrack_framework_qt == "^2.0.0" +clique == "1.6.1" diff --git a/projects/framework-common-extensions/tool-configs/generic-publisher.yaml b/projects/framework-common-extensions/tool-configs/generic-publisher.yaml index 82400195dc..33cba41169 100644 --- a/projects/framework-common-extensions/tool-configs/generic-publisher.yaml +++ b/projects/framework-common-extensions/tool-configs/generic-publisher.yaml @@ -111,8 +111,6 @@ engine: - exporter plugin: rename_file_exporter ui: file_exporter_options - options: - export_destination: "~/Desktop/myPublishedFile.png" # Common validator check all exported paths exists. - type: plugin diff --git a/projects/framework-common-extensions/widgets/flie_export_options.py b/projects/framework-common-extensions/widgets/flie_export_options.py index 47d7fdef23..26badd134d 100644 --- a/projects/framework-common-extensions/widgets/flie_export_options.py +++ b/projects/framework-common-extensions/widgets/flie_export_options.py @@ -55,7 +55,7 @@ def pre_build_ui(self): def build_ui(self): '''build function widgets.''' # Create options: - for option, value in self.plugin_config.get('options').items(): + for option, value in self.plugin_config.get('options', {}).items(): h_layout = QtWidgets.QHBoxLayout() option_widget = QtWidgets.QLabel(option) value_widget = None From e7c9305b6e273cbfdd95babb522deb24960c394b Mon Sep 17 00:00:00 2001 From: Lluis Ftrack Date: Tue, 9 Jul 2024 14:08:24 +0200 Subject: [PATCH 17/20] Fix bug when disabling plugin, wasn't working and now its working --- .../ftrack_framework_core/client/__init__.py | 10 +++---- .../ftrack_framework_core/engine/__init__.py | 8 ++++++ .../ftrack_framework_core/widget/dialog.py | 13 ++++----- .../source/ftrack_qt/utils/widget/__init__.py | 12 ++++++-- .../widgets/accordion/accordion_widget.py | 2 ++ .../dialogs/generic_publisher_dialog.py | 28 +++++++++++++------ .../dialogs/standard_publisher_dialog.py | 13 +++++++++ 7 files changed, 63 insertions(+), 23 deletions(-) diff --git a/libs/framework-core/source/ftrack_framework_core/client/__init__.py b/libs/framework-core/source/ftrack_framework_core/client/__init__.py index 989cbf90bb..898d43c664 100644 --- a/libs/framework-core/source/ftrack_framework_core/client/__init__.py +++ b/libs/framework-core/source/ftrack_framework_core/client/__init__.py @@ -501,26 +501,26 @@ def _connect_getter_property_callback(self, property_name): return self.__getattribute__(property_name) def set_config_options( - self, tool_config_reference, plugin_config_reference=None, options=None + self, tool_config_reference, item_reference=None, options=None ): ''' - Set the given *options* to the given *tool_config_reference* or to the given *plugin_config_reference* if provided. + Set the given *options* to the given *tool_config_reference* or to the given *item_reference*(meaning to plugin or group) if provided. ''' if not options: options = dict() - # TODO_ mayabe we should rename this one to make sure this is just for plugins + if not isinstance(options, dict): raise Exception( "plugin_options should be a dictionary. " "Current given type: {}".format(options) ) - if not plugin_config_reference: + if not item_reference: self._tool_config_options[tool_config_reference][ 'options' ] = options else: self._tool_config_options[tool_config_reference][ - plugin_config_reference + item_reference ] = options def sync_tool_config(self, tool_config): diff --git a/libs/framework-core/source/ftrack_framework_core/engine/__init__.py b/libs/framework-core/source/ftrack_framework_core/engine/__init__.py index 28b9f88e33..386f3a6385 100644 --- a/libs/framework-core/source/ftrack_framework_core/engine/__init__.py +++ b/libs/framework-core/source/ftrack_framework_core/engine/__init__.py @@ -117,6 +117,12 @@ def run_plugin(self, plugin, store, options, reference=None): *options*: options to be passed to the plugin *reference*: reference id of the plugin ''' + # Specifically checking if its False, as we want to skip the plugin in that case. + if options.get('enabled') == False: + self.logger.debug( + f"Plugin {plugin} is disabled, skipping execution." + ) + return registered_plugin = self.plugin_registry.get_one(name=plugin) plugin_instance = registered_plugin['extension']( @@ -201,6 +207,8 @@ def execute_engine(self, engine, user_options): self.run_plugin(item, store, {}) elif isinstance(item, dict): + if item.get("enabled") == False: + continue # If it's a group, execute all plugins from the group if item["type"] == "group": group_options = item.get("options") or {} diff --git a/libs/framework-core/source/ftrack_framework_core/widget/dialog.py b/libs/framework-core/source/ftrack_framework_core/widget/dialog.py index 2460730780..01b98f68fa 100644 --- a/libs/framework-core/source/ftrack_framework_core/widget/dialog.py +++ b/libs/framework-core/source/ftrack_framework_core/widget/dialog.py @@ -467,21 +467,18 @@ def _on_set_plugin_option_callback(self, plugin_reference, options): ''' Pass the given *options* of the *plugin_reference* to the client. ''' - arguments = { - "tool_config_reference": self.tool_config['reference'], - "plugin_config_reference": plugin_reference, - "options": options, - } - self.set_option_callback(arguments) + self.set_tool_config_option(options, plugin_reference) - def set_tool_config_option(self, name, value): + def set_tool_config_option(self, options, item_reference=None): ''' Set the given name and value as options for the current tool config. ''' arguments = { "tool_config_reference": self.tool_config['reference'], - "options": {name: value}, + "options": options, } + if item_reference: + arguments['item_reference'] = item_reference self.set_option_callback(arguments) def set_option_callback(self, args): diff --git a/libs/qt/source/ftrack_qt/utils/widget/__init__.py b/libs/qt/source/ftrack_qt/utils/widget/__init__.py index 5fb2c03dfd..d06159d356 100644 --- a/libs/qt/source/ftrack_qt/utils/widget/__init__.py +++ b/libs/qt/source/ftrack_qt/utils/widget/__init__.py @@ -119,16 +119,24 @@ def build_progress_data(tool_config): '''Build progress data from *tool_config*''' progress_data = [] for plugin_config in get_plugins(tool_config, with_parents=True): + enabled = True + if plugin_config.get('enabled') == False: + enabled = False + continue phase_data = { 'id': plugin_config['reference'], 'label': plugin_config['plugin'].replace('_', ' ').title(), } tags = plugin_config.get('tags') or [] for group in plugin_config.get('parents') or []: + if group.get('enabled') == False: + enabled = False + continue if 'options' in group: tags.extend(list(str(group['options'].values()))) if 'tags' in group: tags.extend(group['tags']) - phase_data['tags'] = reversed(tags) - progress_data.append(phase_data) + if enabled: + phase_data['tags'] = reversed(tags) + progress_data.append(phase_data) return progress_data 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 57d3da34d6..92afb32ce2 100644 --- a/libs/qt/source/ftrack_qt/widgets/accordion/accordion_widget.py +++ b/libs/qt/source/ftrack_qt/widgets/accordion/accordion_widget.py @@ -22,6 +22,7 @@ class AccordionBaseWidget(QtWidgets.QFrame): hide_options_overlay = QtCore.Signal() title_changed = QtCore.Signal(object) title_edited = QtCore.Signal(object) + enabled_changed = QtCore.Signal(object) @property def title(self): @@ -253,6 +254,7 @@ def _on_checkbox_status_changed(self, checked): '''Callback on enable checkbox user interaction''' self._checked = checked self._content_widget.setEnabled(self.checked) + self.enabled_changed.emit(self.checked) def _on_header_clicked(self, event): '''Callback on header user click''' diff --git a/projects/framework-common-extensions/dialogs/generic_publisher_dialog.py b/projects/framework-common-extensions/dialogs/generic_publisher_dialog.py index 5a567261b4..67fc80967f 100644 --- a/projects/framework-common-extensions/dialogs/generic_publisher_dialog.py +++ b/projects/framework-common-extensions/dialogs/generic_publisher_dialog.py @@ -164,11 +164,9 @@ def build_ui(self): # 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 - args = { - 'tool_config': self.tool_config, - } - self.client_method_connection('sync_tool_config', arguments=args) + self.sync_tool_config(self.tool_config) # Build context widgets context_plugins = get_plugins( @@ -260,10 +258,7 @@ def _on_add_component_callback(self, _generic_group): self.insert_group_in_tool_config(new_group, group_accordion_widget) # Sync the tool_config with the host - args = { - 'tool_config': self.tool_config, - } - self.client_method_connection('sync_tool_config', arguments=args) + self.sync_tool_config(self.tool_config) self._progress_widget.set_data(build_progress_data(self.tool_config)) @@ -345,6 +340,10 @@ def add_accordion_group(self, group): 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 @@ -476,6 +475,8 @@ def _on_component_removed_callback(self, event): # 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''' @@ -484,6 +485,17 @@ def show_options_widget(self, widget): 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() diff --git a/projects/framework-common-extensions/dialogs/standard_publisher_dialog.py b/projects/framework-common-extensions/dialogs/standard_publisher_dialog.py index 98eb6a45cb..61ef4c769d 100644 --- a/projects/framework-common-extensions/dialogs/standard_publisher_dialog.py +++ b/projects/framework-common-extensions/dialogs/standard_publisher_dialog.py @@ -1,6 +1,8 @@ # :coding: utf-8 # :copyright: Copyright (c) 2024 ftrack +from functools import partial + try: from PySide6 import QtWidgets, QtCore except ImportError: @@ -194,6 +196,9 @@ def build_ui(self): group_accordion_widget.show_options_overlay.connect( self.show_options_widget ) + group_accordion_widget.enabled_changed.connect( + partial(self._on_enable_component_changed_callback, _group) + ) self._accordion_widgets_registry.append(group_accordion_widget) spacer = QtWidgets.QSpacerItem( @@ -251,6 +256,14 @@ def show_options_widget(self, widget): 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 + 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() From 4b4e56fde52128306dd6d2c0700390866d5740de Mon Sep 17 00:00:00 2001 From: Lluis Ftrack Date: Wed, 10 Jul 2024 11:47:40 +0200 Subject: [PATCH 18/20] Maya generic publisher --- ...neric-publisher.yaml => standalone-generic-publisher.yaml} | 2 +- projects/framework-maya/extensions/maya.yaml | 4 ++-- projects/framework-maya/pyproject.toml | 2 +- projects/framework-maya/release_notes.md | 4 ++++ 4 files changed, 8 insertions(+), 4 deletions(-) rename projects/framework-common-extensions/tool-configs/{generic-publisher.yaml => standalone-generic-publisher.yaml} (98%) diff --git a/projects/framework-common-extensions/tool-configs/generic-publisher.yaml b/projects/framework-common-extensions/tool-configs/standalone-generic-publisher.yaml similarity index 98% rename from projects/framework-common-extensions/tool-configs/generic-publisher.yaml rename to projects/framework-common-extensions/tool-configs/standalone-generic-publisher.yaml index 33cba41169..065dae8809 100644 --- a/projects/framework-common-extensions/tool-configs/generic-publisher.yaml +++ b/projects/framework-common-extensions/tool-configs/standalone-generic-publisher.yaml @@ -1,5 +1,5 @@ type: tool_config -name: generic-publisher +name: standalone-generic-publisher config_type: publisher engine: - type: plugin diff --git a/projects/framework-maya/extensions/maya.yaml b/projects/framework-maya/extensions/maya.yaml index f35d481b67..1fb4ac12e2 100644 --- a/projects/framework-maya/extensions/maya.yaml +++ b/projects/framework-maya/extensions/maya.yaml @@ -4,11 +4,11 @@ tools: - name: publish menu: true label: "Publish" - dialog_name: framework_standard_publisher_dialog + dialog_name: framework_generic_publisher_dialog icon: publish options: tool_configs: - - maya-scene-publisher + - maya-generic-publisher docked: true - name: open menu: true diff --git a/projects/framework-maya/pyproject.toml b/projects/framework-maya/pyproject.toml index 795df8e9b5..edb4057f78 100644 --- a/projects/framework-maya/pyproject.toml +++ b/projects/framework-maya/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "ftrack-framework-maya" -version = "24.6.0" +version = "24.9.0rc1" description='ftrack Maya integration' authors = ["ftrack Integrations Team "] readme = "README.md" diff --git a/projects/framework-maya/release_notes.md b/projects/framework-maya/release_notes.md index dc424d4021..d28f37d091 100644 --- a/projects/framework-maya/release_notes.md +++ b/projects/framework-maya/release_notes.md @@ -1,5 +1,9 @@ # ftrack Framework Maya integration release Notes +## upcoming + +* [new] Generic publisher tool. + ## v24.6.0 2024-06-04 From 5108a227c6b8e07aa1cd08eed0ff15e1bb18ca80 Mon Sep 17 00:00:00 2001 From: Lluis Ftrack Date: Thu, 11 Jul 2024 11:07:27 +0200 Subject: [PATCH 19/20] remove maya changes --- projects/framework-maya/extensions/maya.yaml | 4 ++-- projects/framework-maya/pyproject.toml | 2 +- projects/framework-maya/release_notes.md | 4 ---- 3 files changed, 3 insertions(+), 7 deletions(-) diff --git a/projects/framework-maya/extensions/maya.yaml b/projects/framework-maya/extensions/maya.yaml index 1fb4ac12e2..f35d481b67 100644 --- a/projects/framework-maya/extensions/maya.yaml +++ b/projects/framework-maya/extensions/maya.yaml @@ -4,11 +4,11 @@ tools: - name: publish menu: true label: "Publish" - dialog_name: framework_generic_publisher_dialog + dialog_name: framework_standard_publisher_dialog icon: publish options: tool_configs: - - maya-generic-publisher + - maya-scene-publisher docked: true - name: open menu: true diff --git a/projects/framework-maya/pyproject.toml b/projects/framework-maya/pyproject.toml index edb4057f78..795df8e9b5 100644 --- a/projects/framework-maya/pyproject.toml +++ b/projects/framework-maya/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "ftrack-framework-maya" -version = "24.9.0rc1" +version = "24.6.0" description='ftrack Maya integration' authors = ["ftrack Integrations Team "] readme = "README.md" diff --git a/projects/framework-maya/release_notes.md b/projects/framework-maya/release_notes.md index d28f37d091..dc424d4021 100644 --- a/projects/framework-maya/release_notes.md +++ b/projects/framework-maya/release_notes.md @@ -1,9 +1,5 @@ # ftrack Framework Maya integration release Notes -## upcoming - -* [new] Generic publisher tool. - ## v24.6.0 2024-06-04 From 33518493b83b3ba5e0951d44e2318d5b14190fa7 Mon Sep 17 00:00:00 2001 From: Lluis Ftrack Date: Thu, 11 Jul 2024 11:45:26 +0200 Subject: [PATCH 20/20] remove connect changes --- apps/connect/resource/sass/widget/_scrollarea.scss | 11 ----------- 1 file changed, 11 deletions(-) delete mode 100644 apps/connect/resource/sass/widget/_scrollarea.scss diff --git a/apps/connect/resource/sass/widget/_scrollarea.scss b/apps/connect/resource/sass/widget/_scrollarea.scss deleted file mode 100644 index a1067ba378..0000000000 --- a/apps/connect/resource/sass/widget/_scrollarea.scss +++ /dev/null @@ -1,11 +0,0 @@ -/** Scroll area */ - -QScrollArea { - border: none; - background-color: transparent; - padding: 0px; -} - -QScrollArea QWidget { - background-color: transparent; -}