Building a text editor with PyQt: Part 2

4 PG Peter Goldsborough Sep 10, 2014

Get started with this series with part one!

In the previous part of my tutorial series on Building a text editor with PyQt, we created our text editor's basic skeleton and added some useful features for file management, printing, inserting lists and more. This part will focus on the format bar, which we'll populate with a number of features, including actions to change the font family, background color, alignment and more.

Font

We'll start with actions related to font, meaning the user will be able to:

  • Change font family
  • Adjust font size
  • Set font color
  • Choose background color

Now to the code. Just like last time, I'll only show the functions that change relative to the previous code:

initFormatbar():

fontBox = QtGui.QFontComboBox(self)
fontBox.currentFontChanged.connect(self.fontFamily)

fontSize = QtGui.QComboBox(self)
fontSize.setEditable(True)

# Minimum number of chars displayed
fontSize.setMinimumContentsLength(3)

fontSize.activated.connect(self.fontSize)

# Typical font sizes
fontSizes = ['6','7','8','9','10','11','12','13','14',
             '15','16','18','20','22','24','26','28',
             '32','36','40','44','48','54','60','66',
             '72','80','88','96']

for i in fontSizes:
    fontSize.addItem(i)

fontColor = QtGui.QAction(QtGui.QIcon("icons/font-color.png"),"Change font color",self)
fontColor.triggered.connect(self.fontColor)

backColor = QtGui.QAction(QtGui.QIcon("icons/highlight.png"),"Change background color",self)
backColor.triggered.connect(self.highlight)

self.formatbar = self.addToolBar("Format")

self.formatbar.addWidget(fontBox)
self.formatbar.addWidget(fontSize)

self.formatbar.addSeparator()

self.formatbar.addAction(fontColor)
self.formatbar.addAction(backColor)

self.formatbar.addSeparator()

Below initUI():

def fontFamily(self,font):
  self.text.setCurrentFont(font)

def fontSize(self, fontsize):
    self.text.setFontPointSize(int(fontsize))

def fontColor(self):

    # Get a color from the text dialog
    color = QtGui.QColorDialog.getColor()

    # Set it as the new text color
    self.text.setTextColor(color)

def highlight(self):

    color = QtGui.QColorDialog.getColor()

    self.text.setTextBackgroundColor(color)

Note that the actions we just created don't follow the code pattern for actions I described last time. We don't make these actions class members because we only need to create and use them within the scope of initFormatbar(). We also don't give them tooltips or shortcuts anymore (unless you want to, of course).

We start out by creating a QFontComboBox, which is a very convenient combo box that automatically includes all the fonts available to the system. We instantiate it and connect its currentFontChanged signal to a slot function, self.fontFamily(), which we later created underneath the initUI() method. As you can see, we also give this slot function a second parameter font, so PyQt will pass the user-selected QFont object to our function, reducing our work to setting this font to the text's current font.

Next up, we need a combo box for font sizes. PyQt itself doesn't have such a thing, so we need to create one ourself. This is easily done by instantiating a normal combo box, here called fontSize, which we set editable, meaning the user can enter any number they want for the font. After connecting the activated signal to a slot function, we populate the combo box with some common font sizes. For the slot function, we again set a second parameter, font size, which PyQt passes to us when the user selects a font size from the combo box or, alternatively, enters a custom size. We set the user's selection as the text's current font point size.

The last two actions are very similar. In both cases, we create two actions that open a QColorDialog when activated. In case of fontColor, we set the color selection as the font color. For backColor, we set the color as the current text's background color.

Bold moves

Next, we'll add actions to make text:

  • bold
  • italic
  • underlined
  • strikeout
  • superscript
  • subscript

The code for this is relatively simple:

initFormatbar():

boldAction = QtGui.QAction(QtGui.QIcon("icons/bold.png"),"Bold",self)
boldAction.triggered.connect(self.bold)

italicAction = QtGui.QAction(QtGui.QIcon("icons/italic.png"),"Italic",self)
italicAction.triggered.connect(self.italic)

