Skip to content

comment_completion

MentionHighlighter

Bases: QSyntaxHighlighter

Syntax highlighter for @mentions, raw URLs, and code spans.

Operates on block-local plain text so positions are always correct regardless of any rich-text formatting already present in the document (bold, italic, headings, code spans, etc.).

Patterns highlighted:

  • Fenced code blocks (\`\`\` ... \`\`\) spanning multiple lines — black background, white monospace text. Block state 1 tracks whether the current block is inside a fence.
  • Qt-rendered code blocks (from setMarkdown()) — detected via nonBreakableLines on the block format.
  • Qt-rendered inline code spans (from setMarkdown()) — detected via fontFixedPitch on individual text fragments.
  • Inline code spans (\`code\`) in raw (un-rendered) text — same style, detected by backtick regex.
  • @@@word — task mention
  • @@word — version mention
  • @word — user mention (only the first word if the full name is not in the known user list; both words when it is)
  • https?://… — raw URL

Parameters:

Name Type Description Default
document

The QTextDocument to attach to.

required
user_list list

Live list of :class:~..data_models.User objects used to decide whether a two-word mention should be highlighted in full.

required
Source code in client/ayon_ui_qt/components/comment_completion.py
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
class MentionHighlighter(QSyntaxHighlighter):
    """Syntax highlighter for @mentions, raw URLs, and code spans.

    Operates on block-local plain text so positions are always correct
    regardless of any rich-text formatting already present in the document
    (bold, italic, headings, code spans, etc.).

    Patterns highlighted:

    - Fenced code blocks (```\\`\\`\\` ... \\`\\`\\```) spanning multiple lines —
      black background, white monospace text.  Block state ``1`` tracks
      whether the current block is inside a fence.
    - Qt-rendered code blocks (from ``setMarkdown()``) — detected via
      ``nonBreakableLines`` on the block format.
    - Qt-rendered inline code spans (from ``setMarkdown()``) — detected via
      ``fontFixedPitch`` on individual text fragments.
    - Inline code spans (`` \\`code\\` ``) in raw (un-rendered) text — same
      style, detected by backtick regex.
    - ``@@@word`` — task mention
    - ``@@word``  — version mention
    - ``@word``   — user mention (only the first word if the full name is not
      in the known user list; both words when it is)
    - ``https?://…`` — raw URL

    Args:
        document: The QTextDocument to attach to.
        user_list: Live list of :class:`~..data_models.User` objects used to
            decide whether a two-word mention should be highlighted in full.
    """

    # Compiled patterns — order matters: longer prefixes first so that
    # ``@@@`` is matched before ``@@`` and ``@@`` before ``@``.
    _P_TASK = re.compile(r"@@@\w+( \w+)?")
    _P_VERSION = re.compile(r"@@(?!@)\w+( \w+)?")
    _P_USER = re.compile(r"@(?!@)\w+( \w+)?")
    _P_RAW_LINK = re.compile(r"https?://\S+")
    # Inline code: single backtick pair on the same line.
    _P_CODE_INLINE = re.compile(r"`[^`\n]+`")

    def __init__(self, document, user_list: list) -> None:
        super().__init__(document)
        self._user_list = user_list
        pal = get_ayon_style().model.base_palette
        self._mention_fmt = QTextCharFormat()
        self._mention_fmt.setForeground(pal.link())
        self._code_fmt = QTextCharFormat()
        self._code_fmt.setFontFixedPitch(True)
        self._code_fmt.setFontFamilies(
            ["Courier New", "Menlo", "Monaco", "monospace"]
        )
        self._code_fmt.setBackground(CODE_BG)
        self._code_fmt.setForeground(CODE_FG)

    def update_user_list(self, user_list: list) -> None:
        """Replace the user list and trigger a full rehighlight.

        Args:
            user_list: Updated list of User objects.
        """
        self._user_list = user_list
        self.rehighlight()

    def highlightBlock(self, text: str) -> None:
        """Apply code, mention, and URL highlighting to a single block.

        Two detection strategies are combined so that code is styled in both
        *edit mode* (raw markdown typed by the user) and *display mode*
        (rich text rendered via ``document().setMarkdown()``):

        **Edit mode — raw fence markers:**
        Uses block state ``1`` to track multi-line fenced code blocks. A line
        starting with *```* opens or closes a fence; every line inside the
        fence is styled with :attr:`_code_fmt`.  A line that both opens and
        closes a fence on the same line (e.g. ``\\`\\`\\`code\\`\\`\\```) is
        treated as a single-line code block with no state change.

        **Display mode — Qt-rendered char formats:**
        After ``setMarkdown()`` Qt strips the fence markers and stores rich
        text character formats.  Fenced code blocks have
        ``nonBreakableLines=True`` set on the block format (``fontFixedPitch``
        stays ``False`` on the block-level char format).  Inline code spans
        set ``fontFixedPitch=True`` on individual text *fragments* within a
        paragraph.  Both are detected here and styled with :attr:`_code_fmt`.

        Inline code spans from raw backtick syntax (`` \\`code\\` ``) are also
        detected via :attr:`_P_CODE_INLINE` for live-typed backtick spans.

        Code formatting is applied *after* mention/URL patterns so that it
        takes precedence over any mention highlight inside a code span.

        Called automatically by Qt whenever the block changes.

        Args:
            text: Plain text content of the current block.
        """
        block = self.currentBlock()
        in_fence = self.previousBlockState() == 1

        # ── Edit mode: raw fence markers ─────────────────────────────────
        if in_fence:
            # The entire line belongs to the open fenced block.
            self.setFormat(0, len(text), self._code_fmt)
            # A line starting with ``` closes the fence.
            if text.startswith("```"):
                self.setCurrentBlockState(0)
            else:
                self.setCurrentBlockState(1)
            return

        if text.startswith("```"):
            self.setFormat(0, len(text), self._code_fmt)
            rest = text[3:]
            # Closing ``` on the same line → single-line block, no state.
            if "```" in rest:
                self.setCurrentBlockState(0)
            else:
                self.setCurrentBlockState(1)
            return

        self.setCurrentBlockState(0)

        # ── Display mode: Qt-rendered whole-block code ───────────────────
        # After setMarkdown(), Qt marks fenced code block lines with
        # nonBreakableLines=True on the block format.  The char format
        # carries fontFamilies=['monospace'] but fontFixedPitch stays False.
        # Style the whole line and skip mention/URL patterns — they don't
        # belong inside code.
        if block.blockFormat().nonBreakableLines():
            self.setFormat(0, len(text), self._code_fmt)
            return

        # ── Mentions and URLs (applied before inline code) ───────────────
        users = {u.full_name for u in self._user_list}

        # Task mentions (@@@)
        for m in self._P_TASK.finditer(text):
            self.setFormat(m.start(), m.end() - m.start(), self._mention_fmt)

        # Version mentions (@@)
        for m in self._P_VERSION.finditer(text):
            self.setFormat(m.start(), m.end() - m.start(), self._mention_fmt)

        # User mentions (@) — highlight only the first word unless the full
        # two-word name is in the known user list.
        for m in self._P_USER.finditer(text):
            full_match = m.group(0)
            mention_name = full_match[1:]  # strip leading @
            if mention_name in users:
                length = len(full_match)
            else:
                # Highlight only up to the first word (no trailing space+word)
                length = len(full_match.split()[0])
            self.setFormat(m.start(), length, self._mention_fmt)

        # Raw URLs
        for m in self._P_RAW_LINK.finditer(text):
            self.setFormat(m.start(), m.end() - m.start(), self._mention_fmt)

        # ── Inline code (applied last, overrides mention formatting) ─────

        # Raw backtick syntax `code` — detected in plain text for live
        # editing where backtick characters are still present:
        for m in self._P_CODE_INLINE.finditer(text):
            self.setFormat(m.start(), m.end() - m.start(), self._code_fmt)

        # Qt-rendered inline code spans — after setMarkdown() the backticks
        # are consumed and individual fragments carry fontFixedPitch=True:
        it = block.begin()
        while not it.atEnd():
            fragment = it.fragment()
            if fragment.isValid() and fragment.charFormat().fontFixedPitch():
                frag_start = fragment.position() - block.position()
                self.setFormat(frag_start, fragment.length(), self._code_fmt)
            it += 1

