Skip to content

markdown_edit

Markdown text edit with mentions and checklists.

AYMarkdownEdit

Bases: AYTextEdit

Text edit to display and edit the markdown of a comment.

Supports GitHub-flavored markdown checkboxes (- [ ] and - [x]) which can be toggled even in read-only mode, and mentions of users (@), versions (@@) and tasks (@@@).

Signals

checklist_changed: Emitted when a checkbox state changes. mention_entities_requested: Emitted when the popup to mention a version or task opens. Answer with :meth:set_mention_entities, right away or later.

Source code in client/ayon_core/ui/components/markdown_edit.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
class AYMarkdownEdit(AYTextEdit):
    """Text edit to display and edit the markdown of a comment.

    Supports GitHub-flavored markdown checkboxes (- [ ] and - [x]) which
    can be toggled even in read-only mode, and mentions of users (``@``),
    versions (``@@``) and tasks (``@@@``).

    Signals:
        checklist_changed: Emitted when a checkbox state changes.
        mention_entities_requested: Emitted when the popup to mention a
            version or task opens. Answer with
            :meth:`set_mention_entities`, right away or later.
    """

    Variants = QTextEditVariants
    checklist_changed = Signal()
    mention_entities_requested = Signal()

    # Text inserted in front of a new checkbox
    _checkbox_indent = ""

    def __init__(
        self,
        *args,
        user_list: list[User] | None = None,
        variant: Variants = Variants.Default,
        **kwargs,
    ) -> None:
        self._user_list: list[User] = user_list or []
        self._checkbox_handler: CheckboxHandler | None = None
        # Guard flag: when True, apply_code_block_backgrounds is a no-op.
        self._suppress_formatting: bool = False

        super().__init__(*args, variant=variant, **kwargs)
        # Enable mouse tracking on viewport to receive mouseMoveEvent
        self.viewport().setMouseTracking(True)
        self.document().setIndentWidth(22)  # pixels per indent level
        # automatic bullet lists
        self.setAutoFormatting(QTextEdit.AutoFormattingFlag.AutoAll)

        self._mentions = MentionCompleter(self, self._user_list)
        self._mentions.entities_requested.connect(
            self.mention_entities_requested
        )
        self.document().contentsChanged.connect(self._on_contents_changed)

    def _on_contents_changed(self) -> None:
        apply_code_block_backgrounds(self)

    # MENTIONS ---------------------------------------------------------------

    def set_user_list(self, user_list: list[User]) -> None:
        """Set the users which can be mentioned with ``@``."""
        self._user_list = user_list
        self._mentions.set_users(user_list)

    def set_mention_entities(
        self,
        versions: list[EntityMention] | None = None,
        tasks: list[EntityMention] | None = None,
    ) -> None:
        """Set the versions (``@@``) and tasks (``@@@``) to mention.

        Either up front or, to only get them when they are needed, in
        response to :attr:`mention_entities_requested`.
        """
        self._mentions.set_entities(versions, tasks)

    def clear_mention_entities(self) -> None:
        """Forget the versions and tasks to mention.

        Call it when they are of another context than the one commented
        on now. They show as loading until :meth:`set_mention_entities`.
        """
        self._mentions.clear_entities()

    def insert_mention_trigger(self, trigger: str) -> None:
        """Type ``@``, ``@@`` or ``@@@`` at the text cursor to mention."""
        self._mentions.insert_trigger(trigger)
        self.setFocus()

    # MARKDOWN ---------------------------------------------------------------

    def set_markdown(self, md: str) -> None:
        """Set markdown content with checkbox and mention support.

        Args:
            md: Markdown text to display
        """
        md = mentions_to_display(md)
        if CheckboxHandler.contains_checkboxes(md):
            self._setup_checkbox_handler().parse_and_render(md)
        else:
            self.document().setMarkdown(md, MD_DIALECT)
        apply_code_block_backgrounds(self)

    def as_markdown(self) -> str:
        """Get the content as GitHub-flavored markdown.

        Checkboxes are returned in checkbox syntax (- [ ] and - [x])
        reflecting their current state and mentions as links like
        ``[label](user:name)``.

        Returns:
            Markdown string.
        """
        if self._checkbox_handler:
            md = self._checkbox_handler.to_markdown()
        else:
            md = self.document().toMarkdown(MD_DIALECT)
        return mentions_to_storage(md)

    def clear(self) -> None:
        """Remove the content and the style to type in.

        The text edit keeps the style of the last character as the one to
        continue typing in, also when all text is removed. Without the
        reset a next comment would start as the link, bold text or code
        the previous one ended with.
        """
        super().clear()
        self.setCurrentCharFormat(QTextCharFormat())

    def insertFromMimeData(self, source) -> None:
        """Paste without the underline of rich text.

        Rich text from the clipboard has its links underlined, also when
        copied from a comment. Markdown has no underline, it is written as
        emphasis: text typed after a pasted link continued its underline
        and ended up in the comment with ``_`` around it.
        """
        no_underline = QTextCharFormat()
        no_underline.setFontUnderline(False)

        cursor = self.textCursor()
        start = cursor.selectionStart()
        cursor.beginEditBlock()
        try:
            super().insertFromMimeData(source)
            cursor = self.textCursor()
            end = cursor.position()
            cursor.setPosition(start)
            cursor.setPosition(end, QTextCursor.MoveMode.KeepAnchor)
            cursor.mergeCharFormat(no_underline)
        finally:
            cursor.endEditBlock()
        self.mergeCurrentCharFormat(no_underline)

    # CHECKBOXES -------------------------------------------------------------

    def _setup_checkbox_handler(self) -> CheckboxHandler:
        """Initialize checkbox handler if not already done."""
        if self._checkbox_handler is None:
            self._checkbox_handler = CheckboxHandler(self)
            self._checkbox_handler.checklist_changed.connect(
                self._on_checklist_changed
            )
        return self._checkbox_handler

    def _on_checklist_changed(self) -> None:
        """Handle checkbox state changes."""
        self.checklist_changed.emit()

    def _insert_checkbox_at_cursor(self, cursor: QTextCursor) -> None:
        """Insert a new unchecked checkbox object at the cursor position.

        Inserts the custom checkbox object character (``\\ufffc``) and a
        trailing space, then records the document position on the
        :class:`CheckboxItem` for fast hit-testing.

        Args:
            cursor: Cursor at the insertion point; advanced past the
                inserted characters on return.
        """
        new_item = self._setup_checkbox_handler().add_checkbox()
        fmt = QTextCharFormat()
        fmt.setObjectType(CHECKBOX_FORMAT_TYPE)
        fmt.setProperty(CHECKBOX_CHECKED_PROP, False)
        fmt.setProperty(CHECKBOX_INDEX_PROP, new_item.index)
        fmt.setVerticalAlignment(
            QTextCharFormat.VerticalAlignment.AlignBaseline
        )
        cursor.insertText(self._checkbox_indent)
        new_item.doc_position = cursor.position()
        cursor.insertText("", fmt)
        cursor.insertText(" ")

    def _checkbox_index_at(self, pos: QPoint) -> int | None:
        """Get the index of the checkbox at a viewport position."""
        if not self._checkbox_handler:
            return None

        # Account for scroll offset
        scroll_offset = QPoint(
            -self.horizontalScrollBar().value(),
            -self.verticalScrollBar().value(),
        )
        is_checkbox, doc_pos = self._checkbox_handler.find_checkbox_at_click(
            pos, scroll_offset
        )
        if not is_checkbox or doc_pos is None:
            return None
        return self._checkbox_handler.get_checkbox_at_position(doc_pos)

    def _continue_checklist(self) -> bool:
        """Continue or end the checklist of the current line.

        Returns:
            True if a new checkbox was inserted. False if a regular new line
            should be inserted instead.
        """
        handler = self._checkbox_handler
        cursor = self.textCursor()
        block = cursor.block()
        token_pos = block.text().find("")
        if not handler or not handler.has_checkboxes() or token_pos == -1:
            return False

        self._suppress_formatting = True
        cursor.beginEditBlock()
        try:
            if block.text()[token_pos + 1:].strip():
                # Non-empty checkbox line → insert new checkbox
                cursor.movePosition(QTextCursor.MoveOperation.EndOfBlock)
                cursor.insertBlock()
                self._insert_checkbox_at_cursor(cursor)
                return True

            # Empty checkbox line → end the list
            cb_index = handler.get_checkbox_at_position(
                block.position() + token_pos + 1
            )
            cursor.movePosition(QTextCursor.MoveOperation.StartOfBlock)
            cursor.movePosition(
                QTextCursor.MoveOperation.EndOfBlock,
                QTextCursor.MoveMode.KeepAnchor,
            )
            cursor.removeSelectedText()
            if cb_index is not None:
                handler.remove_checkbox(cb_index)
            return False
        finally:
            cursor.endEditBlock()
            self._suppress_formatting = False
            self.setTextCursor(cursor)

    # EVENTS -----------------------------------------------------------------

    def keyPressEvent(self, event) -> None:
        """Complete mentions and continue checklists on Enter."""
        if self._mentions.handle_key_press(event):
            event.accept()
            return

        if (
            event.key() in {Qt.Key.Key_Return, Qt.Key.Key_Enter}
            and self._continue_checklist()
        ):
            event.accept()
            return

        super().keyPressEvent(event)

    def mousePressEvent(self, event) -> None:
        """Toggle checkboxes, also in read-only mode."""
        checkbox_idx = self._checkbox_index_at(event.pos())
        if checkbox_idx is not None:
            self._checkbox_handler.toggle_checkbox(checkbox_idx)
            self.viewport().update()
            event.accept()
            return

        super().mousePressEvent(event)

    def mouseDoubleClickEvent(self, event) -> None:
        """Prevent text selection when double-clicking checkboxes."""
        if self._checkbox_index_at(event.pos()) is not None:
            event.accept()
            return
        super().mouseDoubleClickEvent(event)

    def mouseMoveEvent(self, event) -> None:
        """Show arrow cursor over checkboxes, hand over clickable links."""
        if self._checkbox_index_at(event.pos()) is not None:
            shape = Qt.CursorShape.ArrowCursor
        elif not self.isReadOnly():
            shape = Qt.CursorShape.IBeamCursor
        elif self.anchorAt(event.pos()):
            shape = Qt.CursorShape.PointingHandCursor
        else:
            shape = Qt.CursorShape.ArrowCursor
        self.viewport().setCursor(shape)
        super().mouseMoveEvent(event)

