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_ui_qt/components/buttons.py
 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
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=False,
        checkable=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_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)

        # Determine the icon color
        color_str = icon_color 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(contrast_color, QColor) and contrast_color.isValid():
            self._icon_color = compute_color_for_contrast(
                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,
            )

        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 _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
        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,
                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,
                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_ui_qt/components/check_box.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
 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
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

    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())

        if variant == AYCheckBox.Variants.Button:
            self.setFixedSize(self.sizeHint())

    @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()

        _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 sizeHint(self) -> QSize:
        size = super().sizeHint()

        if self._variant_str == AYCheckBox.Variants.Button.value:
            h_pad, v_pad = self.style_dict.get("padding", [6, 6])
            size.setWidth(size.width() + h_pad * 2)
            size.setHeight(size.height() + v_pad * 2)
        else:
            # Recalculate width using the custom style's actual metrics
            option = QStyleOptionButton()
            self.initStyleOption(option)
            _style = get_ayon_style()
            ind_w = _style.pixelMetric(
                QStyle.PixelMetric.PM_IndicatorWidth, option, self
            )
            spacing = _style.pixelMetric(
                QStyle.PixelMetric.PM_CheckBoxLabelSpacing, option, self
            )
            fm = self.fontMetrics()
            text_w = fm.horizontalAdvance(self.text())
            size.setWidth(ind_w + spacing + text_w)

        return size

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_ui_qt/components/check_box.py
49
50
51
52
53
54
55
56
57
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()

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_ui_qt/components/check_box.py
 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
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()

    _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_ui_qt.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_ui_qt.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.

False
**kwargs

Additional keyword arguments forwarded to :class:QComboBox.

{}
Example

Basic usage with the built-in status list::

combo = AYComboBox(
    parent=my_widget,
    items=ALL_STATUSES,
    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_ui_qt/components/combo_box.py
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
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_ui_qt.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_ui_qt.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=ALL_STATUSES,
                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 = False,
        **kwargs,
    ) -> None:
        self._uses_incompatible_model = False
        super().__init__(parent, **kwargs)
        self._variant_str = variant.value
        from ..style import get_ayon_style

        self.setStyle(get_ayon_style())
        self.setMouseTracking(True)
        self.setMaximumHeight(height)

        # 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)

        self.update_items(items)

    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_ui_qt.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.
        """
        from ..style import get_ayon_style

        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_ui_qt.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.
        """

        from ..style import get_ayon_style

        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_ui_qt/components/combo_box.py
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
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

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_ui_qt.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_ui_qt/components/combo_box.py
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
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_ui_qt.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.
    """

    from ..style import get_ayon_style

    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_ui_qt/components/combo_box.py
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
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_ui_qt/components/combo_box.py
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
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_ui_qt.data_models.MenuSize value or its lowercase string equivalent ("full", "short", "icon").

required
Source code in client/ayon_ui_qt/components/combo_box.py
669
670
671
672
673
674
675
676
677
678
679
680
681
def set_size(self, size: MenuSize | str):
    """Change the display mode and repaint the widget.

    Args:
        size: New display mode.  Accepts a
            :class:`~ayon_ui_qt.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_ui_qt/components/combo_box.py
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
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.
    """
    from ..style import get_ayon_style

    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_ui_qt/components/combo_box.py
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
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)

AYLabel

Bases: StyleMixin, QLabel

Source code in client/ayon_ui_qt/components/label.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
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
class AYLabel(StyleMixin, QtWidgets.QLabel):
    Variants = QLabelVariants

    def __init__(
        self,
        *args,
        dim: bool = False,
        icon: str = "",
        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,
        **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._text_setup_done = False
        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)

        self.setSizePolicy(
            QtWidgets.QSizePolicy.Policy.Minimum,
            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 trigger a repaint."""
        self._style_font = self._configure_font(font)
        self.update()

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

    def set_icon(self, icon: str | 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")

            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 _configure_font(self, font: QFont) -> QFont:
        """Initialize font configuration on first paint."""
        if self._text_setup_done:
            return 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)

        self._text_setup_done = True
        return font

    def _display_text(self) -> str:
        """Recompute the elided version of the stored text."""
        if (
            self._elide_mode == Qt.TextElideMode.ElideNone
            or not self.fontMetrics()
        ):
            return self._text
        available_w = self.contentsRect().width()
        if self._icon:
            spacing = self._icon_text_spacing
            available_w -= self._icon_size + spacing
        text = self.fontMetrics().elidedText(
            self._text, self._elide_mode, max(0, available_w)
        )
        return text

    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 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()
        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: QEvent) -> 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)
            text_w = 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 {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 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()

    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_ui_qt/components/label.py
762
763
764
765
766
767
768
769
770
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_ui_qt/components/label.py
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
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

paintEvent(arg__1)

Override to support icon + text rendering.

Source code in client/ayon_ui_qt/components/label.py
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
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 trigger a repaint.

Source code in client/ayon_ui_qt/components/label.py
154
155
156
157
def set_font(self, font: QFont) -> None:
    """Set the widget font and trigger a repaint."""
    self._style_font = self._configure_font(font)
    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_ui_qt/components/label.py
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
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_ui_qt/components/label.py
143
144
145
146
147
148
149
150
151
152
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_ui_qt/components/label.py
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
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)
        text_w = 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 {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_ui_qt/components/line_edit.py
 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
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_ui_qt/components/line_edit.py
137
138
139
140
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_ui_qt/components/line_edit.py
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
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_ui_qt/components/line_edit.py
227
228
229
230
231
232
233
234
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_ui_qt/components/line_edit.py
88
89
90
91
92
93
94
95
96
97
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]

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_ui_qt/components/text_edit.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
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)

__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_ui_qt/components/text_edit.py
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
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)

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.

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_ui_qt/components/tree_view.py
 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
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.

    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()
        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._hovered_row_key: tuple | None = None
        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.
        self.setHeaderHidden(True)

        # 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)

    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:
                idx = self.indexAt(event.pos())
                key = (idx.row(), idx.parent()) if idx.isValid() else None
                if key != self._hovered_row_key:
                    self._hovered_row_key = key
                    self.viewport().update()
            elif event.type() == QEvent.Type.Leave:
                if self._hovered_row_key is not None:
                    self._hovered_row_key = None
                    self.viewport().update()
        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_index = self.indexAt(
            self.viewport().mapFromGlobal(QCursor.pos())
        )
        if (
            hovered_index.isValid()
            and hovered_index.row() == index.row()
            and hovered_index.parent() == index.parent()
        ):
            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 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_ui_qt/components/tree_view.py
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
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_index = self.indexAt(
        self.viewport().mapFromGlobal(QCursor.pos())
    )
    if (
        hovered_index.isValid()
        and hovered_index.row() == index.row()
        and hovered_index.parent() == index.parent()
    ):
        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)

mouseDoubleClickEvent(event)

Emit double_clicked signal on double-click.

Source code in client/ayon_ui_qt/components/tree_view.py
202
203
204
205
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_ui_qt/components/tree_view.py
207
208
209
210
211
212
213
214
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_ui_qt/components/tree_view.py
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
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_ui_qt/components/tree_view.py
216
217
218
219
220
221
222
223
224
225
226
227
228
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)