diff --git a/libs/constants/release_notes.md b/libs/constants/release_notes.md index fa1af01831..7304158bb4 100644 --- a/libs/constants/release_notes.md +++ b/libs/constants/release_notes.md @@ -1,5 +1,9 @@ # ftrack Constants library release Notes +## Upcoming + +* [new] Add HOST_SYNC_TOOL_CONFIG_TOPIC constant. + ## v3.0.0 2024-09-19 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 cc08467ad2..a25c0efb04 100644 --- a/libs/framework-core/release_notes.md +++ b/libs/framework-core/release_notes.md @@ -1,5 +1,12 @@ # ftrack Framework Core library release Notes +## upcoming + +* [change] Registry; support get tool config extensions by reference. +* [new] Client; Add method save_tool_config_in_destination; Save given tool config as yaml file in specific destination. +* [new] Client, Host, Event Manager; Add host_sync_tool_config on event manager to sync tool config from the client to the host. + + ## v3.0.0 2024-09-19 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 3dd8068526..d6f001f11a 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 @@ -545,6 +546,22 @@ def set_config_options( item_reference ] = 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, + ) + + 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/event/__init__.py b/libs/framework-core/source/ftrack_framework_core/event/__init__.py index 324dbf20cb..def47ce324 100644 --- a/libs/framework-core/source/ftrack_framework_core/event/__init__.py +++ b/libs/framework-core/source/ftrack_framework_core/event/__init__.py @@ -320,6 +320,25 @@ def host_verify_plugins(self, host_id, plugin_names, callback=None): event_topic = constants.event.HOST_VERIFY_PLUGINS_TOPIC return self._publish_event(event_topic, data, callback) + 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''' @@ -453,3 +472,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 19dc638cdb..e171807f68 100644 --- a/libs/framework-core/source/ftrack_framework_core/host/__init__.py +++ b/libs/framework-core/source/ftrack_framework_core/host/__init__.py @@ -220,6 +220,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 + ) + @delegate_to_main_thread_wrapper def _client_context_change_callback(self, event): '''Callback when the client has changed context''' @@ -310,6 +315,7 @@ def run_ui_hook_callback(self, event): 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: @@ -398,3 +404,32 @@ def verify_plugins(self, plugin_names): f'correct extensions path: {unregistered_plugins}' ) return unregistered_plugins + + 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..e390a856ea 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,37 @@ 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 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 2069614921..372df87e26 100644 --- a/libs/framework-core/source/ftrack_framework_core/widget/dialog.py +++ b/libs/framework-core/source/ftrack_framework_core/widget/dialog.py @@ -423,6 +423,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-style/release_notes.md b/libs/qt-style/release_notes.md index 087ae05bcb..0d7a27019c 100644 --- a/libs/qt-style/release_notes.md +++ b/libs/qt-style/release_notes.md @@ -1,5 +1,9 @@ # ftrack Framework QT Style library release Notes +## upcoming + +* [changed] LineEdit; Support label property. + ## v3.0.0 2024-09-19 diff --git a/libs/qt/release_notes.md b/libs/qt/release_notes.md index 5851f1112e..d876d26778 100644 --- a/libs/qt/release_notes.md +++ b/libs/qt/release_notes.md @@ -1,5 +1,14 @@ # ftrack QT library release Notes + +## upcoming + +* [new] MenuButton; Button supporting menu if multiple action items provided. +* [changed] Improved AccordionWidget and AcordionHeaderWidget to use EditableLabelWidget and possibility to add a remove button. Emits bin_clicked, title_changed, title_edited signals. +* [changed] StatusMaterialIconWidget clickable. +* [new] Editable label widget. + + ## v3.0.0 2024-09-19 diff --git a/libs/qt/source/ftrack_qt/widgets/accordion/accordion_widget.py b/libs/qt/source/ftrack_qt/widgets/accordion/accordion_widget.py index 8b27bc00c4..7f9e4acab9 100644 --- a/libs/qt/source/ftrack_qt/widgets/accordion/accordion_widget.py +++ b/libs/qt/source/ftrack_qt/widgets/accordion/accordion_widget.py @@ -17,15 +17,23 @@ class AccordionBaseWidget(QtWidgets.QFrame): doubleClicked = QtCore.Signal( object ) # Emitted when accordion is double clicked + bin_clicked = QtCore.Signal(object) # Emitted when bin icon is clicked show_options_overlay = QtCore.Signal(object) hide_options_overlay = QtCore.Signal() enabled_changed = QtCore.Signal(object) + title_changed = QtCore.Signal(object) + title_edited = QtCore.Signal(object) @property def title(self): '''Return the title text shown in header by default''' return self._title + @property + def editable_title(self): + '''Return the title text shown in header by default''' + return self._editable_title + @property def selectable(self): '''Return the current selection mode''' @@ -68,16 +76,29 @@ def checked(self): '''(Checkable) Return True if checked''' return self._checked + @property + def removable(self): + ''' + Return True if accordion is removable - can be deleted by user + ''' + return self._removable + + @property + def previous_title(self): + return self._header_widget.previous_title + def __init__( self, selectable=False, show_checkbox=False, checkable=False, title=None, + editable_title=False, selected=False, checked=True, collapsable=True, collapsed=True, + removable=False, parent=None, ): ''' @@ -103,7 +124,9 @@ def __init__( self._checkable = checkable self._show_checkbox = show_checkbox self._title = title + self._editable_title = editable_title self._collapsable = collapsable + self._removable = removable self._selected = selected self._checked = checked @@ -140,11 +163,13 @@ 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, collapsable=self.collapsable, collapsed=self.collapsed, + removable=self.removable, ) # Add header to main widget @@ -170,12 +195,15 @@ def post_build(self): self._header_widget.arrow_clicked.connect( self._on_header_arrow_clicked ) + self._header_widget.bin_clicked.connect(self._on_header_bin_clicked) self._header_widget.show_options_overlay.connect( self._on_show_options_overlay_callback ) self._header_widget.hide_options_overlay.connect( self._on_hide_options_overlay_callback ) + self._header_widget.title_changed.connect(self._on_title_changed) + self._header_widget.title_edited.connect(self._on_title_edited) self._content_widget.setVisible(not self._collapsed) self._content_widget.setEnabled(self.checked) @@ -185,6 +213,12 @@ def _on_show_options_overlay_callback(self, widget): def _on_hide_options_overlay_callback(self): self.hide_options_overlay.emit() + def _on_title_changed(self, title): + self.title_changed.emit(title) + + def _on_title_edited(self, title): + self.title_edited.emit(title) + def add_option_widget(self, widget, section_name): self._header_widget.add_option_widget(widget, section_name) @@ -234,6 +268,10 @@ def _on_header_arrow_clicked(self, event): # This is the way to collapse self.toggle_collapsed() + def _on_header_bin_clicked(self, event): + '''Callback on header arrow user click''' + self.bin_clicked.emit(event) + def toggle_collapsed(self): '''Toggle the accordion collapsed state''' self._collapsed = not self._collapsed @@ -274,6 +312,10 @@ def update_accordion(self): 'selected' if self._selected else 'transparent', ) + def set_title(self, new_title): + self._title = new_title + self._header_widget.set_title(self.title) + def teardown(self): '''Teardown the header widget - properly cleanup the options overlay''' self._header_widget.teardown() diff --git a/libs/qt/source/ftrack_qt/widgets/buttons/__init__.py b/libs/qt/source/ftrack_qt/widgets/buttons/__init__.py index 53152c6e91..b79ce4ef36 100644 --- a/libs/qt/source/ftrack_qt/widgets/buttons/__init__.py +++ b/libs/qt/source/ftrack_qt/widgets/buttons/__init__.py @@ -4,3 +4,4 @@ ProgressStatusButtonWidget, ProgressPhaseButtonWidget, ) +from ftrack_qt.widgets.buttons.menu_button import MenuButton diff --git a/libs/qt/source/ftrack_qt/widgets/buttons/menu_button.py b/libs/qt/source/ftrack_qt/widgets/buttons/menu_button.py new file mode 100644 index 0000000000..4b79645d23 --- /dev/null +++ b/libs/qt/source/ftrack_qt/widgets/buttons/menu_button.py @@ -0,0 +1,75 @@ +# :coding: utf-8 +# :copyright: Copyright (c) 2024 ftrack + +from functools import partial + +try: + from PySide6 import QtWidgets, QtCore +except ImportError: + from PySide2 import QtWidgets, QtCore + + +class MenuButton(QtWidgets.QWidget): + item_clicked = QtCore.Signal(object) + + @property + def button_widget(self): + return self._button_widget + + @property + def menu_items(self): + return self._menu_items + + def __init__(self, button_widget, menu_items=None, parent=None): + super(MenuButton, self).__init__(parent) + + self._button_widget = button_widget + if not menu_items: + menu_items = [] + self._menu_items = menu_items + + self.pre_build() + self.build() + self.post_build() + + def pre_build(self): + layout = QtWidgets.QVBoxLayout() + self.setLayout(layout) + layout.setContentsMargins(0, 0, 0, 0) + + def build(self): + self.layout().addWidget(self.button_widget) + + def post_build(self): + self.button_widget.clicked.connect(self.show_menu) + + def add_item(self, item_data, label): + self.menu_items.append( + { + 'data': item_data, + 'label': label, + } + ) + + def show_menu(self): + if len(self.menu_items) == 1: + return self.menu_item_selected(self.menu_items[0]) + # Create a QMenu + menu = QtWidgets.QMenu(self.button_widget) + + # Add menu items + for _item in self.menu_items: + action = menu.addAction(_item['label']) + # Connect each action to a slot (optional) + action.triggered.connect(partial(self.menu_item_selected, _item)) + + # Show the menu below the button + menu.exec_( + self.button_widget.mapToGlobal( + QtCore.QPoint(0, self.button_widget.height()) + ) + ) + + def menu_item_selected(self, item): + # Handle the selected menu item (this is a placeholder for your custom logic) + self.item_clicked.emit(item['data']) diff --git a/libs/qt/source/ftrack_qt/widgets/headers/accordion_header_widget.py b/libs/qt/source/ftrack_qt/widgets/headers/accordion_header_widget.py index 44881d7cf9..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 @@ -13,6 +13,7 @@ ) from ftrack_qt.widgets.lines import LineWidget from ftrack_qt.widgets.buttons import OptionsButton +from ftrack_qt.widgets.labels import EditableLabel class AccordionHeaderWidget(QtWidgets.QFrame): @@ -21,14 +22,23 @@ class AccordionHeaderWidget(QtWidgets.QFrame): clicked = QtCore.Signal(object) # User header click arrow_clicked = QtCore.Signal(object) # User header click + bin_clicked = QtCore.Signal(object) # User header click checkbox_status_changed = QtCore.Signal(object) show_options_overlay = QtCore.Signal(object) hide_options_overlay = QtCore.Signal() + title_changed = QtCore.Signal(object) + title_edited = QtCore.Signal(object) @property def title(self): + if self._title_label: + return self._title_label.text() return self._title + @property + def editable_title(self): + return self._editable_title + @property def checkable(self): return self._checkable @@ -49,18 +59,28 @@ def collapsable(self): def collapsed(self): return self._collapsed + @property + def removable(self): + return self._removable + @property def options_button(self): return self._options_button + @property + def previous_title(self): + return self._title_label.previous_text + def __init__( self, title=None, + editable_title=False, checkable=False, checked=True, show_checkbox=False, collapsable=True, collapsed=True, + removable=False, parent=None, ): ''' @@ -72,11 +92,13 @@ def __init__( super(AccordionHeaderWidget, self).__init__(parent=parent) self._title = title + self._editable_title = editable_title self._checkable = checkable self._checked = checked self._show_checkbox = show_checkbox self._collapsable = collapsable self._collapsed = collapsed + self._removable = removable self._checkbox = None self._title_label = None @@ -85,6 +107,7 @@ def __init__( self._status = None self._options_button = None self._status_icon = None + self._bin = None self.pre_build() self.build() @@ -104,7 +127,9 @@ def build(self): self._checkbox.setVisible(self.show_checkbox) # Create title - self._title_label = QtWidgets.QLabel(self.title or '') + self._title_label = EditableLabel( + text=self.title, editable=self.editable_title + ) if not self.title: self._title_label.hide() @@ -134,22 +159,31 @@ 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 ) self._options_button.hide_overlay_signal.connect( self.on_hide_options_callback ) + self._title_label.editingFinished.connect(self._on_title_edited) def on_show_options_callback(self, widget): self.show_options_overlay.emit(widget) @@ -169,6 +203,9 @@ def _on_checkbox_status_changed(self): def _on_arrow_clicked(self, event): self.arrow_clicked.emit(event) + def _on_bin_clicked(self, event): + self.bin_clicked.emit(event) + def update_arrow_icon(self, collapsed): '''Update the arrow icon based on collapse state''' if collapsed: @@ -187,6 +224,25 @@ def set_status(self, status, message): '''Set status message within header, to be implemented by child''' pass + def _on_title_changed(self): + ''' + Emit signal when title is changed + ''' + self.title_changed.emit(self._title_label.text()) + + def _on_title_edited(self): + ''' + Emit signal when title is changed + ''' + self.title_edited.emit(self._title_label.text()) + + def set_title(self, new_title): + ''' + Set the title of the header to *new_title* + ''' + self._title_label.setText(new_title) + self._on_title_changed() + def teardown(self): '''Teardown the options button - properly cleanup the options overlay''' self._options_button.teardown() diff --git a/libs/qt/source/ftrack_qt/widgets/icons/status_icon.py b/libs/qt/source/ftrack_qt/widgets/icons/status_icon.py index 50a49dc83e..ec4b4d6d52 100644 --- a/libs/qt/source/ftrack_qt/widgets/icons/status_icon.py +++ b/libs/qt/source/ftrack_qt/widgets/icons/status_icon.py @@ -18,6 +18,8 @@ class StatusMaterialIconWidget(QtWidgets.QWidget): '''Material icon widget, support status > icon encoding''' + clicked = QtCore.Signal(object) + @property def icon(self): '''Return the material icon''' @@ -81,3 +83,7 @@ def set_status(self, status, size=16): icon_name, variant=variant, color='#{}'.format(color), size=size ) return color + + def mousePressEvent(self, event): + self.clicked.emit(event) + return super(StatusMaterialIconWidget, self).mousePressEvent(event) diff --git a/libs/qt/source/ftrack_qt/widgets/labels/__init__.py b/libs/qt/source/ftrack_qt/widgets/labels/__init__.py new file mode 100644 index 0000000000..ac14df64b9 --- /dev/null +++ b/libs/qt/source/ftrack_qt/widgets/labels/__init__.py @@ -0,0 +1 @@ +from ftrack_qt.widgets.labels.editable_label import EditableLabel diff --git a/libs/qt/source/ftrack_qt/widgets/labels/editable_label.py b/libs/qt/source/ftrack_qt/widgets/labels/editable_label.py new file mode 100644 index 0000000000..bd1be53c6b --- /dev/null +++ b/libs/qt/source/ftrack_qt/widgets/labels/editable_label.py @@ -0,0 +1,46 @@ +# :coding: utf-8 +# :copyright: Copyright (c) 2024 ftrack + +try: + from PySide6 import QtWidgets, QtCore +except ImportError: + from PySide2 import QtWidgets, QtCore + + +class EditableLabel(QtWidgets.QLineEdit): + '''Editable label widget.''' + + @property + def previous_text(self): + '''Return previous text.''' + return self._previous_text + + @property + def editable(self): + '''Return editable state.''' + return self._editable + + def __init__(self, text=None, editable=True, parent=None): + super(EditableLabel, self).__init__(parent) + self._previous_text = self.text() + self._editable = editable + self.setReadOnly(True) + + # Set the title line edit to be a label + self.setProperty('label', True) + self.setText(text or '') + + self.editingFinished.connect(self.on_editing_finished) + + def setText(self, event): + super(EditableLabel, self).setText(event) + + def mouseDoubleClickEvent(self, event): + if self._editable: + if self.isReadOnly(): + self._previous_text = self.text() + self.setReadOnly(False) + super(EditableLabel, self).mouseDoubleClickEvent(event) + + def on_editing_finished(self): + self.setReadOnly(True) diff --git a/libs/utils/release_notes.md b/libs/utils/release_notes.md index 4f10768d52..dcd5c5633b 100644 --- a/libs/utils/release_notes.md +++ b/libs/utils/release_notes.md @@ -1,5 +1,8 @@ # ftrack Utils library release Notes +## upcoming + +* [changed] framework tool configs; get_groups method dictionary support on filters argument.. ## v3.0.0 2024-09-19 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/generic_publisher_dialog.py b/projects/framework-common-extensions/dialogs/generic_publisher_dialog.py new file mode 100644 index 0000000000..5173c9be9c --- /dev/null +++ b/projects/framework-common-extensions/dialogs/generic_publisher_dialog.py @@ -0,0 +1,549 @@ +# :coding: utf-8 +# :copyright: Copyright (c) 2024 ftrack +import copy +import os +from functools import partial + +import clique + +try: + from PySide6 import QtWidgets, QtCore +except ImportError: + from PySide2 import QtWidgets, QtCore + +import ftrack_constants as constants +from ftrack_utils.framework.config.tool import get_plugins, get_groups +from ftrack_framework_qt.dialogs import BaseContextDialog +from ftrack_qt.widgets.accordion import AccordionBaseWidget +from ftrack_qt.widgets.progress import ProgressWidget +from ftrack_qt.utils.widget import build_progress_data +from ftrack_qt.utils.decorators import invoke_in_qt_main_thread +from ftrack_qt.widgets.buttons import CircularButton +from ftrack_qt.widgets.buttons import MenuButton + + +class GenericPublisherDialog(BaseContextDialog): + '''Default Framework Publisher dialog''' + + name = 'framework_generic_publisher_dialog' + tool_config_type_filter = ['publisher'] + ui_type = 'qt' + run_button_title = 'PUBLISH' + + @property + def modified_tool_config(self): + '''Return the modified tool config''' + return self._modified_tool_config + + def __init__( + self, + event_manager, + client_id, + connect_methods_callback, + connect_setter_property_callback, + connect_getter_property_callback, + dialog_options, + parent=None, + ): + ''' + Initialize Mixin class publisher dialog. It will load the qt dialog and + mix it with the framework dialog. + *event_manager*: instance of + :class:`~ftrack_framework_core.event.EventManager` + *client_id*: Id of the client that initializes the current dialog + *connect_methods_callback*: Client callback method for the dialog to be + able to execute client methods. + *connect_setter_property_callback*: Client callback property setter for + the dialog to be able to read client properties. + *connect_getter_property_callback*: Client callback property getter for + the dialog to be able to write client properties. + *dialog_options*: Dictionary of arguments passed on to configure the + current dialog. + ''' + self._scroll_area = None + self._scroll_area_widget = None + self._progress_widget = None + self._modified_tool_config = None + self._save_preset_button = None + + super(GenericPublisherDialog, self).__init__( + event_manager, + client_id, + connect_methods_callback, + connect_setter_property_callback, + connect_getter_property_callback, + dialog_options, + parent=parent, + ) + self.setWindowTitle('ftrack Publisher') + + def pre_build_ui(self): + # Make sure to remove self._scroll_area in case of reload + if self._scroll_area: + self._scroll_area.deleteLater() + # Create scroll area to add all the widgets + self._scroll_area = QtWidgets.QScrollArea() + self._scroll_area.setStyle(QtWidgets.QStyleFactory.create("plastique")) + self._scroll_area.setWidgetResizable(True) + self._scroll_area.setHorizontalScrollBarPolicy( + QtCore.Qt.ScrollBarAlwaysOff + ) + + index = self.main_layout.indexOf(self.tool_widget) + run_index = self.main_layout.indexOf(self.run_button) + if index != -1: # Ensure the widget is found in the layout + # Remove the old widget from layout + self.main_layout.takeAt(index) + # Insert the new widget at the same position + self.main_layout.insertWidget(index, self._scroll_area) + elif run_index != -1: + # In case tool_widget is not already parented make sure to add scroll + # area above the run button. + self.main_layout.insertWidget((run_index - 1), self._scroll_area) + else: # otherwise set it at the end + self.main_layout.addWidget(self._scroll_area) + + self._scroll_area.setWidget(self.tool_widget) + + def build_ui(self): + # Select the desired tool_config + + self.tool_config = None + tool_config_message = None + if self.filtered_tool_configs.get("publisher"): + if len(self.tool_config_names or []) != 1: + tool_config_message = ( + 'One(1) tool config name must be supplied to publisher!' + ) + else: + tool_config_name = self.tool_config_names[0] + for tool_config in self.filtered_tool_configs["publisher"]: + if ( + tool_config.get('name', '').lower() + == tool_config_name.lower() + ): + self.logger.debug( + f'Using tool config {tool_config_name}' + ) + if self.tool_config != tool_config: + try: + self.tool_config = tool_config + except Exception as error: + tool_config_message = error + break + if not self._progress_widget: + self._progress_widget = ProgressWidget( + 'publish', build_progress_data(tool_config) + ) + self.header.set_widget( + self._progress_widget.status_widget + ) + self.overlay_layout.addWidget( + self._progress_widget.overlay_widget + ) + break + if not self.tool_config and not tool_config_message: + tool_config_message = ( + f'Could not find tool config: "{tool_config_name}"' + ) + else: + tool_config_message = 'No publisher tool configs available!' + + if not self.tool_config: + self.logger.warning(tool_config_message) + label_widget = QtWidgets.QLabel(f'{tool_config_message}') + label_widget.setStyleSheet( + "font-style: italic; font-weight: bold;" + ) + self.tool_widget.layout().addWidget(label_widget) + self.run_button.setEnabled(False) + return + + # Create a new tool_config + self._original_tool_config = self.tool_config + self._modified_tool_config = copy.deepcopy(self._original_tool_config) + # Make sure we generate new references + self.registry.create_unic_references(self._modified_tool_config) + self.tool_config = self._modified_tool_config + + # Sync the tool_config with the host + self.sync_tool_config(self.tool_config) + + # Build context widgets + context_plugins = get_plugins( + self.tool_config, filters={'tags': ['context']} + ) + for context_plugin in context_plugins: + if not context_plugin.get('ui'): + continue + context_widget = self.init_framework_widget(context_plugin) + self.tool_widget.layout().addWidget(context_widget) + + # Build component widgets + # Build top label layout + components_layout = QtWidgets.QHBoxLayout() + components_label = QtWidgets.QLabel('Components') + self._save_preset_button = QtWidgets.QPushButton('Save Preset') + components_layout.addWidget(components_label) + components_layout.addStretch() + components_layout.addWidget(self._save_preset_button) + self.tool_widget.layout().addLayout(components_layout) + + component_groups = get_groups( + self.tool_config, filters={'tags': ['component']} + ) + + generic_groups = get_groups( + self._original_tool_config, filters={'tags': ['generic']} + ) + self._accordion_widgets_registry = [] + for _group in component_groups: + group_accordion_widget = self.add_accordion_group(_group) + self.tool_widget.layout().addWidget(group_accordion_widget) + + circular_add_button = CircularButton('add', variant='outlined') + self.menu_button = MenuButton(circular_add_button) + self.tool_widget.layout().addWidget(self.menu_button) + for _generic_group in generic_groups: + self.menu_button.add_item( + item_data=_generic_group, + label=_generic_group.get('options').get( + 'button_label', 'Add Component' + ), + ) + self.menu_button.item_clicked.connect(self._on_add_component_callback) + + spacer = QtWidgets.QSpacerItem( + 1, + 1, + QtWidgets.QSizePolicy.Minimum, + QtWidgets.QSizePolicy.Expanding, + ) + self.tool_widget.layout().addItem(spacer) + + def _get_latest_component_index(self, idx=1): + # Get the number of items in the layout + count = self.tool_widget.layout().count() + if count > 0: + # Get the last item + item = self.tool_widget.layout().itemAt(count - idx) + if item: + if item.widget() in self._accordion_widgets_registry: + # Return the widget associated with the item + return self.tool_widget.layout().indexOf(item.widget()) + else: + return self._get_latest_component_index(idx + 1) + return 0 + + def _on_add_component_callback(self, _generic_group): + # Create a new unic group + new_group = copy.deepcopy(_generic_group) + # Make sure that we don't duplicate component names + available_name = self.get_available_component_name( + new_group.get('options').get('component') + ) + new_group['options']['component'] = available_name + + # generate references for the new group + self.registry.create_unic_references(new_group, skip_root=True) + + group_accordion_widget = self.add_accordion_group(new_group) + + # Insert the new group before the add button + latest_idx = self._get_latest_component_index() + self.tool_widget.layout().insertWidget( + latest_idx + 1, group_accordion_widget + ) + + # Insert the new group into the correct position in the tool_config + self.insert_group_in_tool_config(new_group, group_accordion_widget) + + # Sync the tool_config with the host + self.sync_tool_config(self.tool_config) + + self._progress_widget.set_data(build_progress_data(self.tool_config)) + + def insert_group_in_tool_config(self, new_group, group_accordion_widget): + ''' + Insert the new group in the tool config in the right position. + ''' + current_idx = self._accordion_widgets_registry.index( + group_accordion_widget + ) + if current_idx > 0: + previous_widget = self._accordion_widgets_registry[current_idx - 1] + previous_group = get_groups( + self.tool_config, + filters={ + 'tags': ['component'], + 'options': {'component': previous_widget.title}, + }, + ) + if previous_group: + previous_group = previous_group[0] + previous_group_idx = self.tool_config['engine'].index( + previous_group + ) + self.tool_config['engine'].insert( + previous_group_idx + 1, new_group + ) + else: + self.tool_config['engine'].append(new_group) + else: + self.tool_config['engine'].insert(0, new_group) + + def get_available_component_name(self, name, skip_widget=None): + def increment_name(name): + if '_' in name and name.rsplit('_', 1)[-1].isdigit(): + base, num = name.rsplit('_', 1) + return f'{base}_{int(num) + 1}' + else: + return f'{name}_1' + + for widget in self._accordion_widgets_registry: + if widget != skip_widget: + if widget.title == name: + return self.get_available_component_name( + increment_name(name), skip_widget + ) + return name + + def add_accordion_group(self, group): + group_accordion_widget = AccordionBaseWidget( + selectable=False, + show_checkbox=True, + checkable=group.get('optional', False), + title=group.get('options').get('component'), + editable_title=group.get('options', {}).get( + 'editable_component_name', False + ), + selected=False, + checked=group.get('enabled', True), + collapsable=True, + collapsed=True, + removable=group.get('options', {}).get('removable', False), + ) + collectors = get_plugins(group, filters={'tags': ['collector']}) + self.add_collector_widgets(collectors, group_accordion_widget, group) + validators = get_plugins(group, filters={'tags': ['validator']}) + self.add_validator_widgets(validators, group_accordion_widget, group) + exporters = get_plugins(group, filters={'tags': ['exporter']}) + self.add_exporter_widgets(exporters, group_accordion_widget, group) + group_accordion_widget.hide_options_overlay.connect( + self.show_main_widget + ) + group_accordion_widget.show_options_overlay.connect( + self.show_options_widget + ) + group_accordion_widget.title_edited.connect( + self._on_component_name_edited_callback + ) + group_accordion_widget.bin_clicked.connect( + self._on_component_removed_callback + ) + group_accordion_widget.enabled_changed.connect( + partial(self._on_enable_component_changed_callback, group) + ) + + self._accordion_widgets_registry.append(group_accordion_widget) + return group_accordion_widget + + def add_collector_widgets( + self, collectors, accordion_widget, group_config=None + ): + for plugin_config in collectors: + if not plugin_config.get('ui'): + continue + widget = self.init_framework_widget(plugin_config, group_config) + accordion_widget.add_widget(widget) + if hasattr(widget, 'path_changed'): + if group_config.get('options', {}).get( + 'editable_component_name', False + ): + widget.path_changed.connect( + partial( + self._on_path_changed_callback, accordion_widget + ) + ) + + def add_validator_widgets( + self, validators, accordion_widget, group_config=None + ): + for plugin_config in validators: + if not plugin_config.get('ui'): + continue + widget = self.init_framework_widget(plugin_config, group_config) + accordion_widget.add_option_widget( + widget, section_name='Validators' + ) + + def add_exporter_widgets( + self, exporters, accordion_widget, group_config=None + ): + for plugin_config in exporters: + if not plugin_config.get('ui'): + continue + widget = self.init_framework_widget(plugin_config, group_config) + accordion_widget.add_option_widget( + widget, section_name='Exporters' + ) + + def post_build_ui(self): + if self._progress_widget: + self._progress_widget.hide_overlay_signal.connect( + self.show_main_widget + ) + self._progress_widget.show_overlay_signal.connect( + self.show_overlay_widget + ) + self._save_preset_button.clicked.connect( + self._on_save_preset_button_clicked + ) + + def _on_path_changed_callback(self, accordion_widget, new_name): + ''' + Callback to update the component name when the path is changed. + ''' + file_extension = None + try: + collection = clique.parse(new_name) + if collection: + file_extension = collection.tail + except Exception as error: + self.logger.debug( + f"{new_name} is not a clique collection. Error {error}" + ) + + if not file_extension: + file_extension = os.path.splitext(new_name)[1] or os.path.basename( + new_name + ) + + file_extension = file_extension.lstrip('.') + + extension = self.get_available_component_name(file_extension) + + group = get_groups( + self.tool_config, + filters={ + 'tags': ['component'], + 'options': {'component': accordion_widget.title}, + }, + )[0] + group['options']['component'] = extension + + accordion_widget.set_title(extension) + + # Sync the tool_config with the host + self.sync_tool_config(self.tool_config) + + def _on_component_name_edited_callback(self, new_name): + new_name = self.get_available_component_name( + new_name, skip_widget=self.sender() + ) + if self.sender().previous_title: + group = get_groups( + self.tool_config, + filters={ + 'tags': ['component'], + 'options': {'component': self.sender().previous_title}, + }, + )[0] + group['options']['component'] = new_name + + self.sender().set_title(new_name) + + # Sync the tool_config with the host + self.sync_tool_config(self.tool_config) + + def _on_component_removed_callback(self, event): + # Remove the group from the tool_config + group = get_groups( + self.tool_config, + filters={ + 'tags': ['component'], + 'options': {'component': self.sender().title}, + }, + ) + if group: + group = group[0] + self.tool_config['engine'].remove(group) + # Remove the widget from the registry + self._accordion_widgets_registry.remove(self.sender()) + # Remove the widget from the layout + self.sender().teardown() + self.sender().deleteLater() + + # Sync the tool_config with the host + self.sync_tool_config(self.tool_config) + + self._progress_widget.set_data(build_progress_data(self.tool_config)) + + def show_options_widget(self, widget): + '''Sets the given *widget* as the index 2 of the stacked widget and + remove the previous one if it exists''' + if self._stacked_widget.widget(2): + self._stacked_widget.removeWidget(self._stacked_widget.widget(2)) + self._stacked_widget.addWidget(widget) + self._stacked_widget.setCurrentIndex(2) + + def _on_enable_component_changed_callback(self, group_config, enabled): + '''Callback for when the component is enabled/disabled''' + self.set_tool_config_option( + {'enabled': enabled}, group_config['reference'] + ) + group_config['enabled'] = enabled + # Sync the tool_config with the host + self.sync_tool_config(self.tool_config) + + self._progress_widget.set_data(build_progress_data(self.tool_config)) + + def _on_run_button_clicked(self): + '''(Override) Drive the progress widget''' + self.show_overlay_widget() + self._progress_widget.run() + super(GenericPublisherDialog, self)._on_run_button_clicked() + # TODO: This will not work in remote mode (async mode) as plugin events + # will arrive after this point of execution. + if self._progress_widget.status == constants.status.SUCCESS_STATUS: + self.clean_ui() + self.pre_build_ui() + self.build_ui() + self.post_build_ui() + # TODO: there is an error here showing the overlay widget because is not repainting all the widegts that has been parented to the self.layout() in the pre_build_ui build_ui or post_build_ui methods. + + @invoke_in_qt_main_thread + def plugin_run_callback(self, log_item): + '''(Override) Pass framework log item to the progress widget''' + self._progress_widget.update_phase_status( + log_item.reference, + log_item.status, + log_message=log_item.message, + time=log_item.execution_time, + ) + + def _on_save_preset_button_clicked(self): + '''Callback to save the current tool config as a preset''' + # Open a save dialog to get the destination + destination = QtWidgets.QFileDialog.getSaveFileName( + self, 'Save Tool Config Preset', '', 'YAML (*.yaml)' + )[0] + args = { + 'tool_config': self.tool_config, + 'destination': destination, + } + self.client_method_connection( + 'save_tool_config_in_destination', arguments=args + ) + + def closeEvent(self, event): + '''(Override) Close the context and progress widgets''' + if self._context_selector: + self._context_selector.teardown() + if self._progress_widget: + self._progress_widget.teardown() + self._progress_widget.deleteLater() + if self._accordion_widgets_registry: + for accordion in self._accordion_widgets_registry: + accordion.teardown() + super(GenericPublisherDialog, self).closeEvent(event) diff --git a/projects/framework-common-extensions/release_notes.md b/projects/framework-common-extensions/release_notes.md index 1ec000897b..967b4388b8 100644 --- a/projects/framework-common-extensions/release_notes.md +++ b/projects/framework-common-extensions/release_notes.md @@ -2,6 +2,7 @@ ## Upcoming +* [new] Generic publisher dialog added with a generic tool-config. * [fixed] StandardPublisherDialog; Fix bug were _accordion_widgets_registry wasn't initialized if no tool_config available. * [fixed] StandardOpenerDialog, StandardPublisherDialog; Fix bug were dialog creation crashed if not tool configs. Also disabled run button. * [changed] RenameFileExporterPlugin; Accept folder as destination. diff --git a/projects/framework-common-extensions/tool-configs/standalone-generic-publisher.yaml b/projects/framework-common-extensions/tool-configs/standalone-generic-publisher.yaml new file mode 100644 index 0000000000..065dae8809 --- /dev/null +++ b/projects/framework-common-extensions/tool-configs/standalone-generic-publisher.yaml @@ -0,0 +1,124 @@ +type: tool_config +name: standalone-generic-publisher +config_type: publisher +engine: + - type: plugin + tags: + - context + plugin: store_asset_context + options: + asset_type_name: script + ui: publisher_asset_version_selector + + # 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 + - generic + 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 + - generic + 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 + + # 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/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