as_markdown()

Get the content as GitHub-flavored markdown.

Checkboxes are returned in checkbox syntax (- [ ] and - [x]) reflecting their current state and mentions as links like [label](user:name).

Returns:

Type Description
str

Markdown string.

Source code in client/ayon_core/ui/components/markdown_edit.py
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
def as_markdown(self) -> str:
    """Get the content as GitHub-flavored markdown.

    Checkboxes are returned in checkbox syntax (- [ ] and - [x])
    reflecting their current state and mentions as links like
    ``[label](user:name)``.

    Returns:
        Markdown string.
    """
    if self._checkbox_handler:
        md = self._checkbox_handler.to_markdown()
    else:
        md = self.document().toMarkdown(MD_DIALECT)
    return mentions_to_storage(md)

clear()

Remove the content and the style to type in.

The text edit keeps the style of the last character as the one to continue typing in, also when all text is removed. Without the reset a next comment would start as the link, bold text or code the previous one ended with.

Source code in client/ayon_core/ui/components/markdown_edit.py
144
145
146
147
148
149
150
151
152
153
def clear(self) -> None:
    """Remove the content and the style to type in.

    The text edit keeps the style of the last character as the one to
    continue typing in, also when all text is removed. Without the
    reset a next comment would start as the link, bold text or code
    the previous one ended with.
    """
    super().clear()
    self.setCurrentCharFormat(QTextCharFormat())

