Bystroushaak's blog / English section / Python / Active widget in PyQT5 / QTextEdit

Active widget in PyQT5 / QTextEdit

It just happened, that I needed active widget in the rich text / wysiwyg editor in PyQt5. And I googled and googled and I couldn't find any good solution. Only example more complicated than SVG widget (source) one can find is Snippet to embed a widget in an editor (alt source).

This eventually works with PyQt5. Here is a simplified refactored example I've used to test my code:

#! /usr/bin/env python3
from PyQt5 import QtCore
from PyQt5.QtGui import QTextFormat
from PyQt5.QtGui import QTextCharFormat
from PyQt5.QtGui import QTextObjectInterface
from PyQt5.QtCore import QObject
from PyQt5.QtWidgets import QWidget
from PyQt5.QtWidgets import QLineEdit
from PyQt5.QtWidgets import QTextEdit
from PyQt5.QtWidgets import QVBoxLayout
from PyQt5.QtWidgets import QApplication


class ExampleWindow(QWidget):
    def __init__(self):
        super().__init__()
        self.setWindowTitle('Example window')
        self.wysiwyg_editor = QTextEdit()

        layout = QVBoxLayout()
        layout.addWidget(self.wysiwyg_editor)
        self.setLayout(layout)

        cursor = self.wysiwyg_editor.textCursor()
        cursor.insertText('beginning\n')

        widget = QLineEdit()
        widget.setText('I am here!')

        text_format_id = QTextFormat.UserObject + 1001
        self.wrap_with_text_object(text_format_id, widget)

        self.insert_text_object(cursor, chr(0xfffc), text_format_id)

        cursor.insertText('end')
        self.wysiwyg_editor.setTextCursor(cursor)

    def wrap_with_text_object(self, text_format_id, widget):
        class TextObject(QObject, QTextObjectInterface):
            def intrinsicSize(self, doc, pos_in_document, format):
                return QtCore.QSizeF(widget.sizeHint())

            def drawObject(self, painter, rect, doc, pos_in_document, format):
                widget.resize(widget.sizeHint())
                widget.render(painter, QtCore.QPoint(int(rect.x()), int(rect.y())))

        self.wysiwyg_editor.document().documentLayout().registerHandler(text_format_id, TextObject(self))

    def insert_text_object(self, cursor, char, text_format_id):
        char_format = QTextCharFormat()
        char_format.setObjectType(text_format_id)
        cursor.insertText(char, char_format)


if __name__ == '__main__':
    import sys
    app = QApplication(sys.argv)
    window = ExampleWindow()
    window.show()
    sys.exit(app.exec_())

