Megasolid Idiom | Simple rich text editor in Python

Megasolid Idiom is a rich text word processor implemented in Python and Qt. You can use it to open, edit and save HTML-formatted files, with a WYSIWYG (what you see is what you get) format view. Only basic formatting, headings, lists and images are supported.

Megasolid Idiom is based on the same code used for the No2Pads notepad app, so take a look at that if you want an even simpler example.

Megasolid Idiom

Editor subclass

Megasolid Idiom uses the Qt built-in QTextEdit component for our rich text editor, which means that Qt handles a lot of the complicated faff of text editing. Support for rich text (rather than plain text) is enabled by default, or by setting .setAcceptRichText(True) on the editor.

To support drag-drop insert of images into the active document, we subclass QTextEdit to add custom Qt mime handlers.

class TextEdit(QTextEdit):

def canInsertFromMimeData(self, source):

    if source.hasImage():
        return True
    else:
        return super(TextEdit, self).canInsertFromMimeData(source)

def insertFromMimeData(self, source):

    cursor = self.textCursor()
    document = self.document()

    if source.hasUrls():

        for u in source.urls():
            file_ext = splitext(str(u.toLocalFile()))
            if u.isLocalFile() and file_ext in IMAGE_EXTENSIONS:
                image = QImage(u.toLocalFile())
                document.addResource(QTextDocument.ImageResource, u, image)
                cursor.insertImage(u.toLocalFile())

            else:
                # If we hit a non-image or non-local URL break the loop and fall out
                # to the super call & let Qt handle it
                break

        else:
            # If all were valid images, finish here.
            return


    elif source.hasImage():
        image = source.imageData()
        uuid = hexuuid()
        document.addResource(QTextDocument.ImageResource, uuid, image)
        cursor.insertImage(uuid)
        return

    super(TextEdit, self).insertFromMimeData(source)

The two handlers canInsertFromMimeData and insertFromMimeData are Qt's methods for accepting mime data (e.g. images, or other objects) dropped onto your editor. The both receive a signal parameter source which receives a QMimeData object. Similar mechanisms are used for other widget types.

  • canInsertFromMimeData is a check which confirms whether a particular type can be accepted by the widget. This method should return True if you can accept the data being provided. If this method returns True the window manager will usually show an accept-drop indicator, e.g. an icon with a plus-sign or a drop animation. If you return False a cannot-drop indicator will be shown.
  • insertFromMimeData handles the actual adding of the mime content to the document. Here we handle two cases, one where we are adding from an image directly (try dragging an image from a browser window) and one where drop an URL/file (try dragging a file into the window).

You can use these methods to support other types, e.g. drag-dropping text into your window. You need to add the new type to both the canInsertFromMimeData and insertFromMimeData handlers.

Editor config