clear_mention_entities()

Forget the versions and tasks to mention.

Call it when they are of another context than the one commented on now. They show as loading until :meth:set_mention_entities.

Source code in client/ayon_core/ui/components/markdown_edit.py
100
101
102
103
104
105
106
def clear_mention_entities(self) -> None:
    """Forget the versions and tasks to mention.

    Call it when they are of another context than the one commented
    on now. They show as loading until :meth:`set_mention_entities`.
    """
    self._mentions.clear_entities()

insertFromMimeData(source)

Paste without the underline of rich text.

Rich text from the clipboard has its links underlined, also when copied from a comment. Markdown has no underline, it is written as emphasis: text typed after a pasted link continued its underline and ended up in the comment with _ around it.

Source code in client/ayon_core/ui/components/markdown_edit.py
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
def insertFromMimeData(self, source) -> None:
    """Paste without the underline of rich text.

    Rich text from the clipboard has its links underlined, also when
    copied from a comment. Markdown has no underline, it is written as
    emphasis: text typed after a pasted link continued its underline
    and ended up in the comment with ``_`` around it.
    """
    no_underline = QTextCharFormat()
    no_underline.setFontUnderline(False)

    cursor = self.textCursor()
    start = cursor.selectionStart()
    cursor.beginEditBlock()
    try:
        super().insertFromMimeData(source)
        cursor = self.textCursor()
        end = cursor.position()
        cursor.setPosition(start)
        cursor.setPosition(end, QTextCursor.MoveMode.KeepAnchor)
        cursor.mergeCharFormat(no_underline)
    finally:
        cursor.endEditBlock()
    self.mergeCurrentCharFormat(no_underline)