This almost works (I'll get to the details later) :

Basically, everything important happens in the second part of the constructor:

widget = QLineEdit()
widget.setText('I am here!')

text_format_id = QTextFormat.UserObject + 1001
self.wrap_with_text_object(text_format_id, widget)

self.insert_text_object(cursor, chr(0xfffc), text_format_id)

First, just the regular random widget is instantiated. Nothing interesting here.

Then, this widget is registered under given ID.

text_format_id = QTextFormat.UserObject + 1001
self.wrap_with_text_object(text_format_id, widget)

Tutorial says, that you should use at least QTextFormat.UserObject, so + 1001 is safe bet. All magic happens in the .wrap_with_text_object() method:

def wrap_with_text_object(self, text_format_id, widget):
    class TextObject(QObject, QTextObjectInterface):
        def intrinsicSize(self, doc, pos_in_document, format):
            return QtCore.QSizeF(widget.sizeHint())

        def drawObject(self, painter, rect, doc, pos_in_document, format):
            widget.resize(widget.sizeHint())
            widget.render(painter, QtCore.QPoint(int(rect.x()), int(rect.y())))
        
    self.wysiwyg_editor.document().documentLayout().registerHandler(text_format_id, TextObject(self))

This method creates QObject, that is also a QTextObjectInterface instance. This has to have methods .intrinsicSize() and .drawObject(). In this case, it just returns values defined in the widget itself, and also renders the widget to the painter.

Resulting TextObject is then registered as handler for given text_format_id number.

Then, when you actually want to inline the widget to the text in the QTextEdit, you use the second method and map the given text_format_id to unicode character 0xfffc called OBJECT REPLACEMENT CHARACTER:

self.insert_text_object(cursor, chr(0xfffc), text_format_id)

Which does what it says:

def insert_text_object(self, cursor, char, text_format_id):
    char_format = QTextCharFormat()
    char_format.setObjectType(text_format_id)
    cursor.insertText(char, char_format)

That's not bad and it actually works, but the included object doesn't update itself and doesn't react to events and user input. Basically it acts as inlined image.

Editability hack

Naturally, I wanted to use the inlined widget as an actual widget, not as a picture, so I experimented around and managed to come with simple solution - just add the widget on top of the image!

Modification is really simple, just update the draw method by adding:

widget.move(rect.x() + 1, rect.y() + 1)

so it will look like this:

def drawObject(self, painter, rect, doc, pos_in_document, format):
    widget.resize(widget.sizeHint())
    widget.render(painter, QtCore.QPoint(int(rect.x()), int(rect.y())))
    widget.move(rect.x() + 1, rect.y() + 1)

and set widget's parent to the editor itself (this is actually important!). Final code looks like this:

#! /usr/bin/env python3
from PyQt5 import QtCore
from PyQt5.QtGui import QTextFormat
from PyQt5.QtGui import QTextCharFormat
from PyQt5.QtGui import QTextObjectInterface
from PyQt5.QtCore import QObject
from PyQt5.QtWidgets import QWidget
from PyQt5.QtWidgets import QLineEdit
from PyQt5.QtWidgets import QTextEdit
from PyQt5.QtWidgets import QVBoxLayout
from PyQt5.QtWidgets import QApplication


class ExampleWindow(QWidget):
    def __init__(self):
        super().__init__()
        self.setWindowTitle('Example window')
        self.wysiwyg_editor = QTextEdit()

        layout = QVBoxLayout()
        layout.addWidget(self.wysiwyg_editor)
        self.setLayout(layout)

        cursor = self.wysiwyg_editor.textCursor()
        cursor.insertText('beginning\n')

        widget = QLineEdit(self.wysiwyg_editor)
        widget.setText('I am here!')

        text_format_id = QTextFormat.UserObject + 1001
        self.wrap_with_text_object(text_format_id, widget)

        self.insert_text_object(cursor, chr(0xfffc), text_format_id)

        cursor.insertText('end')
        self.wysiwyg_editor.setTextCursor(cursor)

    def wrap_with_text_object(self, text_format_id, widget):
        class TextObject(QObject, QTextObjectInterface):
            def intrinsicSize(self, doc, pos_in_document, format):
                return QtCore.QSizeF(widget.sizeHint())

            def drawObject(self, painter, rect, doc, pos_in_document, format):
                widget.resize(widget.sizeHint())
                widget.render(painter, QtCore.QPoint(int(rect.x()), int(rect.y())))
                widget.move(rect.x() + 1, rect.y() + 1)

        self.wysiwyg_editor.document().documentLayout().registerHandler(text_format_id, TextObject(self))

    def insert_text_object(self, cursor, char, text_format_id):
        char_format = QTextCharFormat()
        char_format.setObjectType(text_format_id)
        cursor.insertText(char, char_format)


if __name__ == '__main__':
    import sys
    app = QApplication(sys.argv)
    window = ExampleWindow()
    window.show()
    sys.exit(app.exec_())

Position is updated when you type, so the widget on top of the image moves accordingly.

Negative effects:

  1. The image (or more specifically, the character that the image is bound to) under the widget can be deleted and so it disappears, but the widget itself is still here. You need to detect whether the character is still present after each modification, and if not, then hide the object.
  1. I am fidgeting with the widget position to align it properly: widget.move(rect.x() + 1, rect.y() + 1), because by default, it is off by one pixel in both directions. This can be solved simply by setting background to the widget, so the image under it won't be visible and this won't be noticeable. At least I hope so.
  1. Scrolling can break things. When the text area is too small and if it is in scrollable container, weird stuff happens with the Y coordinates. This will require improvements.
📌
The widget doesn't redraw the image itself when the text property is updated. You have to trigger any edit action to cause redrawing, or call self.wysiwyg_widget.viewport().update() manually.

Final upgrade

Of course, I wanted have widget that can be edited naturally, without listed problems. Ideally, all of this would be automated and encapsulated somewhere in the background. I've also discovered that my solution doesn't work much for multiple inlined objects, so I had to solve all of the problems.

Here is my current code:

#! /usr/bin/env python3
from PyQt5 import QtCore
from PyQt5.QtGui import QPen
from PyQt5.QtGui import QTextCursor
from PyQt5.QtGui import QTextFormat
from PyQt5.QtGui import QTextCharFormat
from PyQt5.QtGui import QTextObjectInterface
from PyQt5.QtCore import Qt
from PyQt5.QtCore import QObject
from PyQt5.QtWidgets import QWidget
from PyQt5.QtWidgets import QLineEdit
from PyQt5.QtWidgets import QTextEdit
from PyQt5.QtWidgets import QVBoxLayout
from PyQt5.QtWidgets import QApplication


class InlinedWidgetInfo:
    object_replacement_character = chr(0xfffc)
    _instance_counter = 0

    def __init__(self, widget):
        self.widget = widget

        self.text_format_id = QTextFormat.UserObject + InlinedWidgetInfo._instance_counter
        self.char = self.object_replacement_character

        InlinedWidgetInfo._instance_counter += 1


class ExampleWindow(QWidget):
    def __init__(self):
        super().__init__()
        self.setWindowTitle('Example window')
        self.wysiwyg_editor = QTextEdit()

        layout = QVBoxLayout()
        layout.addWidget(self.wysiwyg_editor)
        self.setLayout(layout)

        cursor = self.wysiwyg_editor.textCursor()
        cursor.insertText('beginning\n')

        widget = QLineEdit(self.wysiwyg_editor)
        widget.setText('First')
        widget.hide()

        widget2 = QLineEdit(self.wysiwyg_editor)
        widget2.setText('Second')
        widget2.hide()

        inlined_widget_wrapper = InlinedWidgetInfo(widget)
        inlined_widget_wrapper2 = InlinedWidgetInfo(widget2)

        self.last_text_lenght = 0
        self.text_format_id_to_inlined_widget_map = {}

        cursor.insertText('\n\n')
        self.wrap_with_text_object(inlined_widget_wrapper)
        self.insert_text_object(cursor, inlined_widget_wrapper)
        cursor.insertText('\n\n')
        self.wrap_with_text_object(inlined_widget_wrapper2)
        self.insert_text_object(cursor, inlined_widget_wrapper2)

        cursor.insertText('\n\n')
        cursor.insertText('end')
        self.wysiwyg_editor.setTextCursor(cursor)

        self.wysiwyg_editor.currentCharFormatChanged.connect(self.on_character_format_change)
        self.wysiwyg_editor.selectionChanged.connect(self._trigger_obj_char_rescan)
        self.wysiwyg_editor.textChanged.connect(self.on_text_changed)

    def wrap_with_text_object(self, inlined_widget):
        class TextObject(QObject, QTextObjectInterface):
            def intrinsicSize(self, doc, pos_in_document, format):
                return QtCore.QSizeF(inlined_widget.widget.sizeHint())

            def drawObject(_self, painter, rect, doc, pos_in_document, format):
                inlined_widget.widget.resize(inlined_widget.widget.sizeHint())
                painter.setPen(QPen(Qt.white))
                painter.drawRect(rect)
                inlined_widget.widget.move(rect.x(), rect.y())

        document_layout = self.wysiwyg_editor.document().documentLayout()
        document_layout.registerHandler(inlined_widget.text_format_id, TextObject(self))
        self.text_format_id_to_inlined_widget_map[inlined_widget.text_format_id] = inlined_widget

    def insert_text_object(self, cursor, inlined_widget):
        char_format = QTextCharFormat()
        char_format.setObjectType(inlined_widget.text_format_id)
        cursor.insertText(inlined_widget.char, char_format)
        inlined_widget.widget.show()

    def on_character_format_change(self, qtextcharformat):
        text_format_id = qtextcharformat.objectType()

        # id 0 is used when the object is deselected - I don't really want the id
        # itself, I just want to know that there was some change AFTER it was done
        if text_format_id == 0:
            self._trigger_obj_char_rescan()

    def on_text_changed(self):
        current_text_lenght = len(self.wysiwyg_editor.toPlainText())
        if self.last_text_lenght > current_text_lenght:
            self._trigger_obj_char_rescan()

        self.last_text_lenght = current_text_lenght

    def _trigger_obj_char_rescan(self):
        text = self.wysiwyg_editor.toPlainText()
        character_indexes = [
            cnt for cnt, char in enumerate(text)
            if char == InlinedWidgetInfo.object_replacement_character
        ]

        # get text_format_id for all OBJECT REPLACEMENT CHARACTERs
        present_text_format_ids = set()
        for index in character_indexes:
            cursor = QTextCursor(self.wysiwyg_editor.document())

            # I have to create text selection in order to detect correct character
            cursor.setPosition(index)
            if index < len(text):
                cursor.setPosition(index + 1, QTextCursor.KeepAnchor)

            text_format_id = cursor.charFormat().objectType()

            present_text_format_ids.add(text_format_id)

        # diff for characters that are there and that should be there
        expected_text_format_ids = set(self.text_format_id_to_inlined_widget_map.keys())
        removed_text_ids = expected_text_format_ids - present_text_format_ids

        # hide widgets for characters that were removed
        for text_format_id in removed_text_ids:
            inlined_widget = self.text_format_id_to_inlined_widget_map[text_format_id]
            inlined_widget.widget.hide()
            del self.text_format_id_to_inlined_widget_map[text_format_id]


if __name__ == '__main__':
    import sys
    app = QApplication(sys.argv)
    window = ExampleWindow()
    window.show()
    sys.exit(app.exec_())

As you can see, there are several differences. First and the most obvious one is the InlinedWidgetInfo class:

class InlinedWidgetInfo:
    object_replacement_character = chr(0xfffc)
    _instance_counter = 0

    def __init__(self, widget):
        self.widget = widget

        self.text_format_id = QTextFormat.UserObject + InlinedWidgetInfo._instance_counter
        self.char = self.object_replacement_character

        InlinedWidgetInfo._instance_counter += 1

This is just a container, that manually handles all the fluff with generating new text_format_id. After some experimentation, I've noticed that you always have to use same unicode character, which is meant directly for this - as an object replacement in rich text. All other methods were rewritten to use and expect this class.

You just use it naturally like this:

inlined_widget_wrapper = InlinedWidget(widget)
self.wrap_with_text_object(inlined_widget_wrapper)
self.insert_text_object(cursor, inlined_widget_wrapper)

.wrap_with_text_object() method was redefined, it now stores the inlined_widget object to the .text_format_id_to_inlined_widget_map dictionary attribute:

def wrap_with_text_object(self, inlined_widget):
    class TextObject(QObject, QTextObjectInterface):
        def intrinsicSize(self, doc, pos_in_document, format):
            return QtCore.QSizeF(inlined_widget.widget.sizeHint())

        def drawObject(_self, painter, rect, doc, pos_in_document, format):
            inlined_widget.widget.resize(inlined_widget.widget.sizeHint())
            painter.setPen(QPen(Qt.white))
            painter.drawRect(rect)
            inlined_widget.widget.move(rect.x(), rect.y())

    document_layout = self.wysiwyg_editor.document().documentLayout()
    document_layout.registerHandler(inlined_widget.text_format_id, TextObject(self))
    self.text_format_id_to_inlined_widget_map[inlined_widget.text_format_id] = inlined_widget

.insert_text_object was also slightly redefined, but only so that it now calls .show() on the widget, that is hidden by default right after it's creation.

def insert_text_object(self, cursor, inlined_widget):
    char_format = QTextCharFormat()
    char_format.setObjectType(inlined_widget.text_format_id)
    inlined_widget.widget.show()
    cursor.insertText(inlined_widget.char, char_format)

You maybe noticed, that three new methods were added and connected to proper signals:

self.wysiwyg_editor.currentCharFormatChanged.connect(self.on_character_format_change)
self.wysiwyg_editor.selectionChanged.connect(self._trigger_obj_char_rescan)
self.wysiwyg_editor.textChanged.connect(self.on_text_changed)

First one is automatically called by PyQt5 each time cursor touches any of the object replacement characters. It is also called with objectType zero when the cursor stop touching it. As I want to detect deletion of object replacement character, this is exactly what I want.

def on_character_format_change(self, qtextcharformat):
    text_format_id = qtextcharformat.objectType()

    # id 0 is used when the object is deselected - I don't really want the id
    # itself, I just want to know that there was some change AFTER it was done
    if text_format_id == 0:
        self._trigger_obj_char_rescan()

I was also forced to add .on_text_changed() that is called each time text is changed, and check whether the change was deletion of character (that is, text is now shorter), because .on_character_format_change() isn't called when delete key is used on last item :(.

def on_text_changed(self):
    current_text_lenght = len(self.wysiwyg_editor.toPlainText())
    if self.last_text_lenght > current_text_lenght:
        self._trigger_obj_char_rescan()

    self.last_text_lenght = current_text_lenght

Then there is this slightly more complicated method ._trigger_obj_char_rescan() which is called each time cursor stops touching any of the object replacement characters, or when the text selection is applied or when character is deleted.

def _trigger_obj_char_rescan(self):
    text = self.wysiwyg_editor.toPlainText()
    character_indexes = [
        cnt for cnt, char in enumerate(text)
        if char == InlinedWidgetInfo.object_replacement_character
    ]

    # get text_format_id for all OBJECT REPLACEMENT CHARACTERs
    present_text_format_ids = set()
    for index in character_indexes:
        cursor = QTextCursor(self.wysiwyg_editor.document())

        # I have to create text selection in order to detect correct character
        cursor.setPosition(index)
        if index < len(text):
            cursor.setPosition(index + 1, QTextCursor.KeepAnchor)

        text_format_id = cursor.charFormat().objectType()

        present_text_format_ids.add(text_format_id)

    # diff for characters that are there and that should be there
    expected_text_format_ids = set(self.text_format_id_to_inlined_widget_map.keys())
    removed_text_ids = expected_text_format_ids - present_text_format_ids

    # hide widgets for characters that were removed
    for text_format_id in removed_text_ids:
        inlined_widget = self.text_format_id_to_inlined_widget_map[text_format_id]
        inlined_widget.widget.hide()
        del self.text_format_id_to_inlined_widget_map[text_format_id]

It may look complicated, but it's really simple:

  1. Get text indices for all object replacement characters.
  1. Put cursor at each index and call methods that will get you text_format_id for that character.
  1. Get diff of expected characters and present characters, so you get characters that were removed.
  1. Call .hide() for all widgets that correspond with removed characters.

With all this, it finally works as expected:

What is missing is handling of mime-types, so copy paste and drag & drop would work, but I'll leave that for some other time.

Relevant links

Changelog

Fixed behavior when the delete key is pressed for last inlined item.

Code updated so that it now supports multiple inlined widgets.

Article published.


Tags

PyQT5, objWiki, python

Become a Patron