The QTextEdit component (which we've subclassed as TextEdit) has some additional setup requirements. We switch on rich text mode for the editor component and enable auto-formatting (currently only bullet lists from *). The default font is set to Times New Roman 12pt.

class MainWindow(QMainWindow):

def __init__(self, *args, **kwargs):
    super(MainWindow, self).__init__(*args, **kwargs)

    layout = QVBoxLayout()
    self.editor = TextEdit()
    # Setup the QTextEdit editor configuration
    self.editor.setAutoFormatting(QTextEdit.AutoAll)
    self.editor.selectionChanged.connect(self.update_format)
    # Initialize default font size.
    font = QFont('Times', 12)
    self.editor.setFont(font)
    # We need to repeat the size to init the current format.
    self.editor.setFontPointSize(12)

We need our toolbar to update automatically when clicking/selecting text within the editor. By connecting our custom slot (update_format) to the .selectionChanged signal from the editor, we receive a signal every time the current selection changes.

Toolbars and actions

The editor toolbar is setup using a QToolBar to which we add a number of widgets.

Fonts

The font dropdown is set up using QFontComboBox a Qt builtin which shows the fonts available on the host system, with each font listed by name with a demo of the font.

The .currentFontChanged signal is emitted by the combobox whenever the font is changed, passing the selected font as a parameter. By connecting this to the .setCurrentFont slot on our editor, we can use the dropdown to update the editors' font.

Font size is handled with a standard QCombobox which we pre-fill with a default list from the constant FONT_SIZES. The .currentIndexChanged[str] signal emits the current value of the combobox when it is updated. This is passed to the editor .setFontPointSize using a lambda to wrap the call so we can convert it to a float first.

Styles

Style handling uses checkable (toggleable) QAction widgets. We add a key sequence for each widget to provide standard keyboard shortcuts (e.g. QKeySequence.Bold). Each .toggled signal is connected to an editor slot to trigger updates.

There is no .setFontBold handler, instead we must use .setFontWeight to set the weight specifically. Qt provides a set of default weights in the Qt namespace. The Bold handler wraps the call to .setFontWeight, setting it to QFont.Bold if enabled, or QFont.Normal if not.

    self.bold_action = QAction(QIcon(os.path.join('images', 'edit-bold.png')), "Bold", self)
    self.bold_action.setStatusTip("Bold")
    self.bold_action.setShortcut(QKeySequence.Bold)
    self.bold_action.setCheckable(True)
    self.bold_action.toggled.connect(lambda x: self.editor.setFontWeight(QFont.Bold if x else QFont.Normal))
    format_toolbar.addAction(self.bold_action)
    format_menu.addAction(self.bold_action)

    self.italic_action = QAction(QIcon(os.path.join('images', 'edit-italic.png')), "Italic", self)
    self.italic_action.setStatusTip("Italic")
    self.italic_action.setShortcut(QKeySequence.Italic)
    self.italic_action.setCheckable(True)
    self.italic_action.toggled.connect(self.editor.setFontItalic)
    format_toolbar.addAction(self.italic_action)
    format_menu.addAction(self.italic_action)

    self.underline_action = QAction(QIcon(os.path.join('images', 'edit-underline.png')), "Underline", self)
    self.underline_action.setStatusTip("Underline")
    self.underline_action.setShortcut(QKeySequence.Underline)
    self.underline_action.setCheckable(True)
    self.underline_action.toggled.connect(self.editor.setFontUnderline)
    format_toolbar.addAction(self.underline_action)
    format_menu.addAction(self.underline_action)

The actions are added both to the toolbar at the menus.

Alignment

We finally add the handlers for alignment formatting. These are set up as a QActionGroup because they are mutually exclusive: action groups function like radio buttons. Each action's .triggered signal is connected to set a specific alignment on the current paragraph via the editor .setAlignment. We use a lambda to wrap this call, allowing us to pass the specific alignment type to the target method.

    self.alignl_action = QAction(QIcon(os.path.join('images', 'edit-alignment.png')), "Align left", self)
    self.alignl_action.setStatusTip("Align text left")
    self.alignl_action.setCheckable(True)
    self.alignl_action.triggered.connect(lambda: self.editor.setAlignment(Qt.AlignLeft))
    format_toolbar.addAction(self.alignl_action)
    format_menu.addAction(self.alignl_action)

    self.alignc_action = QAction(QIcon(os.path.join('images', 'edit-alignment-center.png')), "Align center", self)
    self.alignc_action.setStatusTip("Align text center")
    self.alignc_action.setCheckable(True)
    self.alignc_action.triggered.connect(lambda: self.editor.setAlignment(Qt.AlignCenter))
    format_toolbar.addAction(self.alignc_action)
    format_menu.addAction(self.alignc_action)

    self.alignr_action = QAction(QIcon(os.path.join('images', 'edit-alignment-right.png')), "Align right", self)
    self.alignr_action.setStatusTip("Align text right")
    self.alignr_action.setCheckable(True)
    self.alignr_action.triggered.connect(lambda: self.editor.setAlignment(Qt.AlignRight))
    format_toolbar.addAction(self.alignr_action)
    format_menu.addAction(self.alignr_action)

    self.alignj_action = QAction(QIcon(os.path.join('images', 'edit-alignment-justify.png')), "Justify", self)
    self.alignj_action.setStatusTip("Justify text")
    self.alignj_action.setCheckable(True)
    self.alignj_action.triggered.connect(lambda: self.editor.setAlignment(Qt.AlignJustify))
    format_toolbar.addAction(self.alignj_action)
    format_menu.addAction(self.alignj_action)

    format_group = QActionGroup(self)
    format_group.setExclusive(True)
    format_group.addAction(self.alignl_action)
    format_group.addAction(self.alignc_action)
    format_group.addAction(self.alignr_action)
    format_group.addAction(self.alignj_action)

Handling editor updates

We've defined a series of actions which, given user interaction to toggle them, will switch formatting in the editor. When a user selects text we also want to update the toolbar to match — turning the icon to bold, when a user selects some bold text for example. The niggle here is that if we update the actions in the toolbar they themselves will trigger an event which can undo the same action. To avoid this we store a list of actions to be blocked when updating the format.

    # A list of all format-related widgets/actions, so we can disable/enable signals when updating.
    self._format_actions = [
        self.fonts,
        self.fontsize,
        self.bold_action,
        self.italic_action,
        self.underline_action,
        # We don't need to disable signals for alignment, as they are paragraph-wide.
    ]

The format update function then first blocks these signals, updates the toolbar widgets to represent the format of the currently selected text, and then re-enables the format afterwards.

def block_signals(self, objects, b):
    for o in objects:
        o.blockSignals(b)

def update_format(self):
    """
    Update the font format toolbar/actions when a new text selection is made. This is neccessary to keep
    toolbars/etc. in sync with the current edit state.
    :return:
    """
    # Disable signals for all format widgets, so changing values here does not trigger further formatting.
    self.block_signals(self._format_actions, True)

    self.fonts.setCurrentFont(self.editor.currentFont())
    # Nasty, but we get the font-size as a float but want it was an int
    self.fontsize.setCurrentText(str(int(self.editor.fontPointSize())))

    self.italic_action.setChecked(self.editor.fontItalic())
    self.underline_action.setChecked(self.editor.fontUnderline())
    self.bold_action.setChecked(self.editor.fontWeight() == QFont.Bold)

    self.alignl_action.setChecked(self.editor.alignment() == Qt.AlignLeft)
    self.alignc_action.setChecked(self.editor.alignment() == Qt.AlignCenter)
    self.alignr_action.setChecked(self.editor.alignment() == Qt.AlignRight)
    self.alignj_action.setChecked(self.editor.alignment() == Qt.AlignJustify)

    self.block_signals(self._format_actions, False)

Note the different approaches needed to toggle the status icons. Italic and underline are both available as bool values from the editor, while we need to compare the current weight for bold. For alignments, we can compare the current alignment to the Qt namespace values Qt.AlignLeft.

The font size change is a bit unpleasant: we get the point size from the editor, convert it to an integer (to round down) and then to a string, to apply as the current text for the box. This is neccessary since users are free to enter any size value, even one not currently in the list.

Opening & Saving files

The file open and save handlers are almost identical to those used in No2Pads, with the slight tweak that we load and save as HTML for rich text. This is the only format natively supported by the Qt rich text widget for loading and saving — to support other formats you would need to write a converter between these. Plain text loading and saving is also supported.

Although we don't support tables in the editor, you can load documents with them and Qt will render them correctly.

def file_open(self):
    path, _ = QFileDialog.getOpenFileName(self, "Open file", "", "HTML documents (*.html);Text documents (*.txt);All files (*.*)")

    try:
        with open(path, 'rU') as f:
            text = f.read()

    except Exception as e:
        self.dialog_critical(str(e))

    else:
        self.path = path
        # Qt will automatically try and guess the format as txt/html
        self.editor.setText(text)
        self.update_title()

def file_save(self):
    if self.path is None:
        # If we do not have a path, we need to use Save As.
        return self.file_saveas()

    text = self.editor.toHtml() if splitext(self.path) in HTML_EXTENSIONS else self.editor.toPlainText()

    try:
        with open(self.path, 'w') as f:
            f.write(text)

    except Exception as e:
        self.dialog_critical(str(e))

def file_saveas(self):
    path, _ = QFileDialog.getSaveFileName(self, "Save file", "", "HTML documents (*.html);Text documents (*.txt);All files (*.*)")

    if not path:
        # If dialog is cancelled, will return ''
        return

    text = self.editor.toHtml() if splitext(path) in HTML_EXTENSIONS else self.editor.toPlainText()

    try:
        with open(path, 'w') as f:
            f.write(text)

    except Exception as e:
        self.dialog_critical(str(e))

    else:
        self.path = path
        self.update_title()

Ready to build your own apps?

Create Simple GUI Applications with Python & Qt is my beginners guide to building cross-platform GUI applications with Python. In this book I take you step by step from displaying your first window, to fully functional software. Includes tutor support in our student forum.

Future ideas

You could extend the Megasolid Idiom to support —

  1. Text colour formatting. The support is there in QTextEdit for both foreground and background colours. Take a look at the QColor color-selector widget.
  2. Add support for both import/export formats, converting via HTML.

Continue reading

NSAViewer  gui

This app isn't actually a direct line from your webcam to the NSA, it's a demo of using the webcam/camera support in Qt. The name is a nod to the paranoia (or is it...) of being watched through your webcam by government spooks. I did consider making ... More

Discussion

comments powered by Disqus