underlAction = QtGui.QAction(QtGui.QIcon("icons/underline.png"),"Underline",self)
underlAction.triggered.connect(self.underline)

strikeAction = QtGui.QAction(QtGui.QIcon("icons/strike.png"),"Strike-out",self)
strikeAction.triggered.connect(self.strike)

superAction = QtGui.QAction(QtGui.QIcon("icons/superscript.png"),"Superscript",self)
superAction.triggered.connect(self.superScript)

subAction = QtGui.QAction(QtGui.QIcon("icons/subscript.png"),"Subscript",self)
subAction.triggered.connect(self.subScript)

Further below:

self.formatbar.addAction(boldAction)
self.formatbar.addAction(italicAction)
self.formatbar.addAction(underlAction)
self.formatbar.addAction(strikeAction)
self.formatbar.addAction(superAction)
self.formatbar.addAction(subAction)

self.formatbar.addSeparator()

Below initUI():

def bold(self):

    if self.text.fontWeight() == QtGui.QFont.Bold:

        self.text.setFontWeight(QtGui.QFont.Normal)

    else:

        self.text.setFontWeight(QtGui.QFont.Bold)

def italic(self):

    state = self.text.fontItalic()

    self.text.setFontItalic(not state)

def underline(self):

    state = self.text.fontUnderline()

    self.text.setFontUnderline(not state)

def strike(self):

    # Grab the text's format
    fmt = self.text.currentCharFormat()

    # Set the fontStrikeOut property to its opposite
    fmt.setFontStrikeOut(not fmt.fontStrikeOut())

    # And set the next char format
    self.text.setCurrentCharFormat(fmt)

def superScript(self):

    # Grab the current format
    fmt = self.text.currentCharFormat()

    # And get the vertical alignment property
    align = fmt.verticalAlignment()

    # Toggle the state
    if align == QtGui.QTextCharFormat.AlignNormal:

        fmt.setVerticalAlignment(QtGui.QTextCharFormat.AlignSuperScript)

    else:

        fmt.setVerticalAlignment(QtGui.QTextCharFormat.AlignNormal)

    # Set the new format
    self.text.setCurrentCharFormat(fmt)

def subScript(self):

    # Grab the current format
    fmt = self.text.currentCharFormat()

    # And get the vertical alignment property
    align = fmt.verticalAlignment()

    # Toggle the state
    if align == QtGui.QTextCharFormat.AlignNormal:

        fmt.setVerticalAlignment(QtGui.QTextCharFormat.AlignSubScript)

    else:

        fmt.setVerticalAlignment(QtGui.QTextCharFormat.AlignNormal)

    # Set the new format
    self.text.setCurrentCharFormat(fmt)

The changes in initFormatbar() should be relatively understandable by now. We create actions and connect the triggered signals to slot functions, after which we add the actions to the format bar.

In bold(), we invert the font weight of the current text. If the text is bold, we set the font weight to "normal". If the font weight is normal, we set it to bold.

For italic() and underline(), our QTextEdit object has functions for setting and getting the state of the text. Therefore, we just grab the current state of the text and invert it.

The strike() function is a bit different. We retrieve our text's currentCharFormat, invert the state of the fontStrikeOut property and finally set our new char format to the text's "current" char format.

Lastly, in superScript() and subScript(), we again fetch the current char format, toggle the verticalAlignment property like we did in bold() and reset the new char format to make our changes visible.

Alignment

Alignment is very simple, as PyQt provides us with the necessary methods:

initFormatbar():

alignLeft = QtGui.QAction(QtGui.QIcon("icons/align-left.png"),"Align left",self)
alignLeft.triggered.connect(self.alignLeft)

alignCenter = QtGui.QAction(QtGui.QIcon("icons/align-center.png"),"Align center",self)
alignCenter.triggered.connect(self.alignCenter)

alignRight = QtGui.QAction(QtGui.QIcon("icons/align-right.png"),"Align right",self)
alignRight.triggered.connect(self.alignRight)

alignJustify = QtGui.QAction(QtGui.QIcon("icons/align-justify.png"),"Align justify",self)
alignJustify.triggered.connect(self.alignJustify)

