Megasolid Idiom, a Rich Text Editor
Simple WYSIWYG 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.

Editor component

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.

Editor subclass

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

python
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.

python
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.

Editor 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.

python
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 again use a lambda to, allowing us to pass the specific alignment type to the target method.

Create GUI Applications with Python & Qt6
The easy way to create desktop applications

My complete guide, updated for 2021 & PyQt6. Everything you need build real apps.

Downloadable ebook (PDF, ePub) & Complete Source code

To support developers in [[ countryRegion ]] I give a [[ localizedDiscount[couponCode] ]]% discount with the code [[ couponCode ]] — Enjoy!

For [[ activeDiscount.description ]] I'm giving a [[ activeDiscount.discount ]]% discount with the code [[ couponCode ]] — Enjoy!

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.

python
# 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.

python
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.

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

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 this QColor color-selector widget.
  2. Add support for both import/export formats, converting via HTML.

Continue reading

Creating your first app with PyQt5  pyqt

In this tutorial we'll learn how to use PyQt to create desktop applications with Python. First we'll create a series of simple windows on your desktop to ensure that PyQt is working and introduce some of the basic concepts. Then we'll take a brief look at the event loop and how it relates to GUI programming in Python. Finally we'll look at Qt's QMainWindow which offers some useful common interface elements such as toolbars and menus. These will be explored in more detail in the subsequent tutorials. Creating an application Let's create our first application! To start create a new Python file — you can call it whatever you like (e.g. app.py) and save it somewhere accessible. We'll write our simple app in this file. We'll be editing within this file as we go along, and you may want to come back to earlier versions of your code, so remember to keep regular backups. The source code for the application is shown below. Type it in verbatim, and be careful not to make mistakes. If you do mess up, Python will let you know what's wrong. python from PyQt5.QtWidgets import QApplication, QWidget # Only needed for access to command line arguments import sys # You need one (and only one) QApplication instance per application. # Pass in sys.argv to allow command line arguments for your app. # If you know you won't use command line arguments QApplication([]) works too. app = QApplication(sys.argv) # Create a Qt widget, which will be our window. window = QWidget() window.show() # IMPORTANT!!!!! Windows are hidden by default. # Start the event loop. app.exec() # Your application won't reach here until you exit and the event # loop has stopped. First, launch your application. You can run it from the command line like any other Python script, for example -- bash python3 app.py Run it! You will now see your window. Qt automatically creates a window with the normal window decorations and you can drag it around and resize it like any window. What you'll see will depend on what platform you're running this example on. The image below shows the window as displayed on Windows, macOS and Linux (Ubuntu). Our window, as seen on Windows, macOS and Linux. Stepping through the code Let's step through the code line by line, so we understand exactly what is happening. First, we import the PyQt classes that we need for the application. Here we're importing QApplication, the application handler and QWidget, a basic empty GUI widget, both from the QtWidgets module. python from PyQt5.QtWidgets import QApplication, QWidget The main modules for Qt are QtWidgets, QtGui and QtCore. You could do from import * but this kind of global import is generally frowned upon in Python, so we'll avoid it here. Next we create an instance of QApplication, passing in sys.arg, which is Python list containing the command line arguments passed to the application. python app = QApplication(sys.argv) If you know you won't be using command line arguments to control Qt you can pass in an empty list instead, e.g. python app = QApplication([]) Next we create an instance of a QWidget using the variable name window. python window = QWidget() window.show() In Qt all top level widgets are windows -- that is, they don't have a parent and are not nested within another widget or layout. This means you can technically create a window using any widget you like. Widgets without a parent are invisible by default. So, after creating the window object, we must always call .show() to make it visible. You can remove the .show() and run the app, but you'll have no way to quit it! What is a window? - Holds the user-interface of your application - Every application needs at least one (...but can have more) - Application will (by default) exit when last window is closed Finally, we call app.exec() to start up the event loop. In PyQt5 you can also use app.exec_(). This was a legacy feature avoid a clash with the exec reserved word in Python 2. What's the event loop? Before getting the window on the screen, there are a few key concepts to introduce about how applications are organized in the Qt world. If you're already familiar with event loops you can safely skip to the next section. The core of every Qt Applications is the QApplication class. Every application needs one — and only one — QApplication object to function. This object holds the event loop of your application — the core loop which governs all user interaction with the GUI. Each interaction with your application — whether a press of a key, click of a mouse, or mouse movement — generates an event which is placed on the event queue. In the event loop, the queue is checked on each iteration and if a waiting event is found, the event and control is passed to the specific event handler for the event. The event handler deals with the event, then passes control back to the event loop to wait for more events. There is only one running event loop per application. The QApplication class - QApplication holds the Qt event loop - One QApplication instance required - You application sits waiting in the event loop until an action is taken - There is only one event loop running at any time QMainWindow As we discovered in the last part, in Qt any widgets can be windows. For example, if you replace QtWidget with QPushButton. In the example below, you would get a window with a single push-able button in it. python import sys from PyQt5.QtWidgets import QApplication, QPushButton app = QApplication(sys.argv) window = QPushButton("Push Me") window.show() app.exec() This is neat, but not really very useful -- it's rare that you need a UI that consists of only a single control! But, as we'll discover later, the ability to nest widgets within other widgets using layouts means you can construct complex UIs inside an empty QWidget. But, Qt already has a solution for you -- the QMainWindow. This is a pre-made widget which provides a lot of standard window features you'll make use of in your apps, including toolbars, menus, a statusbar, dockable widgets and more. We'll look at these advanced features later, but for now, we'll add a simple empty QMainWindow to our application. python import sys from PyQt5.QtWidgets import QApplication, QMainWindow app = QApplication(sys.argv) window = QMainWindow() window.show() # Start the event loop. app.exec() Run it! You will now see your main window. It looks exactly the same as before! So our QMainWindow isn't very interesting at the moment. We can fix that by adding some content. If you want to create a custom window, the best approach is to subclass QMainWindow and then include the setup for the window in the __init__ block. This allows the window behavior to be self contained. We can add our own subclass of QMainWindow — call it MainWindow to keep things simple. python import sys from PyQt5.QtCore import QSize, Qt from PyQt5.QtWidgets import QApplication, QMainWindow, QPushButton # Subclass QMainWindow to customize your application's main window class MainWindow(QMainWindow): def __init__(self): super().__init__() self.setWindowTitle("My App") button = QPushButton("Press Me!") # Set the central widget of the Window. self.setCentralWidget(button) app = QApplication(sys.argv) window = MainWindow() window.show() app.exec() For this demo we're using a QPushButton. The core Qt widgets are always imported from the QtWidgets namespace, as are the QMainWindow and QApplication classes. When using QMainWindow we use .setCentralWidget to place a widget (here a QPushButton) in the QMainWindow -- by default it takes the whole of the window. We'll look at how to add multiple widgets to windows in the layouts tutorial. When you subclass a Qt class you must always call the super __init__ function to allow Qt to set up the object. In our __init__ block we first use .setWindowTitle() to change the title of our main window. Then we add our first widget — a QPushButton — to the middle of the window. This is one of the basic widgets available in Qt. When creating the button you can pass in the text that you want the button to display. Finally, we call .setCentralWidget() on the window. This is a QMainWindow specific function that allows you to set the widget that goes in the middle of the window. Run it! You will now see your window again, but this time with the QPushButton widget in the middle. Pressing the button will do nothing, we'll sort that next. Our QMainWindow with a single QPushButton on Windows, macOS and Linux. We'll cover more widgets in detail shortly but if you're impatient and would like to jump ahead you can take a look at the http://doc.qt.io/qt-5/widget-classes.html#basic-widget-classes[QWidget documentation]. Try adding the different widgets to your window! Sizing windows and widgets The window is currently freely resizable -- if you grab any corner with your mouse you can drag and resize it to any size you want. While it's good to let your users resize your applications, sometimes you may want to place restrictions on minimum or maximum sizes, or lock a window to a fixed size. In Qt sizes are defined using a QSize object. This accepts width and height parameters in that order. For example, the following will create a fixed size window of 400x300 pixels. python import sys from PyQt5.QtCore import QSize, Qt from PyQt5.QtWidgets import QApplication, QMainWindow, QPushButton # Subclass QMainWindow to customize your application's main window class MainWindow(QMainWindow): def __init__(self): super().__init__() self.setWindowTitle("My App") button = QPushButton("Press Me!") self.setFixedSize(QSize(400, 300)) # Set the central widget of the Window. self.setCentralWidget(button) app = QApplication(sys.argv) window = MainWindow() window.show() app.exec() Run it! You will see a fixed size window -- try and resize it, it won't work. Our fixed-size window, notice that the _maximize control is disabled on Windows & Linux. On macOS you can maximize the app to fill the screen, but the central widget will not resize._ As well as .setFixedSize() you can also call .setMinimumSize() and .setMaximumSize() to set the minimum and maximum sizes respectively. Experiment with this yourself! You can use these size methods on any widget. In this section we've covered the QApplication class, the QMainWindow class, the event loop and experimented with adding a simple widget to a window. In the next section we'll take a look at the mechanisms Qt provides for widgets and windows to communicate with one another and your own code. More