highlightBlock(text)

Apply code, mention, and URL highlighting to a single block.

Two detection strategies are combined so that code is styled in both edit mode (raw markdown typed by the user) and display mode (rich text rendered via document().setMarkdown()):

Edit mode — raw fence markers: Uses block state 1 to track multi-line fenced code blocks. A line starting with ** opens or closes a fence; every line inside the fence is styled with :attr:`_code_fmt`. A line that both opens and closes a fence on the same line (e.g. ``\`\`\`code\`\`\) is treated as a single-line code block with no state change.

Display mode — Qt-rendered char formats: After setMarkdown() Qt strips the fence markers and stores rich text character formats. Fenced code blocks have nonBreakableLines=True set on the block format (fontFixedPitch stays False on the block-level char format). Inline code spans set fontFixedPitch=True on individual text fragments within a paragraph. Both are detected here and styled with :attr:_code_fmt.

Inline code spans from raw backtick syntax (\`code\`) are also detected via :attr:_P_CODE_INLINE for live-typed backtick spans.

Code formatting is applied after mention/URL patterns so that it takes precedence over any mention highlight inside a code span.

Called automatically by Qt whenever the block changes.

Parameters:

Name Type Description Default
text str

Plain text content of the current block.

required
Source code in client/ayon_ui_qt/components/comment_completion.py
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
def highlightBlock(self, text: str) -> None:
    """Apply code, mention, and URL highlighting to a single block.

    Two detection strategies are combined so that code is styled in both
    *edit mode* (raw markdown typed by the user) and *display mode*
    (rich text rendered via ``document().setMarkdown()``):

    **Edit mode — raw fence markers:**
    Uses block state ``1`` to track multi-line fenced code blocks. A line
    starting with *```* opens or closes a fence; every line inside the
    fence is styled with :attr:`_code_fmt`.  A line that both opens and
    closes a fence on the same line (e.g. ``\\`\\`\\`code\\`\\`\\```) is
    treated as a single-line code block with no state change.

    **Display mode — Qt-rendered char formats:**
    After ``setMarkdown()`` Qt strips the fence markers and stores rich
    text character formats.  Fenced code blocks have
    ``nonBreakableLines=True`` set on the block format (``fontFixedPitch``
    stays ``False`` on the block-level char format).  Inline code spans
    set ``fontFixedPitch=True`` on individual text *fragments* within a
    paragraph.  Both are detected here and styled with :attr:`_code_fmt`.

    Inline code spans from raw backtick syntax (`` \\`code\\` ``) are also
    detected via :attr:`_P_CODE_INLINE` for live-typed backtick spans.

    Code formatting is applied *after* mention/URL patterns so that it
    takes precedence over any mention highlight inside a code span.

    Called automatically by Qt whenever the block changes.

    Args:
        text: Plain text content of the current block.
    """
    block = self.currentBlock()
    in_fence = self.previousBlockState() == 1

    # ── Edit mode: raw fence markers ─────────────────────────────────
    if in_fence:
        # The entire line belongs to the open fenced block.
        self.setFormat(0, len(text), self._code_fmt)
        # A line starting with ``` closes the fence.
        if text.startswith("```"):
            self.setCurrentBlockState(0)
        else:
            self.setCurrentBlockState(1)
        return

    if text.startswith("```"):
        self.setFormat(0, len(text), self._code_fmt)
        rest = text[3:]
        # Closing ``` on the same line → single-line block, no state.
        if "```" in rest:
            self.setCurrentBlockState(0)
        else:
            self.setCurrentBlockState(1)
        return

    self.setCurrentBlockState(0)

    # ── Display mode: Qt-rendered whole-block code ───────────────────
    # After setMarkdown(), Qt marks fenced code block lines with
    # nonBreakableLines=True on the block format.  The char format
    # carries fontFamilies=['monospace'] but fontFixedPitch stays False.
    # Style the whole line and skip mention/URL patterns — they don't
    # belong inside code.
    if block.blockFormat().nonBreakableLines():
        self.setFormat(0, len(text), self._code_fmt)
        return

    # ── Mentions and URLs (applied before inline code) ───────────────
    users = {u.full_name for u in self._user_list}

    # Task mentions (@@@)
    for m in self._P_TASK.finditer(text):
        self.setFormat(m.start(), m.end() - m.start(), self._mention_fmt)

    # Version mentions (@@)
    for m in self._P_VERSION.finditer(text):
        self.setFormat(m.start(), m.end() - m.start(), self._mention_fmt)

    # User mentions (@) — highlight only the first word unless the full
    # two-word name is in the known user list.
    for m in self._P_USER.finditer(text):
        full_match = m.group(0)
        mention_name = full_match[1:]  # strip leading @
        if mention_name in users:
            length = len(full_match)
        else:
            # Highlight only up to the first word (no trailing space+word)
            length = len(full_match.split()[0])
        self.setFormat(m.start(), length, self._mention_fmt)

    # Raw URLs
    for m in self._P_RAW_LINK.finditer(text):
        self.setFormat(m.start(), m.end() - m.start(), self._mention_fmt)

    # ── Inline code (applied last, overrides mention formatting) ─────

    # Raw backtick syntax `code` — detected in plain text for live
    # editing where backtick characters are still present:
    for m in self._P_CODE_INLINE.finditer(text):
        self.setFormat(m.start(), m.end() - m.start(), self._code_fmt)

    # Qt-rendered inline code spans — after setMarkdown() the backticks
    # are consumed and individual fragments carry fontFixedPitch=True:
    it = block.begin()
    while not it.atEnd():
        fragment = it.fragment()
        if fragment.isValid() and fragment.charFormat().fontFixedPitch():
            frag_start = fragment.position() - block.position()
            self.setFormat(frag_start, fragment.length(), self._code_fmt)
        it += 1

update_user_list(user_list)

Replace the user list and trigger a full rehighlight.

Parameters:

Name Type Description Default
user_list list

Updated list of User objects.

required
Source code in client/ayon_ui_qt/components/comment_completion.py
369
370
371
372
373
374
375
376
def update_user_list(self, user_list: list) -> None:
    """Replace the user list and trigger a full rehighlight.

    Args:
        user_list: Updated list of User objects.
    """
    self._user_list = user_list
    self.rehighlight()

UserCompleterDelegate

Bases: QStyledItemDelegate

Custom delegate to display user icon and full name in completer.

Source code in client/ayon_ui_qt/components/comment_completion.py
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
class UserCompleterDelegate(QStyledItemDelegate):
    """Custom delegate to display user icon and full name in completer."""

    def __init__(self, parent=None):
        super().__init__(parent)
        self.icon_size = 20
        self._user_pixmap = {}

    def paint(
        self,
        painter: QPainter,
        option: QStyleOptionViewItem,
        index,
    ) -> None:
        """Paint user icon and full name."""
        user: User = index.data(Qt.ItemDataRole.UserRole)
        if not user:
            super().paint(painter, option, index)
            return

        # Draw background
        if option.state & QStyle.StateFlag.State_Selected:
            painter.fillRect(option.rect, option.palette.light())
        else:
            painter.fillRect(option.rect, option.palette.midlight())

        # Draw user icon
        try:
            icon_pixmap = self._user_pixmap[user.name]
        except KeyError:
            user_image = AYUserImage(
                src=user.avatar_url,
                full_name=user.full_name,
                size=self.icon_size,
                outline=False,
            )
            icon_pixmap = user_image.pixmap()
            self._user_pixmap[user.name] = icon_pixmap

        icon_x = option.rect.x() + 4
        icon_y = option.rect.y() + (option.rect.height() - self.icon_size) // 2
        painter.drawPixmap(icon_x, icon_y, icon_pixmap)

        # Draw full name
        text_x = icon_x + self.icon_size + 8
        text_rect = option.rect.adjusted(text_x, 0, 0, 0)
        painter.drawText(
            text_rect,
            Qt.AlignmentFlag.AlignVCenter,
            user.full_name,
        )

    def sizeHint(
        self,
        option: QStyleOptionViewItem,
        index,
    ) -> QSize:
        """Return size hint for completer items."""
        return QSize(option.rect.width(), self.icon_size + 8)

paint(painter, option, index)

Paint user icon and full name.

Source code in client/ayon_ui_qt/components/comment_completion.py
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
def paint(
    self,
    painter: QPainter,
    option: QStyleOptionViewItem,
    index,
) -> None:
    """Paint user icon and full name."""
    user: User = index.data(Qt.ItemDataRole.UserRole)
    if not user:
        super().paint(painter, option, index)
        return

    # Draw background
    if option.state & QStyle.StateFlag.State_Selected:
        painter.fillRect(option.rect, option.palette.light())
    else:
        painter.fillRect(option.rect, option.palette.midlight())

    # Draw user icon
    try:
        icon_pixmap = self._user_pixmap[user.name]
    except KeyError:
        user_image = AYUserImage(
            src=user.avatar_url,
            full_name=user.full_name,
            size=self.icon_size,
            outline=False,
        )
        icon_pixmap = user_image.pixmap()
        self._user_pixmap[user.name] = icon_pixmap

    icon_x = option.rect.x() + 4
    icon_y = option.rect.y() + (option.rect.height() - self.icon_size) // 2
    painter.drawPixmap(icon_x, icon_y, icon_pixmap)

    # Draw full name
    text_x = icon_x + self.icon_size + 8
    text_rect = option.rect.adjusted(text_x, 0, 0, 0)
    painter.drawText(
        text_rect,
        Qt.AlignmentFlag.AlignVCenter,
        user.full_name,
    )

sizeHint(option, index)

Return size hint for completer items.

Source code in client/ayon_ui_qt/components/comment_completion.py
89
90
91
92
93
94
95
def sizeHint(
    self,
    option: QStyleOptionViewItem,
    index,
) -> QSize:
    """Return size hint for completer items."""
    return QSize(option.rect.width(), self.icon_size + 8)

UserCompleterModel

Bases: QStandardItemModel

Model for user completer.

Source code in client/ayon_ui_qt/components/comment_completion.py
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
class UserCompleterModel(QStandardItemModel):
    """Model for user completer."""

    def __init__(self, users: list[User], parent=None):
        super().__init__(parent)
        self.users = users
        self._populate()

    def _populate(self) -> None:
        """Populate model with users."""
        self.clear()
        for user in self.users:
            item = QStandardItem(user.full_name)
            item.setData(user, Qt.ItemDataRole.UserRole)
            self.appendRow(item)

apply_code_block_backgrounds(text_edit)

Apply a full-width background colour to every fenced code block.

QSyntaxHighlighter.setFormat() only paints behind individual text characters, so the end of a short line keeps the regular widget background. Setting QTextBlockFormat.background instead causes Qt's own layout engine to paint the background across the entire width of the block before any characters are drawn.

This function is intentionally separate from :class:MentionHighlighter because Qt's documentation forbids modifying the document from inside highlightBlock().

Two detection strategies are combined so that code blocks are recognised in both scenarios:

  • Display mode (after document().setMarkdown()): Qt marks fenced code block lines with nonBreakableLines=True on the block format.
  • Edit mode (raw typing): Lines inside a `\``…`````` fence are detected by tracking an open-fence flag while iterating from the first block of the document.

The function is guarded by the _suppress_formatting attribute on text_edit to prevent infinite recursion: writing block formats emits contentsChanged, which would otherwise re-enter this function.

Parameters:

Name Type Description Default
text_edit QTextEdit

The QTextEdit whose document should have fenced code block backgrounds applied.

required
Source code in client/ayon_ui_qt/components/comment_completion.py
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
def apply_code_block_backgrounds(text_edit: QTextEdit) -> None:
    """Apply a full-width background colour to every fenced code block.

    ``QSyntaxHighlighter.setFormat()`` only paints behind individual
    text characters, so the end of a short line keeps the regular widget
    background.  Setting ``QTextBlockFormat.background`` instead causes
    Qt's own layout engine to paint the background across the *entire*
    width of the block before any characters are drawn.

    This function is intentionally separate from
    :class:`MentionHighlighter` because Qt's documentation forbids
    modifying the document from inside ``highlightBlock()``.

    Two detection strategies are combined so that code blocks are
    recognised in both scenarios:

    - **Display mode** (after ``document().setMarkdown()``): Qt marks
      fenced code block lines with ``nonBreakableLines=True`` on the
      block format.
    - **Edit mode** (raw typing): Lines inside a ``\\`\\`\\`…\\`\\`\\```` fence
      are detected by tracking an open-fence flag while iterating from
      the first block of the document.

    The function is guarded by the ``_suppress_formatting`` attribute on
    *text_edit* to prevent infinite recursion: writing block formats
    emits ``contentsChanged``, which would otherwise re-enter this
    function.

    Args:
        text_edit: The QTextEdit whose document should have fenced code
            block backgrounds applied.
    """
    if getattr(text_edit, "_suppress_formatting", False):
        return

    doc = text_edit.document()
    setattr(text_edit, "_suppress_formatting", True)

    cursor = QTextCursor(doc)
    cursor.beginEditBlock()

    try:
        in_fence = False
        block = doc.begin()
        while block.isValid():
            text = block.text()
            is_code = False

            # Display mode: Qt-rendered fenced code block.
            if block.blockFormat().nonBreakableLines():
                is_code = True
            else:
                # Edit mode: raw ``` fence markers.
                if in_fence:
                    is_code = True
                    if text.startswith("```"):
                        in_fence = False  # closing fence line — still code
                elif text.startswith("```"):
                    is_code = True
                    rest = text[3:]
                    if "```" not in rest:
                        in_fence = True  # opening fence
                    # else: single-line ```…``` — is_code=True, no state change

            bg_brush = block.blockFormat().background()
            has_code_bg = (
                bg_brush.style() != Qt.BrushStyle.NoBrush
                and bg_brush.color().rgb() == CODE_BG.rgb()
            )

            if is_code and not has_code_bg:
                cursor.setPosition(block.position())
                new_fmt = QTextBlockFormat()
                new_fmt.setBackground(CODE_BG)
                cursor.mergeBlockFormat(new_fmt)
            elif not is_code and has_code_bg:
                # Remove the previously applied code background.
                cursor.setPosition(block.position())
                restored = QTextBlockFormat(block.blockFormat())
                restored.clearBackground()
                cursor.setBlockFormat(restored)

            block = block.next()
    except Exception as e:
        logging.info(f"Error in apply_code_block_backgrounds: {e}")
    finally:
        # Ensure we always end the edit block and reset the flag
        cursor.endEditBlock()
        setattr(text_edit, "_suppress_formatting", False)

format_comment_on_change(text_edit)

Ensure a :class:MentionHighlighter is installed on text_edit.

Idempotent: safe to call on every contentsChanged signal. The highlighter is created once and attached to the document. Subsequent calls only call :meth:MentionHighlighter.update_user_list when the _user_list reference on text_edit has been replaced (e.g. after a server refresh), which avoids triggering an unnecessary rehighlight — and the infinite-recursion that would follow — on every keystroke.

Parameters:

Name Type Description Default
text_edit QTextEdit

The QTextEdit whose document should have mention highlighting applied.

required
Source code in client/ayon_ui_qt/components/comment_completion.py
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
def format_comment_on_change(text_edit: QTextEdit) -> None:
    """Ensure a :class:`MentionHighlighter` is installed on *text_edit*.

    Idempotent: safe to call on every ``contentsChanged`` signal.  The
    highlighter is created once and attached to the document.  Subsequent
    calls only call :meth:`MentionHighlighter.update_user_list` when the
    ``_user_list`` reference on *text_edit* has been replaced (e.g. after a
    server refresh), which avoids triggering an unnecessary ``rehighlight``
    — and the infinite-recursion that would follow — on every keystroke.

    Args:
        text_edit: The QTextEdit whose document should have mention
            highlighting applied.
    """
    highlighter: MentionHighlighter | None = getattr(
        text_edit, "_mention_highlighter", None
    )
    user_list = getattr(text_edit, "_user_list", [])

    if highlighter is None:
        highlighter = MentionHighlighter(text_edit.document(), user_list)
        text_edit._mention_highlighter = highlighter  # type: ignore[attr-defined]
    elif highlighter._user_list is not user_list:
        # The list object was replaced (e.g. after a user-list refresh).
        # update_user_list() calls rehighlight() which is safe here because
        # this branch is only reached when _suppress_formatting is False and
        # the list identity has changed — not on every keystroke.
        highlighter.update_user_list(user_list)

on_completer_activated(text_edit, text)

Handle completer selection.

Parameters:

Name Type Description Default
text_edit QTextEdit

The QTextEdit widget with completer.

required
text str

The selected completion text (user full name).

required
Source code in client/ayon_ui_qt/components/comment_completion.py
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
def on_completer_activated(
    text_edit: QTextEdit,
    text: str,
) -> None:
    """Handle completer selection.

    Args:
        text_edit: The QTextEdit widget with completer.
        text: The selected completion text (user full name).
    """
    cursor = text_edit.textCursor()
    block = cursor.block()
    text_in_block = block.text()
    pos_in_block = cursor.positionInBlock()

    # Find the '@' position
    at_pos = text_in_block.rfind("@", 0, pos_in_block)
    if at_pos == -1:
        return

    # Replace from '@' to cursor with '@' + full_name
    cursor.setPosition(block.position() + at_pos)
    cursor.setPosition(
        block.position() + pos_in_block,
        QTextCursor.MoveMode.KeepAnchor,
    )
    cursor.insertText(f"@{text}")
    text_edit.setTextCursor(cursor)
    popup = text_edit.completer.popup()
    if popup:
        popup.hide()

on_completer_key_press(text_edit, event)

Handle key press events for completer.

Parameters:

Name Type Description Default
text_edit QTextEdit

The QTextEdit widget with completer.

required
event

The key press event.

required

Returns:

Type Description
bool

True if event was handled, False otherwise.

Source code in client/ayon_ui_qt/components/comment_completion.py
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
def on_completer_key_press(
    text_edit: QTextEdit,
    event,
) -> bool:
    """Handle key press events for completer.

    Args:
        text_edit: The QTextEdit widget with completer.
        event: The key press event.

    Returns:
        True if event was handled, False otherwise.
    """
    if not hasattr(text_edit, "completer"):
        return False

    popup = text_edit.completer.popup()
    if popup and popup.isVisible():
        if event.key() in (Qt.Key.Key_Return, Qt.Key.Key_Enter):
            # Get current completion from the selected index
            current_index = popup.currentIndex()
            if current_index.isValid():
                completion = current_index.data()
                if completion:
                    text_edit.completer.activated.emit(completion)
                    return True
    return False

on_completer_text_changed(text_edit)

Handle text changes to show/hide completer.

Parameters:

Name Type Description Default
text_edit QTextEdit

The QTextEdit widget with completer.

required
Source code in client/ayon_ui_qt/components/comment_completion.py
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
def on_completer_text_changed(
    text_edit: QTextEdit,
) -> None:
    """Handle text changes to show/hide completer.

    Args:
        text_edit: The QTextEdit widget with completer.
    """
    if not hasattr(text_edit, "completer") or text_edit.isReadOnly():
        return

    cursor = text_edit.textCursor()
    block = cursor.block()
    text = block.text()
    pos_in_block = cursor.positionInBlock()

    # Find the last '@' before cursor
    at_pos = text.rfind("@", 0, pos_in_block)
    if at_pos == -1:
        popup = text_edit.completer.popup()
        if popup:
            popup.hide()
        return

    # Get text after '@'
    prefix = text[at_pos + 1 : pos_in_block]

    # Show completer if '@' is followed by nothing or non-space characters
    if not prefix or (prefix and not prefix[0].isspace()):
        text_edit.completer.setCompletionPrefix(prefix)
        show_completer_popup(text_edit, at_pos)
        # Auto-select if only one item
        popup = text_edit.completer.popup()
        if popup:
            popup_model = popup.model()
            row_count = popup_model.rowCount() if popup_model else 0
            if row_count == 1:
                popup.setCurrentIndex(popup_model.index(0, 0))
    else:
        popup = text_edit.completer.popup()
        if popup:
            popup.hide()

setup_user_completer(text_edit, on_completer_activated, on_text_changed)

Setup user name completer for a QTextEdit widget.

Parameters:

Name Type Description Default
text_edit QTextEdit

The QTextEdit widget to attach completer to.

required
on_completer_activated

Callback for completer activation.

required
on_text_changed

Callback for text changes.

required
Source code in client/ayon_ui_qt/components/comment_completion.py
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
def setup_user_completer(
    text_edit: QTextEdit,
    on_completer_activated,
    on_text_changed,
) -> None:
    """Setup user name completer for a QTextEdit widget.

    Args:
        text_edit: The QTextEdit widget to attach completer to.
        on_completer_activated: Callback for completer activation.
        on_text_changed: Callback for text changes.
    """
    users = getattr(text_edit, "_user_list")
    if not users:
        users = [
            User(
                name="not available",
                short_name="not available",
                full_name="not available",
                email="",
                avatar_url="",
            )
        ]
    model = UserCompleterModel(users, text_edit)
    text_edit.completer = QCompleter(model, text_edit)
    text_edit.completer.setCompletionMode(
        QCompleter.CompletionMode.PopupCompletion
    )
    text_edit.completer.setFilterMode(Qt.MatchFlag.MatchContains)
    text_edit.completer.setMaxVisibleItems(4)
    text_edit.completer.setCaseSensitivity(Qt.CaseSensitivity.CaseInsensitive)
    text_edit.completer.setWidget(text_edit)

    # Set custom delegate
    popup = text_edit.completer.popup()
    if popup:
        delegate = UserCompleterDelegate(popup)
        popup.setItemDelegate(delegate)
        popup.setWindowFlag(Qt.WindowType.NoDropShadowWindowHint, True)

    # Connect completer signals
    text_edit.completer.activated.connect(on_completer_activated)
    text_edit.textChanged.connect(on_text_changed)

show_completer_popup(text_edit, at_pos)

Show completer popup above the QTextEdit.

Parameters:

Name Type Description Default
text_edit QTextEdit

The QTextEdit widget with completer.

required
at_pos int

Position of '@' character in the block.

required
Source code in client/ayon_ui_qt/components/comment_completion.py
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
def show_completer_popup(text_edit: QTextEdit, at_pos: int) -> None:
    """Show completer popup above the QTextEdit.

    Args:
        text_edit: The QTextEdit widget with completer.
        at_pos: Position of '@' character in the block.
    """
    popup = text_edit.completer.popup()
    if not popup:
        return

    # Get editor dimensions
    editor_rect = text_edit.rect()
    editor_width = editor_rect.width()

    # Show popup to get its height
    popup.show()

    # Calculate height based on max visible items (4)
    max_visible = text_edit.completer.maxVisibleItems()
    item_height = popup.sizeHintForRow(0)
    popup_height = item_height * max_visible

    # Position popup above the QTextEdit with same width as editor
    global_pos = text_edit.mapToGlobal(editor_rect.topLeft())
    popup_x = global_pos.x()
    popup_y = global_pos.y() - popup_height

    popup.setGeometry(popup_x, popup_y, editor_width, popup_height)