Further below:

self.formatbar.addAction(alignLeft)
self.formatbar.addAction(alignCenter)
self.formatbar.addAction(alignRight)
self.formatbar.addAction(alignJustify)

self.formatbar.addSeparator()

Below the initUI() method:

def alignLeft(self):
    self.text.setAlignment(Qt.AlignLeft)

def alignRight(self):
    self.text.setAlignment(Qt.AlignRight)

def alignCenter(self):
    self.text.setAlignment(Qt.AlignCenter)

def alignJustify(self):
    self.text.setAlignment(Qt.AlignJustify)

Changes in the initFormatbar() method follow the previous pattern and the slot functions are also very simple. We change the text's alignment using our QTextEdit's setAlignment method, passing it the respective member of the Qt namespace, e.g. Qt.AlignCenter.

Indent - dedent

Indenting and dedenting is a little more complex, as PyQt provides us with no methods to efficiently adjust the tabbing of a selected area, meaning we need to come up with our own method of doing so:

initFormatbar():

indentAction = QtGui.QAction(QtGui.QIcon("icons/indent.png"),"Indent Area",self)
indentAction.setShortcut("Ctrl+Tab")
indentAction.triggered.connect(self.indent)

dedentAction = QtGui.QAction(QtGui.QIcon("icons/dedent.png"),"Dedent Area",self)
dedentAction.setShortcut("Shift+Tab")
dedentAction.triggered.connect(self.dedent)

Further below:

self.formatbar.addAction(indentAction)
self.formatbar.addAction(dedentAction)  

Below initUI():

def indent(self):

    # Grab the cursor
    cursor = self.text.textCursor()

    if cursor.hasSelection():

        # Store the current line/block number
        temp = cursor.blockNumber()

        # Move to the selection's last line
        cursor.setPosition(cursor.selectionEnd())

        # Calculate range of selection
        diff = cursor.blockNumber() - temp

        # Iterate over lines
        for n in range(diff + 1):

            # Move to start of each line
            cursor.movePosition(QtGui.QTextCursor.StartOfLine)

            # Insert tabbing
            cursor.insertText("\t")

            # And move back up
            cursor.movePosition(QtGui.QTextCursor.Up)

    # If there is no selection, just insert a tab
    else:

        cursor.insertText("\t")

def dedent(self):

    cursor = self.text.textCursor()

    if cursor.hasSelection():

        # Store the current line/block number
        temp = cursor.blockNumber()

        # Move to the selection's last line
        cursor.setPosition(cursor.selectionEnd())

        # Calculate range of selection
        diff = cursor.blockNumber() - temp

        # Iterate over lines
        for n in range(diff + 1):

            self.handleDedent(cursor)

            # Move up
            cursor.movePosition(QtGui.QTextCursor.Up)

    else:
        self.handleDedent(cursor)


def handleDedent(self,cursor):

    cursor.movePosition(QtGui.QTextCursor.StartOfLine)

    # Grab the current line
    line = cursor.block().text()

    # If the line starts with a tab character, delete it
    if line.startswith("\t"):

        # Delete next character
        cursor.deleteChar()

    # Otherwise, delete all spaces until a non-space character is met
    else:
        for char in line[:8]:

            if char != " ":
                break

            cursor.deleteChar()

Changes to initFormatbar() as previously discussed.

Let's go through the indent() function step by step. The first thing we need to do is grab our text's current QTextCursor object. We check if the user currently has any text under selection. If not, we just insert a tab. If he or she does have something under selection, however, we need to get a bit more funky. More specifically, we have to find out how many lines the user has under selection and insert a tab before each line.