insert_mention_trigger(trigger)

Type @, @@ or @@@ at the text cursor to mention.

Source code in client/ayon_core/ui/components/markdown_edit.py
108
109
110
111
def insert_mention_trigger(self, trigger: str) -> None:
    """Type ``@``, ``@@`` or ``@@@`` at the text cursor to mention."""
    self._mentions.insert_trigger(trigger)
    self.setFocus()

keyPressEvent(event)

Complete mentions and continue checklists on Enter.

Source code in client/ayon_core/ui/components/markdown_edit.py
280
281
282
283
284
285
286
287
288
289
290
291
292
293
def keyPressEvent(self, event) -> None:
    """Complete mentions and continue checklists on Enter."""
    if self._mentions.handle_key_press(event):
        event.accept()
        return

    if (
        event.key() in {Qt.Key.Key_Return, Qt.Key.Key_Enter}
        and self._continue_checklist()
    ):
        event.accept()
        return

    super().keyPressEvent(event)

mouseDoubleClickEvent(event)

Prevent text selection when double-clicking checkboxes.

Source code in client/ayon_core/ui/components/markdown_edit.py
306
307
308
309
310
311
def mouseDoubleClickEvent(self, event) -> None:
    """Prevent text selection when double-clicking checkboxes."""
    if self._checkbox_index_at(event.pos()) is not None:
        event.accept()
        return
    super().mouseDoubleClickEvent(event)

mouseMoveEvent(event)

Show arrow cursor over checkboxes, hand over clickable links.

Source code in client/ayon_core/ui/components/markdown_edit.py
313
314
315
316
317
318
319
320
321
322
323
324
def mouseMoveEvent(self, event) -> None:
    """Show arrow cursor over checkboxes, hand over clickable links."""
    if self._checkbox_index_at(event.pos()) is not None:
        shape = Qt.CursorShape.ArrowCursor
    elif not self.isReadOnly():
        shape = Qt.CursorShape.IBeamCursor
    elif self.anchorAt(event.pos()):
        shape = Qt.CursorShape.PointingHandCursor
    else:
        shape = Qt.CursorShape.ArrowCursor
    self.viewport().setCursor(shape)
    super().mouseMoveEvent(event)

mousePressEvent(event)

Toggle checkboxes, also in read-only mode.

Source code in client/ayon_core/ui/components/markdown_edit.py
295
296
297
298
299
300
301
302
303
304
def mousePressEvent(self, event) -> None:
    """Toggle checkboxes, also in read-only mode."""
    checkbox_idx = self._checkbox_index_at(event.pos())
    if checkbox_idx is not None:
        self._checkbox_handler.toggle_checkbox(checkbox_idx)
        self.viewport().update()
        event.accept()
        return

    super().mousePressEvent(event)

set_markdown(md)

Set markdown content with checkbox and mention support.

Parameters:

Name Type Description Default
md str

Markdown text to display

required
Source code in client/ayon_core/ui/components/markdown_edit.py
115
116
117
118
119
120
121
122
123
124
125
126
def set_markdown(self, md: str) -> None:
    """Set markdown content with checkbox and mention support.

    Args:
        md: Markdown text to display
    """
    md = mentions_to_display(md)
    if CheckboxHandler.contains_checkboxes(md):
        self._setup_checkbox_handler().parse_and_render(md)
    else:
        self.document().setMarkdown(md, MD_DIALECT)
    apply_code_block_backgrounds(self)

set_mention_entities(versions=None, tasks=None)

Set the versions (@@) and tasks (@@@) to mention.

Either up front or, to only get them when they are needed, in response to :attr:mention_entities_requested.

Source code in client/ayon_core/ui/components/markdown_edit.py
88
89
90
91
92
93
94
95
96
97
98
def set_mention_entities(
    self,
    versions: list[EntityMention] | None = None,
    tasks: list[EntityMention] | None = None,
) -> None:
    """Set the versions (``@@``) and tasks (``@@@``) to mention.

    Either up front or, to only get them when they are needed, in
    response to :attr:`mention_entities_requested`.
    """
    self._mentions.set_entities(versions, tasks)

set_user_list(user_list)

Set the users which can be mentioned with @.

Source code in client/ayon_core/ui/components/markdown_edit.py
83
84
85
86
def set_user_list(self, user_list: list[User]) -> None:
    """Set the users which can be mentioned with ``@``."""
    self._user_list = user_list
    self._mentions.set_users(user_list)