diff --git a/pype/tools/settings/settings/README.md b/pype/tools/settings/settings/README.md index 47bbf28ba5..9492d8bc7b 100644 --- a/pype/tools/settings/settings/README.md +++ b/pype/tools/settings/settings/README.md @@ -168,6 +168,29 @@ } ``` +### enum +- returns value of single on multiple items from predefined values +- multiselection can be allowed with setting key `"multiselection"` to `True` (Default: `False`) +- values are defined under value of key `"enum_items"` as list + - each item in list is simple dictionary where value is label and key is value which will be stored + - should be possible to enter single dictionary if order of items doesn't matter + +``` +{ + "key": "tags", + "label": "Tags", + "type": "enum", + "multiselection": true, + "enum_items": [ + {"burnin": "Add burnins"}, + {"ftrackreview": "Add to Ftrack"}, + {"delete": "Delete output"}, + {"slate-frame": "Add slate frame"}, + {"no-hnadles": "Skip handle frames"} + ] +} +``` + ## Inputs for setting value using Pure inputs - these inputs also have required `"key"` and `"label"` - they use Pure inputs "as widgets" diff --git a/pype/tools/settings/settings/gui_schemas/projects_schema/1_plugins_gui_schema.json b/pype/tools/settings/settings/gui_schemas/projects_schema/1_plugins_gui_schema.json index f357b51dc5..2fefe8df2d 100644 --- a/pype/tools/settings/settings/gui_schemas/projects_schema/1_plugins_gui_schema.json +++ b/pype/tools/settings/settings/gui_schemas/projects_schema/1_plugins_gui_schema.json @@ -202,8 +202,15 @@ }, { "key": "tags", "label": "Tags", - "type": "list", - "object_type": "text" + "type": "enum", + "multiselection": true, + "enum_items": [ + {"burnin": "Add burnins"}, + {"ftrackreview": "Add to Ftrack"}, + {"delete": "Delete output"}, + {"slate-frame": "Add slate frame"}, + {"no-hnadles": "Skip handle frames"} + ] }, { "key": "ffmpeg_args", "label": "FFmpeg arguments", diff --git a/pype/tools/settings/settings/gui_schemas/system_schema/1_examples.json b/pype/tools/settings/settings/gui_schemas/system_schema/1_examples.json index 0578968508..00aef304a9 100644 --- a/pype/tools/settings/settings/gui_schemas/system_schema/1_examples.json +++ b/pype/tools/settings/settings/gui_schemas/system_schema/1_examples.json @@ -9,6 +9,25 @@ "type": "dict-invisible", "children": [ { + "type": "enum", + "key": "test_enum_singleselection", + "label": "Enum Single Selection", + "enum_items": [ + {"value_1": "Label 1"}, + {"value_2": "Label 2"}, + {"value_3": "Label 3"} + ] + }, { + "type": "enum", + "key": "test_enum_multiselection", + "label": "Enum Multi Selection", + "multiselection": true, + "enum_items": [ + {"value_1": "Label 1"}, + {"value_2": "Label 2"}, + {"value_3": "Label 3"} + ] + }, { "type": "boolean", "key": "bool", "label": "Boolean checkbox" diff --git a/pype/tools/settings/settings/style/style.css b/pype/tools/settings/settings/style/style.css index 3f648abef8..ebea87203d 100644 --- a/pype/tools/settings/settings/style/style.css +++ b/pype/tools/settings/settings/style/style.css @@ -38,6 +38,18 @@ QLineEdit:disabled, QSpinBox:disabled, QDoubleSpinBox:disabled, QPlainTextEdit:d QLineEdit:focus, QSpinBox:focus, QDoubleSpinBox:focus, QPlainTextEdit:focus, QTextEdit:focus { border: 1px solid #ffffff; } + +QComboBox { + border: 1px solid #aaaaaa; + border-radius: 3px; + padding: 2px 2px 4px 4px; + background: #1d272f; +} + +QComboBox QAbstractItemView::item { + padding: 3px; +} + QToolButton { background: transparent; } @@ -102,6 +114,10 @@ QPushButton[btn-type="expand-toggle"] { font-weight: bold; } +#MultiSelectionComboBox { + font-size: 12px; +} + #DictKey[state="studio"] {border-color: #bfccd6;} #DictKey[state="modified"] {border-color: #137cbd;} #DictKey[state="overriden"] {border-color: #00f;} diff --git a/pype/tools/settings/settings/widgets/base.py b/pype/tools/settings/settings/widgets/base.py index ee4762de14..404d7b700b 100644 --- a/pype/tools/settings/settings/widgets/base.py +++ b/pype/tools/settings/settings/widgets/base.py @@ -266,7 +266,7 @@ class SystemWidget(QtWidgets.QWidget): klass = lib.TypeToKlass.types.get(item_type) item = klass(child_configuration, self) self.input_fields.append(item) - self.content_layout.addWidget(item) + self.content_layout.addWidget(item, 0) # Add spacer to stretch children guis spacer = QtWidgets.QWidget(self.content_widget) @@ -532,7 +532,7 @@ class ProjectWidget(QtWidgets.QWidget): klass = lib.TypeToKlass.types.get(item_type) item = klass(child_configuration, self) self.input_fields.append(item) - self.content_layout.addWidget(item) + self.content_layout.addWidget(item, 0) # Add spacer to stretch children guis spacer = QtWidgets.QWidget(self.content_widget) diff --git a/pype/tools/settings/settings/widgets/item_types.py b/pype/tools/settings/settings/widgets/item_types.py index c477a7986a..27c1de8482 100644 --- a/pype/tools/settings/settings/widgets/item_types.py +++ b/pype/tools/settings/settings/widgets/item_types.py @@ -6,8 +6,10 @@ from .widgets import ( ExpandingWidget, NumberSpinBox, PathInput, - GridLabelWidget + GridLabelWidget, + ComboBox ) +from .multiselection_combobox import MultiSelectionComboBox from .lib import NOT_SET, METADATA_KEY, TypeToKlass, CHILD_OFFSET from avalon.vendor import qtawesome @@ -1072,6 +1074,105 @@ class PathInputWidget(QtWidgets.QWidget, InputObject): return self.path_input.text() +class EnumeratorWidget(QtWidgets.QWidget, InputObject): + default_input_value = True + value_changed = QtCore.Signal(object) + + def __init__( + self, input_data, parent, + as_widget=False, label_widget=None, parent_widget=None + ): + if parent_widget is None: + parent_widget = parent + super(EnumeratorWidget, self).__init__(parent_widget) + + self.initial_attributes(input_data, parent, as_widget) + self.multiselection = input_data.get("multiselection") + self.enum_items = input_data["enum_items"] + if not self.enum_items: + raise ValueError("Attribute `enum_items` is not defined.") + + layout = QtWidgets.QHBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(5) + + if not self._as_widget: + self.key = input_data["key"] + if not label_widget: + label = input_data["label"] + label_widget = QtWidgets.QLabel(label) + label_widget.setAttribute(QtCore.Qt.WA_TranslucentBackground) + layout.addWidget(label_widget, 0) + self.label_widget = label_widget + + if self.multiselection: + placeholder = input_data.get("placeholder") + self.input_field = MultiSelectionComboBox( + placeholder=placeholder, parent=self + ) + else: + self.input_field = ComboBox(self) + + first_value = NOT_SET + for enum_item in self.enum_items: + for value, label in enum_item.items(): + if first_value is NOT_SET: + first_value = value + self.input_field.addItem(label, value) + self._first_value = first_value + + if self.multiselection: + model = self.input_field.model() + for idx in range(self.input_field.count()): + model.item(idx).setCheckable(True) + + layout.addWidget(self.input_field, 0) + + self.setFocusProxy(self.input_field) + + self.input_field.value_changed.connect(self._on_value_change) + + @property + def default_input_value(self): + if self.multiselection: + return [] + return self._first_value + + def set_value(self, value): + # Ignore value change because if `self.isChecked()` has same + # value as `value` the `_on_value_change` is not triggered + self.input_field.set_value(value) + + def update_style(self): + if self.as_widget: + state = self.style_state( + False, + self._is_invalid, + False, + self._is_modified + ) + else: + state = self.style_state( + self.has_studio_override, + self.is_invalid, + self.is_overriden, + self.is_modified + ) + + if self._state == state: + return + + self._state = state + self.input_field.setProperty("input-state", state) + self.input_field.style().polish(self.input_field) + if self.label_widget: + self.label_widget.setProperty("state", state) + self.label_widget.style().polish(self.label_widget) + + def item_value(self): + return self.input_field.value() + + class RawJsonInput(QtWidgets.QPlainTextEdit): tab_length = 4 @@ -3496,6 +3597,7 @@ TypeToKlass.types["path-input"] = PathInputWidget TypeToKlass.types["raw-json"] = RawJsonWidget TypeToKlass.types["list"] = ListWidget TypeToKlass.types["list-strict"] = ListStrictWidget +TypeToKlass.types["enum"] = EnumeratorWidget TypeToKlass.types["dict-modifiable"] = ModifiableDict # DEPRECATED - remove when removed from schemas TypeToKlass.types["dict-item"] = DictWidget diff --git a/pype/tools/settings/settings/widgets/multiselection_combobox.py b/pype/tools/settings/settings/widgets/multiselection_combobox.py new file mode 100644 index 0000000000..9a99561ea8 --- /dev/null +++ b/pype/tools/settings/settings/widgets/multiselection_combobox.py @@ -0,0 +1,317 @@ +from Qt import QtCore, QtGui, QtWidgets + + +class ComboItemDelegate(QtWidgets.QStyledItemDelegate): + """ + Helper styled delegate (mostly based on existing private Qt's + delegate used by the QtWidgets.QComboBox). Used to style the popup like a + list view (e.g windows style). + """ + + def paint(self, painter, option, index): + option = QtWidgets.QStyleOptionViewItem(option) + option.showDecorationSelected = True + + # option.state &= ( + # ~QtWidgets.QStyle.State_HasFocus + # & ~QtWidgets.QStyle.State_MouseOver + # ) + super(ComboItemDelegate, self).paint(painter, option, index) + + +class MultiSelectionComboBox(QtWidgets.QComboBox): + value_changed = QtCore.Signal() + ignored_keys = { + QtCore.Qt.Key_Up, + QtCore.Qt.Key_Down, + QtCore.Qt.Key_PageDown, + QtCore.Qt.Key_PageUp, + QtCore.Qt.Key_Home, + QtCore.Qt.Key_End + } + + top_bottom_padding = 2 + left_right_padding = 3 + left_offset = 4 + top_bottom_margins = 2 + item_spacing = 5 + + item_bg_color = QtGui.QColor("#31424e") + + def __init__( + self, parent=None, placeholder="", separator=", ", **kwargs + ): + super(MultiSelectionComboBox, self).__init__(parent=parent, **kwargs) + self.setObjectName("MultiSelectionComboBox") + self.setFocusPolicy(QtCore.Qt.StrongFocus) + + self._popup_is_shown = False + self._block_mouse_release_timer = QtCore.QTimer(self, singleShot=True) + self._initial_mouse_pos = None + self._separator = separator + self.placeholder_text = placeholder + self.delegate = ComboItemDelegate(self) + self.setItemDelegate(self.delegate) + + self.lines = {} + self.item_height = ( + self.fontMetrics().height() + + (2 * self.top_bottom_padding) + + (2 * self.top_bottom_margins) + ) + + def mousePressEvent(self, event): + """Reimplemented.""" + self._popup_is_shown = False + super(MultiSelectionComboBox, self).mousePressEvent(event) + if self._popup_is_shown: + self._initial_mouse_pos = self.mapToGlobal(event.pos()) + self._block_mouse_release_timer.start( + QtWidgets.QApplication.doubleClickInterval() + ) + + def showPopup(self): + """Reimplemented.""" + super(MultiSelectionComboBox, self).showPopup() + view = self.view() + view.installEventFilter(self) + view.viewport().installEventFilter(self) + self._popup_is_shown = True + + def hidePopup(self): + """Reimplemented.""" + self.view().removeEventFilter(self) + self.view().viewport().removeEventFilter(self) + self._popup_is_shown = False + self._initial_mouse_pos = None + super(MultiSelectionComboBox, self).hidePopup() + self.view().clearFocus() + + def _event_popup_shown(self, obj, event): + if not self._popup_is_shown: + return + + current_index = self.view().currentIndex() + model = self.model() + + if event.type() == QtCore.QEvent.MouseMove: + if ( + self.view().isVisible() + and self._initial_mouse_pos is not None + and self._block_mouse_release_timer.isActive() + ): + diff = obj.mapToGlobal(event.pos()) - self._initial_mouse_pos + if diff.manhattanLength() > 9: + self._block_mouse_release_timer.stop() + return + + index_flags = current_index.flags() + state = current_index.data(QtCore.Qt.CheckStateRole) + new_state = None + + if event.type() == QtCore.QEvent.MouseButtonRelease: + if ( + self._block_mouse_release_timer.isActive() + or not current_index.isValid() + or not self.view().isVisible() + or not self.view().rect().contains(event.pos()) + or not index_flags & QtCore.Qt.ItemIsSelectable + or not index_flags & QtCore.Qt.ItemIsEnabled + or not index_flags & QtCore.Qt.ItemIsUserCheckable + ): + return + + if state == QtCore.Qt.Unchecked: + new_state = QtCore.Qt.Checked + else: + new_state = QtCore.Qt.Unchecked + + elif event.type() == QtCore.QEvent.KeyPress: + # TODO: handle QtCore.Qt.Key_Enter, Key_Return? + if event.key() == QtCore.Qt.Key_Space: + # toogle the current items check state + if ( + index_flags & QtCore.Qt.ItemIsUserCheckable + and index_flags & QtCore.Qt.ItemIsTristate + ): + new_state = QtCore.Qt.CheckState((int(state) + 1) % 3) + + elif index_flags & QtCore.Qt.ItemIsUserCheckable: + if state != QtCore.Qt.Checked: + new_state = QtCore.Qt.Checked + else: + new_state = QtCore.Qt.Unchecked + + if new_state is not None: + model.setData(current_index, new_state, QtCore.Qt.CheckStateRole) + self.view().update(current_index) + self.update_size_hint() + self.value_changed.emit() + return True + + def eventFilter(self, obj, event): + """Reimplemented.""" + result = self._event_popup_shown(obj, event) + if result is not None: + return result + + return super(MultiSelectionComboBox, self).eventFilter(obj, event) + + def paintEvent(self, event): + """Reimplemented.""" + painter = QtWidgets.QStylePainter(self) + option = QtWidgets.QStyleOptionComboBox() + self.initStyleOption(option) + painter.drawComplexControl(QtWidgets.QStyle.CC_ComboBox, option) + + # draw the icon and text + items = self.checked_items_text() + if not items: + option.currentText = self.placeholder_text + option.palette.setCurrentColorGroup(QtGui.QPalette.Disabled) + painter.drawControl(QtWidgets.QStyle.CE_ComboBoxLabel, option) + return + + font_metricts = self.fontMetrics() + for line, items in self.lines.items(): + top_y = ( + option.rect.top() + + (line * self.item_height) + + self.top_bottom_margins + ) + left_x = option.rect.left() + self.left_offset + for item in items: + label_rect = font_metricts.boundingRect(item) + label_height = label_rect.height() + + label_rect.moveTop(top_y) + label_rect.moveLeft(left_x) + label_rect.setHeight(self.item_height) + + bg_rect = QtCore.QRectF(label_rect) + bg_rect.setWidth( + label_rect.width() + + (2 * self.left_right_padding) + ) + left_x = bg_rect.right() + self.item_spacing + + label_rect.moveLeft(label_rect.x() + self.left_right_padding) + + bg_rect.setHeight(label_height + (2 * self.top_bottom_padding)) + bg_rect.moveTop(bg_rect.top() + self.top_bottom_margins) + + path = QtGui.QPainterPath() + path.addRoundedRect(bg_rect, 5, 5) + + painter.fillPath(path, self.item_bg_color) + + painter.drawText( + label_rect, + QtCore.Qt.AlignLeft | QtCore.Qt.AlignVCenter, + item + ) + + def resizeEvent(self, *args, **kwargs): + super(MultiSelectionComboBox, self).resizeEvent(*args, **kwargs) + self.update_size_hint() + + def update_size_hint(self): + self.lines = {} + + items = self.checked_items_text() + if not items: + self.update() + return + + option = QtWidgets.QStyleOptionComboBox() + self.initStyleOption(option) + btn_rect = self.style().subControlRect( + QtWidgets.QStyle.CC_ComboBox, + option, + QtWidgets.QStyle.SC_ComboBoxArrow + ) + total_width = option.rect.width() - btn_rect.width() + font_metricts = self.fontMetrics() + + line = 0 + self.lines = {line: []} + + font_metricts = self.fontMetrics() + default_left_x = 0 + self.left_offset + left_x = int(default_left_x) + for item in items: + rect = font_metricts.boundingRect(item) + width = rect.width() + (2 * self.left_right_padding) + right_x = left_x + width + if right_x > total_width: + left_x = int(default_left_x) + if self.lines.get(line): + line += 1 + self.lines[line] = [item] + left_x += width + else: + self.lines[line] = [item] + line += 1 + else: + self.lines[line].append(item) + left_x = left_x + width + self.item_spacing + + self.update() + self.updateGeometry() + + def sizeHint(self): + value = super(MultiSelectionComboBox, self).sizeHint() + lines = len(self.lines) + if lines == 0: + lines = 1 + value.setHeight( + (lines * self.item_height) + + (2 * self.top_bottom_margins) + ) + return value + + def setItemCheckState(self, index, state): + self.setItemData(index, state, QtCore.Qt.CheckStateRole) + + def set_value(self, values): + for idx in range(self.count()): + value = self.itemData(idx, role=QtCore.Qt.UserRole) + if value in values: + check_state = QtCore.Qt.Checked + else: + check_state = QtCore.Qt.Unchecked + self.setItemData(idx, check_state, QtCore.Qt.CheckStateRole) + self.update_size_hint() + + def value(self): + items = list() + for idx in range(self.count()): + state = self.itemData(idx, role=QtCore.Qt.CheckStateRole) + if state == QtCore.Qt.Checked: + items.append( + self.itemData(idx, role=QtCore.Qt.UserRole) + ) + return items + + def checked_items_text(self): + items = list() + for idx in range(self.count()): + state = self.itemData(idx, role=QtCore.Qt.CheckStateRole) + if state == QtCore.Qt.Checked: + items.append(self.itemText(idx)) + return items + + def wheelEvent(self, event): + event.ignore() + + def keyPressEvent(self, event): + if ( + event.key() == QtCore.Qt.Key_Down + and event.modifiers() & QtCore.Qt.AltModifier + ): + return self.showPopup() + + if event.key() in self.ignored_keys: + return event.ignore() + + return super(MultiSelectionComboBox, self).keyPressEvent(event) diff --git a/pype/tools/settings/settings/widgets/widgets.py b/pype/tools/settings/settings/widgets/widgets.py index 2a1f5fe804..616e605a35 100644 --- a/pype/tools/settings/settings/widgets/widgets.py +++ b/pype/tools/settings/settings/widgets/widgets.py @@ -25,6 +25,28 @@ class NumberSpinBox(QtWidgets.QDoubleSpinBox): return output +class ComboBox(QtWidgets.QComboBox): + value_changed = QtCore.Signal() + + def __init__(self, *args, **kwargs): + super(ComboBox, self).__init__(*args, **kwargs) + + self.currentIndexChanged.connect(self._on_change) + + def _on_change(self, *args, **kwargs): + self.value_changed.emit() + + def set_value(self, value): + for idx in range(self.count()): + _value = self.itemData(idx, role=QtCore.Qt.UserRole) + if _value == value: + self.setCurrentIndex(idx) + break + + def value(self): + return self.itemData(self.currentIndex(), role=QtCore.Qt.UserRole) + + class PathInput(QtWidgets.QLineEdit): def clear_end_path(self): value = self.text().strip()