We do so by first getting the current line/block number at the start of the selection, then moving the cursor to the end and subtracting the previously stored block/line number from the new one. This provides us with the range of lines over which we subsequently iterate. For each iteration, we move the cursor to the start of the current line, insert a tab and finally move up one line until we reach the top. (Remember that before we start iterating, we have the cursor at the end of the selection, where we moved it to find out the selection's last line number)

The dedent() method is quite similar, it differs, however, in our need to also handle excess space and not only tabs. That's what handleDedent() is for. It's called at each iteration of the loop that moves up the lines of the selection. In it, we again set the cursor to the beginning of each line, after which we grab the current line's text. If the line starts with a tab, we can just delete it and our job is done. If it doesn't, we also check wether there is any excess space (up to 8 spaces, which equals a tab) and delete it if so. This ensures two things:

  • People who prefer 8 spaces over a tab character ('/t') also get their money's worth
  • Excess space that could block from you from completely dedenting a block of text is deleted

Final customization options

Now that our tool bar, our format bar and our status bar are populated, we can add some final customization options to toggle the visibility of these three bars:

initMenubar():

# Toggling actions for the various bars
toolbarAction = QtGui.QAction("Toggle Toolbar",self)
toolbarAction.triggered.connect(self.toggleToolbar)

formatbarAction = QtGui.QAction("Toggle Formatbar",self)
formatbarAction.triggered.connect(self.toggleFormatbar)

statusbarAction = QtGui.QAction("Toggle Statusbar",self)
statusbarAction.triggered.connect(self.toggleStatusbar)

view.addAction(toolbarAction)
view.addAction(formatbarAction)
view.addAction(statusbarAction)

Below initUI():

def toggleToolbar(self):

  state = self.toolbar.isVisible()

  # Set the visibility to its inverse
  self.toolbar.setVisible(not state)

def toggleFormatbar(self):

    state = self.formatbar.isVisible()

    # Set the visibility to its inverse
    self.formatbar.setVisible(not state)

def toggleStatusbar(self):

    state = self.statusbar.isVisible()

    # Set the visibility to its inverse
    self.statusbar.setVisible(not state)

We create three actions in our initMenubar() method...

  • toolbarAction
  • formatbarAction
  • statusbarAction

...and connect them to slot functions. Note that we don't add these actions to any of the toolbars, but only to the drop-down menus at the top of our screen.

In the slot functions, we do what we did for some of the formatting functions: we retrieve the visibility states of the various bars and set the them to their opposite.

That'll be it for this post, be sure to check back for the upcoming part of this series on Building a text editor with PyQt, in which we'll add some interesting actions for find-and-replace, inserting images and more.

Subscribe below to receive updates on new tutorials!

Read part three here!

4 comments


Or enter your name and Email
  • BH Brian Hanna 3 years ago
    Peter, Your indent and dedent functions only seemed to work properly for me when I select text from the bottom up (i.e., I begin my selection on line 4, and end my selection on line 1). If I select from the top down, only the bottom line of text gets indented. I've altered the indent and dedent functions to work properly no matter what direction text is selected from, I'd love your feedback: http://pastebin.com/Vzmvn6uy
    • PG Peter Goldsborough 3 years ago
      Hi, Thanks for finding this bug! I had a look at and tried your code and it does fix the bug. I did a little more research and found out that if the cursor has a selection, cursor.anchor() returns the other end of the selection, so we can move the cursor there instead of checking whether it's at the start or end. Moreover, we can use the absolute value of `diff` to ensure that the actual range of lines is used, then this leaves us with only the need to check whether diff is positive or negative (before using the absolute value) for the direction, so there's no need to check the cursor position in the if-clause. I created a secret gist and first pasted in your code, then my changes so you can see what I did: https://gist.github.com/goldsborough/3bb50306603a36c4b6b4/revisions I'll update the repository and credit you for finding the bug. Thanks for the effort and don't hesitate to comment if you find any more bugs (I hope not :D)! Commit: https://github.com/goldsborough/Writer-Tutorial/commit/bdf451553057411d2ea3f8acf17f2bb35a6e48f1
  • D Dharmit 3 years ago
    Hi Peter, I am having some issue with font sizes. By default, it looks good. But when I select some text and change the size from dropdown, it gets messed up. If I select a font-size of say 11, it becomes completely impossible to read it. It's a bit visible only at font-size 20 and beyond. Is this expected behavior? -Dharmit.