Skip to content

components

AYON UI Qt components package.

This package provides reusable Qt widgets styled according to the AYON design system.

AYButton

Bases: StyleMixin, QPushButton

Source code in client/ayon_core/ui/components/buttons.py
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
class AYButton(StyleMixin, QtWidgets.QPushButton):
    Variants = QPushButtonVariants

    def __init__(
        self,
        *args,
        variant: Variants = Variants.Surface,
        icon: str | None = None,
        icon_on: str | None = None,
        icon_size: int = 16,
        icon_color: str | None = None,
        icon_fill: bool = False,
        checkable: bool = False,
        tooltip: str = "",
        name_id: str = "",
        contrast_color: QColor | None = None,
        label_alignment: Qt.AlignmentFlag | None = None,
        fixed_width: bool | None = None,
        **kwargs,
    ):
        # style params
        self._variant_str: str = variant.value
        self._style_data = StyleDict()

        # widget params
        self._icon_size = icon_size
        self._tooltip = tooltip
        self._icon = icon
        self._icon_on = icon_on or icon
        self._icon_color_override = icon_color
        self._icon_fill = icon_fill
        self._contrast_color = contrast_color

        super().__init__(*args, **kwargs)
        self.setCheckable(checkable)

        self._style = get_ayon_style()
        self._style_data = get_ayon_style_data("QPushButton", variant.value)
        self._style_data.set_context(self)
        self._refresh_icon_style_values()

        if self._icon:
            self.set_icon(self._icon)

        if self._tooltip:
            self.setToolTip(self._tooltip)

        self._label_alignment = label_alignment

        self._name_id = ""
        if name_id:
            self.setObjectName(name_id)
            self._name_id = name_id

        use_fixed_width = (
            (not bool(self.text()))  # only fixed when icon-only
            if fixed_width is None
            else fixed_width
        )
        if use_fixed_width:
            self.setSizePolicy(
                QtWidgets.QSizePolicy.Policy.Fixed,
                QtWidgets.QSizePolicy.Policy.Fixed,
            )
        else:
            self.setSizePolicy(
                QtWidgets.QSizePolicy.Policy.Preferred,
                QtWidgets.QSizePolicy.Policy.Fixed,
            )

        # self._style.style_widget(self)
        self.setStyle(get_ayon_style())

    @property
    def contrast_color(self):
        return self._contrast_color

    def _refresh_icon_style_values(self):
        # Determine the icon color
        color_str = self._icon_color_override or self._style_data.get(
            "color", "#ffffff"
        )
        self._icon_color = QColor(color_str)
        # Adjust the icon color to have enough contrast with the background
        if (
            isinstance(self._contrast_color, QColor)
            and self._contrast_color.isValid()
        ):
            self._icon_color = compute_color_for_contrast(
                self._contrast_color.toTuple(),
                self._icon_color.toTuple(),
                min_contrast_ratio=7,
            )

        # compute a readable icon hover color
        self._icon_hover_color = self._icon_color
        icon_hover_bg = self._style_data.get("hover", {}).get(
            "background-color", "#000000"
        )
        if isinstance(icon_hover_bg, str) and self._icon_color.isValid():
            self._icon_hover_color = compute_color_for_contrast(
                QColor(icon_hover_bg).toTuple(),
                self._icon_color.toTuple(),
                min_contrast_ratio=7,
            )

        # A checkable button's "checked" state can recolor the
        # background entirely (e.g. Row_Action turning primary-blue) —
        # use that state's own "color" for the icon when checked,
        # instead of reusing the unchecked icon color regardless.
        checked_color_str = self._style_data.get("checked", {}).get("color")
        self._icon_color_on = (
            QColor(checked_color_str)
            if isinstance(checked_color_str, str)
            else self._icon_color
        )

        # get the disabled opacity
        self._disabled_opacity = self._style_data.get("disabled", {}).get(
            "opacity", 0.5
        )

    def set_variant(self, variant: Variants | str):
        variant_str = variant if isinstance(variant, str) else variant.value
        normalized_variant = self.Variants(variant_str).value
        if normalized_variant == self._variant_str:
            return

        self._variant_str = normalized_variant
        self._style_data = get_ayon_style_data(
            "QPushButton", self._variant_str
        )
        self._style_data.set_context(self)
        self._refresh_icon_style_values()
        self._style.style_widget(self)
        if self._icon:
            self.set_icon(self._icon)
        self.updateGeometry()
        self.update()

    def _compute_contrast_text_color(
        self,
        bg_color: QColor | str | None,
        fg_color: QColor,
    ) -> QColor:
        """Compute text color with sufficient contrast against background."""
        if not bg_color:
            return fg_color
        qbg = QColor(bg_color) if isinstance(bg_color, str) else bg_color
        return compute_color_for_contrast(
            qbg.toTuple(),  # type: ignore
            fg_color.toTuple(),
            min_contrast_ratio=7.0,
        )

    def set_palette(self, palette: QPalette) -> None:
        self._style_palette = palette

        if self._style_data.get("contrast-text", False):
            contrast_ref = self._contrast_color or self._icon_color
            if not contrast_ref:
                contrast_ref = self.palette().color(self.backgroundRole())
            txt_color = self._compute_contrast_text_color(
                contrast_ref,
                self.palette().color(self.foregroundRole()),
            )
            self._style_palette.setColor(self.foregroundRole(), txt_color)

    def initStyleOption(self, option: QtWidgets.QStyleOptionButton) -> None:
        super().initStyleOption(option)
        option.iconSize = QtCore.QSize(self._icon_size, self._icon_size)

    def sizeHint(self) -> QtCore.QSize:
        if self.testAttribute(QtCore.Qt.WidgetAttribute.WA_StyleSheet):
            option = QtWidgets.QStyleOptionButton()
            self.initStyleOption(option)
            return get_ayon_style().sizeFromContents(
                QtWidgets.QStyle.ContentsType.CT_PushButton,
                option,
                self.rect().size(),
                self,
            )
        return super().sizeHint()

    def paintEvent(self, arg__1: QtGui.QPaintEvent) -> None:
        p = QtGui.QPainter(self)
        option = QtWidgets.QStyleOptionButton()
        self.initStyleOption(option)
        # override rect set by stylesheet
        size = self.sizeHint()
        if (
            self.sizePolicy().horizontalPolicy()
            == QtWidgets.QSizePolicy.Policy.Fixed
        ):
            self.setFixedSize(size)
            option.rect = QtCore.QRect(0, 0, size.width(), size.height())
        else:
            self.setFixedHeight(size.height())  # draw
        return get_ayon_style().drawControl(
            QtWidgets.QStyle.ControlElement.CE_PushButton, option, p, self
        )

    def set_icon(self, icon_name: str):
        self._icon = icon_name
        # icon conventions
        #   State.Off: checkable off
        #   State.On: checkable on
        #   State.Active: hover
        disabled_color = QColor(self._icon_color)
        disabled_color.setAlphaF(self._disabled_opacity)
        if self.isCheckable():
            icn = get_icon(
                icon_name_off=self._icon,
                color_off=self._icon_color,
                icon_name_on=self._icon_on,
                color_on=self._icon_color_on,
                color_on_disabled=disabled_color,
                color_off_disabled=disabled_color,
                fill=self._icon_fill,
            )
        else:
            icn = get_icon(
                icon_name_off=self._icon,
                color_off=self._icon_color,
                icon_name_on=self._icon,
                color_on=self._icon_hover_color,
                color_on_disabled=disabled_color,
                color_off_disabled=disabled_color,
                fill=self._icon_fill,
            )
        self.setIcon(icn)

AYCheckBox

Bases: StyleMixin, QCheckBox

AYON styled checkbox widget.

Overrides Qt's stylesheet painting with AYONStyle custom rendering.

Parameters:

Name Type Description Default
*args

Positional arguments passed to QCheckBox.

()
**kwargs

Keyword arguments passed to QCheckBox.

{}
Source code in client/ayon_core/ui/components/check_box.py
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
class AYCheckBox(StyleMixin, QCheckBox):
    """AYON styled checkbox widget.

    Overrides Qt's stylesheet painting with AYONStyle custom rendering.

    Args:
        *args: Positional arguments passed to QCheckBox.
        **kwargs: Keyword arguments passed to QCheckBox.
    """

    Variants = QCheckBoxVariants

    _TOGGLE_ANIMATION_DURATION = 120

    def __init__(
        self,
        *args,
        variant: Variants = Variants.Default,
        **kwargs,
    ):
        super().__init__(*args, **kwargs)
        self._variant_str = variant.value
        self._style_dict = None
        self.setStyle(get_ayon_style())

        # 0.0 is fully unchecked, 1.0 is fully checked. Read by the
        # CheckboxDrawer to paint the sliding toggle.
        self._toggle_progress = 1.0 if self.isChecked() else 0.0
        self._toggle_anim = QVariantAnimation(self)
        self._toggle_anim.setDuration(self._TOGGLE_ANIMATION_DURATION)
        self._toggle_anim.setEasingCurve(QEasingCurve.Type.InOutQuad)
        self._toggle_anim.valueChanged.connect(self._on_toggle_anim_changed)
        self.toggled.connect(self._on_toggled)

        if variant == AYCheckBox.Variants.Button:
            # Use a fixed size policy instead of 'setFixedSize' so the size
            # follows text/font changes.
            self.setSizePolicy(
                QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed
            )

    @property
    def toggle_progress(self) -> float:
        """Animated toggle position, 0.0 (unchecked) to 1.0 (checked)."""
        return self._toggle_progress

    def is_animating(self) -> bool:
        """Whether the toggle slider is currently animating."""
        return self._toggle_anim.state() == QVariantAnimation.State.Running

    def _on_toggled(self, checked: bool) -> None:
        target = 1.0 if checked else 0.0
        self._toggle_anim.stop()
        if not (self.isVisible() and self.isEnabled()):
            # Do not animate hidden or disabled checkboxes.
            self._toggle_progress = target
            self.update()
            return
        self._toggle_anim.setStartValue(self._toggle_progress)
        self._toggle_anim.setEndValue(target)
        self._toggle_anim.start()

    def _on_toggle_anim_changed(self, value: float) -> None:
        self._toggle_progress = float(value)
        self.update()

    @property
    def style_dict(self):
        if self._style_dict is None:
            self._style_dict = get_ayon_style().model.get_style(
                "QCheckBox", variant=self._variant_str
            )
            self._style_dict.set_context(self)
        return self._style_dict

    def initStyleOption(self, option: QStyleOptionButton) -> None:
        """Initialize the style option with the default implementation, then
        override any properties needed for our custom painting.

        Args:
            option: The style option to initialize.
        """
        super().initStyleOption(option)
        option.fontMetrics = self.fontMetrics()

    def paintEvent(self, arg__1: QPaintEvent) -> None:
        """Render the checkbox using the AYON custom style.

        Args:
            arg__1: The paint event delivered by Qt.
        """
        p = QPainter(self)
        p.setFont(self.font())

        option = QStyleOptionButton()
        self.initStyleOption(option)
        _style = get_ayon_style()

        if not self.isEnabled():
            # Fade the whole checkbox, the toggle is painted with its
            # checked / unchecked colors only.
            disabled_style = _style.model.get_style(
                "QCheckBox", variant=self._variant_str, state="disabled"
            )
            p.setOpacity(disabled_style.get("opacity", 1.0))

        _expanding = self.sizePolicy().horizontalPolicy() in (
            QSizePolicy.Policy.Expanding,
            QSizePolicy.Policy.MinimumExpanding,
        )
        if _expanding:
            ind_w = _style.pixelMetric(
                QStyle.PixelMetric.PM_IndicatorWidth, option, self
            )
            ind_h = _style.pixelMetric(
                QStyle.PixelMetric.PM_IndicatorHeight, option, self
            )
            spacing = _style.pixelMetric(
                QStyle.PixelMetric.PM_CheckBoxLabelSpacing, option, self
            )
            cy = self.height() // 2

            ind_opt = QStyleOptionButton(option)
            ind_opt.rect = QRect(0, cy - ind_h // 2, ind_w, ind_h)
            _style.drawPrimitive(
                QStyle.PrimitiveElement.PE_IndicatorCheckBox, ind_opt, p, self
            )

            label_opt = QStyleOptionButton(option)
            label_opt.rect = QRect(
                ind_w + spacing,
                0,
                self.width() - ind_w - spacing,
                self.height(),
            )
            _style.drawControl(
                QStyle.ControlElement.CE_CheckBoxLabel, label_opt, p, self
            )
        else:
            _style.drawControl(
                QStyle.ControlElement.CE_CheckBox, option, p, self
            )

    def _indicator_on_right(self) -> bool:
        return self.style_dict.get("indicator-position", "left") == "right"

    def _content_size(self) -> QSize:
        """Size of the painted [indicator + label] group.

        Computed only from the AYON style metrics and the style font
        (``self.fontMetrics()``) used for painting. ``QCheckBox.sizeHint``
        must not be used as it measures with the C++ widget font, which
        may differ (e.g. when an application stylesheet sets a font).
        """
        option = QStyleOptionButton()
        self.initStyleOption(option)
        _style = get_ayon_style()
        ind_w = _style.pixelMetric(
            QStyle.PixelMetric.PM_IndicatorWidth, option, self
        )
        ind_h = _style.pixelMetric(
            QStyle.PixelMetric.PM_IndicatorHeight, option, self
        )
        text = self.text()
        if not text:
            return QSize(ind_w, ind_h)

        spacing = _style.pixelMetric(
            QStyle.PixelMetric.PM_CheckBoxLabelSpacing, option, self
        )
        text_size = _style.itemTextRect(
            self.fontMetrics(),
            QRect(),
            Qt.TextFlag.TextShowMnemonic,
            False,
            text,
        ).size()
        # Qt adds 4px label margins (see QCommonStyle CT_CheckBox).
        return QSize(
            ind_w + spacing + text_size.width() + 4,
            max(ind_h, text_size.height() + 4),
        )

    def sizeHint(self) -> QSize:
        size = self._content_size()
        if self._variant_str == AYCheckBox.Variants.Button.value:
            h_pad, v_pad = self.style_dict.get("padding", [6, 6])
            size += QSize(h_pad * 2, v_pad * 2)
        return size

    def minimumSizeHint(self) -> QSize:
        # QCheckBox.minimumSizeHint returns its own cached sizeHint, make
        # sure the custom one is used.
        return self.sizeHint()

    def hitButton(self, pos: QPoint) -> bool:
        """Clickable area matching what is actually painted.

        Qt's default uses 'SE_CheckBoxClickRect' (left aligned indicator +
        text) which does not match the centered '[label  toggle]' layout
        of right-indicator variants nor the button background.
        """
        if (
            self._variant_str == AYCheckBox.Variants.Button.value
            or self._indicator_on_right()
        ):
            return self.rect().contains(pos)
        content = self._content_size()
        if self.sizePolicy().horizontalPolicy() in (
            QSizePolicy.Policy.Expanding,
            QSizePolicy.Policy.MinimumExpanding,
        ):
            # Label is painted over the remaining width.
            content.setWidth(self.width())
        rect = QRect(0, 0, content.width(), self.height())
        return rect.intersected(self.rect()).contains(pos)

    def set_font(self, font: QFont) -> None:
        super().set_font(font)
        self.updateGeometry()

toggle_progress property

Animated toggle position, 0.0 (unchecked) to 1.0 (checked).

hitButton(pos)

Clickable area matching what is actually painted.

Qt's default uses 'SE_CheckBoxClickRect' (left aligned indicator + text) which does not match the centered '[label toggle]' layout of right-indicator variants nor the button background.

Source code in client/ayon_core/ui/components/check_box.py
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
def hitButton(self, pos: QPoint) -> bool:
    """Clickable area matching what is actually painted.

    Qt's default uses 'SE_CheckBoxClickRect' (left aligned indicator +
    text) which does not match the centered '[label  toggle]' layout
    of right-indicator variants nor the button background.
    """
    if (
        self._variant_str == AYCheckBox.Variants.Button.value
        or self._indicator_on_right()
    ):
        return self.rect().contains(pos)
    content = self._content_size()
    if self.sizePolicy().horizontalPolicy() in (
        QSizePolicy.Policy.Expanding,
        QSizePolicy.Policy.MinimumExpanding,
    ):
        # Label is painted over the remaining width.
        content.setWidth(self.width())
    rect = QRect(0, 0, content.width(), self.height())
    return rect.intersected(self.rect()).contains(pos)

initStyleOption(option)

Initialize the style option with the default implementation, then override any properties needed for our custom painting.

Parameters:

Name Type Description Default
option QStyleOptionButton

The style option to initialize.

required
Source code in client/ayon_core/ui/components/check_box.py
 96
 97
 98
 99
100
101
102
103
104
def initStyleOption(self, option: QStyleOptionButton) -> None:
    """Initialize the style option with the default implementation, then
    override any properties needed for our custom painting.

    Args:
        option: The style option to initialize.
    """
    super().initStyleOption(option)
    option.fontMetrics = self.fontMetrics()

is_animating()

Whether the toggle slider is currently animating.

Source code in client/ayon_core/ui/components/check_box.py
67
68
69
def is_animating(self) -> bool:
    """Whether the toggle slider is currently animating."""
    return self._toggle_anim.state() == QVariantAnimation.State.Running

paintEvent(arg__1)

Render the checkbox using the AYON custom style.

Parameters:

Name Type Description Default
arg__1 QPaintEvent

The paint event delivered by Qt.

required
Source code in client/ayon_core/ui/components/check_box.py
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
def paintEvent(self, arg__1: QPaintEvent) -> None:
    """Render the checkbox using the AYON custom style.

    Args:
        arg__1: The paint event delivered by Qt.
    """
    p = QPainter(self)
    p.setFont(self.font())

    option = QStyleOptionButton()
    self.initStyleOption(option)
    _style = get_ayon_style()

    if not self.isEnabled():
        # Fade the whole checkbox, the toggle is painted with its
        # checked / unchecked colors only.
        disabled_style = _style.model.get_style(
            "QCheckBox", variant=self._variant_str, state="disabled"
        )
        p.setOpacity(disabled_style.get("opacity", 1.0))

    _expanding = self.sizePolicy().horizontalPolicy() in (
        QSizePolicy.Policy.Expanding,
        QSizePolicy.Policy.MinimumExpanding,
    )
    if _expanding:
        ind_w = _style.pixelMetric(
            QStyle.PixelMetric.PM_IndicatorWidth, option, self
        )
        ind_h = _style.pixelMetric(
            QStyle.PixelMetric.PM_IndicatorHeight, option, self
        )
        spacing = _style.pixelMetric(
            QStyle.PixelMetric.PM_CheckBoxLabelSpacing, option, self
        )
        cy = self.height() // 2

        ind_opt = QStyleOptionButton(option)
        ind_opt.rect = QRect(0, cy - ind_h // 2, ind_w, ind_h)
        _style.drawPrimitive(
            QStyle.PrimitiveElement.PE_IndicatorCheckBox, ind_opt, p, self
        )

        label_opt = QStyleOptionButton(option)
        label_opt.rect = QRect(
            ind_w + spacing,
            0,
            self.width() - ind_w - spacing,
            self.height(),
        )
        _style.drawControl(
            QStyle.ControlElement.CE_CheckBoxLabel, label_opt, p, self
        )
    else:
        _style.drawControl(
            QStyle.ControlElement.CE_CheckBox, option, p, self
        )

AYComboBox

Bases: StyleMixin, QComboBox

AYON-styled combo-box with icon, short-text, and inverted-colour support.

:class:AYComboBox wraps :class:QComboBox and adds:

  • Three display modes controlled by :class:~ayon_core.ui.data_models.MenuSize:

  • Full - shows the full item label (default).

  • Short - shows the abbreviated ShortTextRole label.
  • Icon - hides the text and shows only the item icon.

  • Inverted colour mode - swaps the icon foreground/background colours so the icon appears on a coloured pill rather than a neutral background.

  • Placeholder text support for an empty selection state.
  • Custom model support: any model that exposes ShortTextRole and IconNameRole attributes is accepted. Models without those attributes are flagged as incompatible; :meth:add_item and :meth:update_items will raise :exc:RuntimeError when such a model is active.

The default model is :class:AYComboBoxModel.

Parameters:

Name Type Description Default
parent Optional[QWidget]

Optional parent widget.

None
items List[dict] | None

Initial list of item dictionaries. Each dict may contain:

  • "text" (required) - Display label.
  • "color" - Hex foreground colour (default "#ffffff").
  • "icon" - Material Symbol icon name.
  • "short_text" - Abbreviated label for short mode (default "< UNDEFINED >").
None
size MenuSize | str

Initial display mode. Accepts a :class:~ayon_core.ui.data_models.MenuSize value or its string equivalent ("full", "short", "icon").

Full
height int

Fixed maximum height in pixels (default 30).

30
placeholder Optional[str]

Placeholder string shown when no item is selected.

None
inverted bool

When True the icon foreground and background colours are swapped (default False).

False
icon_size int

Icon size in pixels (default 20).

20
show_chevron bool

If False, the dropdown chevron (arrow) will not be drawn in the custom style. Default is False.

True
**kwargs

Additional keyword arguments forwarded to :class:QComboBox.

{}
Example

Basic usage with the built-in status list::

combo = AYComboBox(
    parent=my_widget,
    items=[...],
    size=MenuSize.Short,
    placeholder="Select status…",
)
combo.currentTextChanged.connect(on_status_changed)

Switching modes at runtime::

combo.set_size("icon")
combo.set_inverted(True)
Source code in client/ayon_core/ui/components/combo_box.py
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
class AYComboBox(StyleMixin, QtWidgets.QComboBox):
    """AYON-styled combo-box with icon, short-text, and inverted-colour
    support.

    :class:`AYComboBox` wraps :class:`QComboBox` and adds:

    - **Three display modes** controlled by
      :class:`~ayon_core.ui.data_models.MenuSize`:

      - ``Full``  - shows the full item label (default).
      - ``Short`` - shows the abbreviated ``ShortTextRole`` label.
      - ``Icon``  - hides the text and shows only the item icon.

    - **Inverted colour mode** - swaps the icon foreground/background colours
      so the icon appears on a coloured pill rather than a neutral background.
    - **Placeholder text** support for an empty selection state.
    - **Custom model** support: any model that exposes ``ShortTextRole`` and
      ``IconNameRole`` attributes is accepted. Models without those attributes
      are flagged as incompatible; :meth:`add_item` and :meth:`update_items`
      will raise :exc:`RuntimeError` when such a model is active.

    The default model is :class:`AYComboBoxModel`.

    Args:
        parent: Optional parent widget.
        items: Initial list of item dictionaries.  Each dict may contain:

            - ``"text"``       *(required)* - Display label.
            - ``"color"``      - Hex foreground colour (default ``"#ffffff"``).
            - ``"icon"``       - Material Symbol icon name.
            - ``"short_text"`` - Abbreviated label for short mode
              (default ``"< UNDEFINED >"``).

        size: Initial display mode.  Accepts a
            :class:`~ayon_core.ui.data_models.MenuSize` value or its string
            equivalent (``"full"``, ``"short"``, ``"icon"``).
        height: Fixed maximum height in pixels (default ``30``).
        placeholder: Placeholder string shown when no item is selected.
        inverted: When ``True`` the icon foreground and background colours are
            swapped (default ``False``).
        icon_size: Icon size in pixels (default ``20``).
        show_chevron: If False, the dropdown chevron (arrow) will not be drawn
            in the custom style. Default is False.

        **kwargs: Additional keyword arguments forwarded to
            :class:`QComboBox`.

    Example:
        Basic usage with the built-in status list::

            combo = AYComboBox(
                parent=my_widget,
                items=[...],
                size=MenuSize.Short,
                placeholder="Select status…",
            )
            combo.currentTextChanged.connect(on_status_changed)

        Switching modes at runtime::

            combo.set_size("icon")
            combo.set_inverted(True)
    """

    Variants = QComboBoxVariants

    def __init__(
        self,
        parent: Optional[QtWidgets.QWidget] = None,
        items: List[dict] | None = None,
        size: MenuSize | str = MenuSize.Full,
        height: int = 30,
        placeholder: Optional[str] = None,
        inverted: bool = False,
        icon_size: int = 20,
        variant: Variants = Variants.Default,
        show_chevron: bool = True,
        **kwargs,
    ) -> None:
        self._uses_incompatible_model = False
        super().__init__(parent, **kwargs)
        self._variant_str = variant.value

        self.setStyle(get_ayon_style())
        self.setMouseTracking(True)
        self.setMaximumHeight(height)
        self._popup_pressed_index = QtCore.QPersistentModelIndex()
        self._pending_popup_activation = -1
        self._popup_activation_timer = QtCore.QTimer(self)
        self._popup_activation_timer.setSingleShot(True)
        self._popup_activation_timer.timeout.connect(
            self._emit_pending_popup_activation
        )
        self.view().viewport().installEventFilter(self)

        # Initialize properties
        self._size: MenuSize = (
            size if isinstance(size, MenuSize) else MenuSize(size)
        )
        self._height: int = height
        self._inverted: bool = inverted
        self._icon_size: int = icon_size
        self._inverted_icons: dict[str, QIcon] = {}
        self.show_chevron: bool = show_chevron

        if placeholder:
            self.setPlaceholderText(placeholder)

        # setup model
        model = AYComboBoxModel(self)
        self.setModel(model)

        view = self.view()

        view.setVerticalScrollBar(AYScrollBar(Qt.Orientation.Vertical))
        view.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
        view.setHorizontalScrollBar(AYScrollBar(Qt.Orientation.Horizontal))
        view.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)

        # Paint popup rows with the AYON styled delegate, so every combo-box
        # gets the same row height, padding and menu colors instead of Qt's
        # cramped default rows.
        self.setItemDelegate(
            ComboBoxItemDelegate(
                parent=view,
                style_model=get_ayon_style().model,
            )
        )

        self.update_items(items)

    def eventFilter(
        self,
        watched: QtCore.QObject,
        event: QtCore.QEvent,
    ) -> bool:
        """Commit clicks that begin inside the open popup.

        QComboBox suppresses mouse releases for a short period after opening
        its popup. This prevents the opening click from selecting an item,
        but can also swallow a genuine fast second click. Tracking a new
        press inside the popup distinguishes those two cases.
        """
        viewport = self.view().viewport()
        if watched is viewport:
            event_type = event.type()
            if event_type in (
                QtCore.QEvent.Type.MouseButtonPress,
                QtCore.QEvent.Type.MouseButtonDblClick,
            ):
                index = self.view().indexAt(event.pos())
                self._popup_pressed_index = QtCore.QPersistentModelIndex(
                    index
                )

            elif event_type == QtCore.QEvent.Type.MouseButtonRelease:
                index = self.view().indexAt(event.pos())
                pressed_index = self._popup_pressed_index
                self._popup_pressed_index = QtCore.QPersistentModelIndex()
                if (
                    pressed_index.isValid()
                    and index.isValid()
                    and pressed_index == index
                    and bool(index.flags() & QtCore.Qt.ItemFlag.ItemIsEnabled)
                ):
                    self._pending_popup_activation = index.row()
                    self._popup_activation_timer.start(0)
                    return True

        return super().eventFilter(watched, event)

    def _emit_pending_popup_activation(self) -> None:
        """Emit a popup selection after its mouse event has unwound."""
        row = self._pending_popup_activation
        self._pending_popup_activation = -1
        if 0 <= row < self.count():
            self.setCurrentIndex(row)
            self.hidePopup()
            self.activated.emit(row)

    def hidePopup(self) -> None:
        """Hide the popup and clear any unfinished popup click."""
        self._popup_pressed_index = QtCore.QPersistentModelIndex()
        super().hidePopup()

    def _assert_compatible_model(self) -> None:
        """Raise :exc:`RuntimeError` when an incompatible model is active.

        Called by :meth:`add_item` and :meth:`update_items` to guard against
        direct item mutations when a custom model that does not expose
        ``ShortTextRole`` / ``IconNameRole`` has been set via
        :meth:`setModel`.
        """
        if self._uses_incompatible_model:
            raise RuntimeError(
                "Cannot modify items directly when a custom "
                "model is in use. Modify the model instead."
            )

    def setModel(self, model: QtCore.QAbstractItemModel) -> None:
        """Set the item model and detect compatibility with AYComboBox roles.

        If *model* is not an :class:`AYComboBoxModel` instance **and** it
        does not expose both ``ShortTextRole`` and ``IconNameRole``
        attributes, it is marked as *incompatible*.  In that state:

        - :meth:`add_item` and :meth:`update_items` will raise
          :exc:`RuntimeError`.
        - Short mode falls back to ``"< INCOMPATIBLE MODEL >"`` as the
          displayed text.

        Args:
            model: The new item model to attach to the combo-box.
        """
        mtype = type(model)
        self._uses_incompatible_model = mtype is not AYComboBoxModel and (
            not hasattr(model, "IconNameRole")
            or not hasattr(model, "ShortTextRole")
        )
        super().setModel(model)

    def _make_icon(
        self,
        fg_color: QColor,
        bg_color: QColor,
        icon_name: str | None,
        inverted: bool = False,
    ) -> QIcon | None:
        """Assign a Material Symbol icon to *data_item*.

        Reads the item's background and foreground colours and passes them
        to ``get_icon()``.  When *inverted* mode is active the icon's normal
        colour uses the **background** colour so the icon appears on a
        coloured pill; otherwise it uses the foreground colour.

        Does nothing when *icon_name* is ``None`` or an empty string.

        Args:
            fg_color: Icon color.
            bg_color: Background color.
            icon_name: A Material Symbol identifier (e.g. ``"play_arrow"``),
                or ``None`` / ``""`` to skip icon assignment.
            inverted: When ``True``, the icon's normal colour is taken from
                the item's **background** colour so it renders as a coloured
                icon on a neutral background.  Defaults to ``False``.
        """
        icon = None
        if icon_name:
            icon = get_icon(
                icon_name,
                color=bg_color if inverted else fg_color,
                # TODO: add fill support to get_icon and
                #       pass self._icon_fill here
            )
        return icon

    def _get_inverted_icon(self, default: QIcon) -> QIcon:
        """Return the inverted-colour icon for the currently selected item.

        Looks up the ``IconNameRole`` of the current item and returns a
        cached inverted icon.  If the icon has not been generated yet it is
        created via :meth:`_make_icon` with ``inverted=True`` and stored in
        :attr:`_inverted_icons` for future calls.

        Args:
            default: Fallback icon returned when the current item has no
                ``IconNameRole`` value.

        Returns:
            A :class:`QIcon` rendered with inverted colours, or *default*
            when no icon name is available.
        """
        idx = self.currentIndex()
        if idx < 0:
            return default

        icon_name = self.currentData(self.model().IconNameRole)
        if not icon_name:
            return default

        fg = self.currentData(QtCore.Qt.ItemDataRole.ForegroundRole).color()
        bg = self.currentData(QtCore.Qt.ItemDataRole.BackgroundRole).color()
        key = f"{icon_name}:{fg.name()}:{bg.name()}"

        if key not in self._inverted_icons:
            self._inverted_icons[key] = (
                self._make_icon(fg, bg, icon_name, inverted=True) or default
            )

        return self._inverted_icons[key]

    def add_item(self, item: dict[str, str]):
        """Append a single item to the combo-box model.

        Constructs a :class:`QStandardItem` from *item*, sets its foreground
        colour, background colour (from the current palette), icon, short
        text, and icon name, then appends it to the model.

        Raises:
            RuntimeError: If a custom incompatible model is currently set
                (see :meth:`setModel`).

        Args:
            item: A dict with the following keys:

                - ``"text"``       *(required)* - Display label.
                - ``"color"``      - Hex foreground colour
                  (default ``"#ffffff"``).
                - ``"icon"``       - Material Symbol icon name.
                - ``"short_text"`` - Abbreviated label stored in
                  ``ShortTextRole`` (default ``"< UNDEFINED >"``).
        """
        self._assert_compatible_model()

        bg_color = self.palette().color(
            QPalette.ColorGroup.Active, QPalette.ColorRole.Window
        )
        fg_color = QColor(item.get("color", "#ffffff"))

        text = item.get("text")
        if not text:
            raise ValueError(
                f"Item dict must contain a non-empty 'text' key; got: {item!r}"
            )

        data_item = QStandardItem(text)
        data_item.setData(
            QBrush(fg_color),
            QtCore.Qt.ItemDataRole.ForegroundRole,
        )
        data_item.setData(
            QBrush(bg_color), QtCore.Qt.ItemDataRole.BackgroundRole
        )
        icon = self._make_icon(fg_color, bg_color, item.get("icon"))
        if icon:
            data_item.setIcon(icon)
        data_item.setData(
            item.get("short_text", "< UNDEFINED >"),
            self.model().ShortTextRole,
        )  # type: ignore
        data_item.setData(item.get("icon"), self.model().IconNameRole)  # type: ignore
        self.model().appendRow(data_item)  # type: ignore

    def update_items(self, item_list: list[dict] | None = None):
        """Replace all items in the model with the provided list.

        Clears the model and calls :meth:`add_item` for every entry in
        *item_list*.  If *item_list* is ``None`` or empty the model is left
        unchanged.

        Raises:
            RuntimeError: If a custom incompatible model is currently set
                (see :meth:`setModel`).

        Args:
            item_list: List of item dicts as accepted by :meth:`add_item`.
                Pass ``None`` or an empty list to keep the current items.
        """
        if item_list:
            self._assert_compatible_model()
            self.model().clear()  # type: ignore
            for item in item_list:
                self.add_item(item)

    def set_inverted(self, state: bool):
        """Toggle the inverted colour mode and schedule a repaint.

        In *inverted* mode the icon's normal colour is drawn using the item's
        **background** colour instead of its foreground colour, producing a
        coloured icon on a neutral background.

        Inverted icons are generated lazily on first paint via
        :meth:`_get_inverted_icon` and cached in :attr:`_inverted_icons`.
        Calling this method triggers a repaint so the change is visible
        immediately.

        Args:
            state: ``True`` to enable inverted mode, ``False`` to disable.
        """
        self._inverted = state
        self.update()

    def set_size(self, size: MenuSize | str):
        """Change the display mode and repaint the widget.

        Args:
            size: New display mode.  Accepts a
                :class:`~ayon_core.ui.data_models.MenuSize` value or its
                lowercase string equivalent (``"full"``, ``"short"``,
                ``"icon"``).
        """
        self._size = (
            size if isinstance(size, MenuSize) else MenuSize(size.lower())
        )
        self.update()

    def sizeHint(self) -> QtCore.QSize:
        """Return the preferred size for the combo-box.

        Delegates to the AYON style's
        :meth:`QStyle.sizeFromContents` so that the hint respects the
        custom theme's metrics rather than the system default.

        Returns:
            The preferred :class:`QSize` for this widget.
        """

        option = QtWidgets.QStyleOptionComboBox()
        self.initStyleOption(option)
        return get_ayon_style().sizeFromContents(
            QtWidgets.QStyle.ContentsType.CT_ComboBox,
            option,
            self.rect().size(),
            self,
        )

    def _resolve_current_text(self, idx: int) -> str | None:
        """Return the display text for the current item.

        Args:
            idx: The current combo-box index.

        Returns:
            The text to display, or ``None`` when the option should be left
            unchanged (e.g. no item is selected and no placeholder applies).
        """
        if idx < 0:
            return None

        if self._size == MenuSize.Full:
            return self.currentData(QtCore.Qt.ItemDataRole.DisplayRole)
        if self._size == MenuSize.Short:
            return (
                self.currentData(self.model().ShortTextRole)
                if hasattr(self.model(), "ShortTextRole")
                else "< INCOMPATIBLE MODEL >"
            )
        return ""  # MenuSize.Icon

    def paintEvent(self, arg__1: QPaintEvent) -> None:
        """Render the combo-box using the AYON custom style.

        Overrides the default :meth:`QComboBox.paintEvent` to:

        1. Draw the combo-box frame/control via the AYON style.
        2. Substitute placeholder text (rendered in ``placeholderText``
           palette colour) when no item is selected.
        3. Render the label according to the current
           :class:`~ayon_core.ui.data_models.MenuSize` mode:

           - ``Full``  → item's full text.
           - ``Short`` → item's ``ShortTextRole`` value, or
             ``"< INCOMPATIBLE MODEL >"`` if the role is unavailable.
           - ``Icon``  → empty string (icon only).

        Args:
            arg__1: The paint event delivered by Qt.
        """

        p = QPainter(self)
        option = QtWidgets.QStyleOptionComboBox()
        self.initStyleOption(option)

        p.setFont(self.font())
        option.fontMetrics = self.fontMetrics()

        if self._inverted:
            option.currentIcon = self._get_inverted_icon(option.currentIcon)

        _style = get_ayon_style()
        _style.drawComplexControl(
            QtWidgets.QStyle.ComplexControl.CC_ComboBox, option, p, self
        )

        idx = self.currentIndex()

        if idx < 0 and self.placeholderText():
            option.palette.setBrush(  # type: ignore
                QPalette.ColorRole.ButtonText,
                option.palette.placeholderText(),  # type: ignore
            )
            option.currentText = self.placeholderText()  # type: ignore

        text = self._resolve_current_text(idx)
        if text is not None:
            option.currentText = text  # type: ignore

        _style.drawControl(
            QtWidgets.QStyle.ControlElement.CE_ComboBoxLabel,
            option,
            p,
            self,
        )

add_item(item)

Append a single item to the combo-box model.

Constructs a :class:QStandardItem from item, sets its foreground colour, background colour (from the current palette), icon, short text, and icon name, then appends it to the model.

Raises:

Type Description
RuntimeError

If a custom incompatible model is currently set (see :meth:setModel).

Parameters:

Name Type Description Default
item dict[str, str]

A dict with the following keys:

  • "text" (required) - Display label.
  • "color" - Hex foreground colour (default "#ffffff").
  • "icon" - Material Symbol icon name.
  • "short_text" - Abbreviated label stored in ShortTextRole (default "< UNDEFINED >").
required
Source code in client/ayon_core/ui/components/combo_box.py
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
def add_item(self, item: dict[str, str]):
    """Append a single item to the combo-box model.

    Constructs a :class:`QStandardItem` from *item*, sets its foreground
    colour, background colour (from the current palette), icon, short
    text, and icon name, then appends it to the model.

    Raises:
        RuntimeError: If a custom incompatible model is currently set
            (see :meth:`setModel`).

    Args:
        item: A dict with the following keys:

            - ``"text"``       *(required)* - Display label.
            - ``"color"``      - Hex foreground colour
              (default ``"#ffffff"``).
            - ``"icon"``       - Material Symbol icon name.
            - ``"short_text"`` - Abbreviated label stored in
              ``ShortTextRole`` (default ``"< UNDEFINED >"``).
    """
    self._assert_compatible_model()

    bg_color = self.palette().color(
        QPalette.ColorGroup.Active, QPalette.ColorRole.Window
    )
    fg_color = QColor(item.get("color", "#ffffff"))

    text = item.get("text")
    if not text:
        raise ValueError(
            f"Item dict must contain a non-empty 'text' key; got: {item!r}"
        )

    data_item = QStandardItem(text)
    data_item.setData(
        QBrush(fg_color),
        QtCore.Qt.ItemDataRole.ForegroundRole,
    )
    data_item.setData(
        QBrush(bg_color), QtCore.Qt.ItemDataRole.BackgroundRole
    )
    icon = self._make_icon(fg_color, bg_color, item.get("icon"))
    if icon:
        data_item.setIcon(icon)
    data_item.setData(
        item.get("short_text", "< UNDEFINED >"),
        self.model().ShortTextRole,
    )  # type: ignore
    data_item.setData(item.get("icon"), self.model().IconNameRole)  # type: ignore
    self.model().appendRow(data_item)  # type: ignore

eventFilter(watched, event)

Commit clicks that begin inside the open popup.

QComboBox suppresses mouse releases for a short period after opening its popup. This prevents the opening click from selecting an item, but can also swallow a genuine fast second click. Tracking a new press inside the popup distinguishes those two cases.

Source code in client/ayon_core/ui/components/combo_box.py
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
def eventFilter(
    self,
    watched: QtCore.QObject,
    event: QtCore.QEvent,
) -> bool:
    """Commit clicks that begin inside the open popup.

    QComboBox suppresses mouse releases for a short period after opening
    its popup. This prevents the opening click from selecting an item,
    but can also swallow a genuine fast second click. Tracking a new
    press inside the popup distinguishes those two cases.
    """
    viewport = self.view().viewport()
    if watched is viewport:
        event_type = event.type()
        if event_type in (
            QtCore.QEvent.Type.MouseButtonPress,
            QtCore.QEvent.Type.MouseButtonDblClick,
        ):
            index = self.view().indexAt(event.pos())
            self._popup_pressed_index = QtCore.QPersistentModelIndex(
                index
            )

        elif event_type == QtCore.QEvent.Type.MouseButtonRelease:
            index = self.view().indexAt(event.pos())
            pressed_index = self._popup_pressed_index
            self._popup_pressed_index = QtCore.QPersistentModelIndex()
            if (
                pressed_index.isValid()
                and index.isValid()
                and pressed_index == index
                and bool(index.flags() & QtCore.Qt.ItemFlag.ItemIsEnabled)
            ):
                self._pending_popup_activation = index.row()
                self._popup_activation_timer.start(0)
                return True

    return super().eventFilter(watched, event)

hidePopup()

Hide the popup and clear any unfinished popup click.

Source code in client/ayon_core/ui/components/combo_box.py
550
551
552
553
def hidePopup(self) -> None:
    """Hide the popup and clear any unfinished popup click."""
    self._popup_pressed_index = QtCore.QPersistentModelIndex()
    super().hidePopup()

paintEvent(arg__1)

Render the combo-box using the AYON custom style.

Overrides the default :meth:QComboBox.paintEvent to:

  1. Draw the combo-box frame/control via the AYON style.
  2. Substitute placeholder text (rendered in placeholderText palette colour) when no item is selected.
  3. Render the label according to the current :class:~ayon_core.ui.data_models.MenuSize mode:

  4. Full → item's full text.

  5. Short → item's ShortTextRole value, or "< INCOMPATIBLE MODEL >" if the role is unavailable.
  6. Icon → empty string (icon only).

Parameters:

Name Type Description Default
arg__1 QPaintEvent

The paint event delivered by Qt.

required
Source code in client/ayon_core/ui/components/combo_box.py
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
def paintEvent(self, arg__1: QPaintEvent) -> None:
    """Render the combo-box using the AYON custom style.

    Overrides the default :meth:`QComboBox.paintEvent` to:

    1. Draw the combo-box frame/control via the AYON style.
    2. Substitute placeholder text (rendered in ``placeholderText``
       palette colour) when no item is selected.
    3. Render the label according to the current
       :class:`~ayon_core.ui.data_models.MenuSize` mode:

       - ``Full``  → item's full text.
       - ``Short`` → item's ``ShortTextRole`` value, or
         ``"< INCOMPATIBLE MODEL >"`` if the role is unavailable.
       - ``Icon``  → empty string (icon only).

    Args:
        arg__1: The paint event delivered by Qt.
    """

    p = QPainter(self)
    option = QtWidgets.QStyleOptionComboBox()
    self.initStyleOption(option)

    p.setFont(self.font())
    option.fontMetrics = self.fontMetrics()

    if self._inverted:
        option.currentIcon = self._get_inverted_icon(option.currentIcon)

    _style = get_ayon_style()
    _style.drawComplexControl(
        QtWidgets.QStyle.ComplexControl.CC_ComboBox, option, p, self
    )

    idx = self.currentIndex()

    if idx < 0 and self.placeholderText():
        option.palette.setBrush(  # type: ignore
            QPalette.ColorRole.ButtonText,
            option.palette.placeholderText(),  # type: ignore
        )
        option.currentText = self.placeholderText()  # type: ignore

    text = self._resolve_current_text(idx)
    if text is not None:
        option.currentText = text  # type: ignore

    _style.drawControl(
        QtWidgets.QStyle.ControlElement.CE_ComboBoxLabel,
        option,
        p,
        self,
    )

setModel(model)

Set the item model and detect compatibility with AYComboBox roles.

If model is not an :class:AYComboBoxModel instance and it does not expose both ShortTextRole and IconNameRole attributes, it is marked as incompatible. In that state:

  • :meth:add_item and :meth:update_items will raise :exc:RuntimeError.
  • Short mode falls back to "< INCOMPATIBLE MODEL >" as the displayed text.

Parameters:

Name Type Description Default
model QAbstractItemModel

The new item model to attach to the combo-box.

required
Source code in client/ayon_core/ui/components/combo_box.py
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
def setModel(self, model: QtCore.QAbstractItemModel) -> None:
    """Set the item model and detect compatibility with AYComboBox roles.

    If *model* is not an :class:`AYComboBoxModel` instance **and** it
    does not expose both ``ShortTextRole`` and ``IconNameRole``
    attributes, it is marked as *incompatible*.  In that state:

    - :meth:`add_item` and :meth:`update_items` will raise
      :exc:`RuntimeError`.
    - Short mode falls back to ``"< INCOMPATIBLE MODEL >"`` as the
      displayed text.

    Args:
        model: The new item model to attach to the combo-box.
    """
    mtype = type(model)
    self._uses_incompatible_model = mtype is not AYComboBoxModel and (
        not hasattr(model, "IconNameRole")
        or not hasattr(model, "ShortTextRole")
    )
    super().setModel(model)

set_inverted(state)

Toggle the inverted colour mode and schedule a repaint.

In inverted mode the icon's normal colour is drawn using the item's background colour instead of its foreground colour, producing a coloured icon on a neutral background.

Inverted icons are generated lazily on first paint via :meth:_get_inverted_icon and cached in :attr:_inverted_icons. Calling this method triggers a repaint so the change is visible immediately.

Parameters:

Name Type Description Default
state bool

True to enable inverted mode, False to disable.

required
Source code in client/ayon_core/ui/components/combo_box.py
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
def set_inverted(self, state: bool):
    """Toggle the inverted colour mode and schedule a repaint.

    In *inverted* mode the icon's normal colour is drawn using the item's
    **background** colour instead of its foreground colour, producing a
    coloured icon on a neutral background.

    Inverted icons are generated lazily on first paint via
    :meth:`_get_inverted_icon` and cached in :attr:`_inverted_icons`.
    Calling this method triggers a repaint so the change is visible
    immediately.

    Args:
        state: ``True`` to enable inverted mode, ``False`` to disable.
    """
    self._inverted = state
    self.update()

set_size(size)

Change the display mode and repaint the widget.

Parameters:

Name Type Description Default
size MenuSize | str

New display mode. Accepts a :class:~ayon_core.ui.data_models.MenuSize value or its lowercase string equivalent ("full", "short", "icon").

required
Source code in client/ayon_core/ui/components/combo_box.py
752
753
754
755
756
757
758
759
760
761
762
763
764
def set_size(self, size: MenuSize | str):
    """Change the display mode and repaint the widget.

    Args:
        size: New display mode.  Accepts a
            :class:`~ayon_core.ui.data_models.MenuSize` value or its
            lowercase string equivalent (``"full"``, ``"short"``,
            ``"icon"``).
    """
    self._size = (
        size if isinstance(size, MenuSize) else MenuSize(size.lower())
    )
    self.update()

sizeHint()

Return the preferred size for the combo-box.

Delegates to the AYON style's :meth:QStyle.sizeFromContents so that the hint respects the custom theme's metrics rather than the system default.

Returns:

Type Description
QSize

The preferred :class:QSize for this widget.

Source code in client/ayon_core/ui/components/combo_box.py
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
def sizeHint(self) -> QtCore.QSize:
    """Return the preferred size for the combo-box.

    Delegates to the AYON style's
    :meth:`QStyle.sizeFromContents` so that the hint respects the
    custom theme's metrics rather than the system default.

    Returns:
        The preferred :class:`QSize` for this widget.
    """

    option = QtWidgets.QStyleOptionComboBox()
    self.initStyleOption(option)
    return get_ayon_style().sizeFromContents(
        QtWidgets.QStyle.ContentsType.CT_ComboBox,
        option,
        self.rect().size(),
        self,
    )

update_items(item_list=None)

Replace all items in the model with the provided list.

Clears the model and calls :meth:add_item for every entry in item_list. If item_list is None or empty the model is left unchanged.

Raises:

Type Description
RuntimeError

If a custom incompatible model is currently set (see :meth:setModel).

Parameters:

Name Type Description Default
item_list list[dict] | None

List of item dicts as accepted by :meth:add_item. Pass None or an empty list to keep the current items.

None
Source code in client/ayon_core/ui/components/combo_box.py
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
def update_items(self, item_list: list[dict] | None = None):
    """Replace all items in the model with the provided list.

    Clears the model and calls :meth:`add_item` for every entry in
    *item_list*.  If *item_list* is ``None`` or empty the model is left
    unchanged.

    Raises:
        RuntimeError: If a custom incompatible model is currently set
            (see :meth:`setModel`).

    Args:
        item_list: List of item dicts as accepted by :meth:`add_item`.
            Pass ``None`` or an empty list to keep the current items.
    """
    if item_list:
        self._assert_compatible_model()
        self.model().clear()  # type: ignore
        for item in item_list:
            self.add_item(item)

AYFrame

Bases: StyleMixin, QFrame

Source code in client/ayon_core/ui/components/frame.py
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
class AYFrame(StyleMixin, QtWidgets.QFrame):
    Variants = QFrameVariants

    def __init__(
        self,
        *args,
        bg=False,
        variant: Variants = Variants.Default,
        margin=0,
        bg_tint="",
        hover_enabled=False,
        **kwargs,
    ):
        # Convert enum to string if needed
        self._bg: bool = bg
        self._variant_str = variant.value
        self._bg_tint = bg_tint
        self._bg_color = None
        self._hover_enabled = hover_enabled

        super().__init__(*args, **kwargs)
        self.setStyle(get_ayon_style())

        self.setAttribute(
            QtCore.Qt.WidgetAttribute.WA_TranslucentBackground, True
        )
        if hover_enabled:
            self.setAttribute(QtCore.Qt.WidgetAttribute.WA_Hover, True)
            self.setMouseTracking(True)
        self.setContentsMargins(margin, margin, margin, margin)

    def paintEvent(self, arg__1: QtGui.QPaintEvent) -> None:
        p = QtGui.QPainter(self)
        option = QtWidgets.QStyleOptionFrame()
        self.initStyleOption(option)
        # print(f"opt: {option}")
        get_ayon_style().drawControl(
            QtWidgets.QStyle.ControlElement.CE_ShapedFrame, option, p, self
        )

    def get_bg_color(self, base_color: str):
        if not self._bg_color:
            if self._bg_tint:
                self._bg_color = color_blend(base_color, self._bg_tint, 0.1)
            else:
                return base_color
        return self._bg_color

    def _set_row_state_bit(
        self, bit: QtWidgets.QStyle.StateFlag, on: bool
    ) -> None:
        state = (
            self.property("row_state")
            or QtWidgets.QStyle.StateFlag.State_None
        )
        state = (state | bit) if on else (state & ~bit)
        self.setProperty("row_state", state)
        self.update()

    def set_selected(self, selected: bool) -> None:
        """Mark this frame as selected via the ``row_state`` property.

        The frame drawer reads this to pick the current variant's
        ``selected`` style block (background, border, ...), so a whole
        frame can carry a highlight the way a single-row control would,
        instead of only an inner button doing so.
        """
        self._set_row_state_bit(
            QtWidgets.QStyle.StateFlag.State_Selected, selected
        )

    def set_hovered(self, hovered: bool) -> None:
        """Mark this frame as hovered via the ``row_state`` property.

        Companion to :meth:`set_selected`: driven explicitly (see
        :class:`RowHoverTracker`) instead of relying on
        ``underMouse()``, since an interactive ``QPushButton`` child
        sitting on top of the frame doesn't reliably cascade hover
        state to it.
        """
        self._set_row_state_bit(
            QtWidgets.QStyle.StateFlag.State_MouseOver, hovered
        )

set_hovered(hovered)

Mark this frame as hovered via the row_state property.

Companion to :meth:set_selected: driven explicitly (see :class:RowHoverTracker) instead of relying on underMouse(), since an interactive QPushButton child sitting on top of the frame doesn't reliably cascade hover state to it.

Source code in client/ayon_core/ui/components/frame.py
84
85
86
87
88
89
90
91
92
93
94
95
def set_hovered(self, hovered: bool) -> None:
    """Mark this frame as hovered via the ``row_state`` property.

    Companion to :meth:`set_selected`: driven explicitly (see
    :class:`RowHoverTracker`) instead of relying on
    ``underMouse()``, since an interactive ``QPushButton`` child
    sitting on top of the frame doesn't reliably cascade hover
    state to it.
    """
    self._set_row_state_bit(
        QtWidgets.QStyle.StateFlag.State_MouseOver, hovered
    )

set_selected(selected)

Mark this frame as selected via the row_state property.

The frame drawer reads this to pick the current variant's selected style block (background, border, ...), so a whole frame can carry a highlight the way a single-row control would, instead of only an inner button doing so.

Source code in client/ayon_core/ui/components/frame.py
72
73
74
75
76
77
78
79
80
81
82
def set_selected(self, selected: bool) -> None:
    """Mark this frame as selected via the ``row_state`` property.

    The frame drawer reads this to pick the current variant's
    ``selected`` style block (background, border, ...), so a whole
    frame can carry a highlight the way a single-row control would,
    instead of only an inner button doing so.
    """
    self._set_row_state_bit(
        QtWidgets.QStyle.StateFlag.State_Selected, selected
    )

AYLabel

Bases: StyleMixin, QLabel

Source code in client/ayon_core/ui/components/label.py
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
class AYLabel(StyleMixin, QtWidgets.QLabel):
    Variants = QLabelVariants

    def __init__(
        self,
        *args,
        dim: bool = False,
        icon: str | QIcon = "",
        icon_color: str = "",
        icon_size: int = 20,
        icon_text_spacing=6,
        icon_fill=False,
        text_color: str = "",
        rel_text_size: int = 0,
        bold: bool = False,
        tool_tip="",
        variant: Variants = Variants.Default,
        contrast_color: QColor | None = None,
        elide_mode: Qt.TextElideMode = Qt.TextElideMode.ElideNone,
        copy_text: bool = False,
        flexible: bool = False,
        **kwargs,
    ):
        # style params
        self._variant_str: str = variant.value
        self._style_data = StyleDict()

        # widget params
        self._dim = dim
        self._icon = icon
        self._icon_color = icon_color
        self._icon_size = icon_size
        self._icon_fill = icon_fill
        self._icon_text_spacing = icon_text_spacing
        self._rel_text_size = rel_text_size
        self._text_color = text_color
        self._bold = bold
        self._elide_mode = elide_mode
        # copy the text because setting an icon will blank it, as a label is
        # either text or pixmap.
        self._text: str = ""
        # reference bg color to compute contrast-adapted text color
        self._contrast_color = (
            contrast_color
            if isinstance(contrast_color, QColor) and contrast_color.isValid()
            else None
        )
        self._contrast_adapted = None

        # copy-text feature
        self._copy_text = copy_text
        self._copy_icon_hovered = False
        self._copy_confirmed = False
        self._copy_done_opacity: float = 1.0
        self._copy_pix_normal: QPixmap | None = None
        self._copy_pix_hover: QPixmap | None = None
        self._copy_pix_done: QPixmap | None = None
        self._copy_confirm_timer: QTimer | None = None
        self._copy_fade_timer: QTimer | None = None

        super().__init__(*args, **kwargs)
        self._style = get_ayon_style()
        self._style_data = self._style.model.get_styles(
            "QLabel", variant=self._variant_str
        )
        self._style_data.set_context(self)
        self.setStyle(self._style)

        # used to be in polish
        self.setWindowFlag(Qt.WindowType.FramelessWindowHint, True)
        self.setWindowFlag(Qt.WindowType.NoDropShadowWindowHint, True)

        # Minimum pins the label at its text width; Preferred still asks
        # for that width but lets a layout shrink it, which is what makes
        # eliding possible at all.
        if flexible:
            h_policy = QtWidgets.QSizePolicy.Policy.Ignored
        elif self._elides:
            h_policy = QtWidgets.QSizePolicy.Policy.Preferred
        else:
            h_policy = QtWidgets.QSizePolicy.Policy.Minimum
        self.setSizePolicy(h_policy, QtWidgets.QSizePolicy.Policy.Preferred)

        # set alignment from style data if specified.
        alignment = self._style_data["base"].get("alignment")
        if alignment is not None:
            if alignment == "center":
                self.setAlignment(Qt.AlignmentFlag.AlignCenter)
            elif alignment == "left":
                self.setAlignment(
                    Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter
                )
            elif alignment == "right":
                self.setAlignment(
                    Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter
                )

        self._text = self.text()  # call before setting icon to preserve text
        self.setToolTip(tool_tip)

        if self._copy_text:
            self.setMouseTracking(True)
            self._copy_confirm_timer = QTimer(self)
            self._copy_confirm_timer.setSingleShot(True)
            self._copy_confirm_timer.timeout.connect(
                self._on_copy_confirm_timeout
            )
            self._copy_fade_timer = QTimer(self)
            self._copy_fade_timer.setInterval(16)
            self._copy_fade_timer.timeout.connect(self._on_copy_fade_tick)

        self.set_icon()

    @property
    def contrast_color(self) -> QColor | None:
        return self._contrast_color

    def set_palette(self, palette: QPalette) -> None:
        """Set the widget palette and trigger a repaint."""
        self._style_palette = palette
        self._configure_palette()
        if self._icon and not self._icon_color:
            self.set_icon()  # update icon color from palette
        self._copy_pix_normal = self._copy_pix_hover = self._copy_pix_done = (
            None
        )
        self.update()

    def set_font(self, font: QFont) -> None:
        """Set the widget font and refresh geometry and painting."""
        self._style_font = self._configure_font(font)
        self.updateGeometry()
        self.update()

    # Private methods -------------------------------------------------------

    def set_icon(
        self,
        icon: str | QIcon | None = None,
        color: str = "",
    ) -> None:
        if icon is not None:
            self._icon = icon
        if color:
            self._icon_color = color

        if self._icon:
            same_as_bg = (
                self._icon_color not in (None, "")
                and self._style_data["base"].get("background-color", raw=True)
                == "@_icon_color"
            )
            icon_color = self._icon_color
            if not icon_color:
                icon_color = self.palette().color(self.foregroundRole()).name()
            elif same_as_bg:
                icon_color = self._style_data["base"].get("color")

            if isinstance(self._icon, QIcon):
                icn = self._icon
            elif self._icon == "none":
                icn = QIcon()  # empty icon for spacing
                pxm = QPixmap(self._icon_size, self._icon_size)
                pxm.fill(Qt.GlobalColor.transparent)
                icn.addPixmap(pxm)
            else:
                icn: QIcon = get_icon(
                    self._icon,
                    color=icon_color,
                    fill=self._icon_fill,
                )
            self.setPixmap(icn.pixmap(QSize(self._icon_size, self._icon_size)))

    def set_text_color(self, color: str) -> None:
        self._text_color = color
        self._configure_palette()
        self.repaint()

    def _configure_font(self, font: QFont) -> QFont:
        """Return a freshly configured font derived from the base font."""
        if self._rel_text_size != 0:
            # _rel_text_size is in points but setting pixels is more reliable.
            # use QFontInfo in case PixelSize() or pointSizeF() returns -1
            pt_size = QFontInfo(font).pointSizeF()
            new_pt_size = pt_size + self._rel_text_size
            font.setPointSizeF(new_pt_size)
        weight = QFont.Weight.Bold if self._bold else QFont.Weight.Normal
        font.setWeight(weight)

        return font

    @property
    def _elides(self) -> bool:
        """Whether this label shortens text that does not fit."""
        return self._elide_mode != Qt.TextElideMode.ElideNone

    def _chrome_width(self) -> int:
        """Return the width taken by everything that is not the text.

        That is the variant's horizontal padding on both sides plus, when
        there is an icon, the icon and its spacing - the same amounts the
        painters subtract before drawing the text.
        """
        padding = self._style_data["base"].get("padding", [0, 0])
        width = int(padding[0]) * 2
        if self._icon:
            width += self._icon_size + int(
                self._style_data["base"].get(
                    "icon-text-spacing", self._icon_text_spacing
                )
            )
        return width

    def _display_text(self) -> str:
        """Recompute the elided version of the stored text."""
        if not self._elides or not self.fontMetrics():
            return self._text
        available_w = self.contentsRect().width() - self._chrome_width()
        return self.fontMetrics().elidedText(
            self._text, self._elide_mode, max(0, available_w)
        )

    def _resolve_color(self) -> QColor:
        """Get the effective foreground color (icon_color or palette)."""
        if self._icon_color:
            return QColor(self._icon_color)
        return self.palette().color(self.foregroundRole())

    def _to_qcolor(self, color: QColor | str | None) -> QColor | None:
        """Convert a color value to QColor, handling None and strings."""
        if color is None:
            return None
        if isinstance(color, QColor):
            return color
        return QColor(color)

    def _compute_contrast_text_color(
        self,
        bg_color: QColor | str | None,
        fg_color: QColor,
    ) -> QColor:
        """Compute text color with sufficient contrast against background."""
        if not bg_color:
            return fg_color
        qbg = self._to_qcolor(bg_color)
        return compute_color_for_contrast(
            qbg.toTuple(),  # type: ignore
            fg_color.toTuple(),
            min_contrast_ratio=7.0,
        )

    def _configure_palette(self) -> None:
        """Configure palette based on dim/contrast settings."""
        # _style_palette is guaranteed to be set in paintEvent before this call
        assert self._style_palette is not None

        if self._dim:
            self._style_palette.setColor(
                QPalette.ColorGroup.Active,
                self.foregroundRole(),
                self._style_palette.color(
                    QPalette.ColorGroup.Active,
                    QPalette.ColorRole.PlaceholderText,
                ),
            )
            return

        if self._text_color:
            self._style_palette.setColor(
                self.foregroundRole(), QColor(self._text_color)
            )

        if self._style_data["base"].get("fill-from-foreground", False):
            # background-color is resolved from @_icon_color via style refs
            bg_val = self._style_data["base"].get("background-color", "")
            bg_color = (
                QColor(bg_val)
                if bg_val and bg_val != "transparent"
                else self._style_palette.color(self.foregroundRole())
            )
            self._style_palette.setColor(self.backgroundRole(), bg_color)
            if not self._contrast_color:
                self._contrast_color = bg_color

        if self._style_data["base"].get("contrast-text", False):
            contrast_ref = self._contrast_color or self._icon_color
            txt_color = self._compute_contrast_text_color(
                contrast_ref,
                self.palette().color(self.foregroundRole()),
            )
            self._style_palette.setColor(self.foregroundRole(), txt_color)

        if "disabled" in self._style_data:
            opacity = self._style_data["disabled"].get("opacity", 1.0)
            if opacity < 1.0:
                for role in _iter_enum_values(QPalette.ColorRole):
                    color = self._style_palette.color(role)
                    if color == Qt.GlobalColor.transparent:
                        continue
                    color.setAlphaF(opacity)
                    self._style_palette.setColor(
                        QPalette.ColorGroup.Disabled, role, color
                    )

    def _paint_filled(self, state: str) -> None:
        """Render a filled-background label driven by style data."""
        assert isinstance(self.fontMetrics(), QFontMetrics)

        # Auto-size from text metrics
        if self._style_data[state].get("auto-size"):
            padding = self._style_data[state].get("auto-size-padding", [0, 0])
            t_rect = self.fontMetrics().boundingRect(self.text())
            padx = int(self.fontMetrics().averageCharWidth() * padding[0])
            pady = int(self.fontMetrics().height() * padding[1])
            self.setFixedSize(
                t_rect.width() + padx,
                t_rect.height() + pady,
            )

        p = QPainter(self)
        self.initPainter(p)
        p.setFont(self.font())
        p.setRenderHint(QPainter.RenderHint.Antialiasing)

        # Fill color from foreground
        fill_color = self._resolve_color()
        p.setBrush(QBrush(fill_color))
        p.setPen(Qt.PenStyle.NoPen)

        # handle disabled opacity for both fill and text
        p.setOpacity(self._style_data[state].get("opacity", 1.0))

        # Border radius: fraction of height or fixed
        radius_frac = self._style_data[state].get("border-radius-fraction")
        if radius_frac is not None:
            radius = self.rect().height() * radius_frac
        else:
            radius = self._style_data[state].get("border-radius", 0)

        p.drawRoundedRect(self.rect(), radius, radius)

        # Text color with contrast computation
        if self._style_data[state].get("contrast-text"):
            contrast_ref = self._contrast_color or self._icon_color
            txt_color = self._compute_contrast_text_color(
                contrast_ref,
                self.palette().color(self.foregroundRole()),
            )
            p.setPen(QPen(QBrush(txt_color), 1.0))

        self._style.drawItemText(
            p,
            self.rect(),
            Qt.AlignmentFlag.AlignCenter,
            self.palette(),
            self.isEnabled(),
            self.text(),
            textRole=QPalette.ColorRole.NoRole,
        )
        p.end()

    def _paint_icon_and_text(self, state: str) -> None:
        """Render label with both icon and text.

        The icon and text are treated as a single group and positioned
        within the widget rect according to the current alignment.

        The spacing between icon and text is resolved from the
        ``icon-text-spacing`` property in *style_data*, falling back to
        the value supplied at construction time.

        Args:
            style_data: Variant style properties resolved from the style
                JSON for the current ``QLabel`` variant.
        """
        assert isinstance(self.fontMetrics(), QFontMetrics)

        p = QPainter(self)
        p.setFont(self.font())
        p.setRenderHint(QPainter.RenderHint.Antialiasing)

        text_rect = self.fontMetrics().boundingRect(self._display_text())
        text_rect.adjust(0, 0, 1, 0)  # +1 pixel for antialiasing

        icon_w = self._icon_size
        icon_h = self._icon_size
        spacing = int(
            self._style_data[state].get(
                "icon-text-spacing", self._icon_text_spacing
            )
        )
        group_w = icon_w + spacing + text_rect.width()
        group_h = max(icon_h, text_rect.height())

        # Position the group using the current alignment
        widget_rect = self.contentsRect().normalized()
        explicit_padding = self._style_data[state].get(
            "padding",
            self._style_data["base"].get("padding", [0, 0]),
        )
        pad_h = int(explicit_padding[0])
        pad_v = int(explicit_padding[1])
        widget_rect = widget_rect.adjusted(pad_h, pad_v, -pad_h, -pad_v)

        alignment = self.alignment()

        if alignment & Qt.AlignmentFlag.AlignLeft:
            group_x = widget_rect.left()
        elif alignment & Qt.AlignmentFlag.AlignRight:
            group_x = widget_rect.right() - group_w
        else:  # Center (default)
            group_x = widget_rect.left() + (widget_rect.width() - group_w) // 2

        if alignment & Qt.AlignmentFlag.AlignTop:
            group_y = widget_rect.top()
        elif alignment & Qt.AlignmentFlag.AlignBottom:
            group_y = widget_rect.bottom() - group_h
        else:  # VCenter (default)
            group_y = widget_rect.top() + (widget_rect.height() - group_h) // 2

        # handle disabled opacity for both icon and text
        p.save()
        p.setOpacity(self._style_data[state].get("opacity", 1.0))

        # Draw icon at the left of the group
        icon_y = group_y + (group_h - icon_h) // 2
        icn_rct = QRect(group_x, icon_y, icon_w, icon_h)
        self._style.drawItemPixmap(
            p,
            icn_rct,
            Qt.AlignmentFlag.AlignCenter,
            self.pixmap(),
        )

        # Draw text at the right of the icon
        pal = self.palette()
        if not self._dim:
            pal.setColor(QPalette.ColorRole.Text, self._resolve_color())

        txt_x = group_x + icon_w + spacing
        txt_y = group_y + (group_h - text_rect.height()) // 2
        txt_rct = QRect(txt_x, txt_y, text_rect.width(), text_rect.height())
        self._style.drawItemText(
            p,
            txt_rct,
            Qt.AlignmentFlag.AlignVCenter | Qt.AlignmentFlag.AlignLeft,
            pal,
            self.isEnabled(),
            self._display_text(),
            textRole=self.foregroundRole(),
        )
        p.restore()

    def _paint_text_only(self, state: str) -> None:
        """Render text-only label."""
        p = QPainter(self)
        p.setFont(self.font())
        p.setRenderHint(QPainter.RenderHint.Antialiasing)
        pal = self.palette()
        if self._text_color:
            pal.setColor(self.foregroundRole(), QColor(self._text_color))
            if "disabled" in self._style_data:
                t_color = QColor(self._text_color)
                t_color.setAlphaF(self._style_data[state].get("opacity", 1.0))
                pal.setColor(
                    QPalette.ColorGroup.Disabled,
                    self.foregroundRole(),
                    t_color,
                )
        flags = int(self.alignment())
        if self.wordWrap():
            flags |= int(Qt.TextFlag.TextWordWrap)
        p.save()
        p.setOpacity(self._style_data[state].get("opacity", 1.0))
        self._style.drawItemText(
            p,
            self.contentsRect().normalized(),
            flags,
            pal,
            self.isEnabled(),
            self._display_text(),
            textRole=self.foregroundRole(),
        )
        p.restore()

    _COPY_ICON_SIZE = 16
    _COPY_ICON_PADDING = 0

    def _get_copy_icon_rect(self) -> QRect:
        """Return the bounding rect for the copy icon overlay.

        Returns:
            Rect positioned at the right edge of the content area,
            vertically centered.
        """
        sz = self._COPY_ICON_SIZE
        rect = self.contentsRect()
        x = rect.right() - sz - self._COPY_ICON_PADDING
        y = rect.top() + (rect.height() - sz) // 2
        return QRect(x, y, sz, sz)

    def _make_copy_pixmap(self, icon_name: str, color_str: str) -> QPixmap:
        """Render a single copy-feature icon onto a transparent pixmap.

        Args:
            icon_name: Material symbol name.
            color_str: CSS colour string passed to ``get_icon``.

        Returns:
            A ``QPixmap`` of size ``_COPY_ICON_SIZE × _COPY_ICON_SIZE``.
        """
        sz = self._COPY_ICON_SIZE
        pxm = QPixmap(sz, sz)
        pxm.fill(QColor(0, 0, 0, 200))
        icon_pxm: QPixmap = get_icon(icon_name, color=color_str).pixmap(
            QSize(sz, sz)
        )
        p = QPainter(pxm)
        p.drawPixmap(QPoint(0, 0), icon_pxm)
        p.end()
        return pxm

    def _build_copy_pixmaps(self) -> None:
        """(Re-)build cached copy-icon pixmaps for normal, hover and done
        states."""
        fg = self.palette().color(self.foregroundRole())
        normal_color = QColor(fg)
        normal_color.setAlphaF(0.75)
        self._copy_pix_normal = self._make_copy_pixmap(
            "content_copy",
            normal_color.name(QColor.NameFormat.HexArgb),
        )
        self._copy_pix_hover = self._make_copy_pixmap(
            "content_copy", fg.name()
        )
        self._copy_pix_done = self._make_copy_pixmap("check", fg.name())

    _COPY_FADE_DURATION_MS: int = 500

    def _on_copy_confirm_timeout(self) -> None:
        """Begin fading out the checkmark icon over _COPY_FADE_DURATION_MS."""
        self._copy_done_opacity = 1.0
        assert self._copy_fade_timer is not None
        self._copy_fade_timer.start()

    def _on_copy_fade_tick(self) -> None:
        """Decrement the done-icon opacity and repaint each frame."""
        step = 16.0 / self._COPY_FADE_DURATION_MS
        self._copy_done_opacity = max(0.0, self._copy_done_opacity - step)
        self.update()
        if self._copy_done_opacity <= 0.0:
            assert self._copy_fade_timer is not None
            self._copy_fade_timer.stop()
            self._copy_confirmed = False

    def _trigger_copy_confirmed(self) -> None:
        """Copy the label text to clipboard and start the confirmation
        animation."""
        QtWidgets.QApplication.clipboard().setText(self._text)
        assert self._copy_fade_timer is not None
        assert self._copy_confirm_timer is not None
        # Reset any in-progress fade and restart the hold + fade sequence.
        self._copy_fade_timer.stop()
        self._copy_done_opacity = 1.0
        self._copy_confirmed = True
        self._copy_confirm_timer.start(500)
        self.update()

    def _paint_copy_icon(self) -> None:
        """Draw the copy-to-clipboard icon on the right side of the label.

        Only called when the mouse is over the widget and ``copy_text``
        is True. Shows a fading checkmark briefly after the user clicks.
        """
        if self._copy_pix_normal is None:
            self._build_copy_pixmaps()
        p = QPainter(self)
        p.setRenderHint(QPainter.RenderHint.Antialiasing)
        if self._copy_confirmed:
            p.setOpacity(self._copy_done_opacity)
            pix = self._copy_pix_done
        else:
            pix = (
                self._copy_pix_hover
                if self._copy_icon_hovered
                else self._copy_pix_normal
            )
        self._style.drawItemPixmap(
            p,
            self._get_copy_icon_rect(),
            Qt.AlignmentFlag.AlignCenter,
            pix,  # type: ignore[arg-type]
        )
        p.end()

    def _paint_background(self, state: str) -> None:
        """Draw background if specified by style."""
        bg_color = self._style_data[state].get("background-color")
        if bg_color and bg_color != "transparent":
            p = QPainter(self)
            p.setRenderHint(QPainter.RenderHint.Antialiasing)
            border_radius = self._style_data[state].get("border-radius", 0)
            border_width = self._style_data[state].get("border-width", 0)
            border_color = self._style_data[state].get(
                "border-color", "#00000000"
            )
            qbg_color = QColor(bg_color)
            qbg_color.setAlphaF(self._style_data[state].get("opacity", 1.0))
            p.setBrush(QBrush(QColor(qbg_color)))
            p.setPen(
                QPen(QColor(border_color), border_width)
                if border_width > 0
                else Qt.PenStyle.NoPen
            )
            p.drawRoundedRect(self.rect(), border_radius, border_radius)

    # QLabel overrides ----------------------------------------------------

    def enterEvent(self, event: QEnterEvent) -> None:
        super().enterEvent(event)
        if self._copy_text:
            self.update()

    def leaveEvent(self, event: QEvent) -> None:
        super().leaveEvent(event)
        if self._copy_text:
            self._copy_icon_hovered = False
            self.update()

    def mouseMoveEvent(self, event: QMouseEvent) -> None:
        super().mouseMoveEvent(event)
        if not self._copy_text:
            return
        rect = self._get_copy_icon_rect()
        hovered = rect.contains(event.pos())
        if hovered != self._copy_icon_hovered:
            self._copy_icon_hovered = hovered
            self.update()

    def mousePressEvent(self, event: QMouseEvent) -> None:
        if (
            self._copy_text
            and event.button() == Qt.MouseButton.LeftButton
            and self._get_copy_icon_rect().contains(event.pos())
        ):
            self._trigger_copy_confirmed()
        super().mousePressEvent(event)

    def paintEvent(self, arg__1: QPaintEvent) -> None:
        """Override to support icon + text rendering."""
        state = "disabled" if not self.isEnabled() else "base"
        if (
            state != "disabled"
            and "hover" in self._style_data
            and self.underMouse()
        ):
            state = "hover"

        # Filled-background rendering (driven by JSON properties)
        if self._style_data["base"].get("fill-from-foreground"):
            self._paint_filled(state)
        else:
            self._paint_background(state)
            if self._text and self._icon:
                self._paint_icon_and_text(state)
            elif self._icon and not self._text:
                super().paintEvent(arg__1)
            else:
                self._paint_text_only(state)

        if (
            self._copy_text
            and self.isEnabled()
            and (self.underMouse() or self._copy_confirmed)
        ):
            self._paint_copy_icon()

    def sizeHint(self) -> QSize:
        """Compute a size hint driven by QLabel style data from the style JSON.

        For variants with ``auto-size`` (e.g. badge / pill), the size is
        derived from font-metrics and the ``auto-size-padding`` factor.
        For variants with an explicit ``padding`` list (e.g. entity-label),
        the size is padded accordingly.
        When an icon is present the icon dimensions are added.
        When ``wordWrap`` is True and the widget already has a non-zero width,
        the height is recomputed from the wrapped text bounds at that width;
        otherwise the natural single-line size is returned and the layout
        refines the height via :meth:`heightForWidth`.
        In all other cases the base ``QLabel.sizeHint()`` is returned.

        Returns:
            The recommended widget size.
        """
        fm = self.fontMetrics()

        # --- text size --------------------------------------------------
        if self._text:
            t_rect = fm.boundingRect(self._text)
            # Cover the text's *advance* - what elidedText() and
            # drawText() measure with - or a label handed exactly its
            # own hint would elide text it just asked the room for.
            # boundingRect() is the ink extent, wider only for glyphs
            # that overhang their advance, so keep whichever is larger.
            text_w = max(
                fm.horizontalAdvance(self._text), t_rect.width()
            ) + 1  # +1 pixel for antialiasing
            text_h = t_rect.height()
        else:
            text_w = 0
            text_h = fm.height()

        # --- icon size --------------------------------------------------
        icon_w = icon_h = 0
        if self._icon:
            icon_w = self._icon_size
            icon_h = self._icon_size

        # --- variant-specific sizing ------------------------------------
        if self._style_data["base"].get("auto-size"):
            # badge / pill: padding is expressed as a fraction of the
            # character metrics (x-factor of avgCharWidth, y-factor of height)
            padding = self._style_data["base"].get(
                "auto-size-padding", [0.0, 0.0]
            )
            pad_x = int(fm.averageCharWidth() * padding[0])
            pad_y = int(fm.height() * padding[1])

            content_w = max(text_w, icon_w)
            content_h = max(text_h, icon_h)

            return QSize(content_w + pad_x, content_h + pad_y)

        explicit_padding = self._style_data["base"].get("padding", [0, 0])
        pad_h = int(explicit_padding[0])
        pad_v = int(explicit_padding[1])

        if icon_w and text_w:
            spacing = int(
                self._style_data["base"].get(
                    "icon-text-spacing", self._icon_text_spacing
                )
            )
            content_w = icon_w + spacing + text_w
            content_h = max(text_h, icon_h)
            # print(
            #     f"{self._text!r}: {content_w + 2 * pad_h} x "
            #     f"{content_h + 2 * pad_v}"
            # )
        elif icon_w:
            content_w = icon_w
            content_h = icon_h
        else:
            content_w = text_w
            content_h = text_h

        # --- word-wrap sizing -------------------------------------------
        # When word-wrap is on and the widget already occupies a real width,
        # recompute the height for that width so the hint stays accurate after
        # the first layout pass.  Layouts will also call heightForWidth() for
        # further refinement.
        if self.wordWrap() and self._text and not self._icon:
            available_w = self.width() - 2 * pad_h
            if available_w > 0:
                flags = int(Qt.TextFlag.TextWordWrap) | int(self.alignment())
                wrap_rect = fm.boundingRect(
                    QRect(0, 0, available_w, 0),
                    flags,
                    self._text,
                )
                return QSize(
                    content_w + 2 * pad_h,
                    wrap_rect.height() + 2 * pad_v,
                )

        cm = self.contentsMargins()

        return QSize(
            content_w + 2 * pad_h + cm.left() + cm.right(),
            content_h + 2 * pad_v + cm.top() + cm.bottom(),
        )

    def minimumSizeHint(self) -> QSize:
        """Return the smallest useful size for this label.

        ``QLabel`` reports its full text width, which for an eliding
        label defeats the elide - a layout could never make it narrower
        than the text it is meant to shorten.  An eliding label only
        insists on its icon, padding and an ellipsis.

        Returns:
            The minimum widget size.
        """
        if not self._elides:
            return super().minimumSizeHint()
        hint = self.sizeHint()
        floor = self._chrome_width()
        floor += self.fontMetrics().horizontalAdvance("…")
        return QSize(min(floor, hint.width()), hint.height())

    def hasHeightForWidth(self) -> bool:
        """Return True when word-wrap is active so layouts call heightForWidth.

        Returns:
            True if the label uses word-wrap and has text without an icon.
        """
        if self.wordWrap() and self._text and not self._icon:
            return True
        return super().hasHeightForWidth()

    def heightForWidth(self, width: int) -> int:
        """Compute the height required to render the text wrapped to *width*.

        Only meaningful when ``wordWrap`` is True and there is no icon;
        delegates to the base class otherwise.

        Args:
            width: Available width in pixels.

        Returns:
            Required height in pixels.
        """
        if not self.wordWrap() or not self._text or self._icon:
            return super().heightForWidth(width)

        assert isinstance(self.fontMetrics(), QFontMetrics)
        fm = self.fontMetrics()

        explicit_padding = self._style_data["base"].get("padding", [0, 0])
        pad_h = int(explicit_padding[0])
        pad_v = int(explicit_padding[1])

        available_w = max(1, width - 2 * pad_h)
        flags = int(Qt.TextFlag.TextWordWrap) | int(self.alignment())
        wrap_rect = fm.boundingRect(
            QRect(0, 0, available_w, 0),
            flags,
            self._text,
        )
        return wrap_rect.height() + 2 * pad_v

    def setText(self, arg__1: str) -> None:
        super().setText(arg__1)
        self._text = self.text()
        self.updateGeometry()

    def set_icon_color(self, color: str) -> None:
        """Update the icon color and refresh the widget.

        Resets contrast color cache so filled variants (e.g. Badge) and any
        palette-based contrast logic are recalculated with the new color.

        Args:
            color: New hex color string e.g. '#44ee9f'.
        """
        self._icon_color = color
        self._contrast_color = None  # reset so contrast is recalculated
        self._configure_palette()
        self.set_icon(color=color)  # repaints icon pixmap with new color
        self._copy_pix_normal = self._copy_pix_hover = self._copy_pix_done = (
            None
        )
        self.update()

hasHeightForWidth()

Return True when word-wrap is active so layouts call heightForWidth.

Returns:

Type Description
bool

True if the label uses word-wrap and has text without an icon.

Source code in client/ayon_core/ui/components/label.py
836
837
838
839
840
841
842
843
844
def hasHeightForWidth(self) -> bool:
    """Return True when word-wrap is active so layouts call heightForWidth.

    Returns:
        True if the label uses word-wrap and has text without an icon.
    """
    if self.wordWrap() and self._text and not self._icon:
        return True
    return super().hasHeightForWidth()

heightForWidth(width)

Compute the height required to render the text wrapped to width.

Only meaningful when wordWrap is True and there is no icon; delegates to the base class otherwise.

Parameters:

Name Type Description Default
width int

Available width in pixels.

required

Returns:

Type Description
int

Required height in pixels.

Source code in client/ayon_core/ui/components/label.py
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
def heightForWidth(self, width: int) -> int:
    """Compute the height required to render the text wrapped to *width*.

    Only meaningful when ``wordWrap`` is True and there is no icon;
    delegates to the base class otherwise.

    Args:
        width: Available width in pixels.

    Returns:
        Required height in pixels.
    """
    if not self.wordWrap() or not self._text or self._icon:
        return super().heightForWidth(width)

    assert isinstance(self.fontMetrics(), QFontMetrics)
    fm = self.fontMetrics()

    explicit_padding = self._style_data["base"].get("padding", [0, 0])
    pad_h = int(explicit_padding[0])
    pad_v = int(explicit_padding[1])

    available_w = max(1, width - 2 * pad_h)
    flags = int(Qt.TextFlag.TextWordWrap) | int(self.alignment())
    wrap_rect = fm.boundingRect(
        QRect(0, 0, available_w, 0),
        flags,
        self._text,
    )
    return wrap_rect.height() + 2 * pad_v

minimumSizeHint()

Return the smallest useful size for this label.

QLabel reports its full text width, which for an eliding label defeats the elide - a layout could never make it narrower than the text it is meant to shorten. An eliding label only insists on its icon, padding and an ellipsis.

Returns:

Type Description
QSize

The minimum widget size.

Source code in client/ayon_core/ui/components/label.py
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
def minimumSizeHint(self) -> QSize:
    """Return the smallest useful size for this label.

    ``QLabel`` reports its full text width, which for an eliding
    label defeats the elide - a layout could never make it narrower
    than the text it is meant to shorten.  An eliding label only
    insists on its icon, padding and an ellipsis.

    Returns:
        The minimum widget size.
    """
    if not self._elides:
        return super().minimumSizeHint()
    hint = self.sizeHint()
    floor = self._chrome_width()
    floor += self.fontMetrics().horizontalAdvance("…")
    return QSize(min(floor, hint.width()), hint.height())

paintEvent(arg__1)

Override to support icon + text rendering.

Source code in client/ayon_core/ui/components/label.py
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
def paintEvent(self, arg__1: QPaintEvent) -> None:
    """Override to support icon + text rendering."""
    state = "disabled" if not self.isEnabled() else "base"
    if (
        state != "disabled"
        and "hover" in self._style_data
        and self.underMouse()
    ):
        state = "hover"

    # Filled-background rendering (driven by JSON properties)
    if self._style_data["base"].get("fill-from-foreground"):
        self._paint_filled(state)
    else:
        self._paint_background(state)
        if self._text and self._icon:
            self._paint_icon_and_text(state)
        elif self._icon and not self._text:
            super().paintEvent(arg__1)
        else:
            self._paint_text_only(state)

    if (
        self._copy_text
        and self.isEnabled()
        and (self.underMouse() or self._copy_confirmed)
    ):
        self._paint_copy_icon()

set_font(font)

Set the widget font and refresh geometry and painting.

Source code in client/ayon_core/ui/components/label.py
164
165
166
167
168
def set_font(self, font: QFont) -> None:
    """Set the widget font and refresh geometry and painting."""
    self._style_font = self._configure_font(font)
    self.updateGeometry()
    self.update()

set_icon_color(color)

Update the icon color and refresh the widget.

Resets contrast color cache so filled variants (e.g. Badge) and any palette-based contrast logic are recalculated with the new color.

Parameters:

Name Type Description Default
color str

New hex color string e.g. '#44ee9f'.

required
Source code in client/ayon_core/ui/components/label.py
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
def set_icon_color(self, color: str) -> None:
    """Update the icon color and refresh the widget.

    Resets contrast color cache so filled variants (e.g. Badge) and any
    palette-based contrast logic are recalculated with the new color.

    Args:
        color: New hex color string e.g. '#44ee9f'.
    """
    self._icon_color = color
    self._contrast_color = None  # reset so contrast is recalculated
    self._configure_palette()
    self.set_icon(color=color)  # repaints icon pixmap with new color
    self._copy_pix_normal = self._copy_pix_hover = self._copy_pix_done = (
        None
    )
    self.update()

set_palette(palette)

Set the widget palette and trigger a repaint.

Source code in client/ayon_core/ui/components/label.py
153
154
155
156
157
158
159
160
161
162
def set_palette(self, palette: QPalette) -> None:
    """Set the widget palette and trigger a repaint."""
    self._style_palette = palette
    self._configure_palette()
    if self._icon and not self._icon_color:
        self.set_icon()  # update icon color from palette
    self._copy_pix_normal = self._copy_pix_hover = self._copy_pix_done = (
        None
    )
    self.update()

sizeHint()

Compute a size hint driven by QLabel style data from the style JSON.

For variants with auto-size (e.g. badge / pill), the size is derived from font-metrics and the auto-size-padding factor. For variants with an explicit padding list (e.g. entity-label), the size is padded accordingly. When an icon is present the icon dimensions are added. When wordWrap is True and the widget already has a non-zero width, the height is recomputed from the wrapped text bounds at that width; otherwise the natural single-line size is returned and the layout refines the height via :meth:heightForWidth. In all other cases the base QLabel.sizeHint() is returned.

Returns:

Type Description
QSize

The recommended widget size.

Source code in client/ayon_core/ui/components/label.py
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
def sizeHint(self) -> QSize:
    """Compute a size hint driven by QLabel style data from the style JSON.

    For variants with ``auto-size`` (e.g. badge / pill), the size is
    derived from font-metrics and the ``auto-size-padding`` factor.
    For variants with an explicit ``padding`` list (e.g. entity-label),
    the size is padded accordingly.
    When an icon is present the icon dimensions are added.
    When ``wordWrap`` is True and the widget already has a non-zero width,
    the height is recomputed from the wrapped text bounds at that width;
    otherwise the natural single-line size is returned and the layout
    refines the height via :meth:`heightForWidth`.
    In all other cases the base ``QLabel.sizeHint()`` is returned.

    Returns:
        The recommended widget size.
    """
    fm = self.fontMetrics()

    # --- text size --------------------------------------------------
    if self._text:
        t_rect = fm.boundingRect(self._text)
        # Cover the text's *advance* - what elidedText() and
        # drawText() measure with - or a label handed exactly its
        # own hint would elide text it just asked the room for.
        # boundingRect() is the ink extent, wider only for glyphs
        # that overhang their advance, so keep whichever is larger.
        text_w = max(
            fm.horizontalAdvance(self._text), t_rect.width()
        ) + 1  # +1 pixel for antialiasing
        text_h = t_rect.height()
    else:
        text_w = 0
        text_h = fm.height()

    # --- icon size --------------------------------------------------
    icon_w = icon_h = 0
    if self._icon:
        icon_w = self._icon_size
        icon_h = self._icon_size

    # --- variant-specific sizing ------------------------------------
    if self._style_data["base"].get("auto-size"):
        # badge / pill: padding is expressed as a fraction of the
        # character metrics (x-factor of avgCharWidth, y-factor of height)
        padding = self._style_data["base"].get(
            "auto-size-padding", [0.0, 0.0]
        )
        pad_x = int(fm.averageCharWidth() * padding[0])
        pad_y = int(fm.height() * padding[1])

        content_w = max(text_w, icon_w)
        content_h = max(text_h, icon_h)

        return QSize(content_w + pad_x, content_h + pad_y)

    explicit_padding = self._style_data["base"].get("padding", [0, 0])
    pad_h = int(explicit_padding[0])
    pad_v = int(explicit_padding[1])

    if icon_w and text_w:
        spacing = int(
            self._style_data["base"].get(
                "icon-text-spacing", self._icon_text_spacing
            )
        )
        content_w = icon_w + spacing + text_w
        content_h = max(text_h, icon_h)
        # print(
        #     f"{self._text!r}: {content_w + 2 * pad_h} x "
        #     f"{content_h + 2 * pad_v}"
        # )
    elif icon_w:
        content_w = icon_w
        content_h = icon_h
    else:
        content_w = text_w
        content_h = text_h

    # --- word-wrap sizing -------------------------------------------
    # When word-wrap is on and the widget already occupies a real width,
    # recompute the height for that width so the hint stays accurate after
    # the first layout pass.  Layouts will also call heightForWidth() for
    # further refinement.
    if self.wordWrap() and self._text and not self._icon:
        available_w = self.width() - 2 * pad_h
        if available_w > 0:
            flags = int(Qt.TextFlag.TextWordWrap) | int(self.alignment())
            wrap_rect = fm.boundingRect(
                QRect(0, 0, available_w, 0),
                flags,
                self._text,
            )
            return QSize(
                content_w + 2 * pad_h,
                wrap_rect.height() + 2 * pad_v,
            )

    cm = self.contentsMargins()

    return QSize(
        content_w + 2 * pad_h + cm.left() + cm.right(),
        content_h + 2 * pad_v + cm.top() + cm.bottom(),
    )

AYLineEdit

Bases: StyleMixin, QLineEdit

Custom styled line edit component.

Inherits from QLineEdit and uses the AYON style system for rendering. Paints its own background, border, and focus ring using ayon_style.json data, then calls super().paintEvent() to draw text, cursor, and selection on top.

Parameters:

Name Type Description Default
parent QWidget | None

Parent widget.

None
placeholder str

Placeholder text to display when empty.

''
variant QLineEditVariants

Visual style variant.

Default
name_id str

Object name for identification.

''
Source code in client/ayon_core/ui/components/line_edit.py
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
class AYLineEdit(StyleMixin, QLineEdit):
    """Custom styled line edit component.

    Inherits from QLineEdit and uses the AYON style system for rendering.
    Paints its own background, border, and focus ring using ayon_style.json
    data, then calls super().paintEvent() to draw text, cursor, and
    selection on top.

    Args:
        parent: Parent widget.
        placeholder: Placeholder text to display when empty.
        variant: Visual style variant.
        name_id: Object name for identification.
    """

    Variants = QLineEditVariants

    def __init__(
        self,
        parent: QWidget | None = None,
        placeholder: str = "",
        variant: QLineEditVariants = QLineEditVariants.Default,
        name_id: str = "",
    ) -> None:
        super().__init__(parent)

        self._variant_str = variant.value
        self._pal: QPalette | None = None
        self._variant_styles = {}

        if placeholder:
            self.setPlaceholderText(placeholder)

        if name_id:
            self.setObjectName(name_id)

        # Suppress the native Qt frame so initStyleOption reports lineWidth=0
        self.setFrame(False)

        # Neutralise any ancestor stylesheet that would intercept
        # PE_PanelLineEdit via QStyleSheetStyle, making it visually
        # transparent.
        self.setStyleSheet(
            "AYLineEdit { background: transparent; border: none; "
            "padding: 0px; selection-background-color: none; "
            "selection-color: none; }"
        )

        # Suppress macOS native focus ring (we draw our own)
        self.setAttribute(Qt.WidgetAttribute.WA_MacShowFocusRect, False)

        # Enable hover events so underMouse() is reliable during paintEvent
        self.setAttribute(Qt.WidgetAttribute.WA_Hover, True)

        self._apply_style_palette()

        #  this must be called after the palette has been set for it to stick.
        self.setStyle(get_ayon_style())

    @property
    def ayon_palette(self) -> QPalette:
        """Return the palette used for this widget."""
        if self._pal is None:
            self._apply_style_palette()
        return self._pal

    def variant_style(self, state=None) -> dict:
        """Return the style dict for the current variant."""
        key = (self._variant_str, state)
        if key not in self._variant_styles:
            model = get_ayon_style().model
            self._variant_styles[key] = model.get_style(
                "QLineEdit", variant=self._variant_str, state=state
            )
            self._variant_styles[key].set_context(self)
        return self._variant_styles[key]

    def _apply_style_palette(self) -> None:
        """Push text / placeholder colors and padding from ayon_style.json."""
        style = self.variant_style()

        self._pal = self.palette()

        text_color = QColor(style.get("color", "#ffffff"))
        self._pal.setColor(QPalette.ColorRole.Text, text_color)
        self._pal.setColor(QPalette.ColorRole.BrightText, text_color)

        ph_color = QColor(style.get("placeholder-color", "#888888"))
        self._pal.setColor(QPalette.ColorRole.PlaceholderText, ph_color)

        self._pal.setColor(
            QPalette.ColorRole.Highlight,
            QColor(style.get("selection-background-color", "#4040dd")),
        )
        self._pal.setColor(
            QPalette.ColorRole.HighlightedText,
            QColor(style.get("selection-color", "#ffffff")),
        )

        # Transparent base so the background rect we draw is visible
        self._pal.setColor(QPalette.ColorRole.Base, QColor(0, 0, 0, 0))

        self.setPalette(self._pal)

        # Apply padding as text margins (immune to QSS interception)
        padding = style.get("padding", [8, 4])
        pad_h = padding[0]
        pad_v = padding[1]
        icon_width = 0
        if style.get("icon"):
            icon_width = style.get("icon-size", 16) + style.get(
                "icon-padding", 8
            )
        self.setTextMargins(pad_h + icon_width, pad_v, pad_h, pad_v)

    def initStyleOption(self, option: QStyleOptionFrame) -> None:
        """Override the palette used by the style to paint the widget."""
        self.setPalette(self.ayon_palette)
        super().initStyleOption(option)

    def paintEvent(self, event: QPaintEvent) -> None:
        """Paint background, border, and focus ring, then delegate text
        rendering.

        Draws the styled background rectangle and border first using QPainter
        directly (no style or stylesheet involvement), then calls
        super().paintEvent() which renders the text, cursor, and selection
        highlight on top.  Qt's PE_PanelLineEdit call inside that base
        implementation is intercepted by LineEditDrawer which returns
        immediately for AYLineEdit instances, so our background is preserved.
        """

        is_disabled = not self.isEnabled()
        is_hover = self.underMouse()
        has_focus = self.hasFocus()

        if is_disabled:
            state = "disabled"
        elif is_hover and not has_focus:
            state = "hover"
        else:
            state = "base"

        style = self.variant_style(state)

        bg_color = QColor(style.get("background-color", "#272d35"))
        border_color = QColor(style.get("border-color", "#41474d"))
        border_width = style.get("border-width", 1)
        border_radius = style.get("border-radius", 2)
        opacity = style.get("opacity", 1.0)

        focus_outline_width = style.get("focus-outline-width", 2)
        focus_outline_color = QColor(
            style.get("focus-outline-color", "#8fceff")
        )

        painter = QPainter(self)
        painter.setRenderHint(QPainter.RenderHint.Antialiasing)
        painter.setOpacity(opacity)
        painter.setFont(self.font())

        rect = QRectF(self.rect())
        half_bw = border_width / 2.0

        # Background
        bg_rect = rect.adjusted(half_bw, half_bw, -half_bw, -half_bw)
        painter.setPen(Qt.PenStyle.NoPen)
        painter.setBrush(QBrush(bg_color))
        painter.drawRoundedRect(bg_rect, border_radius, border_radius)

        # icon
        icon_name = style.get("icon")
        if icon_name:
            icon_color = QColor(style.get("icon-color", "#888888"))
            icon_size = style.get("icon-size", 16)
            x = style.get("padding", [8, 4])[0]
            pixmap = get_icon(icon_name, color=icon_color).pixmap(
                icon_size, icon_size
            )
            y = (rect.height() - icon_size) / 2.0
            painter.drawPixmap(x, y, icon_size, icon_size, pixmap)

        # Border
        border_pen = QPen(border_color)
        border_pen.setWidthF(border_width)
        painter.setPen(border_pen)
        painter.setBrush(Qt.BrushStyle.NoBrush)
        painter.drawRoundedRect(bg_rect, border_radius, border_radius)

        # Focus ring (drawn inset so it fits within the widget rect)
        if has_focus:
            half_fw = focus_outline_width / 2.0
            focus_rect = rect.adjusted(half_fw, half_fw, -half_fw, -half_fw)
            focus_pen = QPen(focus_outline_color)
            focus_pen.setWidthF(focus_outline_width)
            painter.setPen(focus_pen)
            painter.setBrush(Qt.BrushStyle.NoBrush)
            painter.drawRoundedRect(focus_rect, border_radius, border_radius)

        painter.end()

        # Let QLineEdit draw text, placeholder, cursor, and selection on top.
        # LineEditDrawer intercepts PE_PanelLineEdit for AYLineEdit and is a
        # no-op, so the background we just drew is preserved.
        super().paintEvent(event)

    def sizeHint(self) -> QSize:
        """Override sizeHint to account for padding."""
        size = super().sizeHint()
        style = self.variant_style()
        padding = style.get("padding", [8, 4])
        size.setWidth(size.width() + padding[0] * 2)
        size.setHeight(size.height() + padding[1] * 2)
        return size

ayon_palette property

Return the palette used for this widget.

initStyleOption(option)

Override the palette used by the style to paint the widget.

Source code in client/ayon_core/ui/components/line_edit.py
134
135
136
137
def initStyleOption(self, option: QStyleOptionFrame) -> None:
    """Override the palette used by the style to paint the widget."""
    self.setPalette(self.ayon_palette)
    super().initStyleOption(option)

paintEvent(event)

Paint background, border, and focus ring, then delegate text rendering.

Draws the styled background rectangle and border first using QPainter directly (no style or stylesheet involvement), then calls super().paintEvent() which renders the text, cursor, and selection highlight on top. Qt's PE_PanelLineEdit call inside that base implementation is intercepted by LineEditDrawer which returns immediately for AYLineEdit instances, so our background is preserved.

Source code in client/ayon_core/ui/components/line_edit.py
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
def paintEvent(self, event: QPaintEvent) -> None:
    """Paint background, border, and focus ring, then delegate text
    rendering.

    Draws the styled background rectangle and border first using QPainter
    directly (no style or stylesheet involvement), then calls
    super().paintEvent() which renders the text, cursor, and selection
    highlight on top.  Qt's PE_PanelLineEdit call inside that base
    implementation is intercepted by LineEditDrawer which returns
    immediately for AYLineEdit instances, so our background is preserved.
    """

    is_disabled = not self.isEnabled()
    is_hover = self.underMouse()
    has_focus = self.hasFocus()

    if is_disabled:
        state = "disabled"
    elif is_hover and not has_focus:
        state = "hover"
    else:
        state = "base"

    style = self.variant_style(state)

    bg_color = QColor(style.get("background-color", "#272d35"))
    border_color = QColor(style.get("border-color", "#41474d"))
    border_width = style.get("border-width", 1)
    border_radius = style.get("border-radius", 2)
    opacity = style.get("opacity", 1.0)

    focus_outline_width = style.get("focus-outline-width", 2)
    focus_outline_color = QColor(
        style.get("focus-outline-color", "#8fceff")
    )

    painter = QPainter(self)
    painter.setRenderHint(QPainter.RenderHint.Antialiasing)
    painter.setOpacity(opacity)
    painter.setFont(self.font())

    rect = QRectF(self.rect())
    half_bw = border_width / 2.0

    # Background
    bg_rect = rect.adjusted(half_bw, half_bw, -half_bw, -half_bw)
    painter.setPen(Qt.PenStyle.NoPen)
    painter.setBrush(QBrush(bg_color))
    painter.drawRoundedRect(bg_rect, border_radius, border_radius)

    # icon
    icon_name = style.get("icon")
    if icon_name:
        icon_color = QColor(style.get("icon-color", "#888888"))
        icon_size = style.get("icon-size", 16)
        x = style.get("padding", [8, 4])[0]
        pixmap = get_icon(icon_name, color=icon_color).pixmap(
            icon_size, icon_size
        )
        y = (rect.height() - icon_size) / 2.0
        painter.drawPixmap(x, y, icon_size, icon_size, pixmap)

    # Border
    border_pen = QPen(border_color)
    border_pen.setWidthF(border_width)
    painter.setPen(border_pen)
    painter.setBrush(Qt.BrushStyle.NoBrush)
    painter.drawRoundedRect(bg_rect, border_radius, border_radius)

    # Focus ring (drawn inset so it fits within the widget rect)
    if has_focus:
        half_fw = focus_outline_width / 2.0
        focus_rect = rect.adjusted(half_fw, half_fw, -half_fw, -half_fw)
        focus_pen = QPen(focus_outline_color)
        focus_pen.setWidthF(focus_outline_width)
        painter.setPen(focus_pen)
        painter.setBrush(Qt.BrushStyle.NoBrush)
        painter.drawRoundedRect(focus_rect, border_radius, border_radius)

    painter.end()

    # Let QLineEdit draw text, placeholder, cursor, and selection on top.
    # LineEditDrawer intercepts PE_PanelLineEdit for AYLineEdit and is a
    # no-op, so the background we just drew is preserved.
    super().paintEvent(event)

sizeHint()

Override sizeHint to account for padding.

Source code in client/ayon_core/ui/components/line_edit.py
225
226
227
228
229
230
231
232
def sizeHint(self) -> QSize:
    """Override sizeHint to account for padding."""
    size = super().sizeHint()
    style = self.variant_style()
    padding = style.get("padding", [8, 4])
    size.setWidth(size.width() + padding[0] * 2)
    size.setHeight(size.height() + padding[1] * 2)
    return size

variant_style(state=None)

Return the style dict for the current variant.

Source code in client/ayon_core/ui/components/line_edit.py
85
86
87
88
89
90
91
92
93
94
def variant_style(self, state=None) -> dict:
    """Return the style dict for the current variant."""
    key = (self._variant_str, state)
    if key not in self._variant_styles:
        model = get_ayon_style().model
        self._variant_styles[key] = model.get_style(
            "QLineEdit", variant=self._variant_str, state=state
        )
        self._variant_styles[key].set_context(self)
    return self._variant_styles[key]

AYMenu

Bases: QMenu

QMenu that paints and measures itself using the AYON style.

Replicates :meth:QMenu.paintEvent but routes every primitive and control draw call through :func:get_ayon_style, so the menu is painted consistently with the rest of the AYON UI.

The style is also assigned to the widget, because painting alone is not enough: Qt asks widget.style() for CT_MenuItem when it lays the rows out. Leaving that to whatever style the host application happens to install means rows are measured by one style and painted by another - AYON's own icon gutter and padding are never accounted for, and longer entries end up clipped inside a row that was sized for something else.

Source code in client/ayon_core/ui/components/option_action.py
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
class AYMenu(QtWidgets.QMenu):
    """QMenu that paints and measures itself using the AYON style.

    Replicates :meth:`QMenu.paintEvent` but routes every primitive and
    control draw call through :func:`get_ayon_style`, so the menu is
    painted consistently with the rest of the AYON UI.

    The style is also assigned to the widget, because painting alone is
    not enough: Qt asks ``widget.style()`` for ``CT_MenuItem`` when it
    lays the rows out.  Leaving that to whatever style the host
    application happens to install means rows are measured by one style
    and painted by another - AYON's own icon gutter and padding are never
    accounted for, and longer entries end up clipped inside a row that
    was sized for something else.
    """

    def __init__(self, *args, **kwargs) -> None:
        super().__init__(*args, **kwargs)
        style = get_ayon_style()
        # Force the menu to use AYONStyle for drawing, even if the app's style
        # is something else.
        self.setStyle(style)
        # Apply eagerly, rather than leaving it to the first time the menu
        # is shown to avoid changing WindowFlags after the menu is already
        # open which recreates the native window and can cause crashes.
        style.style_widget(self)

    def actionEvent(self, arg__1: QtGui.QActionEvent) -> None:
        """Keep the rows wide enough for how the AYON style paints them.

        Qt does not measure the rows with the AYON style when a parent
        widget (or the application) has a stylesheet: the style of the
        menu is then wrapped in a ``QStyleSheetStyle`` that measures them
        using the stylesheet's ``QMenu::item`` rules.  Those rows are
        narrower than what ``paintEvent`` paints, so the label runs into
        the submenu arrow.  Qt has no hook to measure the rows ourselves,
        but it does stretch them to the minimum width of the menu.
        """
        super().actionEvent(arg__1)
        ayon_style = get_ayon_style()
        width = 0
        for action in self.actions():
            if (
                action.isSeparator()
                or not action.isVisible()
                or isinstance(action, QtWidgets.QWidgetAction)
            ):
                continue
            opt = QtWidgets.QStyleOptionMenuItem()
            self.initStyleOption(opt, action)
            size = ayon_style.sizeFromContents(
                QtWidgets.QStyle.ContentsType.CT_MenuItem,
                opt,
                QtCore.QSize(0, 0),
                self,
            )
            width = max(width, size.width())

        # Space around the rows, from the style Qt lays them out with
        pm = QtWidgets.QStyle.PixelMetric
        width += 2 * (
            self.style().pixelMetric(pm.PM_MenuPanelWidth, None, self)
            + self.style().pixelMetric(pm.PM_MenuHMargin, None, self)
        )
        self.setMinimumWidth(width)

    def paintEvent(self, arg__1: QtGui.QPaintEvent) -> None:
        """Paint the menu using AYON's QStyle implementation.

        Mirrors Qt's own ``QMenu::paintEvent`` order:
        1. Draw the menu panel background (``PE_PanelMenu``).
        2. Draw each visible action row (``CE_MenuItem``).
        3. Draw the menu frame on top (``PE_FrameMenu``).

        Args:
            arg__1: The paint event delivered by Qt.
        """
        style = get_ayon_style()
        painter = QtGui.QPainter(self)

        # --- Shared base option (used for panel + frame) ---
        menu_opt = QtWidgets.QStyleOptionMenuItem()
        menu_opt.initFrom(self)
        menu_opt.state = QtWidgets.QStyle.StateFlag.State_None
        menu_opt.checkType = (
            QtWidgets.QStyleOptionMenuItem.CheckType.NotCheckable
        )
        menu_opt.maxIconWidth = 0
        try:
            menu_opt.reservedShortcutWidth = 0
        except AttributeError:
            # Older Qt versions expose tabWidth instead.
            menu_opt.tabWidth = 0
        menu_opt.rect = self.rect()
        menu_opt.menuRect = self.rect()

        # --- 1. Panel background ---
        style.drawPrimitive(
            QtWidgets.QStyle.PrimitiveElement.PE_PanelMenu,
            menu_opt,
            painter,
            self,
        )

        # --- 2. Action rows ---
        event_region = arg__1.region()
        for action in self.actions():
            action_rect = self.actionGeometry(action)
            if not event_region.intersects(action_rect):
                continue

            opt = QtWidgets.QStyleOptionMenuItem()
            self.initStyleOption(opt, action)
            opt.rect = action_rect
            if action is self.activeAction():
                opt.state |= QtWidgets.QStyle.StateFlag.State_Selected

            style.drawControl(
                QtWidgets.QStyle.ControlElement.CE_MenuItem,
                opt,
                painter,
                self,
            )

        # --- 3. Frame on top ---
        frame_width = style.pixelMetric(
            QtWidgets.QStyle.PixelMetric.PM_MenuPanelWidth, menu_opt, self
        )
        if frame_width > 0:
            frame_opt = QtWidgets.QStyleOptionFrame()
            frame_opt.initFrom(self)
            frame_opt.rect = self.rect()
            frame_opt.state = QtWidgets.QStyle.StateFlag.State_None
            frame_opt.lineWidth = frame_width
            frame_opt.midLineWidth = 0
            style.drawPrimitive(
                QtWidgets.QStyle.PrimitiveElement.PE_FrameMenu,
                frame_opt,
                painter,
                self,
            )

        painter.end()

actionEvent(arg__1)

Keep the rows wide enough for how the AYON style paints them.

Qt does not measure the rows with the AYON style when a parent widget (or the application) has a stylesheet: the style of the menu is then wrapped in a QStyleSheetStyle that measures them using the stylesheet's QMenu::item rules. Those rows are narrower than what paintEvent paints, so the label runs into the submenu arrow. Qt has no hook to measure the rows ourselves, but it does stretch them to the minimum width of the menu.

Source code in client/ayon_core/ui/components/option_action.py
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
def actionEvent(self, arg__1: QtGui.QActionEvent) -> None:
    """Keep the rows wide enough for how the AYON style paints them.

    Qt does not measure the rows with the AYON style when a parent
    widget (or the application) has a stylesheet: the style of the
    menu is then wrapped in a ``QStyleSheetStyle`` that measures them
    using the stylesheet's ``QMenu::item`` rules.  Those rows are
    narrower than what ``paintEvent`` paints, so the label runs into
    the submenu arrow.  Qt has no hook to measure the rows ourselves,
    but it does stretch them to the minimum width of the menu.
    """
    super().actionEvent(arg__1)
    ayon_style = get_ayon_style()
    width = 0
    for action in self.actions():
        if (
            action.isSeparator()
            or not action.isVisible()
            or isinstance(action, QtWidgets.QWidgetAction)
        ):
            continue
        opt = QtWidgets.QStyleOptionMenuItem()
        self.initStyleOption(opt, action)
        size = ayon_style.sizeFromContents(
            QtWidgets.QStyle.ContentsType.CT_MenuItem,
            opt,
            QtCore.QSize(0, 0),
            self,
        )
        width = max(width, size.width())

    # Space around the rows, from the style Qt lays them out with
    pm = QtWidgets.QStyle.PixelMetric
    width += 2 * (
        self.style().pixelMetric(pm.PM_MenuPanelWidth, None, self)
        + self.style().pixelMetric(pm.PM_MenuHMargin, None, self)
    )
    self.setMinimumWidth(width)

paintEvent(arg__1)

Paint the menu using AYON's QStyle implementation.

Mirrors Qt's own QMenu::paintEvent order: 1. Draw the menu panel background (PE_PanelMenu). 2. Draw each visible action row (CE_MenuItem). 3. Draw the menu frame on top (PE_FrameMenu).

Parameters:

Name Type Description Default
arg__1 QPaintEvent

The paint event delivered by Qt.

required
Source code in client/ayon_core/ui/components/option_action.py
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
def paintEvent(self, arg__1: QtGui.QPaintEvent) -> None:
    """Paint the menu using AYON's QStyle implementation.

    Mirrors Qt's own ``QMenu::paintEvent`` order:
    1. Draw the menu panel background (``PE_PanelMenu``).
    2. Draw each visible action row (``CE_MenuItem``).
    3. Draw the menu frame on top (``PE_FrameMenu``).

    Args:
        arg__1: The paint event delivered by Qt.
    """
    style = get_ayon_style()
    painter = QtGui.QPainter(self)

    # --- Shared base option (used for panel + frame) ---
    menu_opt = QtWidgets.QStyleOptionMenuItem()
    menu_opt.initFrom(self)
    menu_opt.state = QtWidgets.QStyle.StateFlag.State_None
    menu_opt.checkType = (
        QtWidgets.QStyleOptionMenuItem.CheckType.NotCheckable
    )
    menu_opt.maxIconWidth = 0
    try:
        menu_opt.reservedShortcutWidth = 0
    except AttributeError:
        # Older Qt versions expose tabWidth instead.
        menu_opt.tabWidth = 0
    menu_opt.rect = self.rect()
    menu_opt.menuRect = self.rect()

    # --- 1. Panel background ---
    style.drawPrimitive(
        QtWidgets.QStyle.PrimitiveElement.PE_PanelMenu,
        menu_opt,
        painter,
        self,
    )

    # --- 2. Action rows ---
    event_region = arg__1.region()
    for action in self.actions():
        action_rect = self.actionGeometry(action)
        if not event_region.intersects(action_rect):
            continue

        opt = QtWidgets.QStyleOptionMenuItem()
        self.initStyleOption(opt, action)
        opt.rect = action_rect
        if action is self.activeAction():
            opt.state |= QtWidgets.QStyle.StateFlag.State_Selected

        style.drawControl(
            QtWidgets.QStyle.ControlElement.CE_MenuItem,
            opt,
            painter,
            self,
        )

    # --- 3. Frame on top ---
    frame_width = style.pixelMetric(
        QtWidgets.QStyle.PixelMetric.PM_MenuPanelWidth, menu_opt, self
    )
    if frame_width > 0:
        frame_opt = QtWidgets.QStyleOptionFrame()
        frame_opt.initFrom(self)
        frame_opt.rect = self.rect()
        frame_opt.state = QtWidgets.QStyle.StateFlag.State_None
        frame_opt.lineWidth = frame_width
        frame_opt.midLineWidth = 0
        style.drawPrimitive(
            QtWidgets.QStyle.PrimitiveElement.PE_FrameMenu,
            frame_opt,
            painter,
            self,
        )

    painter.end()

AYOptionBox

Bases: AYButton

Option box widget used as the right-hand button in an action row.

Emits :attr:clicked when the user presses this button. It is a standard :class:AYButton styled with the Optional_Action variant.

Source code in client/ayon_core/ui/components/option_action.py
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
class AYOptionBox(AYButton):
    """Option box widget used as the right-hand button in an action row.

    Emits :attr:`clicked` when the user presses this button.  It is a
    standard :class:`AYButton` styled with the ``Optional_Action``
    variant.
    """

    def __init__(
        self,
        icon_name: str = "check_box_outline_blank",
        icon_size: int = 16,
        parent: QtWidgets.QWidget | None = None,
    ) -> None:
        super().__init__(
            parent,
            variant=AYButton.Variants.Optional_Action,
            icon=icon_name,
            icon_size=icon_size,
            fixed_width=False,
        )

    def is_hovered(self, global_pos: QtCore.QPoint) -> bool:
        """Return whether a global cursor position is inside the button."""
        pos = self.mapFromGlobal(global_pos)
        if isinstance(pos, QtCore.QPointF):
            pos = pos.toPoint()
        return self.rect().contains(pos)

is_hovered(global_pos)

Return whether a global cursor position is inside the button.

Source code in client/ayon_core/ui/components/option_action.py
53
54
55
56
57
58
def is_hovered(self, global_pos: QtCore.QPoint) -> bool:
    """Return whether a global cursor position is inside the button."""
    pos = self.mapFromGlobal(global_pos)
    if isinstance(pos, QtCore.QPointF):
        pos = pos.toPoint()
    return self.rect().contains(pos)

AYOptionalAction

Bases: QWidgetAction

Menu action with an optional right-hand option box button.

Subclasses :class:QtWidgets.QWidgetAction to embed a custom :class:AYOptionalActionWidget inside a standard QMenu.

Set use_option=True to show the option box and connect to :attr:option_clicked for the secondary action.

Parameters:

Name Type Description Default
label str

Display text.

required
icon_name str | QIcon | None

Material symbol name, resolved icon, or "none".

'none'
use_option bool

Whether to show the option box button.

True
parent QWidget | None

Parent widget (typically the owning menu).

None
Source code in client/ayon_core/ui/components/option_action.py
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
class AYOptionalAction(QtWidgets.QWidgetAction):
    """Menu action with an optional right-hand option box button.

    Subclasses :class:`QtWidgets.QWidgetAction` to embed a custom
    :class:`AYOptionalActionWidget` inside a standard ``QMenu``.

    Set ``use_option=True`` to show the option box and connect to
    :attr:`option_clicked` for the secondary action.

    Args:
        label: Display text.
        icon_name: Material symbol name, resolved icon, or ``"none"``.
        use_option: Whether to show the option box button.
        parent: Parent widget (typically the owning menu).
    """

    option_clicked = QtCore.Signal()

    def __init__(
        self,
        label: str,
        icon_name: str | QtGui.QIcon | None = "none",
        use_option: bool = True,
        parent: QtWidgets.QWidget | None = None,
    ) -> None:
        super().__init__(parent)
        self._label = label
        self._icon_name = icon_name or "none"
        self._use_option = use_option
        self.widget: AYOptionalActionWidget | None = None
        self.setText(label)

    def createWidget(self, parent: QtWidgets.QWidget) -> QtWidgets.QWidget:
        """Instantiate and configure the custom action row widget.

        Called by Qt when the action is added to a menu.

        Args:
            parent: The menu widget that will own the row widget.

        Returns:
            The newly created :class:`AYOptionalActionWidget`.
        """
        widget = AYOptionalActionWidget(
            self._label,
            icon_name=self._icon_name,
            use_option=self._use_option,
            parent=parent,
        )
        widget.setEnabled(self.isEnabled())
        widget.set_shortcut_text(self.shortcut().toString())
        self.widget = widget

        return widget

    def _close_menu_chain(self) -> None:
        """Close the menu (and any parent menus) hosting this action."""
        w = self.widget
        while w is not None:
            if isinstance(w, QtWidgets.QMenu):
                w.close()
            w = w.parentWidget()

    def set_highlight(
        self,
        highlighted: bool,
        global_pos: QtCore.QPoint | None = None,
    ) -> None:
        """Synchronize hover styling for legacy optional menus."""
        if self.widget is not None:
            self.widget.set_highlight(highlighted, global_pos)

    def is_option_hovered(self, global_pos: QtCore.QPoint) -> bool:
        """Return whether *global_pos* is inside the option-box region."""
        return bool(
            self._use_option
            and self.widget is not None
            and self.widget.is_option_hovered(global_pos)
        )

    def trigger_option(self) -> None:
        """Emit the secondary action and close its menu hierarchy."""
        self.option_clicked.emit()
        self._close_menu_chain()

createWidget(parent)

Instantiate and configure the custom action row widget.

Called by Qt when the action is added to a menu.

Parameters:

Name Type Description Default
parent QWidget

The menu widget that will own the row widget.

required

Returns:

Type Description
QWidget

The newly created :class:AYOptionalActionWidget.

Source code in client/ayon_core/ui/components/option_action.py
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
def createWidget(self, parent: QtWidgets.QWidget) -> QtWidgets.QWidget:
    """Instantiate and configure the custom action row widget.

    Called by Qt when the action is added to a menu.

    Args:
        parent: The menu widget that will own the row widget.

    Returns:
        The newly created :class:`AYOptionalActionWidget`.
    """
    widget = AYOptionalActionWidget(
        self._label,
        icon_name=self._icon_name,
        use_option=self._use_option,
        parent=parent,
    )
    widget.setEnabled(self.isEnabled())
    widget.set_shortcut_text(self.shortcut().toString())
    self.widget = widget

    return widget

is_option_hovered(global_pos)

Return whether global_pos is inside the option-box region.

Source code in client/ayon_core/ui/components/option_action.py
317
318
319
320
321
322
323
def is_option_hovered(self, global_pos: QtCore.QPoint) -> bool:
    """Return whether *global_pos* is inside the option-box region."""
    return bool(
        self._use_option
        and self.widget is not None
        and self.widget.is_option_hovered(global_pos)
    )

set_highlight(highlighted, global_pos=None)

Synchronize hover styling for legacy optional menus.

Source code in client/ayon_core/ui/components/option_action.py
308
309
310
311
312
313
314
315
def set_highlight(
    self,
    highlighted: bool,
    global_pos: QtCore.QPoint | None = None,
) -> None:
    """Synchronize hover styling for legacy optional menus."""
    if self.widget is not None:
        self.widget.set_highlight(highlighted, global_pos)

trigger_option()

Emit the secondary action and close its menu hierarchy.

Source code in client/ayon_core/ui/components/option_action.py
325
326
327
328
def trigger_option(self) -> None:
    """Emit the secondary action and close its menu hierarchy."""
    self.option_clicked.emit()
    self._close_menu_chain()

AYOptionalActionWidget

Bases: QWidget

Self-painted menu row with main and option action hit regions.

Parameters:

Name Type Description Default
label str

Display text for the action.

required
icon_name str | QIcon

Material symbol name or an already resolved icon.

'none'
use_option bool

Whether to show the option action.

True
parent QWidget | None

Optional parent widget.

None
Source code in client/ayon_core/ui/components/option_action.py
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
class AYOptionalActionWidget(QtWidgets.QWidget):
    """Self-painted menu row with main and option action hit regions.

    Args:
        label: Display text for the action.
        icon_name: Material symbol name or an already resolved icon.
        use_option: Whether to show the option action.
        parent: Optional parent widget.
    """

    def __init__(
        self,
        label: str,
        icon_name: str | QtGui.QIcon = "none",
        use_option: bool = True,
        parent: QtWidgets.QWidget | None = None,
    ) -> None:
        super().__init__(parent)
        self._label = label
        self._icon_name = icon_name
        self._use_option = use_option
        self._shortcut_text = ""
        self._hovered = False
        self._option_hovered = False
        self.setMouseTracking(True)
        self.setAttribute(
            QtCore.Qt.WidgetAttribute.WA_TransparentForMouseEvents
        )

    def set_shortcut_text(self, shortcut_text: str) -> None:
        self._shortcut_text = shortcut_text
        self.updateGeometry()
        self.update()

    def _menu_style(self, state: str = "base") -> StyleDict:
        style = get_ayon_style().model.get_style("QMenu", state=state)
        style.set_context(self.parentWidget())
        return style

    def _option_rect(self) -> QtCore.QRect:
        if not self._use_option:
            return QtCore.QRect()
        row_height = self.height()
        return QtCore.QRect(
            self.width() - row_height,
            0,
            row_height,
            row_height,
        )

    def _set_row_hover(self, hovered: bool) -> None:
        if self._hovered != hovered:
            self._hovered = hovered
            self.update()

    def _resolved_icon(self) -> QtGui.QIcon:
        if isinstance(self._icon_name, QtGui.QIcon):
            return QtGui.QIcon(self._icon_name)
        if not self._icon_name or self._icon_name == "none":
            return QtGui.QIcon()
        color = QtGui.QColor(
            self._menu_style().get("color", "#f4f5f5")
        )
        return get_icon(self._icon_name, color=color.name())

    def _style_option(self) -> QtWidgets.QStyleOptionMenuItem:
        option = QtWidgets.QStyleOptionMenuItem()
        option.initFrom(self)
        option.font = self.font()
        option.menuItemType = (
            QtWidgets.QStyleOptionMenuItem.MenuItemType.Normal
        )
        option.checkType = (
            QtWidgets.QStyleOptionMenuItem.CheckType.NotCheckable
        )
        option.text = self._label
        if self._shortcut_text:
            option.text += f"\t{self._shortcut_text}"
        option.icon = self._resolved_icon()
        option.maxIconWidth = int(
            self._menu_style().get("icon-size", 16)
        )
        if self._hovered:
            option.state |= (
                QtWidgets.QStyle.StateFlag.State_Selected
            )
        return option

    def sizeHint(self) -> QtCore.QSize:
        option = self._style_option()
        size = get_ayon_style().sizeFromContents(
            QtWidgets.QStyle.ContentsType.CT_MenuItem,
            option,
            QtCore.QSize(),
            self.parentWidget(),
        )
        if self._use_option:
            size.setWidth(size.width() + size.height())
        return size

    def paintEvent(self, _event: QtGui.QPaintEvent) -> None:
        option_rect = self._option_rect()
        menu_rect = self.rect()
        if option_rect.isValid():
            menu_rect.setRight(option_rect.left() - 1)

        option = self._style_option()
        option.rect = menu_rect
        painter = QtGui.QPainter(self)
        get_ayon_style().drawControl(
            QtWidgets.QStyle.ControlElement.CE_MenuItem,
            option,
            painter,
            self.parentWidget(),
        )

        if option_rect.isValid():
            state = "hover" if self._hovered else "base"
            style = self._menu_style(state)
            if self._option_hovered:
                painter.fillRect(
                    option_rect,
                    QtGui.QColor(
                        style.get("shortcut-background-color", "#353b46")
                    ),
                )
            icon_size = int(style.get("icon-size", 16))
            color = QtGui.QColor(style.get("color", "#f4f5f5"))
            option_icon = get_icon(
                "check_box_outline_blank",
                color=color.name(),
            )
            option_icon.paint(
                painter,
                QtCore.QRect(
                    option_rect.center().x() - icon_size // 2,
                    option_rect.center().y() - icon_size // 2,
                    icon_size,
                    icon_size,
                ),
            )
        painter.end()

    def enterEvent(self, event: QtCore.QEvent) -> None:
        self._set_row_hover(True)
        super().enterEvent(event)

    def leaveEvent(self, event: QtCore.QEvent) -> None:
        self._set_row_hover(False)
        self._option_hovered = False
        super().leaveEvent(event)

    def mouseMoveEvent(self, event: QtGui.QMouseEvent) -> None:
        option_hovered = self._option_rect().contains(event.pos())
        if self._option_hovered != option_hovered:
            self._option_hovered = option_hovered
            self.update()
        super().mouseMoveEvent(event)

    def is_option_hovered(self, global_pos: QtCore.QPoint) -> bool:
        local_pos = self.mapFromGlobal(global_pos)
        return self._option_rect().contains(local_pos)

    def set_highlight(
        self,
        highlighted: bool,
        global_pos: QtCore.QPoint | None = None,
    ) -> None:
        """Update row and option-box hover from the owning menu."""
        option_hovered = bool(
            highlighted
            and global_pos is not None
            and self.is_option_hovered(global_pos)
        )
        changed = (
            self._hovered != highlighted
            or self._option_hovered != option_hovered
        )
        self._hovered = highlighted
        self._option_hovered = option_hovered
        if changed:
            self.update()

set_highlight(highlighted, global_pos=None)

Update row and option-box hover from the owning menu.

Source code in client/ayon_core/ui/components/option_action.py
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
def set_highlight(
    self,
    highlighted: bool,
    global_pos: QtCore.QPoint | None = None,
) -> None:
    """Update row and option-box hover from the owning menu."""
    option_hovered = bool(
        highlighted
        and global_pos is not None
        and self.is_option_hovered(global_pos)
    )
    changed = (
        self._hovered != highlighted
        or self._option_hovered != option_hovered
    )
    self._hovered = highlighted
    self._option_hovered = option_hovered
    if changed:
        self.update()

AYOptionalMenu

Bases: AYMenu

AYON-styled menu that supports optional-action widget rows.

Source code in client/ayon_core/ui/components/option_action.py
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
class AYOptionalMenu(AYMenu):
    """AYON-styled menu that supports optional-action widget rows."""

    def __init__(self, *args, **kwargs) -> None:
        super().__init__(*args, **kwargs)
        self.setMouseTracking(True)
        self.hovered.connect(self._apply_action_hover)
        self.aboutToHide.connect(self._clear_action_highlights)

    @staticmethod
    def _event_global_pos(event: QtGui.QMouseEvent) -> QtCore.QPoint:
        try:
            return event.globalPosition().toPoint()
        except AttributeError:
            return event.globalPos()

    def mouseMoveEvent(self, event: QtGui.QMouseEvent) -> None:
        """Update option-box hover while preserving native menu behavior."""
        super().mouseMoveEvent(event)
        hovered_action = self.actionAt(event.pos())
        global_pos = self._event_global_pos(event)
        for action in self.actions():
            if isinstance(action, AYOptionalAction):
                action.set_highlight(
                    action is hovered_action,
                    global_pos,
                )

    def mouseReleaseEvent(self, event: QtGui.QMouseEvent) -> None:
        """Handle option-box clicks and delegate main clicks to Qt."""
        action = self.actionAt(event.pos())
        if (
            event.button() == QtCore.Qt.MouseButton.LeftButton
            and isinstance(action, AYOptionalAction)
            and action.is_option_hovered(self._event_global_pos(event))
        ):
            action.trigger_option()
            event.accept()
            return
        super().mouseReleaseEvent(event)

    def _apply_action_hover(
        self,
        hovered_action: QtWidgets.QAction,
    ) -> None:
        for action in self.actions():
            if isinstance(action, AYOptionalAction):
                action.set_highlight(action is hovered_action)
        self.update()

    def _clear_action_highlights(self) -> None:
        for action in self.actions():
            if isinstance(action, AYOptionalAction):
                action.set_highlight(False)

mouseMoveEvent(event)

Update option-box hover while preserving native menu behavior.

Source code in client/ayon_core/ui/components/option_action.py
492
493
494
495
496
497
498
499
500
501
502
def mouseMoveEvent(self, event: QtGui.QMouseEvent) -> None:
    """Update option-box hover while preserving native menu behavior."""
    super().mouseMoveEvent(event)
    hovered_action = self.actionAt(event.pos())
    global_pos = self._event_global_pos(event)
    for action in self.actions():
        if isinstance(action, AYOptionalAction):
            action.set_highlight(
                action is hovered_action,
                global_pos,
            )

mouseReleaseEvent(event)

Handle option-box clicks and delegate main clicks to Qt.

Source code in client/ayon_core/ui/components/option_action.py
504
505
506
507
508
509
510
511
512
513
514
515
def mouseReleaseEvent(self, event: QtGui.QMouseEvent) -> None:
    """Handle option-box clicks and delegate main clicks to Qt."""
    action = self.actionAt(event.pos())
    if (
        event.button() == QtCore.Qt.MouseButton.LeftButton
        and isinstance(action, AYOptionalAction)
        and action.is_option_hovered(self._event_global_pos(event))
    ):
        action.trigger_option()
        event.accept()
        return
    super().mouseReleaseEvent(event)

AYProgressBar

Bases: StyleMixin, QWidget

Determinate and indeterminate progress bar in the AYON style.

The bar is empty while maximum is 0, and indeterminate (a sweeping chunk) while value or maximum is negative.

By default the percentage is drawn right of the track. Its space is reserved also while indeterminate, so the track does not change width when the text appears or disappears. Pass show_value=False for a bar without text.

Parameters:

Name Type Description Default
parent QWidget | None

Parent widget.

None
maximum int

Value that represents a full bar.

0
variant AYProgressBarVariants

Visual style variant.

Default
show_value bool

Whether the percentage is drawn next to the track.

True
Signals

value_changed (int): Current value. maximum_changed (int): Current maximum.

Source code in client/ayon_core/ui/components/progress_bar.py
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
class AYProgressBar(StyleMixin, QWidget):
    """Determinate and indeterminate progress bar in the AYON style.

    The bar is empty while ``maximum`` is ``0``, and indeterminate (a
    sweeping chunk) while ``value`` or ``maximum`` is negative.

    By default the percentage is drawn right of the track. Its space is
    reserved also while indeterminate, so the track does not change width
    when the text appears or disappears. Pass ``show_value=False`` for a
    bar without text.

    Args:
        parent: Parent widget.
        maximum: Value that represents a full bar.
        variant: Visual style variant.
        show_value: Whether the percentage is drawn next to the track.

    Signals:
        value_changed (int): Current value.
        maximum_changed (int): Current maximum.

    """
    value_changed = Signal(int)
    maximum_changed = Signal(int)

    #: Style token used to colour the chunk for each semantic state.
    _STATE_COLOR_KEY = {
        ProgressBarState.Normal: "chunk-color",
        ProgressBarState.Success: "success-color",
        ProgressBarState.Warning: "warning-color",
        ProgressBarState.Error: "failed-color",
    }

    #: Milliseconds for the indeterminate chunk to cross the whole bar.
    _SWEEP_DURATION_MS = 1200

    def __init__(
        self,
        parent: QWidget | None = None,
        *,
        maximum: int = 0,
        variant: AYProgressBarVariants = AYProgressBarVariants.Default,
        show_value: bool = True,
    ) -> None:
        super().__init__(parent)
        self._variant_str = variant.value
        self._style_data = StyleDict()

        self._value: int = 0
        self._maximum: int = maximum
        self._state: ProgressBarState = ProgressBarState.Normal
        self._show_value: bool = show_value

        # Offset of the indeterminate sweep animation, not progress data.
        self._sweep = 0.0

        self.setSizePolicy(
            QSizePolicy.Policy.Expanding,
            QSizePolicy.Policy.Fixed,
        )

        _style = get_ayon_style()
        self.setStyle(_style)
        self._style_data = _style.model.get_styles(
            "AYProgressBar", variant=self._variant_str
        )
        self._style_data.set_context(self)

        # The sweep animation only runs while indeterminate AND visible.
        self._timer = QTimer(self)
        self._timer.setInterval(self._animation_interval())
        self._timer.timeout.connect(self._advance_sweep)

    def value(self) -> int:
        """Return the current value."""
        return self._value

    def maximum(self) -> int:
        """Return the value that represents a full bar."""
        return self._maximum

    def set_value(self, value: int) -> None:
        """Set the current value, negative makes the bar indeterminate."""
        if value == self._value:
            return
        self._value = value
        self._update_animation()
        self.update()
        self.value_changed.emit(self._value)

    def set_maximum(self, value: int) -> None:
        """Set the full-bar value, negative makes the bar indeterminate."""
        if value == self._maximum:
            return
        self._maximum = value
        self._update_animation()
        self.update()
        self.maximum_changed.emit(self._maximum)

    def state(self) -> ProgressBarState:
        """Return the semantic state used to colour the chunk."""
        return self._state

    def set_state(self, state: ProgressBarState) -> None:
        """Set the semantic state used to colour the chunk."""
        if state == self._state:
            return
        self._state = state
        self.update()

    def is_indeterminate(self) -> bool:
        """True while the progress cannot be shown as a fraction."""
        return self._value < 0 or self._maximum < 0

    def is_animating(self) -> bool:
        """True while the indeterminate sweep timer is running."""
        return self._timer.isActive()

    def percentage(self) -> float:
        """Return the completion percentage in the ``0.0``-``100.0`` range."""
        if self.is_indeterminate() or self._maximum == 0:
            return 0.0
        fraction = self._value / self._maximum
        return max(0.0, min(fraction, 1.0)) * 100.0

    def text(self) -> str:
        """Return the percentage text, empty while indeterminate."""
        if self.is_indeterminate():
            return ""
        return f"{self.percentage():.0f}%"

    def show_value(self) -> bool:
        """Return whether the percentage is drawn next to the track."""
        return self._show_value

    def set_show_value(self, show_value: bool) -> None:
        """Set whether the percentage is drawn next to the track."""
        if show_value == self._show_value:
            return
        self._show_value = show_value
        self.updateGeometry()
        self.update()

    def _base_style(self) -> StyleDict:
        """Return the colours and geometry shared by every state.

        ``base`` is the only dict carrying the visual properties; the
        per-state dicts (``disabled``) hold deltas such as ``opacity``
        and must not replace it.

        Returns:
            The style tokens for the widget's base appearance.
        """
        return self._style_data.get("base") or StyleDict()

    def _opacity(self) -> float:
        """Return the opacity for the current state.

        The ``disabled`` state only overrides ``opacity``, so it is read
        separately from the base colours and applied in ``paintEvent``.
        """
        if self.isEnabled():
            return 1.0
        disabled = self._style_data.get("disabled")
        if disabled is None:
            return 1.0
        try:
            return float(disabled.get("opacity", 1.0))
        except (TypeError, ValueError):
            return 1.0

    def _animation_interval(self) -> int:
        """Return the sweep timer interval in milliseconds."""
        value = self._base_style().get("animation-interval-ms", 16)
        try:
            return max(1, int(value))
        except (TypeError, ValueError):
            return 16

    def _advance_sweep(self) -> None:
        """Advance the indeterminate sweep and repaint."""
        step = self._timer.interval() / self._SWEEP_DURATION_MS
        self._sweep = (self._sweep + step) % 1.0
        self.update()

    def _update_animation(self) -> None:
        """Start the sweep timer while indeterminate and visible."""
        if self.is_indeterminate() and self.isVisible():
            if not self._timer.isActive():
                self._sweep = 0.0
                self._timer.start()
        else:
            self._timer.stop()

    # --- painting
    def _chunk_color(self, style: StyleDict) -> QColor:
        """Return the chunk colour for the current semantic state."""
        key = self._STATE_COLOR_KEY.get(self.state(), "chunk-color")
        color = style.get(key) or style.get("chunk-color", "#8fceff")
        return QColor(color)

    def _value_width(self, style: StyleDict) -> int:
        """Return the width reserved for the percentage text."""
        if not self._show_value:
            return 0
        text_width = self.fontMetrics().horizontalAdvance("100%")
        return max(int(style.get("value-min-width", 40)), text_width)

    def _value_space(self, style: StyleDict) -> int:
        """Return the total width taken by the text and its spacing."""
        value_width = self._value_width(style)
        if not value_width:
            return 0
        return value_width + int(style.get("value-spacing", 6))

    def _draw_value(self, painter: QPainter, style: StyleDict) -> None:
        """Draw the percentage right-aligned at the right edge.

        Args:
            painter: Painter configured for this widget.
            style: Style tokens for the current state.
        """
        text = self.text()
        if not self._show_value or not text:
            return
        margin = float(style.get("margin", 0))
        value_width = self._value_width(style)
        rect = QRectF(
            self.width() - margin - value_width,
            0,
            value_width,
            self.height(),
        )
        # Same colour as a dim ``AYLabel``.
        painter.setPen(
            self.palette().color(QPalette.ColorRole.PlaceholderText)
        )
        painter.setFont(self.font())
        painter.drawText(
            rect,
            int(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter),
            text,
        )

    def _draw_bar(self, painter: QPainter, style: StyleDict) -> None:
        """Draw the track and the filled or sweeping chunk.

        Args:
            painter: Painter configured for this widget.
            style: Style tokens for the current state.
        """
        height = float(style.get("height", 6))
        margin = float(style.get("margin", 0))
        width = self.width() - margin * 2 - self._value_space(style)
        if width <= 0 or height <= 0:
            return

        radius = min(float(style.get("radius", height / 2.0)), height / 2.0)
        top = (self.height() - height) / 2.0
        track = QRectF(margin, top, width, height)

        painter.setPen(Qt.PenStyle.NoPen)
        painter.setBrush(QColor(style.get("track-color", "#41474d")))
        painter.drawRoundedRect(track, radius, radius)

        if self.is_indeterminate():
            chunk_w = width * float(style.get("sweep-ratio", 0.3))
            x = track.x() - chunk_w + self._sweep * (width + chunk_w)
            chunk = QRectF(x, top, chunk_w, height)
            painter.save()
            painter.setClipRect(track)
            painter.setBrush(self._chunk_color(style))
            painter.drawRoundedRect(chunk, radius, radius)
            painter.restore()
        elif self._value > 0:
            chunk_w = width * (self.percentage() / 100.0)
            chunk = QRectF(track.x(), top, chunk_w, height)
            painter.setBrush(self._chunk_color(style))
            painter.drawRoundedRect(chunk, radius, radius)

    def paintEvent(self, event: QPaintEvent) -> None:
        """Paint the track, the chunk, and the optional percentage.

        Descriptive text, such as a caption, belongs to the owner.

        Args:
            event: The paint event; the whole widget is repainted.
        """
        style = self._base_style()
        painter = QPainter(self)
        painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
        painter.setOpacity(self._opacity())
        self._draw_bar(painter, style)
        self._draw_value(painter, style)
        painter.end()

    def sizeHint(self) -> QSize:
        """Return the preferred size derived from the style height."""
        style = self._base_style()
        height = int(style.get("height", 6))
        if self._show_value:
            height = max(height, self.fontMetrics().height())
        return QSize(200 + self._value_space(style), height)

    def minimumSizeHint(self) -> QSize:
        """Return the minimum size derived from the style height."""
        style = self._base_style()
        return QSize(
            40 + self._value_space(style), self.sizeHint().height()
        )

    def showEvent(self, event: QShowEvent) -> None:
        super().showEvent(event)
        self._update_animation()

    def hideEvent(self, event: QHideEvent) -> None:
        self._timer.stop()
        super().hideEvent(event)

is_animating()

True while the indeterminate sweep timer is running.

Source code in client/ayon_core/ui/components/progress_bar.py
146
147
148
def is_animating(self) -> bool:
    """True while the indeterminate sweep timer is running."""
    return self._timer.isActive()

is_indeterminate()

True while the progress cannot be shown as a fraction.

Source code in client/ayon_core/ui/components/progress_bar.py
142
143
144
def is_indeterminate(self) -> bool:
    """True while the progress cannot be shown as a fraction."""
    return self._value < 0 or self._maximum < 0

maximum()

Return the value that represents a full bar.

Source code in client/ayon_core/ui/components/progress_bar.py
109
110
111
def maximum(self) -> int:
    """Return the value that represents a full bar."""
    return self._maximum

minimumSizeHint()

Return the minimum size derived from the style height.

Source code in client/ayon_core/ui/components/progress_bar.py
336
337
338
339
340
341
def minimumSizeHint(self) -> QSize:
    """Return the minimum size derived from the style height."""
    style = self._base_style()
    return QSize(
        40 + self._value_space(style), self.sizeHint().height()
    )

paintEvent(event)

Paint the track, the chunk, and the optional percentage.

Descriptive text, such as a caption, belongs to the owner.

Parameters:

Name Type Description Default
event QPaintEvent

The paint event; the whole widget is repainted.

required
Source code in client/ayon_core/ui/components/progress_bar.py
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
def paintEvent(self, event: QPaintEvent) -> None:
    """Paint the track, the chunk, and the optional percentage.

    Descriptive text, such as a caption, belongs to the owner.

    Args:
        event: The paint event; the whole widget is repainted.
    """
    style = self._base_style()
    painter = QPainter(self)
    painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
    painter.setOpacity(self._opacity())
    self._draw_bar(painter, style)
    self._draw_value(painter, style)
    painter.end()

percentage()

Return the completion percentage in the 0.0-100.0 range.

Source code in client/ayon_core/ui/components/progress_bar.py
150
151
152
153
154
155
def percentage(self) -> float:
    """Return the completion percentage in the ``0.0``-``100.0`` range."""
    if self.is_indeterminate() or self._maximum == 0:
        return 0.0
    fraction = self._value / self._maximum
    return max(0.0, min(fraction, 1.0)) * 100.0

set_maximum(value)

Set the full-bar value, negative makes the bar indeterminate.

Source code in client/ayon_core/ui/components/progress_bar.py
122
123
124
125
126
127
128
129
def set_maximum(self, value: int) -> None:
    """Set the full-bar value, negative makes the bar indeterminate."""
    if value == self._maximum:
        return
    self._maximum = value
    self._update_animation()
    self.update()
    self.maximum_changed.emit(self._maximum)

set_show_value(show_value)

Set whether the percentage is drawn next to the track.

Source code in client/ayon_core/ui/components/progress_bar.py
167
168
169
170
171
172
173
def set_show_value(self, show_value: bool) -> None:
    """Set whether the percentage is drawn next to the track."""
    if show_value == self._show_value:
        return
    self._show_value = show_value
    self.updateGeometry()
    self.update()

set_state(state)

Set the semantic state used to colour the chunk.

Source code in client/ayon_core/ui/components/progress_bar.py
135
136
137
138
139
140
def set_state(self, state: ProgressBarState) -> None:
    """Set the semantic state used to colour the chunk."""
    if state == self._state:
        return
    self._state = state
    self.update()

set_value(value)

Set the current value, negative makes the bar indeterminate.

Source code in client/ayon_core/ui/components/progress_bar.py
113
114
115
116
117
118
119
120
def set_value(self, value: int) -> None:
    """Set the current value, negative makes the bar indeterminate."""
    if value == self._value:
        return
    self._value = value
    self._update_animation()
    self.update()
    self.value_changed.emit(self._value)

show_value()

Return whether the percentage is drawn next to the track.

Source code in client/ayon_core/ui/components/progress_bar.py
163
164
165
def show_value(self) -> bool:
    """Return whether the percentage is drawn next to the track."""
    return self._show_value

sizeHint()

Return the preferred size derived from the style height.

Source code in client/ayon_core/ui/components/progress_bar.py
328
329
330
331
332
333
334
def sizeHint(self) -> QSize:
    """Return the preferred size derived from the style height."""
    style = self._base_style()
    height = int(style.get("height", 6))
    if self._show_value:
        height = max(height, self.fontMetrics().height())
    return QSize(200 + self._value_space(style), height)

state()

Return the semantic state used to colour the chunk.

Source code in client/ayon_core/ui/components/progress_bar.py
131
132
133
def state(self) -> ProgressBarState:
    """Return the semantic state used to colour the chunk."""
    return self._state

text()

Return the percentage text, empty while indeterminate.

Source code in client/ayon_core/ui/components/progress_bar.py
157
158
159
160
161
def text(self) -> str:
    """Return the percentage text, empty while indeterminate."""
    if self.is_indeterminate():
        return ""
    return f"{self.percentage():.0f}%"

value()

Return the current value.

Source code in client/ayon_core/ui/components/progress_bar.py
105
106
107
def value(self) -> int:
    """Return the current value."""
    return self._value

AYSearchableComboBox

Bases: StyleMixin, QWidget

AYON-styled line-edit that opens a searchable dropdown on click.

The :class:AYLineEdit acts as the search input — typing filters the dropdown items in real-time with no second search field inside the popup.

This component is designed to be generic and extensible. Applications can customize row rendering with :meth:set_items_with_builder or access existing rows through :meth:get_rows.

Signals

item_selected: Emitted with the chosen item key string.

Parameters:

Name Type Description Default
placeholder str

Hint text shown when the field is empty.

'Search...'
parent QWidget | None

Optional parent widget.

None
Source code in client/ayon_core/ui/components/searchable_combo_box.py
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
class AYSearchableComboBox(StyleMixin, QWidget):
    """AYON-styled line-edit that opens a searchable dropdown on click.

    The :class:`AYLineEdit` acts as the search input — typing filters the
    dropdown items in real-time with no second search field inside the popup.

    This component is designed to be generic and extensible. Applications can
    customize row rendering with :meth:`set_items_with_builder` or access
    existing rows through :meth:`get_rows`.

    Signals:
        item_selected: Emitted with the chosen item key string.

    Args:
        placeholder: Hint text shown when the field is empty.
        parent: Optional parent widget.
    """

    item_selected = Signal(str)

    def __init__(
        self,
        placeholder: str = "Search...",
        parent: QWidget | None = None,
    ) -> None:
        super().__init__(parent)
        self.setStyle(get_ayon_style())

        self._line_edit = AYLineEdit(placeholder=placeholder, parent=self)
        layout = AYVBoxLayout(self, margin=0, spacing=0)
        layout.addWidget(self._line_edit)

        self._dropdown = _SearchableDropdown(parent=self)
        self._dropdown.item_selected.connect(self._on_item_selected)
        self._dropdown.hide()

        _orig_press = self._line_edit.mousePressEvent

        def _on_press(event):
            _orig_press(event)
            self._show_dropdown()

        self._line_edit.mousePressEvent = _on_press

        self._line_edit.textChanged.connect(self._on_text_changed)

    def set_items(self, items: list[dict[str, str] | str]) -> None:
        """Set the list of selectable items shown in the dropdown.

        Args:
            items: A list of dicts with ``key`` and ``label`` keys, or
                plain strings (used as both key and label).
        """
        self._dropdown.set_items(items)

    def get_rows(self) -> list[tuple[str, str, "QWidget"]]:
        """Return the list of all item rows.

        Returns:
            List of (key, label, row_widget) tuples for custom widget
                manipulation.
        """
        return self._dropdown.all_items

    def set_items_with_builder(
        self,
        items: list[dict[str, str] | str],
        row_builder=None,
    ) -> None:
        """Set items and optionally apply a custom row builder function.

        This method provides a convenient way to customize row rendering
        without manually calling :meth:`get_rows` and iterating.

        Args:
            items: A list of dicts with ``key`` and ``label`` keys, or
                plain strings (used as both key and label).
            row_builder: Optional callable that receives (row, entry) and
                adds custom widgets to the row. Called for each item that
                has metadata beyond basic key/label.

        Example::

            def customize_row(row, entry):
                if "extra_data" in entry:
                    label = AYLabel(entry["extra_data"], dim=True)
                    row.add_custom_widget(label)

            items = [
                {"key": "k1", "label": "Item 1", "extra_data": "Extra"},
                {"key": "k2", "label": "Item 2", "extra_data": "More"},
            ]
            combo.set_items_with_builder(items, row_builder=customize_row)
        """
        # Set items normally
        self.set_items(items)

        # Apply custom builder if provided
        if row_builder is None:
            return

        # Create a dict mapping keys to items for quick lookup
        items_by_key = {
            item["key"]: item for item in items if isinstance(item, dict)
        }

        # Apply builder to rows that have metadata
        for key, label, row in self.get_rows():
            if key in items_by_key:
                row_builder(row, items_by_key[key])

    def clear(self) -> None:
        """Clear the line-edit and reset the dropdown filter."""
        self._line_edit.clear()
        self._dropdown.filter_items("")

    def _show_dropdown(self) -> None:
        if not self._dropdown.all_items:
            return
        self._dropdown.setFixedWidth(self.width())
        self._dropdown.show_below(self, y_offset=2)
        self._dropdown.raise_()

    def _on_text_changed(self, text: str) -> None:
        self._dropdown.filter_items(text)
        if not self._dropdown.isVisible():
            self._show_dropdown()

    def _on_item_selected(self, key: str) -> None:
        self._line_edit.clear()
        self._dropdown.filter_items("")
        self.item_selected.emit(key)

clear()

Clear the line-edit and reset the dropdown filter.

Source code in client/ayon_core/ui/components/searchable_combo_box.py
338
339
340
341
def clear(self) -> None:
    """Clear the line-edit and reset the dropdown filter."""
    self._line_edit.clear()
    self._dropdown.filter_items("")

get_rows()

Return the list of all item rows.

Returns:

Type Description
list[tuple[str, str, 'QWidget']]

List of (key, label, row_widget) tuples for custom widget manipulation.

Source code in client/ayon_core/ui/components/searchable_combo_box.py
282
283
284
285
286
287
288
289
def get_rows(self) -> list[tuple[str, str, "QWidget"]]:
    """Return the list of all item rows.

    Returns:
        List of (key, label, row_widget) tuples for custom widget
            manipulation.
    """
    return self._dropdown.all_items

set_items(items)

Set the list of selectable items shown in the dropdown.

Parameters:

Name Type Description Default
items list[dict[str, str] | str]

A list of dicts with key and label keys, or plain strings (used as both key and label).

required
Source code in client/ayon_core/ui/components/searchable_combo_box.py
273
274
275
276
277
278
279
280
def set_items(self, items: list[dict[str, str] | str]) -> None:
    """Set the list of selectable items shown in the dropdown.

    Args:
        items: A list of dicts with ``key`` and ``label`` keys, or
            plain strings (used as both key and label).
    """
    self._dropdown.set_items(items)

set_items_with_builder(items, row_builder=None)

Set items and optionally apply a custom row builder function.

This method provides a convenient way to customize row rendering without manually calling :meth:get_rows and iterating.

Parameters:

Name Type Description Default
items list[dict[str, str] | str]

A list of dicts with key and label keys, or plain strings (used as both key and label).

required
row_builder

Optional callable that receives (row, entry) and adds custom widgets to the row. Called for each item that has metadata beyond basic key/label.

None

Example::

def customize_row(row, entry):
    if "extra_data" in entry:
        label = AYLabel(entry["extra_data"], dim=True)
        row.add_custom_widget(label)

items = [
    {"key": "k1", "label": "Item 1", "extra_data": "Extra"},
    {"key": "k2", "label": "Item 2", "extra_data": "More"},
]
combo.set_items_with_builder(items, row_builder=customize_row)
Source code in client/ayon_core/ui/components/searchable_combo_box.py
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
def set_items_with_builder(
    self,
    items: list[dict[str, str] | str],
    row_builder=None,
) -> None:
    """Set items and optionally apply a custom row builder function.

    This method provides a convenient way to customize row rendering
    without manually calling :meth:`get_rows` and iterating.

    Args:
        items: A list of dicts with ``key`` and ``label`` keys, or
            plain strings (used as both key and label).
        row_builder: Optional callable that receives (row, entry) and
            adds custom widgets to the row. Called for each item that
            has metadata beyond basic key/label.

    Example::

        def customize_row(row, entry):
            if "extra_data" in entry:
                label = AYLabel(entry["extra_data"], dim=True)
                row.add_custom_widget(label)

        items = [
            {"key": "k1", "label": "Item 1", "extra_data": "Extra"},
            {"key": "k2", "label": "Item 2", "extra_data": "More"},
        ]
        combo.set_items_with_builder(items, row_builder=customize_row)
    """
    # Set items normally
    self.set_items(items)

    # Apply custom builder if provided
    if row_builder is None:
        return

    # Create a dict mapping keys to items for quick lookup
    items_by_key = {
        item["key"]: item for item in items if isinstance(item, dict)
    }

    # Apply builder to rows that have metadata
    for key, label, row in self.get_rows():
        if key in items_by_key:
            row_builder(row, items_by_key[key])

AYSpinBox

Bases: StyleMixin, QSpinBox

Custom styled spin box component.

Inherits from QSpinBox and uses the AYON style system for rendering. Paints its own background, border, focus ring, and arrow buttons using ayon_style.json data.

Parameters:

Name Type Description Default
parent QWidget | None

Parent widget.

None
variant QSpinBoxVariants

Visual style variant.

Default
name_id str

Object name for identification.

''
minimum int

Minimum value.

0
maximum int

Maximum value.

99
value int

Initial value.

0
Source code in client/ayon_core/ui/components/spinbox.py
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
class AYSpinBox(StyleMixin, QSpinBox):
    """Custom styled spin box component.

    Inherits from QSpinBox and uses the AYON style system for rendering.
    Paints its own background, border, focus ring, and arrow buttons using
    ayon_style.json data.

    Args:
        parent: Parent widget.
        variant: Visual style variant.
        name_id: Object name for identification.
        minimum: Minimum value.
        maximum: Maximum value.
        value: Initial value.
    """

    Variants = QSpinBoxVariants

    def __init__(
        self,
        parent: QWidget | None = None,
        variant: QSpinBoxVariants = QSpinBoxVariants.Default,
        name_id: str = "",
        minimum: int = 0,
        maximum: int = 99,
        value: int = 0,
    ) -> None:
        super().__init__(parent)

        self._variant_str = variant.value
        self._pal: QPalette | None = None
        self._variant_styles = {}

        if name_id:
            self.setObjectName(name_id)

        # Set range and value
        self.setMinimum(minimum)
        self.setMaximum(maximum)
        self.setValue(value)

        # Suppress the native Qt frame
        self.setFrame(False)
        # Suppress macOS native focus ring (we draw our own)
        self.setAttribute(Qt.WidgetAttribute.WA_MacShowFocusRect, False)

        # Enable hover events
        self.setAttribute(Qt.WidgetAttribute.WA_Hover, True)

        self._apply_style_palette()

        self.setStyle(get_ayon_style())

    @property
    def ayon_palette(self) -> QPalette:
        """Return the palette used for this widget."""
        if self._pal is None:
            self._apply_style_palette()
        return self._pal

    def variant_style(self, state=None) -> dict:
        """Return the style dict for the current variant."""
        key = (self._variant_str, state)
        if key not in self._variant_styles:
            model = get_ayon_style().model
            self._variant_styles[key] = model.get_style(
                "QSpinBox", variant=self._variant_str, state=state
            )
            self._variant_styles[key].set_context(self)
        return self._variant_styles[key]

    def _apply_style_palette(self) -> None:
        """Push text colors and padding from ayon_style.json."""
        style = self.variant_style()

        self._pal = self.palette()

        text_color = QColor(style.get("color", "#D3D8DE"))
        self._pal.setColor(QPalette.ColorRole.Text, text_color)
        self._pal.setColor(QPalette.ColorRole.BrightText, text_color)

        self._pal.setColor(
            QPalette.ColorRole.Highlight,
            QColor(style.get("selection-background-color", "#5CADDD")),
        )
        self._pal.setColor(
            QPalette.ColorRole.HighlightedText,
            QColor(style.get("selection-color", "#ffffff")),
        )

        # Transparent base so the background rect we draw is visible
        self._pal.setColor(QPalette.ColorRole.Base, QColor(0, 0, 0, 0))

        self.setPalette(self._pal)

    def initStyleOption(self, option: QStyleOptionSpinBox) -> None:
        """Override the palette used by the style to paint the widget."""
        self.setPalette(self.ayon_palette)
        super().initStyleOption(option)

    def paintEvent(self, event: QPaintEvent) -> None:
        """Paint background, border, focus ring, and arrow buttons.

        Draws the styled background rectangle, border, and custom arrow
        buttons using QPainter directly, then calls super().paintEvent()
        which renders the text, cursor, and selection on top.
        """
        opt = QStyleOptionSpinBox()
        self.initStyleOption(opt)

        is_disabled = not self.isEnabled()
        is_hover = self.underMouse()
        has_focus = self.hasFocus()

        if is_disabled:
            state = "disabled"
        elif is_hover and not has_focus:
            state = "hover"
        else:
            state = "base"

        style = self.variant_style(state)
        # NOTE A lot of styling of this component is wrong. The spinbox input
        #   should be drawn using internal QLineEdit, which has its own
        #   styling.
        #   The button does not respect "real" button dimensions. The
        #   arrows are defined by internal logic of QSpinBox and style, we
        #   can't just define where it is drawn we also have to handle mouse
        #   hit collisions. That can be affected only by QStyle.

        bg_color = QColor(style.get("background-color", "#21252B"))
        border_color = QColor(style.get("border-color", "#373D48"))
        border_width = style.get("border-width", 1)
        border_radius = style.get("border-radius", 3)
        opacity = style.get("opacity", 1.0)

        focus_outline_width = style.get("focus-outline-width", 2)
        focus_outline_color = QColor(
            style.get("focus-outline-color", "#5CADDD")
        )

        # Button styling
        arrow_color = QColor(style.get("arrow-color", "#D3D8DE"))
        # button_width = style.get("button-width", 16)
        # padding = style.get("padding", [4, 4])

        painter = QPainter(self)
        painter.setRenderHint(QPainter.RenderHint.Antialiasing)
        painter.setOpacity(opacity)
        painter.setFont(self.font())

        rect = QRectF(self.rect())

        if has_focus:
            half_bw = focus_outline_width * 0.5
            border_color = focus_outline_color
        else:
            half_bw = border_width * 0.5
            border_color = border_color

        # Background
        bg_rect = rect.adjusted(half_bw, half_bw, -half_bw, -half_bw)
        border_pen = QPen(border_color)
        border_pen.setWidthF(border_width)
        painter.setPen(border_pen)
        painter.setBrush(QBrush(bg_color))
        painter.drawRoundedRect(bg_rect, border_radius, border_radius)

        # Draw buttons and arrows
        self._draw_buttons(
            painter,
            rect,
            opt,
            border_color,
            arrow_color,
            border_width,
            is_disabled,
        )

        painter.end()

    def _draw_buttons(
        self,
        painter: QPainter,
        rect: QRectF,
        opt: QStyleOptionSpinBox,
        border_color: QColor,
        arrow_color: QColor,
        border_width: float,
        is_disabled: bool,
    ) -> None:
        """Draw the up and down arrow buttons."""
        if opt.buttonSymbols == QAbstractSpinBox.NoButtons:
            return

        # Button container rect (right side of the widget)
        style = self.style()
        up_rect = style.subControlRect(
            QStyle.ComplexControl.CC_SpinBox,
            opt,
            QStyle.SubControl.SC_SpinBoxUp,
            self
        )
        down_rect = style.subControlRect(
            QStyle.ComplexControl.CC_SpinBox,
            opt,
            QStyle.SubControl.SC_SpinBoxDown,
            self
        )
        button_x = int(rect.right() - up_rect.width())

        # Draw vertical separator line
        painter.setPen(QPen(border_color, border_width))
        painter.drawLine(
            button_x, int(rect.top() + border_width) + 1,
            button_x, int(rect.bottom() - border_width) - 1
        )

        # Draw horizontal separator between buttons
        mid = int(rect.height() * 0.5)
        painter.drawLine(
            button_x, mid, int(rect.right()) - 1, mid
        )

        # Draw arrows using Material Symbols icons
        arrow_size = min(12, up_rect.height(), down_rect.height())

        up_icon_rect = QRect(0, 0, arrow_size, arrow_size)
        down_icon_rect = QRect(0, 0, arrow_size, arrow_size)

        up_icon_rect.moveCenter(up_rect.center())
        down_icon_rect.moveCenter(down_rect.center())

        if (
            opt.subControls & QStyle.SubControl.SC_SpinBoxUp
            and opt.activeSubControls == QStyle.SubControl.SC_SpinBoxUp
        ):
            up_icon_rect.moveTop(up_icon_rect.top() - 1)

        if (
            opt.subControls & QStyle.SubControl.SC_SpinBoxDown
            and opt.activeSubControls == QStyle.SubControl.SC_SpinBoxDown
        ):
            down_icon_rect.moveTop(down_icon_rect.top() + 1)

        # Up arrow
        up_icon: QIcon = get_icon(
            "arrow_drop_up",
            color=arrow_color if not is_disabled else "#5b6779"
        )
        up_icon.paint(painter, up_icon_rect)

        # Down arrow
        down_icon: QIcon = get_icon(
            "arrow_drop_down",
            color=arrow_color if not is_disabled else "#5b6779"
        )
        down_icon.paint(painter, down_icon_rect)

    def sizeHint(self) -> QSize:
        """Override sizeHint to account for padding and buttons."""
        size = super().sizeHint()
        style = self.variant_style()
        padding = style.get("padding", [4, 4])
        button_width = style.get("button-width", 18)

        # Add horizontal padding and button width
        size.setWidth(size.width() + padding[0] * 2 + button_width)
        # Ensure minimum height with vertical padding
        size.setHeight(max(size.height() + padding[1] * 2, 28))
        return size

ayon_palette property

Return the palette used for this widget.

initStyleOption(option)

Override the palette used by the style to paint the widget.

Source code in client/ayon_core/ui/components/spinbox.py
119
120
121
122
def initStyleOption(self, option: QStyleOptionSpinBox) -> None:
    """Override the palette used by the style to paint the widget."""
    self.setPalette(self.ayon_palette)
    super().initStyleOption(option)

paintEvent(event)

Paint background, border, focus ring, and arrow buttons.

Draws the styled background rectangle, border, and custom arrow buttons using QPainter directly, then calls super().paintEvent() which renders the text, cursor, and selection on top.

Source code in client/ayon_core/ui/components/spinbox.py
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
def paintEvent(self, event: QPaintEvent) -> None:
    """Paint background, border, focus ring, and arrow buttons.

    Draws the styled background rectangle, border, and custom arrow
    buttons using QPainter directly, then calls super().paintEvent()
    which renders the text, cursor, and selection on top.
    """
    opt = QStyleOptionSpinBox()
    self.initStyleOption(opt)

    is_disabled = not self.isEnabled()
    is_hover = self.underMouse()
    has_focus = self.hasFocus()

    if is_disabled:
        state = "disabled"
    elif is_hover and not has_focus:
        state = "hover"
    else:
        state = "base"

    style = self.variant_style(state)
    # NOTE A lot of styling of this component is wrong. The spinbox input
    #   should be drawn using internal QLineEdit, which has its own
    #   styling.
    #   The button does not respect "real" button dimensions. The
    #   arrows are defined by internal logic of QSpinBox and style, we
    #   can't just define where it is drawn we also have to handle mouse
    #   hit collisions. That can be affected only by QStyle.

    bg_color = QColor(style.get("background-color", "#21252B"))
    border_color = QColor(style.get("border-color", "#373D48"))
    border_width = style.get("border-width", 1)
    border_radius = style.get("border-radius", 3)
    opacity = style.get("opacity", 1.0)

    focus_outline_width = style.get("focus-outline-width", 2)
    focus_outline_color = QColor(
        style.get("focus-outline-color", "#5CADDD")
    )

    # Button styling
    arrow_color = QColor(style.get("arrow-color", "#D3D8DE"))
    # button_width = style.get("button-width", 16)
    # padding = style.get("padding", [4, 4])

    painter = QPainter(self)
    painter.setRenderHint(QPainter.RenderHint.Antialiasing)
    painter.setOpacity(opacity)
    painter.setFont(self.font())

    rect = QRectF(self.rect())

    if has_focus:
        half_bw = focus_outline_width * 0.5
        border_color = focus_outline_color
    else:
        half_bw = border_width * 0.5
        border_color = border_color

    # Background
    bg_rect = rect.adjusted(half_bw, half_bw, -half_bw, -half_bw)
    border_pen = QPen(border_color)
    border_pen.setWidthF(border_width)
    painter.setPen(border_pen)
    painter.setBrush(QBrush(bg_color))
    painter.drawRoundedRect(bg_rect, border_radius, border_radius)

    # Draw buttons and arrows
    self._draw_buttons(
        painter,
        rect,
        opt,
        border_color,
        arrow_color,
        border_width,
        is_disabled,
    )

    painter.end()

sizeHint()

Override sizeHint to account for padding and buttons.

Source code in client/ayon_core/ui/components/spinbox.py
283
284
285
286
287
288
289
290
291
292
293
294
def sizeHint(self) -> QSize:
    """Override sizeHint to account for padding and buttons."""
    size = super().sizeHint()
    style = self.variant_style()
    padding = style.get("padding", [4, 4])
    button_width = style.get("button-width", 18)

    # Add horizontal padding and button width
    size.setWidth(size.width() + padding[0] * 2 + button_width)
    # Ensure minimum height with vertical padding
    size.setHeight(max(size.height() + padding[1] * 2, 28))
    return size

variant_style(state=None)

Return the style dict for the current variant.

Source code in client/ayon_core/ui/components/spinbox.py
84
85
86
87
88
89
90
91
92
93
def variant_style(self, state=None) -> dict:
    """Return the style dict for the current variant."""
    key = (self._variant_str, state)
    if key not in self._variant_styles:
        model = get_ayon_style().model
        self._variant_styles[key] = model.get_style(
            "QSpinBox", variant=self._variant_str, state=state
        )
        self._variant_styles[key].set_context(self)
    return self._variant_styles[key]

AYTextEdit

Bases: StyleMixin, QTextEdit

AYON styled text edit widget.

Overrides Qt's stylesheet painting with AYONStyle custom rendering.

Parameters:

Name Type Description Default
*args

Positional arguments passed to QTextEdit.

()
**kwargs

Keyword arguments passed to QTextEdit.

{}
Source code in client/ayon_core/ui/components/text_edit.py
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
class AYTextEdit(StyleMixin, QTextEdit):
    """AYON styled text edit widget.

    Overrides Qt's stylesheet painting with AYONStyle custom rendering.

    Args:
        *args: Positional arguments passed to QTextEdit.
        **kwargs: Keyword arguments passed to QTextEdit.
    """

    Variants = QTextEditVariants

    def __init__(
        self,
        *args,
        variant: Variants = Variants.Default,
        **kwargs,
    ):
        """Initialize AYTextEdit widget.

        Args:
            *args: Positional arguments passed to QTextEdit.
            variant: Text edit variant.
            **kwargs: Keyword arguments passed to QTextEdit.
        """
        self._variant_str: str = variant.value
        super().__init__(*args, **kwargs)
        self.setStyle(get_ayon_style())
        self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground, True)

        self.setVerticalScrollBar(AYScrollBar(Qt.Orientation.Vertical))
        self.setVerticalScrollBarPolicy(
            Qt.ScrollBarPolicy.ScrollBarAsNeeded
        )
        self.setHorizontalScrollBar(AYScrollBar(Qt.Orientation.Horizontal))
        self.setHorizontalScrollBarPolicy(
            Qt.ScrollBarPolicy.ScrollBarAsNeeded
        )

__init__(*args, variant=Variants.Default, **kwargs)

Initialize AYTextEdit widget.

Parameters:

Name Type Description Default
*args

Positional arguments passed to QTextEdit.

()
variant Variants

Text edit variant.

Default
**kwargs

Keyword arguments passed to QTextEdit.

{}
Source code in client/ayon_core/ui/components/text_edit.py
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
def __init__(
    self,
    *args,
    variant: Variants = Variants.Default,
    **kwargs,
):
    """Initialize AYTextEdit widget.

    Args:
        *args: Positional arguments passed to QTextEdit.
        variant: Text edit variant.
        **kwargs: Keyword arguments passed to QTextEdit.
    """
    self._variant_str: str = variant.value
    super().__init__(*args, **kwargs)
    self.setStyle(get_ayon_style())
    self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground, True)

    self.setVerticalScrollBar(AYScrollBar(Qt.Orientation.Vertical))
    self.setVerticalScrollBarPolicy(
        Qt.ScrollBarPolicy.ScrollBarAsNeeded
    )
    self.setHorizontalScrollBar(AYScrollBar(Qt.Orientation.Horizontal))
    self.setHorizontalScrollBarPolicy(
        Qt.ScrollBarPolicy.ScrollBarAsNeeded
    )

AYTreeView

Bases: StyleMixin, QTreeView

AYON-styled tree view.

Fully self-contained: uses AYONStyle for all painting, a custom item delegate that draws directly bypassing any parent QSS, and AYScrollBar instances for scrollbars.

The view shows an animated skeleton placeholder (AYSkeletonLoader) while its model is loading and has no rows to show yet. A model opts in by exposing a loading_changed = Signal(bool) signal and, optionally, an is_loading() -> bool method. The model is found automatically when set with setModel, also through proxy models. Loading state can be also set manually with set_loading.

Parameters:

Name Type Description Default
parent QWidget | None

Optional parent widget.

None
variant QTreeViewVariants

Visual style variant controlling background colour and item-state colours.

Default
Source code in client/ayon_core/ui/components/tree_view.py
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
class AYTreeView(StyleMixin, QTreeView):
    """AYON-styled tree view.

    Fully self-contained: uses AYONStyle for all painting, a custom
    item delegate that draws directly bypassing any parent QSS, and
    AYScrollBar instances for scrollbars.

    The view shows an animated skeleton placeholder (`AYSkeletonLoader`)
    while its model is loading and has no rows to show yet. A model opts
    in by exposing a ``loading_changed = Signal(bool)`` signal and,
    optionally, an ``is_loading() -> bool`` method. The model is found
    automatically when set with ``setModel``, also through proxy models.
    Loading state can be also set manually with ``set_loading``.

    Args:
        parent: Optional parent widget.
        variant: Visual style variant controlling background colour and
            item-state colours.
    """

    Variants = QTreeViewVariants
    selection_changed = Signal(QItemSelection, QItemSelection)
    double_clicked = Signal(QMouseEvent)

    def __init__(
        self,
        parent: QWidget | None = None,
        variant: QTreeViewVariants = QTreeViewVariants.Default,
    ) -> None:
        self._variant_str: str = variant.value

        super().__init__(parent)

        style = get_ayon_style()

        header = AYHeaderView(
            Qt.Orientation.Horizontal,
            parent=self,
            style_model=style.model,
            variant=self._variant_str,
        )
        self.setHeader(header)

        self.setStyle(style)

        # Self-contained: do not inherit parent background or stylesheet.
        self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground, True)
        self.setAttribute(Qt.WidgetAttribute.WA_Hover, True)
        self.setMouseTracking(True)

        # Viewport must also be opaque with our colour.
        self.viewport().setAttribute(
            Qt.WidgetAttribute.WA_TranslucentBackground, False
        )
        self.viewport().setAttribute(Qt.WidgetAttribute.WA_Hover, True)
        self.viewport().setMouseTracking(True)
        self.viewport().installEventFilter(self)
        self._mouse_pos: QPoint = QPoint(-1, -1)
        self._sync_viewport_palette()

        # Custom item delegate — paints items directly, avoids QSS.
        delegate = TreeViewItemDelegate(
            parent=self,
            style_model=style.model,
            variant=self._variant_str,
        )
        self.setItemDelegate(delegate)

        # Styled scrollbars.
        vsb = AYScrollBar(Qt.Orientation.Vertical, self)
        self.setVerticalScrollBar(vsb)
        self.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
        hsb = AYScrollBar(Qt.Orientation.Horizontal, self)
        self.setHorizontalScrollBar(hsb)
        self.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)

        # No header — single-column hierarchical browser.

        # Indentation from style data.
        tv_style = style.model.get_style("QTreeView", self._variant_str)
        self.setIndentation(int(tv_style.get("indent", 20)))

        # Selection behaviour.
        self.setSelectionMode(QTreeView.SelectionMode.ExtendedSelection)
        self.setSelectionBehavior(QTreeView.SelectionBehavior.SelectRows)

        # No default frame — drawn manually in paintEvent.
        self.setFrameShape(QTreeView.Shape.NoFrame)

        # Loading placeholder, created lazily when first needed.
        self._skeleton: AYSkeletonLoader | None = None
        self._skeleton_delay: int = 80
        self._loading: bool = False
        self._loading_model: QAbstractItemModel | None = None

    def setModel(self, model: QAbstractItemModel | None) -> None:
        """Set model and connect to its loading state if it has one.

        Args:
            model: Model to show, can be a proxy model.
        """
        self._disconnect_loading_model()
        super().setModel(model)
        self._connect_loading_model(model)

    def is_loading(self) -> bool:
        """Whether the view is in loading state.

        Returns:
            bool: True if the view's data are loading.
        """
        return self._loading

    def set_loading(self, loading: bool) -> None:
        """Show animated placeholder rows while data are loading.

        The placeholder shows only when the model has no rows, a refresh
        of visible rows happens in place. Called automatically when
        the model has ``loading_changed`` signal.

        Args:
            loading: Whether the model is loading data.
        """
        self._loading = loading
        self._update_skeleton()

    def set_loading_delay(self, delay: int) -> None:
        """Delay before the loading placeholder appears.

        Loads finishing sooner never show the placeholder.

        Args:
            delay: Delay in milliseconds.
        """
        self._skeleton_delay = delay
        if self._skeleton is not None:
            self._skeleton.set_show_delay(delay)

    def _update_skeleton(self) -> None:
        show = self._loading
        if show:
            model = self._loading_model or self.model()
            show = model is None or model.rowCount() == 0

        if self._skeleton is None:
            if not show:
                return
            self._skeleton = AYSkeletonLoader(
                self,
                variant=QTreeViewVariants(self._variant_str),
                show_delay=self._skeleton_delay,
            )
        self._skeleton.set_loading(show)

    def _on_loading_model_rows_changed(self, *args) -> None:
        # Rows appeared or were removed while loading
        if self._loading:
            self._update_skeleton()

    @staticmethod
    def _find_loading_model(
        model: QAbstractItemModel | None,
    ) -> QAbstractItemModel | None:
        """Find model with 'loading_changed' signal, look through proxies.

        Args:
            model: Model set to the view.

        Returns:
            QAbstractItemModel | None: Model reporting its loading state.
        """
        while model is not None:
            if hasattr(model, "loading_changed"):
                return model
            if not isinstance(model, QAbstractProxyModel):
                break
            model = model.sourceModel()
        return None

    def _connect_loading_model(
        self, model: QAbstractItemModel | None
    ) -> None:
        loading_model = self._find_loading_model(model)
        self._loading_model = loading_model
        if loading_model is None:
            self.set_loading(False)
            return

        loading_model.loading_changed.connect(self.set_loading)
        for signal in self._loading_model_row_signals(loading_model):
            signal.connect(self._on_loading_model_rows_changed)

        is_loading = getattr(loading_model, "is_loading", None)
        self.set_loading(bool(is_loading()) if is_loading else False)

    def _disconnect_loading_model(self) -> None:
        loading_model, self._loading_model = self._loading_model, None
        if loading_model is None:
            return
        try:
            loading_model.loading_changed.disconnect(self.set_loading)
            for signal in self._loading_model_row_signals(loading_model):
                signal.disconnect(self._on_loading_model_rows_changed)
        except (RuntimeError, TypeError):
            # Model was already deleted or signals were not connected
            pass

    @staticmethod
    def _loading_model_row_signals(model: QAbstractItemModel) -> list:
        return [
            model.rowsInserted,
            model.rowsRemoved,
            model.modelReset,
            model.layoutChanged,
        ]

    def _sync_viewport_palette(self) -> None:
        """Apply the variant background colour to the viewport palette."""
        style = get_ayon_style()
        tv_style = style.model.get_style("QTreeView", self._variant_str)
        bg = QColor(tv_style.get("background-color", "#252a31"))
        p = self.viewport().palette()
        p.setColor(QPalette.ColorRole.Base, bg)
        p.setColor(QPalette.ColorRole.Window, bg)
        self.viewport().setPalette(p)

    def paintEvent(self, event: QPaintEvent) -> None:
        """Draw the outer container background before the items.

        Args:
            event: The paint event.
        """
        painter = QPainter(self.viewport())
        painter.setRenderHint(QPainter.RenderHint.Antialiasing, False)
        style = get_ayon_style()
        tv_style = style.model.get_style("QTreeView", self._variant_str)
        bg = QColor(tv_style.get("background-color", "#252a31"))
        painter.fillRect(self.viewport().rect(), bg)
        painter.end()

        # Let QTreeView draw its items on top.
        super().paintEvent(event)

    def eventFilter(self, obj, event):
        if obj is self.viewport():
            if event.type() == QEvent.Type.MouseMove:
                self._mouse_pos = event.pos()
            elif event.type() == QEvent.Type.Leave:
                self._mouse_pos = QPoint(-1, -1)
        return super().eventFilter(obj, event)

    def drawBranches(self, painter, rect, index):
        """Draw branch indicators with AYONStyle directly.

        Bypasses ``self.style()`` because, when an application-level QSS is
        active, Qt wraps the widget's style in a ``QStyleSheetStyle`` proxy
        which would otherwise intercept ``PE_IndicatorBranch`` and apply
        QSS ``QTreeView::branch`` rules on top of (or instead of) ours.
        """
        style = get_ayon_style()  # the raw AYONStyle, never wrapped

        opt = QStyleOption()
        opt.rect = rect
        opt.palette = self.palette()
        state = QStyle.StateFlag.State_Item
        if self.model() is not None and self.model().hasChildren(index):
            state |= QStyle.StateFlag.State_Children
        if self.isExpanded(index):
            state |= QStyle.StateFlag.State_Open
        if self.selectionModel().isSelected(index):
            state |= QStyle.StateFlag.State_Selected
        if self.isEnabled():
            state |= QStyle.StateFlag.State_Enabled

        # Row-level hover: is the cursor on the same row as `index`?
        hovered_idx = self.indexAt(self._mouse_pos)
        idx_rows = self._get_index_rows(index)
        if idx_rows == self._get_index_rows(hovered_idx):
            state |= QStyle.StateFlag.State_MouseOver

        opt.state = state

        # Call our drawer directly, not through self.style().
        style.drawers[
            enum_to_str(
                QStyle.PrimitiveElement,
                QStyle.PrimitiveElement.PE_IndicatorBranch,
                "QTreeView",
            )
        ](opt, painter, self)

    def _get_index_rows(self, index: QModelIndex) -> list[int]:
        """Return a list of row numbers from the given index to the root."""
        rows = []
        while index.isValid():
            rows.append(index.row())
            index = index.parent()
        return rows

    def mouseDoubleClickEvent(self, event: QMouseEvent) -> None:
        """Emit double_clicked signal on double-click."""
        self.double_clicked.emit(event)
        super().mouseDoubleClickEvent(event)

    def mousePressEvent(self, event) -> None:
        """Deselect all items when clicking in an empty area."""
        index = self.indexAt(event.pos())
        if not index.isValid():
            self.clearSelection()
            self.setCurrentIndex(self.rootIndex())
            return
        super().mousePressEvent(event)

    def selectionChanged(
        self,
        selected: QItemSelection,
        deselected: QItemSelection,
    ) -> None:
        """Override to emit a public signal on selection change.

        Args:
            selected: Newly selected items.
            deselected: Newly deselected items.
        """
        super().selectionChanged(selected, deselected)
        self.selection_changed.emit(selected, deselected)

drawBranches(painter, rect, index)

Draw branch indicators with AYONStyle directly.

Bypasses self.style() because, when an application-level QSS is active, Qt wraps the widget's style in a QStyleSheetStyle proxy which would otherwise intercept PE_IndicatorBranch and apply QSS QTreeView::branch rules on top of (or instead of) ours.

Source code in client/ayon_core/ui/components/tree_view.py
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
def drawBranches(self, painter, rect, index):
    """Draw branch indicators with AYONStyle directly.

    Bypasses ``self.style()`` because, when an application-level QSS is
    active, Qt wraps the widget's style in a ``QStyleSheetStyle`` proxy
    which would otherwise intercept ``PE_IndicatorBranch`` and apply
    QSS ``QTreeView::branch`` rules on top of (or instead of) ours.
    """
    style = get_ayon_style()  # the raw AYONStyle, never wrapped

    opt = QStyleOption()
    opt.rect = rect
    opt.palette = self.palette()
    state = QStyle.StateFlag.State_Item
    if self.model() is not None and self.model().hasChildren(index):
        state |= QStyle.StateFlag.State_Children
    if self.isExpanded(index):
        state |= QStyle.StateFlag.State_Open
    if self.selectionModel().isSelected(index):
        state |= QStyle.StateFlag.State_Selected
    if self.isEnabled():
        state |= QStyle.StateFlag.State_Enabled

    # Row-level hover: is the cursor on the same row as `index`?
    hovered_idx = self.indexAt(self._mouse_pos)
    idx_rows = self._get_index_rows(index)
    if idx_rows == self._get_index_rows(hovered_idx):
        state |= QStyle.StateFlag.State_MouseOver

    opt.state = state

    # Call our drawer directly, not through self.style().
    style.drawers[
        enum_to_str(
            QStyle.PrimitiveElement,
            QStyle.PrimitiveElement.PE_IndicatorBranch,
            "QTreeView",
        )
    ](opt, painter, self)

is_loading()

Whether the view is in loading state.

Returns:

Name Type Description
bool bool

True if the view's data are loading.

Source code in client/ayon_core/ui/components/tree_view.py
150
151
152
153
154
155
156
def is_loading(self) -> bool:
    """Whether the view is in loading state.

    Returns:
        bool: True if the view's data are loading.
    """
    return self._loading

mouseDoubleClickEvent(event)

Emit double_clicked signal on double-click.

Source code in client/ayon_core/ui/components/tree_view.py
344
345
346
347
def mouseDoubleClickEvent(self, event: QMouseEvent) -> None:
    """Emit double_clicked signal on double-click."""
    self.double_clicked.emit(event)
    super().mouseDoubleClickEvent(event)

mousePressEvent(event)

Deselect all items when clicking in an empty area.

Source code in client/ayon_core/ui/components/tree_view.py
349
350
351
352
353
354
355
356
def mousePressEvent(self, event) -> None:
    """Deselect all items when clicking in an empty area."""
    index = self.indexAt(event.pos())
    if not index.isValid():
        self.clearSelection()
        self.setCurrentIndex(self.rootIndex())
        return
    super().mousePressEvent(event)

paintEvent(event)

Draw the outer container background before the items.

Parameters:

Name Type Description Default
event QPaintEvent

The paint event.

required
Source code in client/ayon_core/ui/components/tree_view.py
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
def paintEvent(self, event: QPaintEvent) -> None:
    """Draw the outer container background before the items.

    Args:
        event: The paint event.
    """
    painter = QPainter(self.viewport())
    painter.setRenderHint(QPainter.RenderHint.Antialiasing, False)
    style = get_ayon_style()
    tv_style = style.model.get_style("QTreeView", self._variant_str)
    bg = QColor(tv_style.get("background-color", "#252a31"))
    painter.fillRect(self.viewport().rect(), bg)
    painter.end()

    # Let QTreeView draw its items on top.
    super().paintEvent(event)

selectionChanged(selected, deselected)

Override to emit a public signal on selection change.

Parameters:

Name Type Description Default
selected QItemSelection

Newly selected items.

required
deselected QItemSelection

Newly deselected items.

required
Source code in client/ayon_core/ui/components/tree_view.py
358
359
360
361
362
363
364
365
366
367
368
369
370
def selectionChanged(
    self,
    selected: QItemSelection,
    deselected: QItemSelection,
) -> None:
    """Override to emit a public signal on selection change.

    Args:
        selected: Newly selected items.
        deselected: Newly deselected items.
    """
    super().selectionChanged(selected, deselected)
    self.selection_changed.emit(selected, deselected)

setModel(model)

Set model and connect to its loading state if it has one.

Parameters:

Name Type Description Default
model QAbstractItemModel | None

Model to show, can be a proxy model.

required
Source code in client/ayon_core/ui/components/tree_view.py
140
141
142
143
144
145
146
147
148
def setModel(self, model: QAbstractItemModel | None) -> None:
    """Set model and connect to its loading state if it has one.

    Args:
        model: Model to show, can be a proxy model.
    """
    self._disconnect_loading_model()
    super().setModel(model)
    self._connect_loading_model(model)

set_loading(loading)

Show animated placeholder rows while data are loading.

The placeholder shows only when the model has no rows, a refresh of visible rows happens in place. Called automatically when the model has loading_changed signal.

Parameters:

Name Type Description Default
loading bool

Whether the model is loading data.

required
Source code in client/ayon_core/ui/components/tree_view.py
158
159
160
161
162
163
164
165
166
167
168
169
def set_loading(self, loading: bool) -> None:
    """Show animated placeholder rows while data are loading.

    The placeholder shows only when the model has no rows, a refresh
    of visible rows happens in place. Called automatically when
    the model has ``loading_changed`` signal.

    Args:
        loading: Whether the model is loading data.
    """
    self._loading = loading
    self._update_skeleton()

set_loading_delay(delay)

Delay before the loading placeholder appears.

Loads finishing sooner never show the placeholder.

Parameters:

Name Type Description Default
delay int

Delay in milliseconds.

required
Source code in client/ayon_core/ui/components/tree_view.py
171
172
173
174
175
176
177
178
179
180
181
def set_loading_delay(self, delay: int) -> None:
    """Delay before the loading placeholder appears.

    Loads finishing sooner never show the placeholder.

    Args:
        delay: Delay in milliseconds.
    """
    self._skeleton_delay = delay
    if self._skeleton is not None:
        self._skeleton.set_show_delay(delay)

AYViewEditor

Bases: QDialog

Modal editor for the metadata fields of a :class:View.

Parameters:

Name Type Description Default
view View

The :class:View to edit. Pass a fresh View() with an empty id to create a new view.

required
current_user str

User identifier of the editor. Used to gate the studio scope when applicable.

''
allow_studio_scope bool

Whether Scope.STUDIO is offered to the user. Consumer apps gate this via their permission model.

False
usernames_and_groups dict[str, list] | None

Users and access groups offered in the "People with access" selector.

None
allow_sharing bool

Whether the "People with access" section is shown. When False the view's access is left unchanged.

True
parent QWidget | None

Optional parent widget.

None
Source code in client/ayon_core/ui/components/views/view_editor.py
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
class AYViewEditor(QDialog):
    """Modal editor for the metadata fields of a :class:`View`.

    Args:
        view: The :class:`View` to edit.  Pass a fresh ``View()`` with
            an empty ``id`` to create a new view.
        current_user: User identifier of the editor.  Used to gate the
            studio scope when applicable.
        allow_studio_scope: Whether ``Scope.STUDIO`` is offered to the
            user.  Consumer apps gate this via their permission model.
        usernames_and_groups: Users and access groups offered in the
            "People with access" selector.
        allow_sharing: Whether the "People with access" section is
            shown.  When False the view's access is left unchanged.
        parent: Optional parent widget.
    """

    class Mode(IntEnum):
        """Dialog mode: create or edit."""

        CREATE = 0
        EDIT = 1

    def __init__(
        self,
        view: View,
        current_user: str = "",
        current_project: str = "",
        allow_studio_scope: bool = False,
        usernames_and_groups: dict[str, list] | None = None,
        allow_sharing: bool = True,
        parent: QWidget | None = None,
    ) -> None:
        self._dialog_mode = (
            AYViewEditor.Mode.EDIT if view.id else AYViewEditor.Mode.CREATE
        )

        super().__init__(parent)
        self.setStyle(get_ayon_style())
        self.setWindowTitle(
            "Edit View"
            if self._dialog_mode == AYViewEditor.Mode.EDIT
            else "Create New View"
        )
        self.setModal(True)
        self.setContentsMargins(0, 0, 0, 0)

        self._view = view
        self._current_user = current_user
        self.usernames_and_groups = usernames_and_groups or {
            "users": [],
            "groups": [],
        }
        self._current_project = current_project
        self._allow_studio_scope = bool(allow_studio_scope)
        self._allow_sharing = bool(allow_sharing)
        self._delete_requested = False

        # Access control state: {key: access_level}
        #   where key is "user:name" or "group:name"
        self._access_dict: dict[str, int] = {}
        # Stores row widgets by access key
        self._access_row_widgets: dict[str, QWidget] = {}

        self._build_ui()
        self._load_from_view(view)

    # ------------------------------------------------------------------
    # UI construction
    # ------------------------------------------------------------------

    def _build_ui(self) -> None:
        """Create the form and button row."""
        root = AYVBoxLayout(self, spacing=0, margin=0)

        static_layout = AYContainer(
            layout=AYContainer.Layout.VBox,
            layout_margin=20,
            layout_spacing=12,
        )

        static_layout.add_widget(AYLabel("View name"))

        # view name field — required.
        self._view_name_edit = AYLineEdit(placeholder="View name")
        static_layout.add_widget(self._view_name_edit)

        # Scope combo (project / all projects).
        self._scope_combo = AYComboBox()
        scope_items = [
            {
                "text": f"Project - {self._current_project}",
                "short_text": "Project",
            }
        ]
        if self._allow_studio_scope:
            scope_items.append(
                {"text": "All Projects", "short_text": "All Projects"}
            )
        self._scope_combo.update_items(scope_items)
        static_layout.add_widget(AYLabel("Scope"))
        static_layout.add_widget(self._scope_combo)

        # Everything about sharing lives in one section, so it can be
        # hidden as a whole when sharing is unavailable.
        access_section = AYContainer(
            layout=AYContainer.Layout.VBox,
            layout_margin=0,
            layout_spacing=12,
        )
        access_section.setVisible(self._allow_sharing)
        static_layout.add_widget(access_section)

        # User/group selector dropdown
        self._user_selector = AYSearchableComboBox(
            placeholder="Add people or access groups"
        )

        access_section.add_widget(AYLabel("People with access"))
        access_section.add_widget(self._user_selector)

        self._user_selector.item_selected.connect(self._on_user_selected)

        # Owner row
        self._owner_avatar = AYUserImage(
            name="",
            full_name="",
            size=24,
            outline=False,
        )
        self._owner_avatar.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed)
        self._owner_label = AYLabel("-")

        owner_row = AYContainer(
            layout=AYContainer.Layout.HBox,
            layout_spacing=8,
            layout_margin=0,
        )
        owner_row.add_widget(self._owner_avatar, stretch=0)
        owner_row.add_widget(self._owner_label, stretch=0)
        owner_row.addStretch()
        owner_row.add_widget(AYLabel("Owner", dim=True))
        owner_row.layout().addSpacerItem(
            QSpacerItem(32, 0, QSizePolicy.Fixed, QSizePolicy.Minimum)
        )
        access_section.add_widget(owner_row)

        # Everyone row
        self._everyone_combo = AYComboBox()
        access_items = [
            {"text": ACCESS_LEVELS[level]} for level in ACCESS_LEVEL_VALUES
        ]
        self._everyone_combo.update_items(access_items)
        self._everyone_combo.setFixedWidth(120)

        def on_everyone_changed(idx: int) -> None:
            self._access_dict["__everyone__"] = ACCESS_LEVEL_VALUES[idx]

        self._everyone_combo.currentIndexChanged.connect(on_everyone_changed)

        everyone_row = AYContainer(
            layout=AYContainer.Layout.HBox,
            layout_spacing=8,
            layout_margin=0,
        )
        everyone_icon = AYLabel(icon="groups", icon_size=24)
        everyone_row.add_widget(everyone_icon, stretch=0)
        everyone_row.add_widget(AYLabel("Everyone"), stretch=0)
        everyone_row.addStretch()
        everyone_row.add_widget(self._everyone_combo)
        everyone_row.layout().addSpacerItem(
            QSpacerItem(32, 0, QSizePolicy.Fixed, QSizePolicy.Minimum)
        )
        access_section.add_widget(everyone_row)

        # Dynamic user/group access rows
        self._access_rows_container = AYContainer(
            layout=AYContainer.Layout.VBox,
            layout_spacing=4,
            layout_margin=0,
        )
        access_section.add_widget(self._access_rows_container)

        root.addWidget(static_layout)
        root.addStretch()

        # Button row - positioned at bottom with Delete on left, Save on right
        button_container = QWidget()
        button_layout = QHBoxLayout(button_container)
        button_layout.setContentsMargins(16, 16, 16, 16)
        button_layout.setSpacing(8)

        save_btn = AYButton(
            "Save"
            if self._dialog_mode == AYViewEditor.Mode.EDIT
            else "Create",
            variant=AYButton.Variants.Filled,
            icon="check",
        )
        delete_btn = AYButton(
            "Delete", variant=AYButton.Variants.Danger, icon="delete"
        )

        if self._dialog_mode == AYViewEditor.Mode.EDIT:
            # Delete on left, stretch, Save on right
            button_layout.addWidget(delete_btn)
            button_layout.addStretch()
            button_layout.addWidget(save_btn)
            delete_btn.clicked.connect(self._on_delete)
        else:
            # Just Save on right for create mode
            button_layout.addStretch()
            button_layout.addWidget(save_btn)

        save_btn.clicked.connect(self._on_accept)

        root.addWidget(button_container)

        self.setMinimumWidth(500)
        self.setContentsMargins(0, 0, 0, 0)

    # ------------------------------------------------------------------
    # Data loading
    # ------------------------------------------------------------------

    def _load_from_view(self, view: View) -> None:
        """Populate widgets from *view*.

        Args:
            view: The view to copy values from.
        """
        self._view_name_edit.setText(view.label)

        # Scope: select matching item, falling back to project when
        # studio is disallowed but the view is scoped studio.
        if view.scope == Scope.STUDIO and self._allow_studio_scope:
            self._scope_combo.setCurrentIndex(1)
        else:
            self._scope_combo.setCurrentIndex(0)

        # Load access data from view
        self._access_dict = dict(view.access) if view.access else {}

        # Update Owner
        owner_name = self._view.owner or self._current_user or "-"
        self._owner_label.setText(owner_name)

        # Recreate owner avatar with updated name
        self._owner_avatar._name = owner_name
        self._owner_avatar._full_name = owner_name
        self._owner_avatar.set_image()

        # Update Everyone
        everyone_level = self._access_dict.get("__everyone__", 0)
        if everyone_level not in ACCESS_LEVEL_VALUES:
            everyone_level = 0
        self._access_dict["__everyone__"] = everyone_level
        self._everyone_combo.setCurrentIndex(
            ACCESS_LEVEL_VALUES.index(everyone_level)
        )

        self._populate_access_rows()
        self._update_user_dropdown()

    def _update_user_dropdown(self) -> None:
        """Update dropdown to show only users/groups not yet added."""
        added_keys = set(self._access_dict.keys())

        items = []
        users = self.usernames_and_groups.get("users", [])
        groups = self.usernames_and_groups.get("groups", [])

        if users:
            for user in users:
                user_name = user.get("name", "")
                user_full_name = user.get("fullName", "") or user_name
                user_key = f"user:{user_name}"
                if user_key not in added_keys:
                    items.append({
                        "key": user_key,
                        "label": user_full_name,
                        "identifier": user_name,
                        "type": "user"
                    })

        for group in groups:
            group_key = f"group:{group}"
            if group_key not in added_keys:
                items.append({
                    "key": group_key,
                    "label": group.capitalize(),
                    "identifier": "group",
                    "type": "group"
                })

        # Set items
        self._user_selector.set_items_with_builder(
            items,
            row_builder=self._build_access_list_row
        )

    def _on_user_selected(self, access_key: str) -> None:
        """Handle user selection from dropdown."""
        if not access_key:
            return

        self._add_access_row(access_key, 10)  # Default to Viewer access
        self._update_user_dropdown()

    def _add_access_row(self, key: str, access_level: int) -> None:
        """Add an access control row for the given user/group.

        Args:
            key: The access key (e.g., "user:name" or "group:name").
            access_level: The access level (0, 10, 20, or 30).
        """
        if key in self._access_dict:
            return  # Already exists

        self._access_dict[key] = access_level
        self._populate_access_rows()

    def _remove_access_row(self, key: str) -> None:
        """Remove an access control row.

        Args:
            key: The access key to remove.
        """
        if key not in self._access_dict:
            return

        del self._access_dict[key]

        self._populate_access_rows()
        self._update_user_dropdown()

    def _render_access_row(self, key: str, access_level: int) -> None:
        """Render a single access row"""
        row = AYContainer(
            layout=AYContainer.Layout.HBox,
            layout_spacing=4,
            layout_margin=0,
        )

        access_combo = AYComboBox(show_chevron=True)
        access_combo.setFixedWidth(120)
        access_items = [
            {"text": ACCESS_LEVELS[level]}
            for level in ACCESS_LEVEL_VALUES
        ]
        access_combo.update_items(access_items)

        if access_level not in ACCESS_LEVEL_VALUES:
            access_level = 10
        access_combo.setCurrentIndex(ACCESS_LEVEL_VALUES.index(access_level))

        def on_access_changed(idx: int) -> None:
            self._access_dict[key] = ACCESS_LEVEL_VALUES[idx]

        access_combo.currentIndexChanged.connect(on_access_changed)

        remove_btn = AYButton("", icon="close", variant=AYButton.Variants.Nav)
        remove_btn.setFixedWidth(32)
        remove_btn.clicked.connect(lambda: self._remove_access_row(key))

        label_widget = self._build_access_row_label(key)
        row.add_widget(label_widget, stretch=1)
        row.addStretch()
        row.add_widget(access_combo)
        row.add_widget(remove_btn)

        self._access_rows_container.add_widget(row)
        self._access_row_widgets[key] = row

    def _populate_access_rows(self) -> None:
        """Populate access control rows from _access_dict."""
        # Clear existing rows
        for widget in self._access_row_widgets.values():
            widget.setVisible(False)
            widget.deleteLater()
        self._access_row_widgets.clear()

        self._access_rows_container.clear()

        # Add user/group rows
        for key in sorted(self._access_dict.keys()):
            if key == "__everyone__":
                continue
            self._render_access_row(key, int(self._access_dict[key]))

    def _build_access_list_row(self, row, entry: dict) -> None:
        """Build custom identifier widgets for a dropdown row.

        This row builder is used with :meth:`AYSearchableComboBox.set_items_with_builder`
        to add avatar and identifier labels to user/group rows.

        Args:
            row: The _ItemRow widget to customize.
            entry: The item dict containing metadata.
        """  # noqa: E501
        entry_type = entry.get("type", "")
        identifier = entry.get("identifier", "")
        label = entry.get("label", "")

        # Insert avatar/icon at the beginning for users and groups
        if entry_type == "user":
            avatar = AYUserImage(
                name=identifier,
                full_name=label,
                size=20,
                outline=False,
            )
            avatar.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed)
            row.layout().insertWidget(0, avatar)
        elif entry_type == "group":
            group_icon = AYLabel(
                icon="shield_person",
                icon_size=20,
            )
            row.layout().insertWidget(0, group_icon)

        if "identifier" in entry:
            # Add spacer item with stretch
            row.layout().addSpacerItem(
                QSpacerItem(0, 0, QSizePolicy.Expanding, QSizePolicy.Minimum)
            )
            # Add identifier label (dim)
            id_label = AYLabel(f"({identifier})", dim=True)
            row.add_custom_widget(id_label, stretch=0)

    def _build_access_row_label(self, key: str) -> AYContainer:
        """Build formatted label widget for access control row.

        For users: Shows avatar with "Full Name" and "(username)" in dim text
        For groups: Shows capitalized group name

        Args:
            key: The access key (e.g., "user:name" or "group:name")

        Returns:
            AYContainer with formatted widgets for the access row.
        """
        if key.startswith("user:"):
            # Extract username from key
            user_name = key.replace("user:", "")

            # Find the full name from usernames_and_groups
            user_full_name = user_name
            for user in self.usernames_and_groups["users"]:
                if user["name"] == user_name:
                    user_full_name = user["fullName"] or user_name
                    break

            # Create container with avatar and text
            user_row_container = AYContainer(
                layout=AYContainer.Layout.HBox,
                layout_spacing=8,
                layout_margin=0,
            )

            # Add Avatar
            avatar = AYUserImage(
                name=user_name,
                full_name=user_full_name,
                size=24,
                outline=False,
                parent=user_row_container,
            )
            avatar.setSizePolicy(
                QSizePolicy.Fixed,
                QSizePolicy.Fixed,
            )
            user_row_container.add_widget(avatar, stretch=0)

            # Add full name
            user_row_container.add_widget(
                AYLabel(user_full_name),
                stretch=0
            )
            user_row_container.add_widget(
                AYLabel(f"({user_name})", dim=True),
                stretch=0
            )
            user_row_container.layout().addSpacerItem(
                QSpacerItem(
                    0,
                    0,
                    QSizePolicy.Expanding,
                    QSizePolicy.Minimum,
                )
            )

            return user_row_container

        # Group access row
        group_name = key.replace("group:", "")

        # Create container with groups icon and name
        group_row_container = AYContainer(
            layout=AYContainer.Layout.HBox,
            layout_spacing=8,
            layout_margin=0,
        )

        # Add groups icon
        group_icon = AYLabel(
            icon="shield_person",
            icon_size=24,
        )
        group_row_container.add_widget(group_icon, stretch=0)

        # Add group name
        group_row_container.add_widget(
            AYLabel(group_name.capitalize()),
            stretch=0
        )
        group_row_container.layout().addSpacerItem(
            QSpacerItem(
                0,
                0,
                QSizePolicy.Expanding,
                QSizePolicy.Minimum,
            )
        )

        return group_row_container

    # ------------------------------------------------------------------
    # Accept handling
    # ------------------------------------------------------------------

    def _on_accept(self) -> None:
        """Validate inputs and accept the dialog.

        Labels must be non-empty; the dialog stays open otherwise.
        """
        label = self._view_name_edit.text().strip()
        if not label:
            self._view_name_edit.setFocus(Qt.FocusReason.OtherFocusReason)
            return

        self.accept()

    def _on_delete(self) -> None:
        """Delete the view and close the dialog."""
        self._delete_requested = True
        self.accept()

    # ------------------------------------------------------------------
    # Public API
    # ------------------------------------------------------------------

    def mode(self) -> AYViewEditor.Mode:
        """Return the dialog mode: create or edit."""
        return self._dialog_mode

    def delete_requested(self) -> bool:
        """Whether the user requested deletion of the view."""
        return self._delete_requested

    def get_view(self) -> View:
        """Return the edited :class:`View`.

        The original :attr:`View.settings`, :attr:`View.id`,
        :attr:`View.view_type`, :attr:`View.owner`, :attr:`View.access`,
        :attr:`View.position` and :attr:`View.extra` are kept intact;
        only the metadata edited in this dialog is mutated.

        Returns:
            The (mutated) original :class:`View` instance.
        """
        view = self._view
        view.label = self._view_name_edit.text().strip()

        if self._scope_combo.currentIndex() == 1 and self._allow_studio_scope:
            view.scope = Scope.STUDIO
        else:
            view.scope = Scope.PROJECT

        # The access editor is hidden without sharing; keep whatever
        # access the view already has rather than the editor defaults.
        if self._allow_sharing:
            view.access = dict(self._access_dict)

        if not view.owner and self._current_user:
            view.owner = self._current_user

        return view

Mode

Bases: IntEnum

Dialog mode: create or edit.

Source code in client/ayon_core/ui/components/views/view_editor.py
69
70
71
72
73
class Mode(IntEnum):
    """Dialog mode: create or edit."""

    CREATE = 0
    EDIT = 1

delete_requested()

Whether the user requested deletion of the view.

Source code in client/ayon_core/ui/components/views/view_editor.py
608
609
610
def delete_requested(self) -> bool:
    """Whether the user requested deletion of the view."""
    return self._delete_requested

get_view()

Return the edited :class:View.

The original :attr:View.settings, :attr:View.id, :attr:View.view_type, :attr:View.owner, :attr:View.access, :attr:View.position and :attr:View.extra are kept intact; only the metadata edited in this dialog is mutated.

Returns:

Type Description
View

The (mutated) original :class:View instance.

Source code in client/ayon_core/ui/components/views/view_editor.py
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
def get_view(self) -> View:
    """Return the edited :class:`View`.

    The original :attr:`View.settings`, :attr:`View.id`,
    :attr:`View.view_type`, :attr:`View.owner`, :attr:`View.access`,
    :attr:`View.position` and :attr:`View.extra` are kept intact;
    only the metadata edited in this dialog is mutated.

    Returns:
        The (mutated) original :class:`View` instance.
    """
    view = self._view
    view.label = self._view_name_edit.text().strip()

    if self._scope_combo.currentIndex() == 1 and self._allow_studio_scope:
        view.scope = Scope.STUDIO
    else:
        view.scope = Scope.PROJECT

    # The access editor is hidden without sharing; keep whatever
    # access the view already has rather than the editor defaults.
    if self._allow_sharing:
        view.access = dict(self._access_dict)

    if not view.owner and self._current_user:
        view.owner = self._current_user

    return view

mode()

Return the dialog mode: create or edit.

Source code in client/ayon_core/ui/components/views/view_editor.py
604
605
606
def mode(self) -> AYViewEditor.Mode:
    """Return the dialog mode: create or edit."""
    return self._dialog_mode

AYViewSelector

Bases: AYButtonMenu

Icon button exposing the Views feature.

Public API:

  • :meth:set_view_type — switch the view-type identifier and reload.
  • :meth:refresh — re-pull the view list from the manager.
  • :meth:current_view — return the active :class:View (or None).
Signals

view_applied(View): Emitted after a view has been applied to the bindings. view_saved(View): Emitted after a view has been persisted. view_deleted(str): Emitted with the view id after deletion. view_modified(str, bool): Emitted when the live UI no longer matches the loaded view settings, or when it returns to the saved state. Payload is (current_view_name, modified). binding_error(str, str): Emitted with (stage, message) when :class:ViewBindings reports a non-fatal failure while applying or capturing a view.

Source code in client/ayon_core/ui/components/views/view_selector.py
 124
 125
 126
 127
 128
 129
 130
 131
 132
 133
 134
 135
 136
 137
 138
 139
 140
 141
 142
 143
 144
 145
 146
 147
 148
 149
 150
 151
 152
 153
 154
 155
 156
 157
 158
 159
 160
 161
 162
 163
 164
 165
 166
 167
 168
 169
 170
 171
 172
 173
 174
 175
 176
 177
 178
 179
 180
 181
 182
 183
 184
 185
 186
 187
 188
 189
 190
 191
 192
 193
 194
 195
 196
 197
 198
 199
 200
 201
 202
 203
 204
 205
 206
 207
 208
 209
 210
 211
 212
 213
 214
 215
 216
 217
 218
 219
 220
 221
 222
 223
 224
 225
 226
 227
 228
 229
 230
 231
 232
 233
 234
 235
 236
 237
 238
 239
 240
 241
 242
 243
 244
 245
 246
 247
 248
 249
 250
 251
 252
 253
 254
 255
 256
 257
 258
 259
 260
 261
 262
 263
 264
 265
 266
 267
 268
 269
 270
 271
 272
 273
 274
 275
 276
 277
 278
 279
 280
 281
 282
 283
 284
 285
 286
 287
 288
 289
 290
 291
 292
 293
 294
 295
 296
 297
 298
 299
 300
 301
 302
 303
 304
 305
 306
 307
 308
 309
 310
 311
 312
 313
 314
 315
 316
 317
 318
 319
 320
 321
 322
 323
 324
 325
 326
 327
 328
 329
 330
 331
 332
 333
 334
 335
 336
 337
 338
 339
 340
 341
 342
 343
 344
 345
 346
 347
 348
 349
 350
 351
 352
 353
 354
 355
 356
 357
 358
 359
 360
 361
 362
 363
 364
 365
 366
 367
 368
 369
 370
 371
 372
 373
 374
 375
 376
 377
 378
 379
 380
 381
 382
 383
 384
 385
 386
 387
 388
 389
 390
 391
 392
 393
 394
 395
 396
 397
 398
 399
 400
 401
 402
 403
 404
 405
 406
 407
 408
 409
 410
 411
 412
 413
 414
 415
 416
 417
 418
 419
 420
 421
 422
 423
 424
 425
 426
 427
 428
 429
 430
 431
 432
 433
 434
 435
 436
 437
 438
 439
 440
 441
 442
 443
 444
 445
 446
 447
 448
 449
 450
 451
 452
 453
 454
 455
 456
 457
 458
 459
 460
 461
 462
 463
 464
 465
 466
 467
 468
 469
 470
 471
 472
 473
 474
 475
 476
 477
 478
 479
 480
 481
 482
 483
 484
 485
 486
 487
 488
 489
 490
 491
 492
 493
 494
 495
 496
 497
 498
 499
 500
 501
 502
 503
 504
 505
 506
 507
 508
 509
 510
 511
 512
 513
 514
 515
 516
 517
 518
 519
 520
 521
 522
 523
 524
 525
 526
 527
 528
 529
 530
 531
 532
 533
 534
 535
 536
 537
 538
 539
 540
 541
 542
 543
 544
 545
 546
 547
 548
 549
 550
 551
 552
 553
 554
 555
 556
 557
 558
 559
 560
 561
 562
 563
 564
 565
 566
 567
 568
 569
 570
 571
 572
 573
 574
 575
 576
 577
 578
 579
 580
 581
 582
 583
 584
 585
 586
 587
 588
 589
 590
 591
 592
 593
 594
 595
 596
 597
 598
 599
 600
 601
 602
 603
 604
 605
 606
 607
 608
 609
 610
 611
 612
 613
 614
 615
 616
 617
 618
 619
 620
 621
 622
 623
 624
 625
 626
 627
 628
 629
 630
 631
 632
 633
 634
 635
 636
 637
 638
 639
 640
 641
 642
 643
 644
 645
 646
 647
 648
 649
 650
 651
 652
 653
 654
 655
 656
 657
 658
 659
 660
 661
 662
 663
 664
 665
 666
 667
 668
 669
 670
 671
 672
 673
 674
 675
 676
 677
 678
 679
 680
 681
 682
 683
 684
 685
 686
 687
 688
 689
 690
 691
 692
 693
 694
 695
 696
 697
 698
 699
 700
 701
 702
 703
 704
 705
 706
 707
 708
 709
 710
 711
 712
 713
 714
 715
 716
 717
 718
 719
 720
 721
 722
 723
 724
 725
 726
 727
 728
 729
 730
 731
 732
 733
 734
 735
 736
 737
 738
 739
 740
 741
 742
 743
 744
 745
 746
 747
 748
 749
 750
 751
 752
 753
 754
 755
 756
 757
 758
 759
 760
 761
 762
 763
 764
 765
 766
 767
 768
 769
 770
 771
 772
 773
 774
 775
 776
 777
 778
 779
 780
 781
 782
 783
 784
 785
 786
 787
 788
 789
 790
 791
 792
 793
 794
 795
 796
 797
 798
 799
 800
 801
 802
 803
 804
 805
 806
 807
 808
 809
 810
 811
 812
 813
 814
 815
 816
 817
 818
 819
 820
 821
 822
 823
 824
 825
 826
 827
 828
 829
 830
 831
 832
 833
 834
 835
 836
 837
 838
 839
 840
 841
 842
 843
 844
 845
 846
 847
 848
 849
 850
 851
 852
 853
 854
 855
 856
 857
 858
 859
 860
 861
 862
 863
 864
 865
 866
 867
 868
 869
 870
 871
 872
 873
 874
 875
 876
 877
 878
 879
 880
 881
 882
 883
 884
 885
 886
 887
 888
 889
 890
 891
 892
 893
 894
 895
 896
 897
 898
 899
 900
 901
 902
 903
 904
 905
 906
 907
 908
 909
 910
 911
 912
 913
 914
 915
 916
 917
 918
 919
 920
 921
 922
 923
 924
 925
 926
 927
 928
 929
 930
 931
 932
 933
 934
 935
 936
 937
 938
 939
 940
 941
 942
 943
 944
 945
 946
 947
 948
 949
 950
 951
 952
 953
 954
 955
 956
 957
 958
 959
 960
 961
 962
 963
 964
 965
 966
 967
 968
 969
 970
 971
 972
 973
 974
 975
 976
 977
 978
 979
 980
 981
 982
 983
 984
 985
 986
 987
 988
 989
 990
 991
 992
 993
 994
 995
 996
 997
 998
 999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
class AYViewSelector(AYButtonMenu):
    """Icon button exposing the Views feature.

    Public API:

    - :meth:`set_view_type` — switch the view-type identifier and reload.
    - :meth:`refresh` — re-pull the view list from the manager.
    - :meth:`current_view` — return the active :class:`View` (or
      ``None``).

    Signals:
        view_applied(View): Emitted after a view has been applied to
            the bindings.
        view_saved(View): Emitted after a view has been persisted.
        view_deleted(str): Emitted with the view id after deletion.
        view_modified(str, bool): Emitted when the live UI no longer
            matches the loaded view settings, or when it returns to the
            saved state. Payload is ``(current_view_name, modified)``.
        binding_error(str, str): Emitted with ``(stage, message)`` when
            :class:`ViewBindings` reports a non-fatal failure while
            applying or capturing a view.
    """

    view_applied = Signal(object)
    view_saved = Signal(object)
    view_deleted = Signal(str)
    view_modified = Signal(str, bool)
    binding_error = Signal(str, str)
    default_view_message = Signal(str, bool)

    def __init__(
        self,
        bindings: ViewBindings,
        manager: ViewManager,
        view_type: str,
        current_user: str = "",
        user_access_level: int = _DEFAULT_USER_ACCESS,
        allow_studio_scope: bool = False,
        parent: QWidget | None = None,
    ) -> None:
        # Store state before super().__init__() because the base class
        # invokes ``populate_callback`` during construction.
        self._bindings = bindings
        self._manager = manager
        self._view_type = view_type
        self._current_user = current_user
        self._user_access = int(user_access_level)
        self._allow_studio_scope = bool(allow_studio_scope)
        # Dirty flag: set on any UI change, cleared on apply/save/reset.
        # just highlight as soon as the user makes any change.
        self._view_modified = False

        self._current_view: View | None = None
        self._views: list[View] = []
        # The view list is only needed to draw the dropdown, so it is
        # fetched the first time the menu opens rather than at startup.
        self._views_loaded: bool = False
        # When True, :meth:`refresh` skips its auto-apply-working-view
        # branch.  Toggled by :class:`_SuspendAutoApply` around save
        # operations so the manager-driven refresh during save_view
        # does not trigger a redundant page-0 refetch.
        self._suppress_auto_apply: bool = False
        self._ensuring_working_view: bool = False
        # True while :meth:`_apply_view` is pushing settings into the
        # widgets. Those widgets emit the same change signals a user edit
        # does, and echoing them back to the server would rewrite the
        # working view with what we just read from it.
        self._applying_view: bool = False

        # Per-section collapsed state for the dropdown headers ("My
        # views", "Shared views", "Default view"). Kept for the
        # lifetime of the selector, mirroring the frontend's
        # collapsible section headers.
        self._collapsed_sections: dict[str, bool] = {}

        self._dropdown_layout = None  # type: ignore[assignment]
        self._default_view_control = DefaultViewControl(self)

        super().__init__(
            populate_callback=self._populate_menu,
            icon="view_quilt",
            variant=AYButton.Variants.Surface,
            dropdown_variant=AYContainer.Variants.Popover,
            dropdown_margin=6,
            tooltip="Views",
            parent=parent,
        )
        self.setObjectName("AYViewSelector")
        self.setFixedSize(32, 32)

        # Applying a view, or a single user gesture, moves several widgets
        # at once. Coalesce the resulting saves into one round trip.
        # Built here rather than with the state above: parenting a QTimer
        # needs this object's base class to be initialised first.
        self._working_view_timer = QTimer(self)
        self._working_view_timer.setSingleShot(True)
        self._working_view_timer.setInterval(300)
        self._working_view_timer.timeout.connect(self._update_working_view)

        # Rebuild the menu contents each time it opens, since the view
        # list may change between openings.
        self.menu_opened.connect(self._rebuild_menu)

        # Refresh when the manager changes.
        self._manager.views_changed.connect(self._on_manager_changed)
        self._manager.project_changed.connect(self._on_project_changed)
        self._connect_modified_state_sources()

        # Forward binding errors via the public ``binding_error`` signal so
        # hosts can surface them.  Overrides any pre-existing
        # ``on_error`` hook (the caller can wrap it themselves to chain).
        if self._bindings.on_error is None:
            self._bindings.on_error = self._on_binding_error

        self.refresh()
        # After refresh(), since it may apply a working view and set
        # its own "View: ..." tooltip — this call must have the final
        # say when there's no project, overriding that.
        self._update_enabled_for_project()

    # ------------------------------------------------------------------
    # Menu population
    # ------------------------------------------------------------------

    def _populate_menu(self, container: QFrame) -> None:
        """Initial populate callback invoked by :class:`AYButtonMenu`.

        We only cache the container's layout here; actual contents are
        (re)built on every menu open via :meth:`_rebuild_menu`.

        Args:
            container: The dropdown ``QFrame`` provided by the base.
        """
        layout = container.layout()
        if layout is not None:
            layout.setContentsMargins(4, 4, 4, 4)
            layout.setSpacing(2)
        self._dropdown_layout = layout

    def _rebuild_menu(self) -> None:
        """Rebuild the dropdown contents from the current view list."""
        layout = self._dropdown_layout
        if layout is None:
            return

        # Wipe previous contents.
        while layout.count():
            item = layout.takeAt(0)
            if item and item.widget():
                item.widget().deleteLater()

        # First open of the menu is what pays for the view list.
        self._ensure_views_listed()

        # The working view + "My views" + "Shared views" portion lives
        # in its own scrollable area, capped at _VIEWS_LIST_MAX_HEIGHT,
        # so a long view list scrolls instead of growing the dropdown
        # indefinitely — "Default view" and "Create new view…" stay
        # outside it, always visible.
        layout.addWidget(self._make_views_list_scroll_area())

        layout.addWidget(
            self._make_section_header("Default view", "default_view")
        )
        if not self._collapsed_sections.get("default_view"):
            layout.addWidget(self._make_default_view_row())
        layout.addWidget(self._make_separator())

        layout.addWidget(self._make_create_view_row())

        self.refresh_dropdown_size()

    def _make_views_list_scroll_area(self) -> AYScrollArea:
        """Build the scrollable working-view + "My views" + "Shared
        views" portion of the dropdown, capped at
        ``_VIEWS_LIST_MAX_HEIGHT``.
        """
        # A real, opaquely-painted Popover frame — same variant as the
        # dropdown and its rows — rather than a bare QWidget stylesheet
        # "background: transparent". The dropdown is a translucent
        # popup: a bare widget's "transparent" paints nothing of its
        # own, and any pixel none of its rows happen to cover (the
        # inter-row spacing, in particular) has nothing to fall back
        # to, so it stayed see-through — a visible darker gap/border
        # around the whole list. Painting Popover here directly makes
        # every pixel in the list opaque and correct, regardless of
        # what any translucent ancestor does or doesn't cover.
        scroll_content = AYContainer(
            layout=AYContainer.Layout.VBox,
            variant=AYContainer.Variants.Popover_Square,
            layout_spacing=2,
            layout_margin=0,
        )

        # Current view row with inline reset button.
        scroll_content.add_widget(
            self._make_working_view_row(self._current_view)
        )
        scroll_content.add_widget(self._make_separator())

        # Exclude working view as it stored with private Visibility
        private_views = [
            v for v in self._views
            if v.visibility == Visibility.PRIVATE
               and not v.working
               and v.label != DEFAULT_VIEW_LABEL
        ]
        public_views = [
            v for v in self._views if v.visibility == Visibility.PUBLIC
        ]

        if private_views:
            scroll_content.add_widget(
                self._make_section_header("My views", "my_views")
            )
            if not self._collapsed_sections.get("my_views"):
                for view in private_views:
                    scroll_content.add_widget(self._make_row(view))

        if public_views and self._manager.supports_sharing():
            scroll_content.add_widget(
                self._make_section_header("Shared views", "shared_views")
            )
            if not self._collapsed_sections.get("shared_views"):
                for view in public_views:
                    scroll_content.add_widget(self._make_row(view))

        if not self._views:
            no_views_label = AYLabel("No saved views.", dim=True)
            # Match the left indent every row's own label gets from its
            # row's contents margins + the label's own inner margin
            # (2 + 6 = 8), since this one isn't wrapped in a row.
            no_views_label.setContentsMargins(8, 4, 8, 4)
            scroll_content.add_widget(no_views_label)

        scroll_area = AYScrollArea()
        scroll_area.setWidget(scroll_content)
        scroll_area.setWidgetResizable(True)
        scroll_area.setHorizontalScrollBarPolicy(
            Qt.ScrollBarPolicy.ScrollBarAlwaysOff
        )

        # Natural content height, capped — lets the scroll area shrink
        # to fit a short list instead of always reserving max height.
        scroll_content.adjustSize()
        content_height = scroll_content.sizeHint().height()
        scroll_area.setFixedHeight(
            min(content_height, _VIEWS_LIST_MAX_HEIGHT)
        )
        return scroll_area

    # ------------------------------------------------------------------
    # Row helpers
    # ------------------------------------------------------------------

    def _style_row(self, row: AYContainer) -> None:
        """Apply the shared row inset/height to a dropdown list row.

        Uses the row's *layout* contents margins rather than the row
        widget's own ``setContentsMargins`` — ``AYFrame`` paints its own
        background using ``frameRect()`` (== ``contentsRect()``), which
        is inset by the widget's own contents margins. Insetting the
        widget itself would shrink the row's background fill by the
        same amount as its children, leaving the fill and a trailing
        action button flush against each other with no visible gap.
        The layout's margins only affect child placement, so this
        keeps the row's fill full-bleed while still inserting real
        padding around its children.
        """
        row.layout().setContentsMargins(4, 4, 8, 4)
        row.setMinimumHeight(_ROW_MIN_HEIGHT)

    def _make_separator(self) -> QFrame:
        """Return a subtle 1px divider line (outline-variant color)."""
        sep = QFrame()
        sep.setFrameShape(QFrame.Shape.HLine)
        sep.setFrameShadow(QFrame.Shadow.Plain)
        sep.setFixedHeight(1)
        sep.setStyleSheet("QFrame { border-top: 1px solid #41474d; }")
        return sep

    def _make_section_header(
        self, title: str, section_key: str
    ) -> _SectionHeader:
        """Return a clickable, collapsible section header."""
        return _SectionHeader(
            title,
            collapsed=bool(self._collapsed_sections.get(section_key)),
            on_toggle=lambda key=section_key: self._on_section_toggle(key),
        )

    def _on_section_toggle(self, section_key: str) -> None:
        """Flip a section's collapsed state and redraw the dropdown."""
        self._collapsed_sections[section_key] = (
            not self._collapsed_sections.get(section_key)
        )
        self._rebuild_menu()

    def _make_working_view_row(self, view: View) -> AYContainer:
        """Build the current-view row with an inline reset button.

        The row itself is the click target and carries the selected
        highlight — the label is plain text, not a button — so the
        label and the reset button read as one merged control, and
        clicking anywhere in the row (including the margins around the
        reset button) selects the working view. Matches the frontend,
        where ``.selected`` is applied to the whole row element.
        """
        is_current_view_is_working_view = view is not None and view.working
        is_selected = self._view_modified or is_current_view_is_working_view

        row = AYClickableRow(
            layout=AYContainer.Layout.HBox,
            variant=AYContainer.Variants.Popover,
            layout_spacing=4,
            layout_margin=0,
            hover_enabled=True,
            on_click=self._on_working_view_clicked,
        )
        self._style_row(row)
        row.set_selected(is_selected)

        label = AYLabel("Working view")
        label.setContentsMargins(6, 0, 0, 0)
        row.add_widget(label, stretch=1)

        reset_btn = AYButton(
            icon="restart_alt",
            variant=AYButton.Variants.Row_Action,
            tooltip="Reset to default",
        )
        reset_btn.clicked.connect(self._on_reset_clicked)
        row.add_widget(reset_btn)

        RowHoverTracker(row).watch(reset_btn)

        return row

    def _make_view_label(self, view: View) -> AYLabel:
        label = AYLabel(view.label or "(unnamed view)")
        label.setContentsMargins(6, 0, 0, 0)
        return label

    def _make_save_btn(self, view: View, row: AYContainer) -> AYButton:
        """Build the save button — same size as any other row action.

        It's the plain ``Row_Action`` variant, not a bespoke bigger
        one: the "modified" highlight is just its ``checked`` state
        (like the filter chips and other checkable buttons elsewhere)
        turning it primary-blue, the same mechanism, not a special
        button.
        """
        is_modified = bool(
            self._current_view
            and view.id == self._current_view.id
            and self._view_modified
        )
        btn = AYButton(
            icon="save",
            variant=AYButton.Variants.Row_Action,
            checkable=True,
            tooltip="Save view settings from current view",
        )
        btn.setChecked(is_modified)
        btn.clicked.connect(
            lambda _checked=False, v=view: self._on_view_save_clicked(v)
        )

        if not is_modified:
            sp = btn.sizePolicy()
            sp.setRetainSizeWhenHidden(True)
            btn.setSizePolicy(sp)
            btn.setVisible(False)
            row.installEventFilter(HoverReveal(btn, row))

        return btn

    def _make_edit_btn(self, view: View) -> AYButton:
        btn = AYButton(
            icon="more_horiz",
            variant=AYButton.Variants.Row_Action,
            tooltip="Edit view",
        )
        btn.clicked.connect(
            lambda _checked=False, v=view: self._on_edit_clicked(v)
        )
        return btn

    def _make_row(self, view: View) -> AYContainer:
        """Build one selectable row for *view*."""
        modified = not (
            self._current_view
            and view.id == self._current_view.id
            and not self._view_modified
        )

        row = AYClickableRow(
            layout=AYContainer.Layout.HBox,
            variant=AYContainer.Variants.Popover,
            layout_spacing=4,
            layout_margin=0,
            hover_enabled=True,
            on_click=lambda v=view: self._on_view_selected(v),
        )
        self._style_row(row)
        row.set_selected(not modified)

        row.add_widget(self._make_view_label(view), stretch=1)
        hover_watched = []

        if view.can_edit(self._current_user, self._user_access):
            if modified:
                save_btn = self._make_save_btn(view, row)
                row.add_widget(save_btn)
                hover_watched.append(save_btn)
            edit_btn = self._make_edit_btn(view)
            row.add_widget(edit_btn)
            hover_watched.append(edit_btn)

        RowHoverTracker(row).watch(*hover_watched)

        return row

    def _make_default_view_row(self) -> AYContainer:
        """Build a row for the default view."""
        return self._default_view_control.build_row()

    def _make_create_view_row(self) -> AYContainer:
        """Build the "Create new view…" row.

        Wrapped the same way as every other row (a hover-enabled
        Popover frame whose click and hover the row itself owns) so
        its look matches the rest of the dropdown instead of the plain
        Text-button gray. No :class:`RowHoverTracker` here: unlike the
        other rows, this one has no interactive child button to steal
        Enter/Leave events, so the frame drawer's own ``underMouse()``
        fallback (see ``hover_enabled``) already keeps it highlighted.
        """
        row = AYClickableRow(
            layout=AYContainer.Layout.HBox,
            variant=AYContainer.Variants.Popover,
            layout_spacing=4,
            layout_margin=0,
            hover_enabled=True,
            on_click=self._on_create_clicked,
        )
        self._style_row(row)

        label = AYLabel("Create new view…", icon="add")
        label.setContentsMargins(6, 0, 0, 0)
        row.add_widget(label, stretch=1)
        return row

    def _on_binding_error(self, stage: str, exc: BaseException) -> None:
        """Forward a :class:`ViewBindings` error via :attr:`binding_error`."""
        self.binding_error.emit(stage, str(exc) or exc.__class__.__name__)

    def _connect_modified_state_sources(self) -> None:
        """Wire widget signals that mark the view as dirty."""
        self._bindings.filter_bar.filters_changed.connect(self._mark_modified)
        self._bindings.table_view.column_state_changed.connect(self._mark_modified)
        self._bindings.table_view.header().sortIndicatorChanged.connect(
            self._mark_modified
        )

    # ------------------------------------------------------------------
    # Public API
    # ------------------------------------------------------------------

    def set_view_type(self, view_type: str) -> None:
        """Switch to a new view-type identifier."""
        if view_type == self._view_type:
            return
        self._view_type = view_type
        self._current_view = None
        self.refresh()

    def refresh(self) -> None:
        """Re-resolve the working view and drop the cached view list.

        Restoring the user's last state only needs the working view,
        which has its own endpoint. The dropdown is not on screen yet, so
        listing the rest - and each one's settings - waits until the menu
        is opened; see :meth:`_ensure_views_listed`.
        """
        self._views = []
        self._views_loaded = False

        working_view = self._ensure_working_view_exists()
        if (
            working_view is not None
            and self._current_view is None
            and not self._suppress_auto_apply
        ):
            self._apply_view(working_view, emit=True)
            return

    def _ensure_views_listed(self) -> None:
        """Fetch the view list on first use, then keep it until refresh."""
        if self._views_loaded:
            return
        try:
            self._views = list(self._manager.list_views(self._view_type))
        except Exception:
            log.exception("Failed to list views for %r", self._view_type)
            self._views = []
        self._views_loaded = True

    def current_view(self) -> View | None:
        """Return the currently active view, or ``None``."""
        return self._current_view

    def notify_view_modified(self, *_args) -> None:
        """Mark the view as modified after a UI settings change.

        Consumer code may connect custom widgets here when they affect
        :meth:`ViewBindings.capture` but are not part of the built-in
        table/filter stack.
        """
        self._mark_modified()

    def apply_default_settings(self) -> None:
        """Reset every tracked setting to the built-in AYON defaults.

        Used when neither a project nor a studio default view is
        configured, so "reset" has to fall back to what the consumer
        declared as its defaults.  Applying an empty
        :class:`ViewSettings` makes :class:`ViewBindings` fill in every
        slice from ``default_settings`` - columns, sort, filter,
        grouping, row height and the consumer's extras alike - so
        nothing carries over from the view that was active before.

        The reset lands on the *working* view: it is emitted through
        :attr:`view_applied` (so consumers can sync widgets the
        bindings do not own, such as the Group By menu) and then
        persisted, which is what makes it survive a restart.
        """
        defaults = (
            self._bindings.default_settings()
            if self._bindings.default_settings is not None
            else ViewSettings()
        )

        self._applying_view = True
        try:
            self._bindings.apply(ViewSettings())
        except Exception:
            log.exception("Failed to apply default view settings")
            return
        finally:
            self._applying_view = False
            # Nothing the user did is pending; this method saves the
            # working view itself once the widgets have settled.
            self._working_view_timer.stop()

        try:
            working_view = self._manager.get_working_view(self._view_type)
        except Exception:
            log.exception(
                "Failed to resolve working view for %r", self._view_type
            )
            working_view = None
        if working_view is None:
            working_view = View(
                label="Working",
                working=True,
                visibility=Visibility.PRIVATE,
                view_type=self._view_type,
                owner=self._current_user,
                scope=Scope.PROJECT,
            )
        working_view.settings = defaults
        working_view.loaded = True
        self._current_view = working_view
        self.setToolTip(f"View: {working_view.label}")

        # Consumers apply the slices the bindings do not own (grouping,
        # toolbar widgets) from this signal, so it has to fire before
        # the working view is captured back below.  Clearing the dirty
        # flag afterwards discards the changes those handlers report -
        # they are this reset's own writes, not the user's edits.
        self.view_applied.emit(working_view)
        self._working_view_timer.stop()
        self._clear_modified()
        self._update_working_view()

    def _capture_into(self, view: View) -> None:
        """Store the live widget state as *view*'s settings.

        Also flags the view as loaded.  Rows built from the listing
        endpoint arrive with ``loaded=False`` and empty settings, and a
        manager that fetches settings lazily reads that flag as "this
        view's settings still need fetching" - which would make it
        refetch the stored settings on save and silently discard the
        capture we just made (see ``ServerViewManager.save_view``).

        Args:
            view: The view to write the captured settings onto.
        """
        view.settings = self._bindings.capture()
        view.loaded = True

    # ------------------------------------------------------------------
    # Actions
    # ------------------------------------------------------------------

    def _on_view_selected(self, view: View) -> None:
        """Handle a view-row click in the dropdown."""
        self._apply_view(view, emit=True)
        self._close_menu()

    def _on_edit_clicked(self, view: View) -> None:
        """Open the editor for an existing view."""
        self._close_menu()
        # A row from the listing lacks the view's access grants; the
        # editor must start from the stored ones or saving wipes them.
        loaded_view = self._manager.load_view(view)
        if not loaded_view.loaded:
            # On failure to load, just stop here. Manager would've already
            # emitted the error
            return
        view_copy = View.from_payload(loaded_view.to_payload())
        self._capture_into(view_copy)
        editor = self._create_editor(view_copy)
        if editor.exec() != QDialog.DialogCode.Accepted:
            return
        with self._suspend_auto_apply():
            if editor.delete_requested():
                self._delete_view(editor.get_view())
                return
            saved = self._save_view(editor.get_view())
        if saved is not None and (
            self._current_view is not None
            and self._current_view.id == saved.id
        ):
            self._apply_view(saved, emit=True)

    def _on_create_clicked(self) -> None:
        """Open the editor for a new view, then save it."""
        self._close_menu()
        new_view = View(view_type=self._view_type)
        self._capture_into(new_view)
        editor = self._create_editor(new_view)
        if editor.exec() != QDialog.DialogCode.Accepted:
            return
        with self._suspend_auto_apply():
            if editor.delete_requested():
                self._delete_view(editor.get_view())
                return
            saved = self._save_view(editor.get_view())
        if saved is not None:
            self._apply_view(saved, emit=True)

    def _create_editor(self, view: View) -> AYViewEditor:
        """Build the metadata editor dialog for *view*."""
        allow_sharing = self._manager.supports_sharing()
        usernames_and_groups = (
            self._get_usernames_and_groups() if allow_sharing else None
        )
        return AYViewEditor(
            view,
            current_user=self._current_user,
            allow_studio_scope=self._allow_studio_scope,
            current_project=self._current_project_name(),
            usernames_and_groups=usernames_and_groups,
            allow_sharing=allow_sharing,
            parent=self,
        )

    def _delete_view(self, view: View) -> None:
        if not view.id:
            return
        if self._current_view is not None and self._current_view.id == view.id:
            self._current_view = None
            working = self._manager.get_working_view(self._view_type)
            if working:
                self._current_view = working
        try:
            self._manager.delete_view(view.id)
        except Exception:
            log.exception("Failed to delete view %r", view.id)
            return
        self.view_deleted.emit(view.id)
        self._apply_view(self._current_view, emit=True)

    def _on_reset_clicked(self) -> None:
        """Reset the current view to the default view for the current scope."""
        ctrl = self._default_view_control

        if ctrl.project_default_view:
            ctrl.load_project_default_view()
            return

        if ctrl.studio_default_view:
            ctrl.load_studio_default_view()
            return

        if ctrl.make_default_view_settings:
            ctrl.make_default_view_settings()
            return

        self.emit_default_view_message(
            "No default view configured.",
            False,
        )

    def _on_manager_changed(self, view_type: str) -> None:
        """Refresh when the manager signals a change for our type."""
        if view_type == self._view_type:
            self.refresh()

    def _on_project_changed(self, project_name: str) -> None:
        """Reset state and refresh when the manager switches project.
        clear active view belongs to the old project so the new
        project's working view can be auto-applied.
        """
        self._current_view = None
        self._clear_modified()
        self.refresh()
        self._update_enabled_for_project()

    def _update_enabled_for_project(self) -> None:
        """Enable the Views button only while a project is active.

        With no project there is nothing sensible for a view to apply
        to, so opening the dropdown wouldn't do anything useful — keep
        it disabled instead. Called after :meth:`refresh`, since that
        may apply a working view and set its own "View: ..." tooltip;
        this must have the final say when there's no project.
        """
        has_project = bool(self._current_project_name())
        self.setEnabled(has_project)
        if not has_project:
            self.setToolTip("Views (select a project first)")

    def _get_usernames_and_groups(self) -> dict[str, list]:
        """Fetch active project users and return unique usernames."""
        project_name = getattr(self._manager, "project_name", "")
        if not project_name:
            return {"users": [], "groups": []}
        users = []
        groups = []
        try:
            users = ayon_api.get_users(project_name=project_name) or []
        except Exception:  # noqa: BLE001
            log.exception("Failed to fetch users for project %r",
                          project_name)

        try:
            group_data = ayon_api.get(f"/accessGroups/{project_name}").data
            groups = [group.get('name') for group in group_data]
        except Exception:
            log.exception("Failed to fetch project groups")

        users_data: list[dict[str, str]] = []
        seen: set[str] = set()
        for user in users:
            if not isinstance(user, dict):
                continue
            if not bool(user.get("active", False)):
                continue
            name = str(user.get("name") or "").strip()
            if not name or name in seen or name == self._current_user:
                continue

            full_name = user["attrib"].get("fullName") or name

            seen.add(name)
            users_data.append({"name": name, "fullName": full_name})
        return {"users": users_data, "groups": groups}

    # ------------------------------------------------------------------
    # Helpers
    # ------------------------------------------------------------------

    def _close_menu(self) -> None:
        """Close the dropdown popup if currently open."""
        try:
            self._dropdown.close()
        except Exception:
            pass

    def refresh_dropdown_size(self) -> None:
        """Resize the dropdown popup to fit its current content.

        Call after any change to the dropdown's contents (a full
        rebuild, or swapping a single row/pill's children in place):
        rebuilding a widget's children leaves stale cached size hints
        on its ancestors, so a plain ``adjustSize()`` alone can settle
        on a size that no longer fits — e.g. clipping the label text on
        a default-view pill after it switches between its "unset" and
        "active" (wider) look. Invalidating the layout chain first
        forces every ancestor to recompute before the popup resizes.
        """
        layout = self._dropdown_layout
        if layout is not None:
            layout.invalidate()
            layout.activate()
        self._dropdown.updateGeometry()
        self._dropdown.adjustSize()

    def emit_default_view_message(self, message: str, success: bool) -> None:
        """Emit default-view feedback for host UIs.

        Hosts can map this to the same toast presentation used by loader
        action results.
        """
        self.default_view_message.emit(message, success)

    def _current_project_name(self) -> str:
        """Return the manager's current project name when available."""
        project_name = getattr(self._manager, "project_name", "")
        return str(project_name or "")

    def _ensure_working_view_exists(self) -> View | None:
        """Return the current working view, creating one when missing."""
        if self._ensuring_working_view or self._current_view is not None:
            return None

        try:
            existing = self._manager.get_working_view(self._view_type)
        except Exception:
            log.exception(
                "Failed to resolve working view for %r", self._view_type
            )
            existing = None
        if existing is not None:
            return existing

        project_name = getattr(self._manager, "project_name", None)
        if project_name is not None and not str(project_name or ""):
            return None

        working_view = View(
            label="Working",
            settings=self._bindings.capture(),
            working=True,
            visibility=Visibility.PRIVATE,
            view_type=self._view_type,
            owner=self._current_user,
            scope=Scope.PROJECT
        )

        self._ensuring_working_view = True
        try:
            with self._suspend_auto_apply():
                self._manager.set_working_view(working_view)
        except Exception:
            log.exception(
                "Failed to create initial working view for %r",
                self._view_type,
            )
            return None
        finally:
            self._ensuring_working_view = False

        refreshed_working = self._manager.get_working_view(self._view_type)
        if refreshed_working is not None:
            return refreshed_working
        return working_view

    def _mark_modified(self, *_args) -> None:
        """Set the dirty flag on the first UI change after a view is applied.
            and cleared by applying, saving, or resetting a view.
        """
        if self._applying_view:
            # Our own writes bouncing back off the widgets.
            return

        self._working_view_timer.start()

        if self._view_modified:
            return

        self._view_modified = True

        if self._current_view and not self._current_view.working:
            self.set_variant(AYButton.Variants.Filled)

        if self._current_view:
            view_name = self._current_view.label or self._current_view.id
            self.view_modified.emit(view_name, True)

    def _apply_view(self, view: View, emit: bool) -> None:
        """Apply *view* to the bindings and clear the dirty flag.

        Default views are applied onto the working-view context, so edits
        continue against the working view.
        """
        is_default_view = (
            not view.working
            and view.label == DEFAULT_VIEW_LABEL
        )

        target_view = view
        if is_default_view:
            try:
                working_view = self._manager.get_working_view(self._view_type)
            except Exception:
                log.exception(
                    "Failed to resolve working view for %r", self._view_type
                )
                return
            target_view = working_view
            self._close_menu()

        # A view picked from the dropdown carries only listing metadata;
        # this is where its settings are actually needed.
        view = self._manager.load_view(view)
        if target_view is not None and target_view.id == view.id:
            target_view = view
        self._current_view = target_view

        self._applying_view = True
        try:
            self._bindings.apply(view.settings)
        except Exception:
            log.exception("Failed to apply view %r", view.id)
            return
        finally:
            self._applying_view = False
            # Nothing was modified, so drop any save this apply queued.
            self._working_view_timer.stop()

        self.setToolTip(f"View: {self._current_view.label}")
        self._clear_modified()

        if emit:
            self.view_applied.emit(view)

    def _on_view_save_clicked(self, view: View) -> None:
        self._close_menu()
        self._capture_into(view)
        with self._suspend_auto_apply():
            saved = self._save_view(view)
        if saved is not None:
            self._apply_view(saved, emit=True)

    def _clear_modified(self) -> None:
        """Clear the dirty flag and restore the default button variant."""
        if not self._view_modified:
            return
        self._view_modified = False
        self.set_variant(AYButton.Variants.Surface)
        if self._current_view:
            view_name = self._current_view.label or self._current_view.id
            self.view_modified.emit(view_name, False)

    def _save_view(self, view: View) -> View | None:
        """
        This called when new view is created or existing view is updated.
        Persist *view* and return the manager's response."""
        try:
            saved = self._manager.save_view(view)
        except Exception:
            log.exception("Failed to save view %r", view.label)
            return None
        self.view_saved.emit(saved)
        return saved

    def _on_working_view_clicked(self, _checked: bool = False) -> None:
        try:
            working_view = self._manager.get_working_view(self._view_type)
        except Exception:
            log.exception(
                "Failed to resolve working view for %r", self._view_type
            )
            return

        if not working_view:
            return

        self._apply_view(working_view, emit=True)

        # Change views icon state to normal
        self.set_variant(AYButton.Variants.Surface)
        self._close_menu()

    def hideEvent(self, event) -> None:
        """Flush a pending working-view save before going away.

        The debounce would otherwise drop the last few hundred
        milliseconds of edits when the window closes.
        """
        if self._working_view_timer.isActive():
            self._working_view_timer.stop()
            self._update_working_view()
        super().hideEvent(event)

    def _update_working_view(self) -> None:
        """Persist the live widget state onto the working view."""
        # The applied view is normally the working view already, so going
        # back to the server for it would just re-read what we hold.
        working_view = self._current_view
        if working_view is None or not working_view.working:
            try:
                working_view = self._manager.get_working_view(self._view_type)
            except Exception:
                log.exception(
                    "Failed to resolve working view for %r", self._view_type
                )
                return
        if working_view is None:
            return
        self._capture_into(working_view)
        with self._suspend_auto_apply():
            self._manager.set_working_view(working_view)

    def get_default_views(self):
        """Return the current default views from the default view control."""
        return (
            self._default_view_control.studio_default_view,
            self._default_view_control.project_default_view,
        )

    def _suspend_auto_apply(self) -> "_SuspendAutoApply":
        """Return a context manager that suppresses the auto-apply branch
        of :meth:`refresh` while a save is in flight.
        """
        return _SuspendAutoApply(self)

apply_default_settings()

Reset every tracked setting to the built-in AYON defaults.

Used when neither a project nor a studio default view is configured, so "reset" has to fall back to what the consumer declared as its defaults. Applying an empty :class:ViewSettings makes :class:ViewBindings fill in every slice from default_settings - columns, sort, filter, grouping, row height and the consumer's extras alike - so nothing carries over from the view that was active before.

The reset lands on the working view: it is emitted through :attr:view_applied (so consumers can sync widgets the bindings do not own, such as the Group By menu) and then persisted, which is what makes it survive a restart.

Source code in client/ayon_core/ui/components/views/view_selector.py
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
def apply_default_settings(self) -> None:
    """Reset every tracked setting to the built-in AYON defaults.

    Used when neither a project nor a studio default view is
    configured, so "reset" has to fall back to what the consumer
    declared as its defaults.  Applying an empty
    :class:`ViewSettings` makes :class:`ViewBindings` fill in every
    slice from ``default_settings`` - columns, sort, filter,
    grouping, row height and the consumer's extras alike - so
    nothing carries over from the view that was active before.

    The reset lands on the *working* view: it is emitted through
    :attr:`view_applied` (so consumers can sync widgets the
    bindings do not own, such as the Group By menu) and then
    persisted, which is what makes it survive a restart.
    """
    defaults = (
        self._bindings.default_settings()
        if self._bindings.default_settings is not None
        else ViewSettings()
    )

    self._applying_view = True
    try:
        self._bindings.apply(ViewSettings())
    except Exception:
        log.exception("Failed to apply default view settings")
        return
    finally:
        self._applying_view = False
        # Nothing the user did is pending; this method saves the
        # working view itself once the widgets have settled.
        self._working_view_timer.stop()

    try:
        working_view = self._manager.get_working_view(self._view_type)
    except Exception:
        log.exception(
            "Failed to resolve working view for %r", self._view_type
        )
        working_view = None
    if working_view is None:
        working_view = View(
            label="Working",
            working=True,
            visibility=Visibility.PRIVATE,
            view_type=self._view_type,
            owner=self._current_user,
            scope=Scope.PROJECT,
        )
    working_view.settings = defaults
    working_view.loaded = True
    self._current_view = working_view
    self.setToolTip(f"View: {working_view.label}")

    # Consumers apply the slices the bindings do not own (grouping,
    # toolbar widgets) from this signal, so it has to fire before
    # the working view is captured back below.  Clearing the dirty
    # flag afterwards discards the changes those handlers report -
    # they are this reset's own writes, not the user's edits.
    self.view_applied.emit(working_view)
    self._working_view_timer.stop()
    self._clear_modified()
    self._update_working_view()

current_view()

Return the currently active view, or None.

Source code in client/ayon_core/ui/components/views/view_selector.py
632
633
634
def current_view(self) -> View | None:
    """Return the currently active view, or ``None``."""
    return self._current_view

emit_default_view_message(message, success)

Emit default-view feedback for host UIs.

Hosts can map this to the same toast presentation used by loader action results.

Source code in client/ayon_core/ui/components/views/view_selector.py
925
926
927
928
929
930
931
def emit_default_view_message(self, message: str, success: bool) -> None:
    """Emit default-view feedback for host UIs.

    Hosts can map this to the same toast presentation used by loader
    action results.
    """
    self.default_view_message.emit(message, success)

get_default_views()

Return the current default views from the default view control.

Source code in client/ayon_core/ui/components/views/view_selector.py
1132
1133
1134
1135
1136
1137
def get_default_views(self):
    """Return the current default views from the default view control."""
    return (
        self._default_view_control.studio_default_view,
        self._default_view_control.project_default_view,
    )

hideEvent(event)

Flush a pending working-view save before going away.

The debounce would otherwise drop the last few hundred milliseconds of edits when the window closes.

Source code in client/ayon_core/ui/components/views/view_selector.py
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
def hideEvent(self, event) -> None:
    """Flush a pending working-view save before going away.

    The debounce would otherwise drop the last few hundred
    milliseconds of edits when the window closes.
    """
    if self._working_view_timer.isActive():
        self._working_view_timer.stop()
        self._update_working_view()
    super().hideEvent(event)

notify_view_modified(*_args)

Mark the view as modified after a UI settings change.

Consumer code may connect custom widgets here when they affect :meth:ViewBindings.capture but are not part of the built-in table/filter stack.

Source code in client/ayon_core/ui/components/views/view_selector.py
636
637
638
639
640
641
642
643
def notify_view_modified(self, *_args) -> None:
    """Mark the view as modified after a UI settings change.

    Consumer code may connect custom widgets here when they affect
    :meth:`ViewBindings.capture` but are not part of the built-in
    table/filter stack.
    """
    self._mark_modified()

refresh()

Re-resolve the working view and drop the cached view list.

Restoring the user's last state only needs the working view, which has its own endpoint. The dropdown is not on screen yet, so listing the rest - and each one's settings - waits until the menu is opened; see :meth:_ensure_views_listed.

Source code in client/ayon_core/ui/components/views/view_selector.py
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
def refresh(self) -> None:
    """Re-resolve the working view and drop the cached view list.

    Restoring the user's last state only needs the working view,
    which has its own endpoint. The dropdown is not on screen yet, so
    listing the rest - and each one's settings - waits until the menu
    is opened; see :meth:`_ensure_views_listed`.
    """
    self._views = []
    self._views_loaded = False

    working_view = self._ensure_working_view_exists()
    if (
        working_view is not None
        and self._current_view is None
        and not self._suppress_auto_apply
    ):
        self._apply_view(working_view, emit=True)
        return

refresh_dropdown_size()

Resize the dropdown popup to fit its current content.

Call after any change to the dropdown's contents (a full rebuild, or swapping a single row/pill's children in place): rebuilding a widget's children leaves stale cached size hints on its ancestors, so a plain adjustSize() alone can settle on a size that no longer fits — e.g. clipping the label text on a default-view pill after it switches between its "unset" and "active" (wider) look. Invalidating the layout chain first forces every ancestor to recompute before the popup resizes.

Source code in client/ayon_core/ui/components/views/view_selector.py
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
def refresh_dropdown_size(self) -> None:
    """Resize the dropdown popup to fit its current content.

    Call after any change to the dropdown's contents (a full
    rebuild, or swapping a single row/pill's children in place):
    rebuilding a widget's children leaves stale cached size hints
    on its ancestors, so a plain ``adjustSize()`` alone can settle
    on a size that no longer fits — e.g. clipping the label text on
    a default-view pill after it switches between its "unset" and
    "active" (wider) look. Invalidating the layout chain first
    forces every ancestor to recompute before the popup resizes.
    """
    layout = self._dropdown_layout
    if layout is not None:
        layout.invalidate()
        layout.activate()
    self._dropdown.updateGeometry()
    self._dropdown.adjustSize()

set_view_type(view_type)

Switch to a new view-type identifier.

Source code in client/ayon_core/ui/components/views/view_selector.py
593
594
595
596
597
598
599
def set_view_type(self, view_type: str) -> None:
    """Switch to a new view-type identifier."""
    if view_type == self._view_type:
        return
    self._view_type = view_type
    self._current_view = None
    self.refresh()

ColumnState dataclass

State of a single column inside a View.

Attributes:

Name Type Description
name str

Column key, matches :attr:TableColumn.key.

visible bool

Whether the column is currently shown.

pinned bool

Whether the column is pinned to the left (Phase 3 polish; stored but not visually frozen in Phase 1).

width int | None

Pixel width override, or None to use the column's default / auto width. Note that :class:~ayon_core.ui.components.table_model.TableColumn uses 0 to mean auto; the mapping is handled by PaginatedTableModel.apply_settings.

Source code in client/ayon_core/ui/components/views/data_models.py
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
@dataclass
class ColumnState:
    """State of a single column inside a View.

    Attributes:
        name: Column key, matches :attr:`TableColumn.key`.
        visible: Whether the column is currently shown.
        pinned: Whether the column is pinned to the left (Phase 3 polish;
            stored but not visually frozen in Phase 1).
        width: Pixel width override, or ``None`` to use the column's
            default / auto width.  Note that
            :class:`~ayon_core.ui.components.table_model.TableColumn`
            uses ``0`` to mean *auto*; the mapping is handled by
            ``PaginatedTableModel.apply_settings``.
    """

    name: str
    visible: bool = True
    pinned: bool = False
    width: int | None = None

    @classmethod
    def from_payload(cls, payload: dict[str, Any]) -> "ColumnState":
        """Build a ColumnState from a single payload column dict.

        Args:
            payload: Dict like ``{"name": "...", "visible": True, ...}``.

        Returns:
            A new ColumnState instance.
        """
        width = payload.get("width")
        if width is not None:
            try:
                width = int(width)
            except (TypeError, ValueError):
                width = None
        return cls(
            name=str(payload.get("name", "")),
            visible=bool(payload.get("visible", True)),
            pinned=bool(payload.get("pinned", False)),
            width=width,
        )

    def to_payload(self) -> dict[str, Any]:
        """Serialise this column state to the payload format.

        Returns:
            A new dict with payload-friendly keys.
        """
        out: dict[str, Any] = {
            "name": self.name,
            "visible": self.visible,
            "pinned": self.pinned,
        }
        if self.width is not None:
            out["width"] = self.width
        return out

from_payload(payload) classmethod

Build a ColumnState from a single payload column dict.

Parameters:

Name Type Description Default
payload dict[str, Any]

Dict like {"name": "...", "visible": True, ...}.

required

Returns:

Type Description
'ColumnState'

A new ColumnState instance.

Source code in client/ayon_core/ui/components/views/data_models.py
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
@classmethod
def from_payload(cls, payload: dict[str, Any]) -> "ColumnState":
    """Build a ColumnState from a single payload column dict.

    Args:
        payload: Dict like ``{"name": "...", "visible": True, ...}``.

    Returns:
        A new ColumnState instance.
    """
    width = payload.get("width")
    if width is not None:
        try:
            width = int(width)
        except (TypeError, ValueError):
            width = None
    return cls(
        name=str(payload.get("name", "")),
        visible=bool(payload.get("visible", True)),
        pinned=bool(payload.get("pinned", False)),
        width=width,
    )

to_payload()

Serialise this column state to the payload format.

Returns:

Type Description
dict[str, Any]

A new dict with payload-friendly keys.

Source code in client/ayon_core/ui/components/views/data_models.py
151
152
153
154
155
156
157
158
159
160
161
162
163
164
def to_payload(self) -> dict[str, Any]:
    """Serialise this column state to the payload format.

    Returns:
        A new dict with payload-friendly keys.
    """
    out: dict[str, Any] = {
        "name": self.name,
        "visible": self.visible,
        "pinned": self.pinned,
    }
    if self.width is not None:
        out["width"] = self.width
    return out

FilterDef dataclass

Server-side filter configuration captured in a View.

The condition dicts are opaque to ayon-core: they are passed through to the filter proxy untouched. The proxy converts them to :class:FilterCriterion via :meth:FilterCriterion.from_def / :meth:to_def.

Attributes:

Name Type Description
conditions list[dict[str, Any]]

List of condition dicts. Each dict has at least a key and values field.

operator str

How conditions combine — "and" or "or".

Source code in client/ayon_core/ui/components/views/data_models.py
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
@dataclass
class FilterDef:
    """Server-side filter configuration captured in a View.

    The condition dicts are opaque to ``ayon-core``: they are passed
    through to the filter proxy untouched.  The proxy converts them
    to :class:`FilterCriterion` via
    :meth:`FilterCriterion.from_def` / :meth:`to_def`.

    Attributes:
        conditions: List of condition dicts.  Each dict has at least a
            ``key`` and ``values`` field.
        operator: How conditions combine — ``"and"`` or ``"or"``.
    """

    conditions: list[dict[str, Any]] = field(default_factory=list)
    operator: str = "and"

    @classmethod
    def from_payload(cls, payload: dict[str, Any] | None) -> "FilterDef":
        """Build a FilterDef from the payload's ``filter`` dict.

        Args:
            payload: Filter sub-dict, or ``None`` for an empty filter.

        Returns:
            A new FilterDef.
        """
        if not payload:
            return cls()
        raw_conds = payload.get("conditions") or []
        operator = str(payload.get("operator", "and")).lower()
        if operator not in ("and", "or"):
            operator = "and"
        return cls(
            conditions=[
                copy.deepcopy(c) for c in raw_conds if isinstance(c, dict)
            ],
            operator=operator,
        )

    def to_payload(self) -> dict[str, Any]:
        """Serialise this filter to a payload dict.

        Returns:
            A dict with ``conditions`` and ``operator`` keys.
        """
        return {
            "conditions": [copy.deepcopy(c) for c in self.conditions],
            "operator": self.operator,
        }

    def is_empty(self) -> bool:
        """Return True when there are no filter conditions.

        Returns:
            True when there are no conditions configured.
        """
        return not self.conditions

from_payload(payload) classmethod

Build a FilterDef from the payload's filter dict.

Parameters:

Name Type Description Default
payload dict[str, Any] | None

Filter sub-dict, or None for an empty filter.

required

Returns:

Type Description
'FilterDef'

A new FilterDef.

Source code in client/ayon_core/ui/components/views/data_models.py
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
@classmethod
def from_payload(cls, payload: dict[str, Any] | None) -> "FilterDef":
    """Build a FilterDef from the payload's ``filter`` dict.

    Args:
        payload: Filter sub-dict, or ``None`` for an empty filter.

    Returns:
        A new FilterDef.
    """
    if not payload:
        return cls()
    raw_conds = payload.get("conditions") or []
    operator = str(payload.get("operator", "and")).lower()
    if operator not in ("and", "or"):
        operator = "and"
    return cls(
        conditions=[
            copy.deepcopy(c) for c in raw_conds if isinstance(c, dict)
        ],
        operator=operator,
    )

is_empty()

Return True when there are no filter conditions.

Returns:

Type Description
bool

True when there are no conditions configured.

Source code in client/ayon_core/ui/components/views/data_models.py
219
220
221
222
223
224
225
def is_empty(self) -> bool:
    """Return True when there are no filter conditions.

    Returns:
        True when there are no conditions configured.
    """
    return not self.conditions

to_payload()

Serialise this filter to a payload dict.

Returns:

Type Description
dict[str, Any]

A dict with conditions and operator keys.

Source code in client/ayon_core/ui/components/views/data_models.py
208
209
210
211
212
213
214
215
216
217
def to_payload(self) -> dict[str, Any]:
    """Serialise this filter to a payload dict.

    Returns:
        A dict with ``conditions`` and ``operator`` keys.
    """
    return {
        "conditions": [copy.deepcopy(c) for c in self.conditions],
        "operator": self.operator,
    }

GroupingDef dataclass

Grouping configuration captured in a View.

Attributes:

Name Type Description
group_by str | None

Column key to group rows by, or None for no grouping.

group_sort_desc bool

Whether the group ordering is descending.

show_empty_groups bool

Whether groups with no visible rows are rendered or hidden.

Source code in client/ayon_core/ui/components/views/data_models.py
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
@dataclass
class GroupingDef:
    """Grouping configuration captured in a View.

    Attributes:
        group_by: Column key to group rows by, or ``None`` for no
            grouping.
        group_sort_desc: Whether the group ordering is descending.
        show_empty_groups: Whether groups with no visible rows are
            rendered or hidden.
    """

    group_by: str | None = None
    group_sort_desc: bool = False
    show_empty_groups: bool = False

    def is_empty(self) -> bool:
        """Return True when no grouping is configured.

        Returns:
            True when ``group_by`` is ``None``.
        """
        return self.group_by is None

is_empty()

Return True when no grouping is configured.

Returns:

Type Description
bool

True when group_by is None.

Source code in client/ayon_core/ui/components/views/data_models.py
244
245
246
247
248
249
250
def is_empty(self) -> bool:
    """Return True when no grouping is configured.

    Returns:
        True when ``group_by`` is ``None``.
    """
    return self.group_by is None

InMemoryViewManager

Bases: ViewManager

Lightweight in-process implementation used by tests / demos.

Stores views in a dict keyed by view.id. Useful when the consumer app has not yet wired the real server backend, or when running the standalone tester harness.

Source code in client/ayon_core/ui/components/views/view_manager.py
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
class InMemoryViewManager(ViewManager):
    """Lightweight in-process implementation used by tests / demos.

    Stores views in a dict keyed by ``view.id``.  Useful when the
    consumer app has not yet wired the real server backend, or when
    running the standalone tester harness.
    """

    def __init__(
        self,
        views: list[View] | None = None,
        parent: QObject | None = None,
    ) -> None:
        """Seed the manager with an optional initial set of views.

        Args:
            views: Initial views to register.  Each view must have a
                non-empty :attr:`View.id`; one will be assigned if
                missing.
            parent: Optional parent QObject.
        """
        super().__init__(parent)
        self._views: dict[str, View] = {}
        for view in views or []:
            if not view.id:
                view.id = self._new_id()
            self._views[view.id] = view

    # -- ViewManager API ---------------------------------------------------

    def list_views(self, view_type: str) -> list[View]:
        """Return views matching *view_type*, sorted for display.

        Args:
            view_type: The view-type identifier.

        Returns:
            Sorted list of views.
        """
        matching = [
            v for v in self._views.values() if v.view_type == view_type
        ]
        matching.sort(key=lambda v: (v.position, v.label))
        return matching

    def save_view(self, view: View) -> View:
        """Insert or replace *view* in the in-memory store.

        Args:
            view: The view to save.  An id is assigned if missing.

        Returns:
            The same view instance (with assigned id).
        """
        if not view.id:
            view.id = self._new_id()
        self._views[view.id] = view
        self.view_saved.emit(view.id)
        self.views_changed.emit(view.view_type)
        return view

    def delete_view(self, view_id: str) -> None:
        """Remove *view_id* from the store, if present.

        Args:
            view_id: Identifier of the view to remove.  Missing ids
                are silently ignored after a debug log entry.
        """
        existing = self._views.pop(view_id, None)
        if existing is None:
            log.debug("delete_view: no view with id %r", view_id)
            return
        self.view_deleted.emit(view_id)
        self.views_changed.emit(existing.view_type)

    # -- helpers -----------------------------------------------------------

    @staticmethod
    def _new_id() -> str:
        """Return a new unique view identifier.

        Returns:
            A short uuid4 hex string.
        """
        return uuid.uuid4().hex

    def clear(self) -> None:
        """Remove all stored views (intended for tests)."""
        types = {v.view_type for v in self._views.values()}
        self._views.clear()
        for view_type in types:
            self.views_changed.emit(view_type)

    def all_views(self) -> list[View]:
        """Return every stored view across all view types.

        Returns:
            A snapshot list of all stored views.
        """
        return list(self._views.values())

__init__(views=None, parent=None)

Seed the manager with an optional initial set of views.

Parameters:

Name Type Description Default
views list[View] | None

Initial views to register. Each view must have a non-empty :attr:View.id; one will be assigned if missing.

None
parent QObject | None

Optional parent QObject.

None
Source code in client/ayon_core/ui/components/views/view_manager.py
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
def __init__(
    self,
    views: list[View] | None = None,
    parent: QObject | None = None,
) -> None:
    """Seed the manager with an optional initial set of views.

    Args:
        views: Initial views to register.  Each view must have a
            non-empty :attr:`View.id`; one will be assigned if
            missing.
        parent: Optional parent QObject.
    """
    super().__init__(parent)
    self._views: dict[str, View] = {}
    for view in views or []:
        if not view.id:
            view.id = self._new_id()
        self._views[view.id] = view

all_views()

Return every stored view across all view types.

Returns:

Type Description
list[View]

A snapshot list of all stored views.

Source code in client/ayon_core/ui/components/views/view_manager.py
294
295
296
297
298
299
300
def all_views(self) -> list[View]:
    """Return every stored view across all view types.

    Returns:
        A snapshot list of all stored views.
    """
    return list(self._views.values())

clear()

Remove all stored views (intended for tests).

Source code in client/ayon_core/ui/components/views/view_manager.py
287
288
289
290
291
292
def clear(self) -> None:
    """Remove all stored views (intended for tests)."""
    types = {v.view_type for v in self._views.values()}
    self._views.clear()
    for view_type in types:
        self.views_changed.emit(view_type)

delete_view(view_id)

Remove view_id from the store, if present.

Parameters:

Name Type Description Default
view_id str

Identifier of the view to remove. Missing ids are silently ignored after a debug log entry.

required
Source code in client/ayon_core/ui/components/views/view_manager.py
262
263
264
265
266
267
268
269
270
271
272
273
274
def delete_view(self, view_id: str) -> None:
    """Remove *view_id* from the store, if present.

    Args:
        view_id: Identifier of the view to remove.  Missing ids
            are silently ignored after a debug log entry.
    """
    existing = self._views.pop(view_id, None)
    if existing is None:
        log.debug("delete_view: no view with id %r", view_id)
        return
    self.view_deleted.emit(view_id)
    self.views_changed.emit(existing.view_type)

list_views(view_type)

Return views matching view_type, sorted for display.

Parameters:

Name Type Description Default
view_type str

The view-type identifier.

required

Returns:

Type Description
list[View]

Sorted list of views.

Source code in client/ayon_core/ui/components/views/view_manager.py
231
232
233
234
235
236
237
238
239
240
241
242
243
244
def list_views(self, view_type: str) -> list[View]:
    """Return views matching *view_type*, sorted for display.

    Args:
        view_type: The view-type identifier.

    Returns:
        Sorted list of views.
    """
    matching = [
        v for v in self._views.values() if v.view_type == view_type
    ]
    matching.sort(key=lambda v: (v.position, v.label))
    return matching

save_view(view)

Insert or replace view in the in-memory store.

Parameters:

Name Type Description Default
view View

The view to save. An id is assigned if missing.

required

Returns:

Type Description
View

The same view instance (with assigned id).

Source code in client/ayon_core/ui/components/views/view_manager.py
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
def save_view(self, view: View) -> View:
    """Insert or replace *view* in the in-memory store.

    Args:
        view: The view to save.  An id is assigned if missing.

    Returns:
        The same view instance (with assigned id).
    """
    if not view.id:
        view.id = self._new_id()
    self._views[view.id] = view
    self.view_saved.emit(view.id)
    self.views_changed.emit(view.view_type)
    return view

ProgressBarState

Bases: Enum

Semantic state controlling the colour of the filled chunk.

Source code in client/ayon_core/ui/components/progress_bar.py
23
24
25
26
27
28
29
class ProgressBarState(Enum):
    """Semantic state controlling the colour of the filled chunk."""

    Normal = "normal"
    Error = "error"
    Warning = "warning"
    Success = "success"

Scope

Bases: str, Enum

The level at which a view is stored on the server.

Source code in client/ayon_core/ui/components/views/data_models.py
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
class Scope(str, Enum):
    """The level at which a view is stored on the server."""

    PROJECT = "project"
    STUDIO = "studio"

    @classmethod
    def _missing_(cls, value: Any) -> "Scope":
        """Fall back to ``PROJECT`` for unknown values.

        Args:
            value: Raw value found in the payload.

        Returns:
            The matching enum member, or ``PROJECT`` if unknown.
        """
        log.warning("Unknown Scope value %r, defaulting to PROJECT", value)
        return cls.PROJECT

ServerViewManager

Bases: ViewManager

ViewManager backed by AYON server REST endpoints.

Endpoints (project-scoped, all take ?project_name=<p>):

  • GET /api/views/{view_type}
  • GET /api/views/{view_type}/working
  • POST /api/views/{view_type}
  • PATCH /api/views/{view_type}/{view_id}
  • POST /api/addons/powerpack/{version}/views/{view_type}/{id}/share
  • DELETE /api/views/{view_type}/{view_id}

Attributes:

Name Type Description
project_name str

Name of the project all calls are scoped to.

Source code in client/ayon_core/ui/components/views/server_view_manager.py
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
class ServerViewManager(ViewManager):
    """ViewManager backed by AYON server REST endpoints.

    Endpoints (project-scoped, all take ``?project_name=<p>``):

    * ``GET    /api/views/{view_type}``
    * ``GET    /api/views/{view_type}/working``
    * ``POST   /api/views/{view_type}``
    * ``PATCH  /api/views/{view_type}/{view_id}``
    * ``POST   /api/addons/powerpack/{version}/views/{view_type}/{id}/share``
    * ``DELETE /api/views/{view_type}/{view_id}``

    Attributes:
        project_name: Name of the project all calls are scoped to.
    """

    def __init__(
            self,
            project_name: str,
            parent: QObject | None = None,
    ) -> None:
        """Initialise the manager.

        Args:
            project_name: Initial project the manager is scoped to.
                May be empty when the loader has no project yet.
            parent: Optional parent QObject.
        """
        super().__init__(parent=parent)
        self._project_name = project_name
        # view_type -> sorted list of views (per-type list cache)
        self._cache: dict[str, list[View]] = {}
        # view_id -> (view_type, scope) (survives list_views; used by
        # delete_view so it doesn't require a populated per-type cache).
        self._id_to_view_attributes: dict[str, tuple[str, Scope]] = {}
        self._powerpack_version: str | None = None
        self._powerpack_resolved: bool = False

    # ------------------------------------------------------------------
    # Project scope
    # ------------------------------------------------------------------

    @property
    def project_name(self) -> str:
        """Current project the manager is scoped to."""
        return self._project_name

    def set_project(self, project_name: str) -> None:
        """Rebind the manager to a different project.

        Clears both caches and emits :attr:`project_changed` with the
        new project name so listeners can refresh.

        Args:
            project_name: Target project name.  No-op when the same as
                the current project.
        """
        if project_name == self._project_name:
            return
        self._project_name = project_name
        self._id_to_view_attributes.clear()
        self._cache.clear()
        self.project_changed.emit(project_name)

    # ------------------------------------------------------------------
    # ViewManager API
    # ------------------------------------------------------------------

    def get_working_view(self, view_type: str) -> View | None:
        """Return the working view for *view_type* using the direct endpoint.

        Uses ``GET /api/views/{view_type}/working`` for faster lookup than
        scanning ``list_views``.

        Args:
            view_type: View-type identifier.

        Returns:
            Parsed working :class:`View`, or ``None`` when not found.
        """
        if not self._project_name:
            return None

        try:
            resp = ayon_api.get(
                f"views/{view_type}/working",
                project_name=self._project_name,
            )
        except Exception as exc:  # noqa: BLE001
            log.exception("Failed to fetch working view for %s", view_type)
            self.error.emit(f"Failed to fetch working view: {exc}")
            return None

        # No working view yet (404) - the error body is not a view, parsing
        #   it would give a view without id and view type which then fails
        #   to save.
        if resp.status_code != 200 or not resp.data:
            return None
        view = View.from_payload(resp.data)
        if not view.view_type:
            view.view_type = view_type
        return view

    def get_default_project_view(self, view_type: str) -> View | None:
        """Return project default view."""
        if not self._project_name:
            return None

        try:
            resp = ayon_api.get(
                f"views/{view_type}/base",
                project_name=self._project_name,
            )
        except Exception as exc:  # noqa: BLE001
            log.exception(
                "Failed to fetch project default view for %s", view_type
            )
            self.error.emit(f"Failed to fetch project default view: {exc}")
            return None
        if resp.status_code != 200:
            return None
        view = View.from_payload(resp.data)
        self._id_to_view_attributes[view.id] = (view_type, view.scope)
        return view

    def get_default_studio_view(self, view_type: str) -> View | None:
        """Return studio default view."""
        try:
            resp = ayon_api.get(f"views/{view_type}/base")
        except Exception as exc:  # noqa: BLE001
            log.exception(
                "Failed to fetch studio default view for %s", view_type
            )
            self.error.emit(f"Failed to fetch studio default view: {exc}")
            return None
        if resp.status_code != 200:
            return None
        view = View.from_payload(resp.data)
        self._id_to_view_attributes[view.id] = (view_type, view.scope)
        return view

    def list_views(self, view_type: str) -> list[View]:
        """Return views for *view_type*, fetching from server on miss.

        Returns an empty list (without emitting :attr:`error`) when the
        project name is empty — callers should wait for
        ``project_changed`` before listing.

        Args:
            view_type: View-type identifier (e.g. ``"versions"``).

        Returns:
            Sorted list of :class:`View` instances.  Empty list on
            network or parse error.
        """
        # Skip the network call when no project is set.
        if not self._project_name:
            return []

        if view_type in self._cache:
            return list(self._cache[view_type])

        try:
            resp = ayon_api.get(
                f"views/{view_type}",
                project_name=self._project_name,
            )
        except Exception as exc:  # noqa: BLE001
            log.exception("Failed to list views for %s", view_type)
            self.error.emit(f"Failed to list views: {exc}")
            return []

        payload = getattr(resp, "data", None)
        if isinstance(payload, dict):
            items = payload.get("views", payload)
        else:
            items = payload
        if not isinstance(items, list):
            items = []

        views: list[View] = []
        for item in items:
            # Skip the default view, should not be listed in the Views UI.
            if item.get("label") == DEFAULT_VIEW_LABEL:
                continue
            try:
                view = View.from_payload(item)
            except Exception:  # noqa: BLE001
                log.exception("Skipping malformed view payload: %r", item)
                continue
            # The listing carries only the metadata the selector needs to
            # draw its rows; settings arrive via :meth:`load_view` when a
            # view is actually applied or saved.
            view.loaded = False
            # Nor does it carry the view type, which every per-view
            # endpoint needs in its path.
            if not view.view_type:
                view.view_type = view_type
            views.append(view)

        views.sort(key=lambda v: (v.position, v.label.lower()))
        self._cache[view_type] = views
        # Populate id-map for delete_view lookups.
        for v in views:
            if v.id:
                self._id_to_view_attributes[v.id] = (view_type, v.scope)
        return list(views)

    def set_working_view(self, view: View) -> None:
        """Mark *view* as the sole working view for its type.

        The base implementation scans :meth:`list_views` to clear the flag
        on every other view, which costs a full listing on each save. The
        server exposes the working view directly, so at most one other
        view can be holding the flag and one request finds it - and when
        *view* already is the working view, none is needed at all.

        Args:
            view: The view to mark as working.
        """
        if not (view.working and view.id):
            current = self.get_working_view(view.view_type)
            if current is not None and current.id and current.id != view.id:
                current.working = False
                self.save_view(current)
        view.working = True
        self.save_view(view)

    def load_view(self, view: View) -> View:
        """Return *view* with its settings fetched from the server.

        :meth:`list_views` builds its rows from the listing endpoint,
        which omits ``settings``, so opening the Views menu costs one
        request instead of one per view. This fills in the rest, and is
        a no-op for a view that already carries its settings.

        Args:
            view: A view, possibly built from a listing summary.

        Returns:
            A fully populated view. The passed view is returned unchanged
            when it is already loaded, has no id, or the fetch fails -
            callers must still tolerate empty settings.
        """
        if view.loaded or not view.id:
            return view

        view_type = view.view_type or ""
        try:
            resp = ayon_api.get(
                f"views/{view_type}/{view.id}",
                project_name=self._project_name,
            )
        except Exception as exc:  # noqa: BLE001
            log.exception("Failed to fetch view %s", view.id)
            self.error.emit(f"Failed to fetch view: {exc}")
            return view

        try:
            loaded = View.from_payload(resp.data)
        except Exception:  # noqa: BLE001
            log.exception("Malformed view payload for %s", view.id)
            return view

        cached = self._cache.get(view_type)
        if cached is not None:
            for idx, item in enumerate(cached):
                if item.id == loaded.id:
                    cached[idx] = loaded
                    break
        return loaded

    def save_view(self, view: View) -> View:
        """POST a new view or PATCH an existing one.

        The new-vs-update decision is based solely on whether
        :attr:`View.id` is non-empty.  A view with a non-empty id is
        PATCHed; an empty id results in a POST.  This is correct because
        ids are server-assigned — clients never mint them — so a
        non-empty id means the view was previously persisted.

        The per-type cache is updated in-place (rather than invalidated)
        so the subsequent :attr:`views_changed` emission does not
        trigger an extra round-trip.

        Args:
            view: The view to save.

        Returns:
            The saved view, rebuilt from the server response when
            the response includes data, otherwise the input view.

        Raises:
            Exception: Re-raises any ``ayon_api`` failure after
                emitting :attr:`error`.
        """
        # A view straight off the listing has empty settings, and
        # to_payload always sends them - persisting one as-is would wipe
        # what is stored. Fetch the real settings unless the caller has
        # already supplied their own.
        if not view.loaded and view.id:
            stored = self.load_view(view)
            if stored is not view:
                view.settings = stored.settings
                view.loaded = True

        payload = view.to_payload()
        access_data = self._normalize_access_payload(payload.get("access"))
        should_share_access = self._has_positive_access(access_data)

        is_update = bool(view.id)
        if not is_update:
            payload.pop("id", None)

        try:
            # Use studio or project endpoints based on view scope
            if view.scope == Scope.STUDIO:
                # Studio-scoped views use studio endpoints
                if is_update:
                    endpoint = f"views/{view.view_type}/{view.id}"
                    resp = ayon_api.patch(endpoint, **payload)
                else:
                    endpoint = f"views/{view.view_type}"
                    resp = ayon_api.post(endpoint, **payload)
            else:
                # Project-scoped views use project-specific endpoints
                if is_update:
                    endpoint = self._endpoint(
                        f"views/{view.view_type}/{view.id}"
                    )
                    resp = ayon_api.patch(endpoint, **payload)
                else:
                    endpoint = self._endpoint(f"views/{view.view_type}")
                    resp = ayon_api.post(endpoint, **payload)
            resp.raise_for_status()
        except Exception as exc:  # noqa: BLE001
            log.exception("Failed to save view %s", view.id)
            self.error.emit(f"Failed to save view: {exc}")
            raise

        data = self._extract_data(resp)
        if isinstance(data, dict) and data:
            merged = {**payload, **data}
            try:
                saved = View.from_payload(merged)
            except Exception:  # noqa: BLE001
                log.exception(
                    "Server returned malformed view payload: %r", data
                )
                saved = view
        else:
            saved = view

        # Update the id-map and per-type cache in-place.
        if saved.id:
            self._id_to_view_attributes[saved.id] = (
                saved.view_type, saved.scope
            )
        self._upsert_cache(saved)

        # Access grants are managed by the dedicated share endpoint.
        if self.supports_sharing():
            self.patch_view_access(
                saved.id, access_data, should_share_access
            )

        self.view_saved.emit(saved.id)
        self.views_changed.emit(saved.view_type)
        return saved

    def patch_view_access(
        self,
        view_id: str,
        access_data: dict[str, Any],
        should_share_access: bool,
    ) -> None:
        """Patch per-view access data via the share endpoint.

        Args:
            view_id: Existing view identifier.
            access_data: Access mapping (e.g. ``{"__everyone__": 20}``).
            should_share_access: Whether to set the view visibility to
                public (True) or private (False).
        """
        if not view_id:
            return
        if not self._project_name:
            self.error.emit("Cannot patch view access without project name")
            return

        view_attributes = self._id_to_view_attributes.get(view_id)
        if not view_attributes:
            self.error.emit(f"Unknown view id for access patch: {view_id}")
            return
        view_type, _ = view_attributes

        payload_access = self._normalize_access_payload(access_data)
        if not payload_access:
            return

        powerpack_version = self._get_powerpack_version()
        if not powerpack_version:
            self.error.emit("Could not resolve powerpack addon version")
            return
        visibility = (
            Visibility.PUBLIC.value
            if should_share_access
            else Visibility.PRIVATE.value
        )
        try:
            con = ayon_api.get_server_api_connection()
            resp = con.raw_post(
                "addons/"
                f"{_POWERPACK_ADDON_NAME}/{powerpack_version}"
                f"/views/{view_type}/{view_id}/share",
                params={"project_name": self._project_name},
                json={
                    "visibility": visibility,
                    "access": payload_access,
                },
            )
            resp.raise_for_status()
            # Update cache with new visibility
            view_list = self._cache.get(view_type)
            if view_list is not None:
                for view in view_list:
                    if view.id == view_id:
                        view.visibility = Visibility(visibility)
                        break
        except Exception as exc:  # noqa: BLE001
            log.exception("Failed to patch view access for %s", view_id)
            self.error.emit(f"Failed to patch view access: {exc}")

    def delete_view(self, view_id: str) -> None:
        """Delete *view_id* on the server.

        The view type is resolved via the id-map (populated by
        :meth:`list_views` and :meth:`save_view`), so this works even
        when the per-type list cache has been cleared by
        :meth:`set_project`.  Unknown ids emit an :attr:`error` signal.

        Does nothing (without emitting :attr:`error`) when no project
        is set.

        Args:
            view_id: Identifier of the view to delete.
        """
        if not self._project_name:
            return

        view_attributes = self._id_to_view_attributes.get(view_id)
        if view_attributes is None:
            self.error.emit(f"Unknown view id: {view_id}")
            return

        view_type, view_scope = view_attributes
        is_studio = view_scope == Scope.STUDIO

        try:
            body = {} if is_studio else {"project_name": self._project_name}
            ayon_api.delete(f"views/{view_type}/{view_id}", **body)
        except Exception as exc:  # noqa: BLE001
            log.exception("Failed to delete view %s", view_id)
            self.error.emit(f"Failed to delete view: {exc}")
            return

        # Remove in-place from the per-type cache if populated.
        self._id_to_view_attributes.pop(view_id, None)
        view_list = self._cache.get(view_type)
        if view_list is not None:
            self._cache[view_type] = [v for v in view_list if v.id != view_id]

        self.view_deleted.emit(view_id)
        self.views_changed.emit(view_type)

    # ------------------------------------------------------------------
    # Helpers
    # ------------------------------------------------------------------

    def _endpoint(self, path: str) -> str:
        """Return *path* with ``project_name`` query string appended.

        Used for POST / PATCH which must send the body as JSON; the
        kwargs-as-body convention of :func:`ayon_api.post` clashes with
        the project_name-as-query-param requirement of the endpoint.

        Args:
            path: Endpoint path without leading slash.

        Returns:
            ``"{path}?project_name=<p>"`` when a project is set,
            otherwise *path* unchanged.
        """
        if not self._project_name:
            return path
        query = urlencode({"project_name": self._project_name})
        sep = "&" if "?" in path else "?"
        return f"{path}{sep}{query}"

    @staticmethod
    def _extract_data(resp: Any) -> Any:
        """Best-effort extraction of the JSON body from a response."""
        data = getattr(resp, "data", None)
        if data is not None:
            return data
        # Fall back to .json() for raw requests.Response-like objects.
        json_fn = getattr(resp, "json", None)
        if callable(json_fn):
            try:
                return json_fn()
            except Exception:  # noqa: BLE001
                return None
        return None

    def _upsert_cache(self, view: View) -> None:
        """Insert or replace *view* in the per-type cache in place.

        Does nothing if the type is not yet in the cache (the next
        ``list_views`` call will fetch a fresh list anyway).

        Args:
            view: The view to upsert.
        """
        view_list = self._cache.get(view.view_type)
        if view_list is None:
            return
        for i, v in enumerate(view_list):
            if v.id == view.id:
                view_list[i] = view
                break
        else:
            view_list.append(view)
        view_list.sort(key=lambda v: (v.position, v.label.lower()))

    @staticmethod
    def _normalize_access_payload(
        raw_access: dict[str, Any]
    ) -> dict[str, int]:
        """Return a normalized ``access`` payload with integer values."""
        if not isinstance(raw_access, dict):
            return {}
        normalized: dict[str, int] = {}
        for key, value in raw_access.items():
            name = str(key).strip()
            if not name:
                continue
            try:
                normalized[name] = int(value)
            except (TypeError, ValueError) as exc:
                raise ValueError(
                    f"Access value for '{name}' must be an int, got {value!r}"
                ) from exc
        return normalized

    @staticmethod
    def _has_positive_access(access_data: dict[str, int]) -> bool:
        """Return True when any access level is above zero."""
        return any(level > 0 for level in access_data.values())

    def supports_sharing(self) -> bool:
        """Whether view sharing is available on the server.

        Sharing is a powerpack feature: it requires the powerpack addon
        in the current bundle.

        Returns:
            True when powerpack is available and licensed.
        """
        return self._get_powerpack_version() is not None

    def _get_powerpack_version(self) -> str | None:
        """Return powerpack version from the current session bundle."""
        if self._powerpack_resolved:
            return self._powerpack_version

        try:
            bundle_info = get_bundle_information()
        except Exception:  # noqa: BLE001
            log.exception("Failed to query bundle information")
            return None

        self._powerpack_resolved = True
        for addon in bundle_info.addons:
            if addon.name != _POWERPACK_ADDON_NAME:
                continue
            if addon.version:
                self._powerpack_version = addon.version
                return self._powerpack_version

        return None

project_name property

Current project the manager is scoped to.

__init__(project_name, parent=None)

Initialise the manager.

Parameters:

Name Type Description Default
project_name str

Initial project the manager is scoped to. May be empty when the loader has no project yet.

required
parent QObject | None

Optional parent QObject.

None
Source code in client/ayon_core/ui/components/views/server_view_manager.py
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
def __init__(
        self,
        project_name: str,
        parent: QObject | None = None,
) -> None:
    """Initialise the manager.

    Args:
        project_name: Initial project the manager is scoped to.
            May be empty when the loader has no project yet.
        parent: Optional parent QObject.
    """
    super().__init__(parent=parent)
    self._project_name = project_name
    # view_type -> sorted list of views (per-type list cache)
    self._cache: dict[str, list[View]] = {}
    # view_id -> (view_type, scope) (survives list_views; used by
    # delete_view so it doesn't require a populated per-type cache).
    self._id_to_view_attributes: dict[str, tuple[str, Scope]] = {}
    self._powerpack_version: str | None = None
    self._powerpack_resolved: bool = False

delete_view(view_id)

Delete view_id on the server.

The view type is resolved via the id-map (populated by :meth:list_views and :meth:save_view), so this works even when the per-type list cache has been cleared by :meth:set_project. Unknown ids emit an :attr:error signal.

Does nothing (without emitting :attr:error) when no project is set.

Parameters:

Name Type Description Default
view_id str

Identifier of the view to delete.

required
Source code in client/ayon_core/ui/components/views/server_view_manager.py
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
def delete_view(self, view_id: str) -> None:
    """Delete *view_id* on the server.

    The view type is resolved via the id-map (populated by
    :meth:`list_views` and :meth:`save_view`), so this works even
    when the per-type list cache has been cleared by
    :meth:`set_project`.  Unknown ids emit an :attr:`error` signal.

    Does nothing (without emitting :attr:`error`) when no project
    is set.

    Args:
        view_id: Identifier of the view to delete.
    """
    if not self._project_name:
        return

    view_attributes = self._id_to_view_attributes.get(view_id)
    if view_attributes is None:
        self.error.emit(f"Unknown view id: {view_id}")
        return

    view_type, view_scope = view_attributes
    is_studio = view_scope == Scope.STUDIO

    try:
        body = {} if is_studio else {"project_name": self._project_name}
        ayon_api.delete(f"views/{view_type}/{view_id}", **body)
    except Exception as exc:  # noqa: BLE001
        log.exception("Failed to delete view %s", view_id)
        self.error.emit(f"Failed to delete view: {exc}")
        return

    # Remove in-place from the per-type cache if populated.
    self._id_to_view_attributes.pop(view_id, None)
    view_list = self._cache.get(view_type)
    if view_list is not None:
        self._cache[view_type] = [v for v in view_list if v.id != view_id]

    self.view_deleted.emit(view_id)
    self.views_changed.emit(view_type)

get_default_project_view(view_type)

Return project default view.

Source code in client/ayon_core/ui/components/views/server_view_manager.py
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
def get_default_project_view(self, view_type: str) -> View | None:
    """Return project default view."""
    if not self._project_name:
        return None

    try:
        resp = ayon_api.get(
            f"views/{view_type}/base",
            project_name=self._project_name,
        )
    except Exception as exc:  # noqa: BLE001
        log.exception(
            "Failed to fetch project default view for %s", view_type
        )
        self.error.emit(f"Failed to fetch project default view: {exc}")
        return None
    if resp.status_code != 200:
        return None
    view = View.from_payload(resp.data)
    self._id_to_view_attributes[view.id] = (view_type, view.scope)
    return view

get_default_studio_view(view_type)

Return studio default view.

Source code in client/ayon_core/ui/components/views/server_view_manager.py
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
def get_default_studio_view(self, view_type: str) -> View | None:
    """Return studio default view."""
    try:
        resp = ayon_api.get(f"views/{view_type}/base")
    except Exception as exc:  # noqa: BLE001
        log.exception(
            "Failed to fetch studio default view for %s", view_type
        )
        self.error.emit(f"Failed to fetch studio default view: {exc}")
        return None
    if resp.status_code != 200:
        return None
    view = View.from_payload(resp.data)
    self._id_to_view_attributes[view.id] = (view_type, view.scope)
    return view

get_working_view(view_type)

Return the working view for view_type using the direct endpoint.

Uses GET /api/views/{view_type}/working for faster lookup than scanning list_views.

Parameters:

Name Type Description Default
view_type str

View-type identifier.

required

Returns:

Type Description
View | None

Parsed working :class:View, or None when not found.

Source code in client/ayon_core/ui/components/views/server_view_manager.py
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
def get_working_view(self, view_type: str) -> View | None:
    """Return the working view for *view_type* using the direct endpoint.

    Uses ``GET /api/views/{view_type}/working`` for faster lookup than
    scanning ``list_views``.

    Args:
        view_type: View-type identifier.

    Returns:
        Parsed working :class:`View`, or ``None`` when not found.
    """
    if not self._project_name:
        return None

    try:
        resp = ayon_api.get(
            f"views/{view_type}/working",
            project_name=self._project_name,
        )
    except Exception as exc:  # noqa: BLE001
        log.exception("Failed to fetch working view for %s", view_type)
        self.error.emit(f"Failed to fetch working view: {exc}")
        return None

    # No working view yet (404) - the error body is not a view, parsing
    #   it would give a view without id and view type which then fails
    #   to save.
    if resp.status_code != 200 or not resp.data:
        return None
    view = View.from_payload(resp.data)
    if not view.view_type:
        view.view_type = view_type
    return view

list_views(view_type)

Return views for view_type, fetching from server on miss.

Returns an empty list (without emitting :attr:error) when the project name is empty — callers should wait for project_changed before listing.

Parameters:

Name Type Description Default
view_type str

View-type identifier (e.g. "versions").

required

Returns:

Type Description
list[View]

Sorted list of :class:View instances. Empty list on

list[View]

network or parse error.

Source code in client/ayon_core/ui/components/views/server_view_manager.py
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
def list_views(self, view_type: str) -> list[View]:
    """Return views for *view_type*, fetching from server on miss.

    Returns an empty list (without emitting :attr:`error`) when the
    project name is empty — callers should wait for
    ``project_changed`` before listing.

    Args:
        view_type: View-type identifier (e.g. ``"versions"``).

    Returns:
        Sorted list of :class:`View` instances.  Empty list on
        network or parse error.
    """
    # Skip the network call when no project is set.
    if not self._project_name:
        return []

    if view_type in self._cache:
        return list(self._cache[view_type])

    try:
        resp = ayon_api.get(
            f"views/{view_type}",
            project_name=self._project_name,
        )
    except Exception as exc:  # noqa: BLE001
        log.exception("Failed to list views for %s", view_type)
        self.error.emit(f"Failed to list views: {exc}")
        return []

    payload = getattr(resp, "data", None)
    if isinstance(payload, dict):
        items = payload.get("views", payload)
    else:
        items = payload
    if not isinstance(items, list):
        items = []

    views: list[View] = []
    for item in items:
        # Skip the default view, should not be listed in the Views UI.
        if item.get("label") == DEFAULT_VIEW_LABEL:
            continue
        try:
            view = View.from_payload(item)
        except Exception:  # noqa: BLE001
            log.exception("Skipping malformed view payload: %r", item)
            continue
        # The listing carries only the metadata the selector needs to
        # draw its rows; settings arrive via :meth:`load_view` when a
        # view is actually applied or saved.
        view.loaded = False
        # Nor does it carry the view type, which every per-view
        # endpoint needs in its path.
        if not view.view_type:
            view.view_type = view_type
        views.append(view)

    views.sort(key=lambda v: (v.position, v.label.lower()))
    self._cache[view_type] = views
    # Populate id-map for delete_view lookups.
    for v in views:
        if v.id:
            self._id_to_view_attributes[v.id] = (view_type, v.scope)
    return list(views)

load_view(view)

Return view with its settings fetched from the server.

:meth:list_views builds its rows from the listing endpoint, which omits settings, so opening the Views menu costs one request instead of one per view. This fills in the rest, and is a no-op for a view that already carries its settings.

Parameters:

Name Type Description Default
view View

A view, possibly built from a listing summary.

required

Returns:

Type Description
View

A fully populated view. The passed view is returned unchanged

View

when it is already loaded, has no id, or the fetch fails -

View

callers must still tolerate empty settings.

Source code in client/ayon_core/ui/components/views/server_view_manager.py
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
def load_view(self, view: View) -> View:
    """Return *view* with its settings fetched from the server.

    :meth:`list_views` builds its rows from the listing endpoint,
    which omits ``settings``, so opening the Views menu costs one
    request instead of one per view. This fills in the rest, and is
    a no-op for a view that already carries its settings.

    Args:
        view: A view, possibly built from a listing summary.

    Returns:
        A fully populated view. The passed view is returned unchanged
        when it is already loaded, has no id, or the fetch fails -
        callers must still tolerate empty settings.
    """
    if view.loaded or not view.id:
        return view

    view_type = view.view_type or ""
    try:
        resp = ayon_api.get(
            f"views/{view_type}/{view.id}",
            project_name=self._project_name,
        )
    except Exception as exc:  # noqa: BLE001
        log.exception("Failed to fetch view %s", view.id)
        self.error.emit(f"Failed to fetch view: {exc}")
        return view

    try:
        loaded = View.from_payload(resp.data)
    except Exception:  # noqa: BLE001
        log.exception("Malformed view payload for %s", view.id)
        return view

    cached = self._cache.get(view_type)
    if cached is not None:
        for idx, item in enumerate(cached):
            if item.id == loaded.id:
                cached[idx] = loaded
                break
    return loaded

patch_view_access(view_id, access_data, should_share_access)

Patch per-view access data via the share endpoint.

Parameters:

Name Type Description Default
view_id str

Existing view identifier.

required
access_data dict[str, Any]

Access mapping (e.g. {"__everyone__": 20}).

required
should_share_access bool

Whether to set the view visibility to public (True) or private (False).

required
Source code in client/ayon_core/ui/components/views/server_view_manager.py
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
def patch_view_access(
    self,
    view_id: str,
    access_data: dict[str, Any],
    should_share_access: bool,
) -> None:
    """Patch per-view access data via the share endpoint.

    Args:
        view_id: Existing view identifier.
        access_data: Access mapping (e.g. ``{"__everyone__": 20}``).
        should_share_access: Whether to set the view visibility to
            public (True) or private (False).
    """
    if not view_id:
        return
    if not self._project_name:
        self.error.emit("Cannot patch view access without project name")
        return

    view_attributes = self._id_to_view_attributes.get(view_id)
    if not view_attributes:
        self.error.emit(f"Unknown view id for access patch: {view_id}")
        return
    view_type, _ = view_attributes

    payload_access = self._normalize_access_payload(access_data)
    if not payload_access:
        return

    powerpack_version = self._get_powerpack_version()
    if not powerpack_version:
        self.error.emit("Could not resolve powerpack addon version")
        return
    visibility = (
        Visibility.PUBLIC.value
        if should_share_access
        else Visibility.PRIVATE.value
    )
    try:
        con = ayon_api.get_server_api_connection()
        resp = con.raw_post(
            "addons/"
            f"{_POWERPACK_ADDON_NAME}/{powerpack_version}"
            f"/views/{view_type}/{view_id}/share",
            params={"project_name": self._project_name},
            json={
                "visibility": visibility,
                "access": payload_access,
            },
        )
        resp.raise_for_status()
        # Update cache with new visibility
        view_list = self._cache.get(view_type)
        if view_list is not None:
            for view in view_list:
                if view.id == view_id:
                    view.visibility = Visibility(visibility)
                    break
    except Exception as exc:  # noqa: BLE001
        log.exception("Failed to patch view access for %s", view_id)
        self.error.emit(f"Failed to patch view access: {exc}")

save_view(view)

POST a new view or PATCH an existing one.

The new-vs-update decision is based solely on whether :attr:View.id is non-empty. A view with a non-empty id is PATCHed; an empty id results in a POST. This is correct because ids are server-assigned — clients never mint them — so a non-empty id means the view was previously persisted.

The per-type cache is updated in-place (rather than invalidated) so the subsequent :attr:views_changed emission does not trigger an extra round-trip.

Parameters:

Name Type Description Default
view View

The view to save.

required

Returns:

Type Description
View

The saved view, rebuilt from the server response when

View

the response includes data, otherwise the input view.

Raises:

Type Description
Exception

Re-raises any ayon_api failure after emitting :attr:error.

Source code in client/ayon_core/ui/components/views/server_view_manager.py
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
def save_view(self, view: View) -> View:
    """POST a new view or PATCH an existing one.

    The new-vs-update decision is based solely on whether
    :attr:`View.id` is non-empty.  A view with a non-empty id is
    PATCHed; an empty id results in a POST.  This is correct because
    ids are server-assigned — clients never mint them — so a
    non-empty id means the view was previously persisted.

    The per-type cache is updated in-place (rather than invalidated)
    so the subsequent :attr:`views_changed` emission does not
    trigger an extra round-trip.

    Args:
        view: The view to save.

    Returns:
        The saved view, rebuilt from the server response when
        the response includes data, otherwise the input view.

    Raises:
        Exception: Re-raises any ``ayon_api`` failure after
            emitting :attr:`error`.
    """
    # A view straight off the listing has empty settings, and
    # to_payload always sends them - persisting one as-is would wipe
    # what is stored. Fetch the real settings unless the caller has
    # already supplied their own.
    if not view.loaded and view.id:
        stored = self.load_view(view)
        if stored is not view:
            view.settings = stored.settings
            view.loaded = True

    payload = view.to_payload()
    access_data = self._normalize_access_payload(payload.get("access"))
    should_share_access = self._has_positive_access(access_data)

    is_update = bool(view.id)
    if not is_update:
        payload.pop("id", None)

    try:
        # Use studio or project endpoints based on view scope
        if view.scope == Scope.STUDIO:
            # Studio-scoped views use studio endpoints
            if is_update:
                endpoint = f"views/{view.view_type}/{view.id}"
                resp = ayon_api.patch(endpoint, **payload)
            else:
                endpoint = f"views/{view.view_type}"
                resp = ayon_api.post(endpoint, **payload)
        else:
            # Project-scoped views use project-specific endpoints
            if is_update:
                endpoint = self._endpoint(
                    f"views/{view.view_type}/{view.id}"
                )
                resp = ayon_api.patch(endpoint, **payload)
            else:
                endpoint = self._endpoint(f"views/{view.view_type}")
                resp = ayon_api.post(endpoint, **payload)
        resp.raise_for_status()
    except Exception as exc:  # noqa: BLE001
        log.exception("Failed to save view %s", view.id)
        self.error.emit(f"Failed to save view: {exc}")
        raise

    data = self._extract_data(resp)
    if isinstance(data, dict) and data:
        merged = {**payload, **data}
        try:
            saved = View.from_payload(merged)
        except Exception:  # noqa: BLE001
            log.exception(
                "Server returned malformed view payload: %r", data
            )
            saved = view
    else:
        saved = view

    # Update the id-map and per-type cache in-place.
    if saved.id:
        self._id_to_view_attributes[saved.id] = (
            saved.view_type, saved.scope
        )
    self._upsert_cache(saved)

    # Access grants are managed by the dedicated share endpoint.
    if self.supports_sharing():
        self.patch_view_access(
            saved.id, access_data, should_share_access
        )

    self.view_saved.emit(saved.id)
    self.views_changed.emit(saved.view_type)
    return saved

set_project(project_name)

Rebind the manager to a different project.

Clears both caches and emits :attr:project_changed with the new project name so listeners can refresh.

Parameters:

Name Type Description Default
project_name str

Target project name. No-op when the same as the current project.

required
Source code in client/ayon_core/ui/components/views/server_view_manager.py
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
def set_project(self, project_name: str) -> None:
    """Rebind the manager to a different project.

    Clears both caches and emits :attr:`project_changed` with the
    new project name so listeners can refresh.

    Args:
        project_name: Target project name.  No-op when the same as
            the current project.
    """
    if project_name == self._project_name:
        return
    self._project_name = project_name
    self._id_to_view_attributes.clear()
    self._cache.clear()
    self.project_changed.emit(project_name)

set_working_view(view)

Mark view as the sole working view for its type.

The base implementation scans :meth:list_views to clear the flag on every other view, which costs a full listing on each save. The server exposes the working view directly, so at most one other view can be holding the flag and one request finds it - and when view already is the working view, none is needed at all.

Parameters:

Name Type Description Default
view View

The view to mark as working.

required
Source code in client/ayon_core/ui/components/views/server_view_manager.py
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
def set_working_view(self, view: View) -> None:
    """Mark *view* as the sole working view for its type.

    The base implementation scans :meth:`list_views` to clear the flag
    on every other view, which costs a full listing on each save. The
    server exposes the working view directly, so at most one other
    view can be holding the flag and one request finds it - and when
    *view* already is the working view, none is needed at all.

    Args:
        view: The view to mark as working.
    """
    if not (view.working and view.id):
        current = self.get_working_view(view.view_type)
        if current is not None and current.id and current.id != view.id:
            current.working = False
            self.save_view(current)
    view.working = True
    self.save_view(view)

supports_sharing()

Whether view sharing is available on the server.

Sharing is a powerpack feature: it requires the powerpack addon in the current bundle.

Returns:

Type Description
bool

True when powerpack is available and licensed.

Source code in client/ayon_core/ui/components/views/server_view_manager.py
604
605
606
607
608
609
610
611
612
613
def supports_sharing(self) -> bool:
    """Whether view sharing is available on the server.

    Sharing is a powerpack feature: it requires the powerpack addon
    in the current bundle.

    Returns:
        True when powerpack is available and licensed.
    """
    return self._get_powerpack_version() is not None

View dataclass

A saved configuration that can be applied to a table/card view.

Attributes:

Name Type Description
id str

Server-assigned identifier. Empty string for views that have not been persisted yet.

label str

Human-readable name shown in the view selector.

view_type str

Identifier of the page/context the view belongs to (e.g. "versions", "review_sessions").

settings ViewSettings

The actual UI configuration.

owner str

User name / id of the creator.

scope Scope

"project" or "studio".

visibility Visibility

:class:Visibility enum.

loaded bool

False for a view built from a listing summary, whose settings are therefore empty rather than merely unset. Client-side only - never serialised. Managers that fetch settings lazily flip this once the full payload arrives; see :meth:ViewManager.load_view.

working bool

True for the per-user fallback view that captures ad-hoc tweaks. At most one working view per (user, view_type) should be flagged.

position int

Sort order hint within the selector.

access_level int

Required access level (0–30, step 10) for editing public views. Components store the value and gate UI via :meth:can_edit.

access dict[str, int]

Per-user/per-group access dict mapping names to integer access levels.

Source code in client/ayon_core/ui/components/views/data_models.py
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
@dataclass
class View:
    """A saved configuration that can be applied to a table/card view.

    Attributes:
        id: Server-assigned identifier.  Empty string for views that
            have not been persisted yet.
        label: Human-readable name shown in the view selector.
        view_type: Identifier of the page/context the view belongs to
            (e.g. ``"versions"``, ``"review_sessions"``).
        settings: The actual UI configuration.
        owner: User name / id of the creator.
        scope: ``"project"`` or ``"studio"``.
        visibility: :class:`Visibility` enum.
        loaded: False for a view built from a listing summary, whose
            ``settings`` are therefore empty rather than merely unset.
            Client-side only - never serialised. Managers that fetch
            settings lazily flip this once the full payload arrives; see
            :meth:`ViewManager.load_view`.
        working: True for the per-user fallback view that captures
            ad-hoc tweaks.  At most one working view per (user,
            view_type) should be flagged.
        position: Sort order hint within the selector.
        access_level: Required access level (0–30, step 10) for editing
            public views.  Components store the value and gate UI via
            :meth:`can_edit`.
        access: Per-user/per-group access dict mapping names to
            integer access levels.
    """

    id: str = ""
    label: str = ""
    view_type: str = ""
    settings: ViewSettings = field(default_factory=ViewSettings)
    owner: str = ""
    scope: Scope = Scope.PROJECT
    visibility: Visibility = Visibility.PRIVATE
    working: bool = False
    loaded: bool = True
    position: int = 0
    access_level: int = DEFAULT_ACCESS_LEVEL
    access: dict[str, int] = field(default_factory=dict)
    extra: dict[str, Any] = field(default_factory=dict)

    @classmethod
    def from_payload(cls, payload: dict[str, Any]) -> "View":
        """Build a View from a raw server payload.

        Unknown top-level keys are preserved in :attr:`extra`.

        Args:
            payload: Raw server payload dict.

        Returns:
            A new View instance.
        """
        if not isinstance(payload, dict):
            raise TypeError(
                f"View payload must be a dict, got {type(payload).__name__}"
            )

        access_level_raw = payload.get("accessLevel", DEFAULT_ACCESS_LEVEL)
        try:
            access_level = int(access_level_raw)
        except (TypeError, ValueError):
            access_level = DEFAULT_ACCESS_LEVEL

        position_raw = payload.get("position", 0)
        try:
            position = int(position_raw)
        except (TypeError, ValueError):
            position = 0

        extra = {k: v for k, v in payload.items() if k not in _KNOWN_VIEW_KEYS}

        return cls(
            id=str(payload.get("id", "")),
            label=str(payload.get("label", "")),
            view_type=str(payload.get("viewType", "")),
            settings=ViewSettings.from_payload(payload.get("settings")),
            owner=str(payload.get("owner", "")),
            scope=Scope(payload.get("scope", Scope.PROJECT.value)),
            visibility=Visibility(
                payload.get("visibility", Visibility.PRIVATE.value)
            ),
            working=bool(payload.get("working", False)),
            position=position,
            access_level=access_level,
            access=dict(payload.get("access") or {}),
            extra=extra,
        )

    def to_payload(self) -> dict[str, Any]:
        """Serialise this view to a payload dict.

        Returns:
            A dict suitable for sending to the server.
        """
        out: dict[str, Any] = {
            "id": self.id,
            "label": self.label,
            "viewType": self.view_type,
            "settings": self.settings.to_payload(),
            "owner": self.owner,
            "scope": self.scope.value,
            "visibility": self.visibility.value,
            "working": self.working,
            "position": self.position,
            "accessLevel": self.access_level,
            "access": dict(self.access),
        }
        for k, v in self.extra.items():
            if k not in _KNOWN_VIEW_KEYS:
                out[k] = v
        return out

    def can_edit(self, current_user: str, user_access_level: int = 50) -> bool:
        """Return True when *current_user* may edit this view.

        Args:
            current_user: The viewer's user name / id.
            user_access_level: The viewer's effective access level on
                the relevant scope (0–50).  Defaults to ``50`` (full
                access) so callers that don't know the user's level
                don't unexpectedly read-lock the editor.

        Returns:
            True when:

            - The view is private and the viewer owns it.
            - Or the view is public and the viewer's access level is
              greater or equal to the view's required level.
        """
        if self.visibility == Visibility.PRIVATE:
            return bool(current_user) and current_user == self.owner
        return user_access_level >= self.access_level

can_edit(current_user, user_access_level=50)

Return True when current_user may edit this view.

Parameters:

Name Type Description Default
current_user str

The viewer's user name / id.

required
user_access_level int

The viewer's effective access level on the relevant scope (0–50). Defaults to 50 (full access) so callers that don't know the user's level don't unexpectedly read-lock the editor.

50

Returns:

Type Description
bool

True when:

bool
  • The view is private and the viewer owns it.
bool
  • Or the view is public and the viewer's access level is greater or equal to the view's required level.
Source code in client/ayon_core/ui/components/views/data_models.py
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
def can_edit(self, current_user: str, user_access_level: int = 50) -> bool:
    """Return True when *current_user* may edit this view.

    Args:
        current_user: The viewer's user name / id.
        user_access_level: The viewer's effective access level on
            the relevant scope (0–50).  Defaults to ``50`` (full
            access) so callers that don't know the user's level
            don't unexpectedly read-lock the editor.

    Returns:
        True when:

        - The view is private and the viewer owns it.
        - Or the view is public and the viewer's access level is
          greater or equal to the view's required level.
    """
    if self.visibility == Visibility.PRIVATE:
        return bool(current_user) and current_user == self.owner
    return user_access_level >= self.access_level

from_payload(payload) classmethod

Build a View from a raw server payload.

Unknown top-level keys are preserved in :attr:extra.

Parameters:

Name Type Description Default
payload dict[str, Any]

Raw server payload dict.

required

Returns:

Type Description
'View'

A new View instance.

Source code in client/ayon_core/ui/components/views/data_models.py
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
@classmethod
def from_payload(cls, payload: dict[str, Any]) -> "View":
    """Build a View from a raw server payload.

    Unknown top-level keys are preserved in :attr:`extra`.

    Args:
        payload: Raw server payload dict.

    Returns:
        A new View instance.
    """
    if not isinstance(payload, dict):
        raise TypeError(
            f"View payload must be a dict, got {type(payload).__name__}"
        )

    access_level_raw = payload.get("accessLevel", DEFAULT_ACCESS_LEVEL)
    try:
        access_level = int(access_level_raw)
    except (TypeError, ValueError):
        access_level = DEFAULT_ACCESS_LEVEL

    position_raw = payload.get("position", 0)
    try:
        position = int(position_raw)
    except (TypeError, ValueError):
        position = 0

    extra = {k: v for k, v in payload.items() if k not in _KNOWN_VIEW_KEYS}

    return cls(
        id=str(payload.get("id", "")),
        label=str(payload.get("label", "")),
        view_type=str(payload.get("viewType", "")),
        settings=ViewSettings.from_payload(payload.get("settings")),
        owner=str(payload.get("owner", "")),
        scope=Scope(payload.get("scope", Scope.PROJECT.value)),
        visibility=Visibility(
            payload.get("visibility", Visibility.PRIVATE.value)
        ),
        working=bool(payload.get("working", False)),
        position=position,
        access_level=access_level,
        access=dict(payload.get("access") or {}),
        extra=extra,
    )

to_payload()

Serialise this view to a payload dict.

Returns:

Type Description
dict[str, Any]

A dict suitable for sending to the server.

Source code in client/ayon_core/ui/components/views/data_models.py
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
def to_payload(self) -> dict[str, Any]:
    """Serialise this view to a payload dict.

    Returns:
        A dict suitable for sending to the server.
    """
    out: dict[str, Any] = {
        "id": self.id,
        "label": self.label,
        "viewType": self.view_type,
        "settings": self.settings.to_payload(),
        "owner": self.owner,
        "scope": self.scope.value,
        "visibility": self.visibility.value,
        "working": self.working,
        "position": self.position,
        "accessLevel": self.access_level,
        "access": dict(self.access),
    }
    for k, v in self.extra.items():
        if k not in _KNOWN_VIEW_KEYS:
            out[k] = v
    return out

ViewBindings dataclass

Adapter wiring a :class:View to a concrete widget stack.

Only :attr:model is required. Every other widget is optional and skipped when None.

Attributes:

Name Type Description
model PaginatedTableModel

The shared :class:PaginatedTableModel instance.

table_view Any | None

Optional :class:AYTableView for column state.

card_view Any | None

Optional :class:AYCardView for row-height / card-size mapping.

filter_bar Any | None

Optional :class:AYTableFilter exposing :meth:get_criteria / :meth:set_criteria.

on_extra_apply Any | None

Optional callback (extra: dict) -> None invoked at the end of :meth:apply with the un-claimed :attr:ViewSettings.extra dict. Lets the consumer wire up custom settings (showProducts, gridHeight, …) without subclassing.

on_extra_capture Any | None

Optional callback () -> dict invoked during :meth:capture to merge consumer-specific keys back into :attr:ViewSettings.extra.

default_filter Callable[[], FilterDef] | None

Optional callback returning a FilterDef used when the incoming view has no filter conditions.

default_settings Callable[[], ViewSettings] | None

Optional callback returning complete defaults. When supplied, it is the preferred source for columns, sorting, filtering, grouping, row height, and consumer extras.

default_sort_by str | None

Optional column key used when the incoming view does not define a sort column.

on_error ErrorCallback | None

Optional callback (stage: str, exc: BaseException) -> None invoked for non-fatal failures during :meth:apply and :meth:capture so the host UI can surface a message. When None the failure is only logged.

Source code in client/ayon_core/ui/components/views/view_bindings.py
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
@dataclass
class ViewBindings:
    """Adapter wiring a :class:`View` to a concrete widget stack.

    Only :attr:`model` is required.  Every other widget is optional and
    skipped when ``None``.

    Attributes:
        model: The shared :class:`PaginatedTableModel` instance.
        table_view: Optional :class:`AYTableView` for column state.
        card_view: Optional :class:`AYCardView` for row-height /
            card-size mapping.
        filter_bar: Optional :class:`AYTableFilter` exposing
            :meth:`get_criteria` / :meth:`set_criteria`.
        on_extra_apply: Optional callback ``(extra: dict) -> None``
            invoked at the end of :meth:`apply` with the un-claimed
            :attr:`ViewSettings.extra` dict.  Lets the consumer wire
            up custom settings (``showProducts``, ``gridHeight``, …)
            without subclassing.
        on_extra_capture: Optional callback ``() -> dict`` invoked
            during :meth:`capture` to merge consumer-specific keys
            back into :attr:`ViewSettings.extra`.
        default_filter: Optional callback returning a ``FilterDef`` used
            when the incoming view has no filter conditions.
        default_settings: Optional callback returning complete defaults.
            When supplied, it is the preferred source for columns, sorting,
            filtering, grouping, row height, and consumer extras.
        default_sort_by: Optional column key used when the incoming view
            does not define a sort column.
        on_error: Optional callback
            ``(stage: str, exc: BaseException) -> None`` invoked for
            non-fatal failures during :meth:`apply` and :meth:`capture`
            so the host UI can surface a message.  When ``None`` the
            failure is only logged.
    """

    model: PaginatedTableModel
    table_view: Any | None = None
    card_view: Any | None = None
    filter_bar: Any | None = None
    on_extra_apply: Any | None = None
    on_extra_capture: Any | None = None
    default_columns: Callable[[], list[ColumnState]] | None = None
    default_filter: Callable[[], FilterDef] | None = None
    default_settings: Callable[[], ViewSettings] | None = None
    default_sort_by: str | None = None
    default_sort_desc: bool = False
    on_error: ErrorCallback | None = None
    # Keys not consumed by built-in handlers; preserved verbatim for
    # roundtrip even when the consumer does not supply a callback.
    _untouched_extra: dict[str, Any] = field(default_factory=dict)
    # Last applied filter operator — preserved across capture so an
    # incoming ``operator="or"`` is not silently downgraded to ``"and"``
    # when AYTableFilter does not yet expose operator semantics.
    _last_filter_operator: str = "and"
    # Last applied grouping — round-tripped verbatim because no
    # grouping widget exists yet.
    _last_grouping: GroupingDef = field(default_factory=GroupingDef)

    # ------------------------------------------------------------------
    # Error reporting
    # ------------------------------------------------------------------

    def _report_error(self, stage: str, exc: BaseException) -> None:
        """Log the failure and forward it to :attr:`on_error` when set.

        The log line always uses ``logging.ERROR`` so problems are
        visible without enabling debug output.  The optional
        :attr:`on_error` callback receives the same ``(stage, exc)``
        pair so the host UI can surface a message to the user.

        Args:
            stage: Short identifier of the failing operation
                (e.g. ``"column_state"``).
            exc: The caught exception.
        """
        log.exception("ViewBindings %s failed: %s", stage, exc)
        if self.on_error is None:
            return
        try:
            self.on_error(stage, exc)
        except Exception:
            log.exception("on_error callback raised while handling %s", stage)

    # ------------------------------------------------------------------
    # Apply
    # ------------------------------------------------------------------

    def apply(self, settings: ViewSettings) -> None:
        """Apply *settings* to every wired widget.

        Args:
            settings: The :class:`ViewSettings` to apply.

        Raises:
            TypeError: If *settings* is not a :class:`ViewSettings`.
        """
        if not isinstance(settings, ViewSettings):
            raise TypeError(
                f"apply expected ViewSettings, got {type(settings).__name__}"
            )

        defaults = (
            self.default_settings()
            if self.default_settings is not None
            else None
        )

        # 1. Columns + sort live on the model.  When the consumer also
        #    wired a table view, widths and visibility are the header's
        #    responsibility — strip them from the model payload so the
        #    model only owns ordering and sort, avoiding cross-view
        #    contamination of the shared TableColumn catalog.
        if not settings.columns and defaults is not None:
            settings = copy.copy(settings)
            settings.columns = copy.deepcopy(defaults.columns)
        elif not settings.columns and self.default_columns is not None:
            settings = copy.copy(settings)
            settings.columns = self.default_columns()

        if settings.sort_by is None and defaults is not None:
            settings = copy.copy(settings)
            settings.sort_by = defaults.sort_by
            settings.sort_desc = defaults.sort_desc
        elif settings.sort_by is None and self.default_sort_by is not None:
            settings = copy.copy(settings)
            settings.sort_by = self.default_sort_by
            settings.sort_desc = self.default_sort_desc

        model_settings = self._settings_for_model(settings)
        self.model.apply_settings(model_settings)

        # 1b. Column hidden state / pinning / width live on the header.
        if self.table_view is not None and settings.columns:
            try:
                self.table_view.set_column_state(settings.columns)
            except Exception as exc:
                self._report_error("column_state_apply", exc)

        # 2. Filter — opaque condition dicts in the View are converted
        #    to FilterCriterion via FilterCriterion.from_def.  The
        #    operator is stashed for capture() because AYTableFilter
        #    does not yet expose AND/OR semantics.
        self._last_filter_operator = settings.filter.operator or "and"
        if self.filter_bar is not None:
            filter_def = settings.filter
            if not filter_def.conditions and defaults is not None:
                filter_def = defaults.filter
            elif (
                not filter_def.conditions
                and self.default_filter is not None
            ):
                filter_def = self.default_filter()
            self._apply_filter(filter_def)

        # 3. Grouping — placeholder for future grouping widget; we keep
        #    the data on the binding so capture() can re-emit it.
        grouping = settings.grouping
        if grouping.is_empty() and defaults is not None:
            grouping = defaults.grouping
        self._last_grouping = copy.deepcopy(grouping)

        # 4. Row height — applied to the table view (real per-row
        #    override) and best-effort on the card view.
        row_height = settings.row_height
        if row_height <= 0 and defaults is not None:
            row_height = defaults.row_height
        self._apply_row_height(row_height)

        # 5. Extras — let the consumer handle the un-claimed slice.
        self._untouched_extra = dict(settings.extra)
        applied_extra = (
            dict(defaults.extra)
            if defaults is not None
            else {}
        )
        applied_extra.update(settings.extra)
        if self.on_extra_apply is not None and applied_extra:
            try:
                self.on_extra_apply(applied_extra)
            except Exception as exc:
                self._report_error("on_extra_apply", exc)

    # ------------------------------------------------------------------
    # Capture
    # ------------------------------------------------------------------

    def capture(self) -> ViewSettings:
        """Build a fresh :class:`ViewSettings` from the live widget state.

        Returns:
            A new :class:`ViewSettings` instance.
        """
        settings = self.model.capture_settings()

        # Column visibility/pinning come from the header, overriding the
        # plain key/width snapshot returned by the model.
        if self.table_view is not None:
            try:
                header_states = self.table_view.get_column_state()
            except Exception as exc:
                self._report_error("column_state_capture", exc)
                header_states = []
            if header_states:
                settings.columns = list(header_states)

        # Filter conditions.
        if self.filter_bar is not None:
            settings.filter = self.capture_filter()

        # Grouping (round-tripped verbatim; no widget yet).
        settings.grouping = copy.deepcopy(self._last_grouping)

        # Row height.  Never let a 0 ("unset") reach the payload - a
        # stored 0 makes the next apply() silently fall back to the
        # defaults, so a row height the user did pick would be lost on
        # the next round trip.
        row_height = self._capture_row_height(settings.row_height)
        if row_height <= 0:
            row_height = self._default_row_height()
        settings.row_height = row_height

        # Extras.
        extras: dict[str, Any] = dict(self._untouched_extra)
        if self.on_extra_capture is not None:
            try:
                emitted = self.on_extra_capture() or {}
            except Exception as exc:
                self._report_error("on_extra_capture", exc)
                emitted = {}
            if isinstance(emitted, dict):
                extras.update(emitted)
        settings.extra = extras

        return settings

    # ------------------------------------------------------------------
    # Settings helpers
    # ------------------------------------------------------------------

    def _settings_for_model(self, settings: ViewSettings) -> ViewSettings:
        """Return a copy of *settings* with widths stripped for the model.

        The model and the table-view header both consume
        ``settings.columns``.  Letting both write widths mutates the
        shared :class:`TableColumn` catalog and can leak widths across
        views that share the same model.  This helper hands the model a
        copy where every column ``width`` is ``None`` so only the
        header (via :meth:`AYTableView.set_column_state`) ever writes
        widths.

        Args:
            settings: The original :class:`ViewSettings`.

        Returns:
            A shallow-cloned :class:`ViewSettings` whose
            :class:`ColumnState` instances have ``width=None`` when a
            table view is wired; the original object otherwise.
        """
        if self.table_view is None:
            return settings
        stripped_cols = [
            ColumnState(
                name=c.name,
                visible=c.visible,
                pinned=c.pinned,
                width=None,
            )
            for c in settings.columns
        ]
        return ViewSettings(
            columns=stripped_cols,
            sort_by=settings.sort_by,
            sort_desc=settings.sort_desc,
            row_height=settings.row_height,
            grouping=settings.grouping,
            filter=settings.filter,
            extra=settings.extra,
        )

    # ------------------------------------------------------------------
    # Filter helpers
    # ------------------------------------------------------------------

    def _apply_filter(self, filter_def: FilterDef) -> None:
        """Convert *filter_def* to FilterCriteria and push to the bar.

        Args:
            filter_def: The :class:`FilterDef` to apply.
        """
        # Lazy import keeps the views package self-contained and avoids
        # a circular import between views and table_filter.
        from ..table_filter import FilterCriterion

        criteria = [
            FilterCriterion.from_def(c)
            for c in filter_def.conditions
            if isinstance(c, dict)
        ]
        try:
            self.filter_bar.set_active_criteria(criteria)
        except Exception as exc:
            self._report_error("filter_apply", exc)

    def capture_filter(self) -> FilterDef:
        """Capture filter criteria from the live filter bar.

        The operator is preserved from the last :meth:`apply` call so a
        view that arrived with ``operator="or"`` roundtrips losslessly,
        even though :class:`AYTableFilter` only implements AND.

        Returns:
            A new :class:`FilterDef`.
        """
        try:
            criteria = self.filter_bar.get_criteria()
        except Exception as exc:
            self._report_error("filter_capture", exc)
            return FilterDef(operator=self._last_filter_operator)
        return FilterDef(
            conditions=[c.to_def() for c in criteria],
            operator=self._last_filter_operator,
        )

    # ------------------------------------------------------------------
    # Row-height helpers
    # ------------------------------------------------------------------

    def _apply_row_height(self, row_height: int) -> None:
        """Apply *row_height* to whichever views are wired.

        The table view receives the real pixel value via
        :meth:`AYTableView.set_row_height`; the card view is mapped
        only when it exposes a real ``set_card_height`` setter.
        Mapping table-row-height to card *width* is never done — the
        units do not match and a 32px-wide card is not useful.

        Args:
            row_height: Row height in pixels (``<= 0`` = ignore).
        """
        if row_height <= 0:
            return

        if self.table_view is not None:
            setter = getattr(self.table_view, "set_row_height", None)
            if callable(setter):
                try:
                    setter(row_height)
                except Exception as exc:
                    self._report_error("row_height_table", exc)

        if self.card_view is not None:
            setter = getattr(self.card_view, "set_card_height", None)
            if callable(setter):
                try:
                    setter(row_height)
                except Exception as exc:
                    self._report_error("row_height_card", exc)

    def _default_row_height(self) -> int:
        """Return the consumer's default row height, or ``0``.

        Returns:
            ``default_settings().row_height`` when a defaults callback
            is wired and returns a positive height; ``0`` otherwise.
        """
        if self.default_settings is None:
            return 0
        try:
            return int(self.default_settings().row_height)
        except Exception as exc:  # noqa: BLE001
            self._report_error("default_row_height", exc)
            return 0

    def _capture_row_height(self, fallback: int) -> int:
        """Best-effort read of the current row height.

        Args:
            fallback: Value to return when no widget can answer.

        Returns:
            Row height in pixels.
        """
        if self.table_view is not None:
            getter = getattr(self.table_view, "row_height", None)
            override = (
                getter()
                if callable(getter)
                else getattr(self.table_view, "_row_height_override", None)
            )
            if isinstance(override, int) and override > 0:
                return int(override)
            # Use the visualRect of the first index when available.
            try:
                model = self.table_view.model()
                if model is not None and model.rowCount() > 0:
                    idx = model.index(0, 0)
                    rect = self.table_view.visualRect(idx)
                    if rect.height() > 0:
                        return int(rect.height())
            except Exception:
                log.debug("Could not measure table row height", exc_info=True)
        return fallback

apply(settings)

Apply settings to every wired widget.

Parameters:

Name Type Description Default
settings ViewSettings

The :class:ViewSettings to apply.

required

Raises:

Type Description
TypeError

If settings is not a :class:ViewSettings.

Source code in client/ayon_core/ui/components/views/view_bindings.py
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
def apply(self, settings: ViewSettings) -> None:
    """Apply *settings* to every wired widget.

    Args:
        settings: The :class:`ViewSettings` to apply.

    Raises:
        TypeError: If *settings* is not a :class:`ViewSettings`.
    """
    if not isinstance(settings, ViewSettings):
        raise TypeError(
            f"apply expected ViewSettings, got {type(settings).__name__}"
        )

    defaults = (
        self.default_settings()
        if self.default_settings is not None
        else None
    )

    # 1. Columns + sort live on the model.  When the consumer also
    #    wired a table view, widths and visibility are the header's
    #    responsibility — strip them from the model payload so the
    #    model only owns ordering and sort, avoiding cross-view
    #    contamination of the shared TableColumn catalog.
    if not settings.columns and defaults is not None:
        settings = copy.copy(settings)
        settings.columns = copy.deepcopy(defaults.columns)
    elif not settings.columns and self.default_columns is not None:
        settings = copy.copy(settings)
        settings.columns = self.default_columns()

    if settings.sort_by is None and defaults is not None:
        settings = copy.copy(settings)
        settings.sort_by = defaults.sort_by
        settings.sort_desc = defaults.sort_desc
    elif settings.sort_by is None and self.default_sort_by is not None:
        settings = copy.copy(settings)
        settings.sort_by = self.default_sort_by
        settings.sort_desc = self.default_sort_desc

    model_settings = self._settings_for_model(settings)
    self.model.apply_settings(model_settings)

    # 1b. Column hidden state / pinning / width live on the header.
    if self.table_view is not None and settings.columns:
        try:
            self.table_view.set_column_state(settings.columns)
        except Exception as exc:
            self._report_error("column_state_apply", exc)

    # 2. Filter — opaque condition dicts in the View are converted
    #    to FilterCriterion via FilterCriterion.from_def.  The
    #    operator is stashed for capture() because AYTableFilter
    #    does not yet expose AND/OR semantics.
    self._last_filter_operator = settings.filter.operator or "and"
    if self.filter_bar is not None:
        filter_def = settings.filter
        if not filter_def.conditions and defaults is not None:
            filter_def = defaults.filter
        elif (
            not filter_def.conditions
            and self.default_filter is not None
        ):
            filter_def = self.default_filter()
        self._apply_filter(filter_def)

    # 3. Grouping — placeholder for future grouping widget; we keep
    #    the data on the binding so capture() can re-emit it.
    grouping = settings.grouping
    if grouping.is_empty() and defaults is not None:
        grouping = defaults.grouping
    self._last_grouping = copy.deepcopy(grouping)

    # 4. Row height — applied to the table view (real per-row
    #    override) and best-effort on the card view.
    row_height = settings.row_height
    if row_height <= 0 and defaults is not None:
        row_height = defaults.row_height
    self._apply_row_height(row_height)

    # 5. Extras — let the consumer handle the un-claimed slice.
    self._untouched_extra = dict(settings.extra)
    applied_extra = (
        dict(defaults.extra)
        if defaults is not None
        else {}
    )
    applied_extra.update(settings.extra)
    if self.on_extra_apply is not None and applied_extra:
        try:
            self.on_extra_apply(applied_extra)
        except Exception as exc:
            self._report_error("on_extra_apply", exc)

capture()

Build a fresh :class:ViewSettings from the live widget state.

Returns:

Type Description
ViewSettings

A new :class:ViewSettings instance.

Source code in client/ayon_core/ui/components/views/view_bindings.py
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
def capture(self) -> ViewSettings:
    """Build a fresh :class:`ViewSettings` from the live widget state.

    Returns:
        A new :class:`ViewSettings` instance.
    """
    settings = self.model.capture_settings()

    # Column visibility/pinning come from the header, overriding the
    # plain key/width snapshot returned by the model.
    if self.table_view is not None:
        try:
            header_states = self.table_view.get_column_state()
        except Exception as exc:
            self._report_error("column_state_capture", exc)
            header_states = []
        if header_states:
            settings.columns = list(header_states)

    # Filter conditions.
    if self.filter_bar is not None:
        settings.filter = self.capture_filter()

    # Grouping (round-tripped verbatim; no widget yet).
    settings.grouping = copy.deepcopy(self._last_grouping)

    # Row height.  Never let a 0 ("unset") reach the payload - a
    # stored 0 makes the next apply() silently fall back to the
    # defaults, so a row height the user did pick would be lost on
    # the next round trip.
    row_height = self._capture_row_height(settings.row_height)
    if row_height <= 0:
        row_height = self._default_row_height()
    settings.row_height = row_height

    # Extras.
    extras: dict[str, Any] = dict(self._untouched_extra)
    if self.on_extra_capture is not None:
        try:
            emitted = self.on_extra_capture() or {}
        except Exception as exc:
            self._report_error("on_extra_capture", exc)
            emitted = {}
        if isinstance(emitted, dict):
            extras.update(emitted)
    settings.extra = extras

    return settings

capture_filter()

Capture filter criteria from the live filter bar.

The operator is preserved from the last :meth:apply call so a view that arrived with operator="or" roundtrips losslessly, even though :class:AYTableFilter only implements AND.

Returns:

Type Description
FilterDef

A new :class:FilterDef.

Source code in client/ayon_core/ui/components/views/view_bindings.py
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
def capture_filter(self) -> FilterDef:
    """Capture filter criteria from the live filter bar.

    The operator is preserved from the last :meth:`apply` call so a
    view that arrived with ``operator="or"`` roundtrips losslessly,
    even though :class:`AYTableFilter` only implements AND.

    Returns:
        A new :class:`FilterDef`.
    """
    try:
        criteria = self.filter_bar.get_criteria()
    except Exception as exc:
        self._report_error("filter_capture", exc)
        return FilterDef(operator=self._last_filter_operator)
    return FilterDef(
        conditions=[c.to_def() for c in criteria],
        operator=self._last_filter_operator,
    )

ViewManager

Bases: QObject

Abstract storage backend for :class:View instances.

Signals

views_changed(str): Emitted with view_type whenever the list of views for that type may have changed. view_saved(str): Emitted with the view id after a successful save_view. view_deleted(str): Emitted with the view id after a successful delete_view. project_changed(str): Emitted with the new project name when the manager is rebound to a different project. error(str): Emitted with an error message when an operation fails (subclasses may emit this instead of raising).

Source code in client/ayon_core/ui/components/views/view_manager.py
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
class ViewManager(QObject):
    """Abstract storage backend for :class:`View` instances.

    Signals:
        views_changed(str): Emitted with ``view_type`` whenever the
            list of views for that type may have changed.
        view_saved(str): Emitted with the view id after a successful
            ``save_view``.
        view_deleted(str): Emitted with the view id after a successful
            ``delete_view``.
        project_changed(str): Emitted with the new project name when the
            manager is rebound to a different project.
        error(str): Emitted with an error message when an operation
            fails (subclasses may emit this instead of raising).
    """

    views_changed = Signal(str)
    view_saved = Signal(str)
    view_deleted = Signal(str)
    project_changed = Signal(str)
    error = Signal(str)

    def __init__(self, parent: QObject | None = None) -> None:
        """Initialise the manager.

        Args:
            parent: Optional parent QObject.
        """
        super().__init__(parent)

    # -- abstract API -------------------------------------------------------

    @abstractmethod
    def list_views(self, view_type: str) -> list[View]:
        """Return the list of views for the given view type.

        Args:
            view_type: The view-type identifier (e.g. ``"versions"``).

        Returns:
            List of :class:`View` instances, sorted by
            :attr:`View.position` then :attr:`View.label`.
        """

    @abstractmethod
    def save_view(self, view: View) -> View:
        """Persist a new or updated view.

        Implementations must assign a non-empty :attr:`View.id` when
        creating a new view and return the (possibly modified) view.

        Args:
            view: The view to save.

        Returns:
            The saved view, with its assigned id.
        """

    @abstractmethod
    def delete_view(self, view_id: str) -> None:
        """Delete the view with the given id.

        Args:
            view_id: Identifier of the view to delete.
        """

    # -- optional helpers ---------------------------------------------------

    def supports_sharing(self) -> bool:
        """Whether views can be shared with other users.

        Sharing controls in the UI (the "Shared views" section, the
        people-with-access editor) are shown only when this returns
        True.  The default implementation has no restrictions.

        Returns:
            True when views can be shared.
        """
        return True

    def load_view(self, view: View) -> View:
        """Return *view* with its :attr:`View.settings` populated.

        Managers that list views cheaply - fetching only the metadata the
        dropdown needs - override this to fetch the full payload the first
        time a view is actually used. The default implementation assumes
        :meth:`list_views` already returned complete views.

        Args:
            view: The view to complete.

        Returns:
            A view whose settings are safe to apply or persist.
        """
        return view

    def get_working_view(self, view_type: str) -> View | None:
        """Return the working view for *view_type*, if any.

        Args:
            view_type: The view-type identifier.

        Returns:
            The view flagged with ``working=True`` for *view_type*, or
            ``None`` when no working view exists.
        """
        for view in self.list_views(view_type):
            if view.working:
                return view
        return None

    def get_default_studio_view(self, view_type: str) -> View | None:
        """Return the studio-scope default view for *view_type*, if any.

        Default views are saved with :data:`DEFAULT_VIEW_LABEL` and the
        matching :class:`Scope` (see
        ``DefaultViewControl._set_default_for_scope``). Subclasses backed
        by a dedicated endpoint (e.g. :class:`ServerViewManager`) should
        override this rather than rely on filtering :meth:`list_views`.

        Args:
            view_type: The view-type identifier.

        Returns:
            The studio default view for *view_type*, or ``None``.
        """
        for view in self.list_views(view_type):
            if view.scope == Scope.STUDIO and view.label == DEFAULT_VIEW_LABEL:
                return view
        return None

    def get_default_project_view(self, view_type: str) -> View | None:
        """Return the project-scope default view for *view_type*, if any.

        See :meth:`get_default_studio_view` for how default views are
        identified.

        Args:
            view_type: The view-type identifier.

        Returns:
            The project default view for *view_type*, or ``None``.
        """
        for view in self.list_views(view_type):
            if (
                view.scope == Scope.PROJECT
                and view.label == DEFAULT_VIEW_LABEL
            ):
                return view
        return None

    def set_working_view(self, view: View) -> None:
        """Mark *view* as the (sole) working view for its view type.

        The default implementation clears the ``working`` flag on every
        other view of the same type and saves them all.  Subclasses can
        override this with a bulk-update endpoint for efficiency.

        Args:
            view: The view to mark as working.
        """
        for other in self.list_views(view.view_type):
            if other.id == view.id:
                continue
            if other.working:
                other.working = False
                self.save_view(other)
        view.working = True
        self.save_view(view)

__init__(parent=None)

Initialise the manager.

Parameters:

Name Type Description Default
parent QObject | None

Optional parent QObject.

None
Source code in client/ayon_core/ui/components/views/view_manager.py
52
53
54
55
56
57
58
def __init__(self, parent: QObject | None = None) -> None:
    """Initialise the manager.

    Args:
        parent: Optional parent QObject.
    """
    super().__init__(parent)

delete_view(view_id) abstractmethod

Delete the view with the given id.

Parameters:

Name Type Description Default
view_id str

Identifier of the view to delete.

required
Source code in client/ayon_core/ui/components/views/view_manager.py
88
89
90
91
92
93
94
@abstractmethod
def delete_view(self, view_id: str) -> None:
    """Delete the view with the given id.

    Args:
        view_id: Identifier of the view to delete.
    """

get_default_project_view(view_type)

Return the project-scope default view for view_type, if any.

See :meth:get_default_studio_view for how default views are identified.

Parameters:

Name Type Description Default
view_type str

The view-type identifier.

required

Returns:

Type Description
View | None

The project default view for view_type, or None.

Source code in client/ayon_core/ui/components/views/view_manager.py
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
def get_default_project_view(self, view_type: str) -> View | None:
    """Return the project-scope default view for *view_type*, if any.

    See :meth:`get_default_studio_view` for how default views are
    identified.

    Args:
        view_type: The view-type identifier.

    Returns:
        The project default view for *view_type*, or ``None``.
    """
    for view in self.list_views(view_type):
        if (
            view.scope == Scope.PROJECT
            and view.label == DEFAULT_VIEW_LABEL
        ):
            return view
    return None

get_default_studio_view(view_type)

Return the studio-scope default view for view_type, if any.

Default views are saved with :data:DEFAULT_VIEW_LABEL and the matching :class:Scope (see DefaultViewControl._set_default_for_scope). Subclasses backed by a dedicated endpoint (e.g. :class:ServerViewManager) should override this rather than rely on filtering :meth:list_views.

Parameters:

Name Type Description Default
view_type str

The view-type identifier.

required

Returns:

Type Description
View | None

The studio default view for view_type, or None.

Source code in client/ayon_core/ui/components/views/view_manager.py
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
def get_default_studio_view(self, view_type: str) -> View | None:
    """Return the studio-scope default view for *view_type*, if any.

    Default views are saved with :data:`DEFAULT_VIEW_LABEL` and the
    matching :class:`Scope` (see
    ``DefaultViewControl._set_default_for_scope``). Subclasses backed
    by a dedicated endpoint (e.g. :class:`ServerViewManager`) should
    override this rather than rely on filtering :meth:`list_views`.

    Args:
        view_type: The view-type identifier.

    Returns:
        The studio default view for *view_type*, or ``None``.
    """
    for view in self.list_views(view_type):
        if view.scope == Scope.STUDIO and view.label == DEFAULT_VIEW_LABEL:
            return view
    return None

get_working_view(view_type)

Return the working view for view_type, if any.

Parameters:

Name Type Description Default
view_type str

The view-type identifier.

required

Returns:

Type Description
View | None

The view flagged with working=True for view_type, or

View | None

None when no working view exists.

Source code in client/ayon_core/ui/components/views/view_manager.py
126
127
128
129
130
131
132
133
134
135
136
137
138
139
def get_working_view(self, view_type: str) -> View | None:
    """Return the working view for *view_type*, if any.

    Args:
        view_type: The view-type identifier.

    Returns:
        The view flagged with ``working=True`` for *view_type*, or
        ``None`` when no working view exists.
    """
    for view in self.list_views(view_type):
        if view.working:
            return view
    return None

list_views(view_type) abstractmethod

Return the list of views for the given view type.

Parameters:

Name Type Description Default
view_type str

The view-type identifier (e.g. "versions").

required

Returns:

Type Description
list[View]

List of :class:View instances, sorted by

list[View]

attr:View.position then :attr:View.label.

Source code in client/ayon_core/ui/components/views/view_manager.py
62
63
64
65
66
67
68
69
70
71
72
@abstractmethod
def list_views(self, view_type: str) -> list[View]:
    """Return the list of views for the given view type.

    Args:
        view_type: The view-type identifier (e.g. ``"versions"``).

    Returns:
        List of :class:`View` instances, sorted by
        :attr:`View.position` then :attr:`View.label`.
    """

load_view(view)

Return view with its :attr:View.settings populated.

Managers that list views cheaply - fetching only the metadata the dropdown needs - override this to fetch the full payload the first time a view is actually used. The default implementation assumes :meth:list_views already returned complete views.

Parameters:

Name Type Description Default
view View

The view to complete.

required

Returns:

Type Description
View

A view whose settings are safe to apply or persist.

Source code in client/ayon_core/ui/components/views/view_manager.py
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
def load_view(self, view: View) -> View:
    """Return *view* with its :attr:`View.settings` populated.

    Managers that list views cheaply - fetching only the metadata the
    dropdown needs - override this to fetch the full payload the first
    time a view is actually used. The default implementation assumes
    :meth:`list_views` already returned complete views.

    Args:
        view: The view to complete.

    Returns:
        A view whose settings are safe to apply or persist.
    """
    return view

save_view(view) abstractmethod

Persist a new or updated view.

Implementations must assign a non-empty :attr:View.id when creating a new view and return the (possibly modified) view.

Parameters:

Name Type Description Default
view View

The view to save.

required

Returns:

Type Description
View

The saved view, with its assigned id.

Source code in client/ayon_core/ui/components/views/view_manager.py
74
75
76
77
78
79
80
81
82
83
84
85
86
@abstractmethod
def save_view(self, view: View) -> View:
    """Persist a new or updated view.

    Implementations must assign a non-empty :attr:`View.id` when
    creating a new view and return the (possibly modified) view.

    Args:
        view: The view to save.

    Returns:
        The saved view, with its assigned id.
    """

set_working_view(view)

Mark view as the (sole) working view for its view type.

The default implementation clears the working flag on every other view of the same type and saves them all. Subclasses can override this with a bulk-update endpoint for efficiency.

Parameters:

Name Type Description Default
view View

The view to mark as working.

required
Source code in client/ayon_core/ui/components/views/view_manager.py
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
def set_working_view(self, view: View) -> None:
    """Mark *view* as the (sole) working view for its view type.

    The default implementation clears the ``working`` flag on every
    other view of the same type and saves them all.  Subclasses can
    override this with a bulk-update endpoint for efficiency.

    Args:
        view: The view to mark as working.
    """
    for other in self.list_views(view.view_type):
        if other.id == view.id:
            continue
        if other.working:
            other.working = False
            self.save_view(other)
    view.working = True
    self.save_view(view)

supports_sharing()

Whether views can be shared with other users.

Sharing controls in the UI (the "Shared views" section, the people-with-access editor) are shown only when this returns True. The default implementation has no restrictions.

Returns:

Type Description
bool

True when views can be shared.

Source code in client/ayon_core/ui/components/views/view_manager.py
 98
 99
100
101
102
103
104
105
106
107
108
def supports_sharing(self) -> bool:
    """Whether views can be shared with other users.

    Sharing controls in the UI (the "Shared views" section, the
    people-with-access editor) are shown only when this returns
    True.  The default implementation has no restrictions.

    Returns:
        True when views can be shared.
    """
    return True

ViewSettings dataclass

The actual user-visible configuration of a View.

All "generic" knobs (columns, sort, row height, filter, grouping) are first-class fields. Anything else (showProducts, gridHeight, featuredVersionOrder, slicerType, …) is stashed verbatim in :attr:extra so the payload roundtrips losslessly across app versions.

Attributes:

Name Type Description
columns list[ColumnState]

Ordered list of column states.

sort_by str | None

Column key to sort by, or None for no sort.

sort_desc bool

Whether the sort is descending.

row_height int

Table row height in pixels. Consumers without a table may use this for another height-based presentation setting.

grouping GroupingDef

Grouping configuration.

filter FilterDef

Filter configuration.

extra dict[str, Any]

All other settings keys, preserved untouched.

Source code in client/ayon_core/ui/components/views/data_models.py
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
@dataclass
class ViewSettings:
    """The actual user-visible configuration of a View.

    All "generic" knobs (columns, sort, row height, filter, grouping)
    are first-class fields.  Anything else (``showProducts``,
    ``gridHeight``, ``featuredVersionOrder``, ``slicerType``, …) is
    stashed verbatim in :attr:`extra` so the payload roundtrips
    losslessly across app versions.

    Attributes:
        columns: Ordered list of column states.
        sort_by: Column key to sort by, or ``None`` for no sort.
        sort_desc: Whether the sort is descending.
        row_height: Table row height in pixels. Consumers without a table
            may use this for another height-based presentation setting.
        grouping: Grouping configuration.
        filter: Filter configuration.
        extra: All other settings keys, preserved untouched.
    """

    columns: list[ColumnState] = field(default_factory=list)
    sort_by: str | None = DEFAULT_SORT_BY
    sort_desc: bool = DEFAULT_SORT_DESC
    row_height: int = DEFAULT_ROW_HEIGHT
    grouping: GroupingDef = field(default_factory=GroupingDef)
    filter: FilterDef = field(default_factory=FilterDef)
    extra: dict[str, Any] = field(default_factory=dict)

    @classmethod
    def from_payload(cls, payload: dict[str, Any] | None) -> "ViewSettings":
        """Build ViewSettings from the payload's ``settings`` sub-dict.

        Unknown keys are preserved verbatim in :attr:`extra`.

        Args:
            payload: Settings sub-dict, or ``None`` for defaults.

        Returns:
            A new ViewSettings instance.
        """
        if not payload:
            return cls()

        raw_cols = payload.get("columns") or []
        columns = [
            ColumnState.from_payload(c)
            for c in raw_cols
            if isinstance(c, dict)
        ]

        sort_by = payload.get("sortBy")
        if sort_by is not None:
            sort_by = str(sort_by) or None

        row_height_raw = payload.get("rowHeight", DEFAULT_ROW_HEIGHT)
        try:
            row_height = int(row_height_raw)
        except (TypeError, ValueError):
            row_height = DEFAULT_ROW_HEIGHT

        grouping = GroupingDef(
            group_by=(
                str(payload["groupBy"]) if payload.get("groupBy") else None
            ),
            group_sort_desc=bool(payload.get("groupSortByDesc", False)),
            show_empty_groups=bool(payload.get("showEmptyGroups", False)),
        )

        filter_def = FilterDef.from_payload(payload.get("filter"))

        extra = {
            k: v for k, v in payload.items() if k not in _KNOWN_SETTINGS_KEYS
        }

        return cls(
            columns=columns,
            sort_by=sort_by,
            sort_desc=bool(payload.get("sortDesc", False)),
            row_height=row_height,
            grouping=grouping,
            filter=filter_def,
            extra=extra,
        )

    def to_payload(self) -> dict[str, Any]:
        """Serialise these settings to a payload dict.

        Unknown keys from :attr:`extra` are merged back in.  Known keys
        in ``extra`` are ignored (first-class fields win) so accidental
        duplication can't corrupt a roundtrip.

        Returns:
            A dict suitable for embedding under ``"settings"`` in a
            View payload.
        """
        out: dict[str, Any] = {
            "columns": [c.to_payload() for c in self.columns],
            "sortDesc": self.sort_desc,
            "rowHeight": self.row_height,
            "groupSortByDesc": self.grouping.group_sort_desc,
            "showEmptyGroups": self.grouping.show_empty_groups,
            "filter": self.filter.to_payload(),
        }
        if self.sort_by is not None:
            out["sortBy"] = self.sort_by
        if self.grouping.group_by is not None:
            out["groupBy"] = self.grouping.group_by

        # Merge extras last but never let them override first-class keys.
        for k, v in self.extra.items():
            if k not in _KNOWN_SETTINGS_KEYS:
                out[k] = v
        return out

from_payload(payload) classmethod

Build ViewSettings from the payload's settings sub-dict.

Unknown keys are preserved verbatim in :attr:extra.

Parameters:

Name Type Description Default
payload dict[str, Any] | None

Settings sub-dict, or None for defaults.

required

Returns:

Type Description
'ViewSettings'

A new ViewSettings instance.

Source code in client/ayon_core/ui/components/views/data_models.py
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
@classmethod
def from_payload(cls, payload: dict[str, Any] | None) -> "ViewSettings":
    """Build ViewSettings from the payload's ``settings`` sub-dict.

    Unknown keys are preserved verbatim in :attr:`extra`.

    Args:
        payload: Settings sub-dict, or ``None`` for defaults.

    Returns:
        A new ViewSettings instance.
    """
    if not payload:
        return cls()

    raw_cols = payload.get("columns") or []
    columns = [
        ColumnState.from_payload(c)
        for c in raw_cols
        if isinstance(c, dict)
    ]

    sort_by = payload.get("sortBy")
    if sort_by is not None:
        sort_by = str(sort_by) or None

    row_height_raw = payload.get("rowHeight", DEFAULT_ROW_HEIGHT)
    try:
        row_height = int(row_height_raw)
    except (TypeError, ValueError):
        row_height = DEFAULT_ROW_HEIGHT

    grouping = GroupingDef(
        group_by=(
            str(payload["groupBy"]) if payload.get("groupBy") else None
        ),
        group_sort_desc=bool(payload.get("groupSortByDesc", False)),
        show_empty_groups=bool(payload.get("showEmptyGroups", False)),
    )

    filter_def = FilterDef.from_payload(payload.get("filter"))

    extra = {
        k: v for k, v in payload.items() if k not in _KNOWN_SETTINGS_KEYS
    }

    return cls(
        columns=columns,
        sort_by=sort_by,
        sort_desc=bool(payload.get("sortDesc", False)),
        row_height=row_height,
        grouping=grouping,
        filter=filter_def,
        extra=extra,
    )

to_payload()

Serialise these settings to a payload dict.

Unknown keys from :attr:extra are merged back in. Known keys in extra are ignored (first-class fields win) so accidental duplication can't corrupt a roundtrip.

Returns:

Type Description
dict[str, Any]

A dict suitable for embedding under "settings" in a

dict[str, Any]

View payload.

Source code in client/ayon_core/ui/components/views/data_models.py
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
def to_payload(self) -> dict[str, Any]:
    """Serialise these settings to a payload dict.

    Unknown keys from :attr:`extra` are merged back in.  Known keys
    in ``extra`` are ignored (first-class fields win) so accidental
    duplication can't corrupt a roundtrip.

    Returns:
        A dict suitable for embedding under ``"settings"`` in a
        View payload.
    """
    out: dict[str, Any] = {
        "columns": [c.to_payload() for c in self.columns],
        "sortDesc": self.sort_desc,
        "rowHeight": self.row_height,
        "groupSortByDesc": self.grouping.group_sort_desc,
        "showEmptyGroups": self.grouping.show_empty_groups,
        "filter": self.filter.to_payload(),
    }
    if self.sort_by is not None:
        out["sortBy"] = self.sort_by
    if self.grouping.group_by is not None:
        out["groupBy"] = self.grouping.group_by

    # Merge extras last but never let them override first-class keys.
    for k, v in self.extra.items():
        if k not in _KNOWN_SETTINGS_KEYS:
            out[k] = v
    return out

Visibility

Bases: str, Enum

Whether a view is owner-only or public to others.

Subclasses str so it serialises naturally to JSON.

Source code in client/ayon_core/ui/components/views/data_models.py
72
73
74
75
76
77
78
79
class Visibility(str, Enum):
    """Whether a view is owner-only or public to others.

    Subclasses ``str`` so it serialises naturally to JSON.
    """

    PRIVATE = "private"
    PUBLIC = "public"