binpress

Building a Text Editor with PyQt: Part 2

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

  1. fontBox = QtGui.QFontComboBox(self)
  2. fontBox.currentFontChanged.connect(self.fontFamily)
  3.  
  4. fontSize = QtGui.QComboBox(self)
  5. fontSize.setEditable(True)
  6.  
  7. # Minimum number of chars displayed
  8. fontSize.setMinimumContentsLength(3)
  9.  
  10. fontSize.activated.connect(self.fontSize)
  11.  
  12. # Typical font sizes
  13. fontSizes = ['6','7','8','9','10','11','12','13','14',
  14.              '15','16','18','20','22','24','26','28',
  15.              '32','36','40','44','48','54','60','66',
  16.              '72','80','88','96']
  17.  
  18. for i in fontSizes:
  19.     fontSize.addItem(i)
  20.  
  21. fontColor = QtGui.QAction(QtGui.QIcon("icons/font-color.png"),"Change font color",self)
  22. fontColor.triggered.connect(self.fontColor)
  23.  
  24. backColor = QtGui.QAction(QtGui.QIcon("icons/highlight.png"),"Change background color",self)
  25. backColor.triggered.connect(self.highlight)
  26.  
  27. self.formatbar = self.addToolBar("Format")
  28.  
  29. self.formatbar.addWidget(fontBox)
  30. self.formatbar.addWidget(fontSize)
  31.  
  32. self.formatbar.addSeparator()
  33.  
  34. self.formatbar.addAction(fontColor)
  35. self.formatbar.addAction(backColor)
  36.  
  37. self.formatbar.addSeparator()

Below initUI():

  1. def fontFamily(self,font):
  2.   self.text.setCurrentFont(font)
  3.  
  4. def fontSize(self, fontsize):
  5.     self.text.setFontPointSize(int(fontsize))
  6.  
  7. def fontColor(self):
  8.  
  9.     # Get a color from the text dialog
  10.     color = QtGui.QColorDialog.getColor()
  11.  
  12.     # Set it as the new text color
  13.     self.text.setTextColor(color)
  14.  
  15. def highlight(self):
  16.  
  17.     color = QtGui.QColorDialog.getColor()
  18.  
  19.     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():

  1. boldAction = QtGui.QAction(QtGui.QIcon("icons/bold.png"),"Bold",self)
  2. boldAction.triggered.connect(self.bold)
  3.  
  4. italicAction = QtGui.QAction(QtGui.QIcon("icons/italic.png"),"Italic",self)
  5. italicAction.triggered.connect(self.italic)
  6.  
  7. underlAction = QtGui.QAction(QtGui.QIcon("icons/underline.png"),"Underline",self)
  8. underlAction.triggered.connect(self.underline)
  9.  
  10. strikeAction = QtGui.QAction(QtGui.QIcon("icons/strike.png"),"Strike-out",self)
  11. strikeAction.triggered.connect(self.strike)
  12.  
  13. superAction = QtGui.QAction(QtGui.QIcon("icons/superscript.png"),"Superscript",self)
  14. superAction.triggered.connect(self.superScript)
  15.  
  16. subAction = QtGui.QAction(QtGui.QIcon("icons/subscript.png"),"Subscript",self)
  17. subAction.triggered.connect(self.subScript)

Further below:

  1. self.formatbar.addAction(boldAction)
  2. self.formatbar.addAction(italicAction)
  3. self.formatbar.addAction(underlAction)
  4. self.formatbar.addAction(strikeAction)
  5. self.formatbar.addAction(superAction)
  6. self.formatbar.addAction(subAction)
  7.  
  8. self.formatbar.addSeparator()

Below initUI():

  1. def bold(self):
  2.  
  3.     if self.text.fontWeight() == QtGui.QFont.Bold:
  4.  
  5.         self.text.setFontWeight(QtGui.QFont.Normal)
  6.  
  7.     else:
  8.  
  9.         self.text.setFontWeight(QtGui.QFont.Bold)
  10.  
  11. def italic(self):
  12.  
  13.     state = self.text.fontItalic()
  14.  
  15.     self.text.setFontItalic(not state)
  16.  
  17. def underline(self):
  18.  
  19.     state = self.text.fontUnderline()
  20.  
  21.     self.text.setFontUnderline(not state)
  22.  
  23. def strike(self):
  24.  
  25.     # Grab the text's format
  26.     fmt = self.text.currentCharFormat()
  27.  
  28.     # Set the fontStrikeOut property to its opposite
  29.     fmt.setFontStrikeOut(not fmt.fontStrikeOut())
  30.  
  31.     # And set the next char format
  32.     self.text.setCurrentCharFormat(fmt)
  33.  
  34. def superScript(self):
  35.  
  36.     # Grab the current format
  37.     fmt = self.text.currentCharFormat()
  38.  
  39.     # And get the vertical alignment property
  40.     align = fmt.verticalAlignment()
  41.  
  42.     # Toggle the state
  43.     if align == QtGui.QTextCharFormat.AlignNormal:
  44.  
  45.         fmt.setVerticalAlignment(QtGui.QTextCharFormat.AlignSuperScript)
  46.  
  47.     else:
  48.  
  49.         fmt.setVerticalAlignment(QtGui.QTextCharFormat.AlignNormal)
  50.  
  51.     # Set the new format
  52.     self.text.setCurrentCharFormat(fmt)
  53.  
  54. def subScript(self):
  55.  
  56.     # Grab the current format
  57.     fmt = self.text.currentCharFormat()
  58.  
  59.     # And get the vertical alignment property
  60.     align = fmt.verticalAlignment()
  61.  
  62.     # Toggle the state
  63.     if align == QtGui.QTextCharFormat.AlignNormal:
  64.  
  65.         fmt.setVerticalAlignment(QtGui.QTextCharFormat.AlignSubScript)
  66.  
  67.     else:
  68.  
  69.         fmt.setVerticalAlignment(QtGui.QTextCharFormat.AlignNormal)
  70.  
  71.     # Set the new format
  72.     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():

  1. alignLeft = QtGui.QAction(QtGui.QIcon("icons/align-left.png"),"Align left",self)
  2. alignLeft.triggered.connect(self.alignLeft)
  3.  
  4. alignCenter = QtGui.QAction(QtGui.QIcon("icons/align-center.png"),"Align center",self)
  5. alignCenter.triggered.connect(self.alignCenter)
  6.  
  7. alignRight = QtGui.QAction(QtGui.QIcon("icons/align-right.png"),"Align right",self)
  8. alignRight.triggered.connect(self.alignRight)
  9.  
  10. alignJustify = QtGui.QAction(QtGui.QIcon("icons/align-justify.png"),"Align justify",self)
  11. alignJustify.triggered.connect(self.alignJustify)

Further below:

  1. self.formatbar.addAction(alignLeft)
  2. self.formatbar.addAction(alignCenter)
  3. self.formatbar.addAction(alignRight)
  4. self.formatbar.addAction(alignJustify)
  5.  
  6. self.formatbar.addSeparator()

Below the initUI() method:

  1. def alignLeft(self):
  2.     self.text.setAlignment(Qt.AlignLeft)
  3.  
  4. def alignRight(self):
  5.     self.text.setAlignment(Qt.AlignRight)
  6.  
  7. def alignCenter(self):
  8.     self.text.setAlignment(Qt.AlignCenter)
  9.  
  10. def alignJustify(self):
  11.     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():

  1. indentAction = QtGui.QAction(QtGui.QIcon("icons/indent.png"),"Indent Area",self)
  2. indentAction.setShortcut("Ctrl+Tab")
  3. indentAction.triggered.connect(self.indent)
  4.  
  5. dedentAction = QtGui.QAction(QtGui.QIcon("icons/dedent.png"),"Dedent Area",self)
  6. dedentAction.setShortcut("Shift+Tab")
  7. dedentAction.triggered.connect(self.dedent)

Further below:

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

Below initUI():

  1. def indent(self):
  2.  
  3.     # Grab the cursor
  4.     cursor = self.text.textCursor()
  5.  
  6.     if cursor.hasSelection():
  7.  
  8.         # Store the current line/block number
  9.         temp = cursor.blockNumber()
  10.  
  11.         # Move to the selection's last line
  12.         cursor.setPosition(cursor.selectionEnd())
  13.  
  14.         # Calculate range of selection
  15.         diff = cursor.blockNumber() - temp
  16.  
  17.         # Iterate over lines
  18.         for n in range(diff + 1):
  19.  
  20.             # Move to start of each line
  21.             cursor.movePosition(QtGui.QTextCursor.StartOfLine)
  22.  
  23.             # Insert tabbing
  24.             cursor.insertText("\t")
  25.  
  26.             # And move back up
  27.             cursor.movePosition(QtGui.QTextCursor.Up)
  28.  
  29.     # If there is no selection, just insert a tab
  30.     else:
  31.  
  32.         cursor.insertText("\t")
  33.  
  34. def dedent(self):
  35.  
  36.     cursor = self.text.textCursor()
  37.  
  38.     if cursor.hasSelection():
  39.  
  40.         # Store the current line/block number
  41.         temp = cursor.blockNumber()
  42.  
  43.         # Move to the selection's last line
  44.         cursor.setPosition(cursor.selectionEnd())
  45.  
  46.         # Calculate range of selection
  47.         diff = cursor.blockNumber() - temp
  48.  
  49.         # Iterate over lines
  50.         for n in range(diff + 1):
  51.  
  52.             self.handleDedent(cursor)
  53.  
  54.             # Move up
  55.             cursor.movePosition(QtGui.QTextCursor.Up)
  56.  
  57.     else:
  58.         self.handleDedent(cursor)
  59.  
  60.  
  61. def handleDedent(self,cursor):
  62.  
  63.     cursor.movePosition(QtGui.QTextCursor.StartOfLine)
  64.  
  65.     # Grab the current line
  66.     line = cursor.block().text()
  67.  
  68.     # If the line starts with a tab character, delete it
  69.     if line.startswith("\t"):
  70.  
  71.         # Delete next character
  72.         cursor.deleteChar()
  73.  
  74.     # Otherwise, delete all spaces until a non-space character is met
  75.     else:
  76.         for char in line[:8]:
  77.  
  78.             if char != " ":
  79.                 break
  80.  
  81.             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():

  1. # Toggling actions for the various bars
  2. toolbarAction = QtGui.QAction("Toggle Toolbar",self)
  3. toolbarAction.triggered.connect(self.toggleToolbar)
  4.  
  5. formatbarAction = QtGui.QAction("Toggle Formatbar",self)
  6. formatbarAction.triggered.connect(self.toggleFormatbar)
  7.  
  8. statusbarAction = QtGui.QAction("Toggle Statusbar",self)
  9. statusbarAction.triggered.connect(self.toggleStatusbar)
  10.  
  11. view.addAction(toolbarAction)
  12. view.addAction(formatbarAction)
  13. view.addAction(statusbarAction)

Below initUI():

  1. def toggleToolbar(self):
  2.  
  3.   state = self.toolbar.isVisible()
  4.  
  5.   # Set the visibility to its inverse
  6.   self.toolbar.setVisible(not state)
  7.  
  8. def toggleFormatbar(self):
  9.  
  10.     state = self.formatbar.isVisible()
  11.  
  12.     # Set the visibility to its inverse
  13.     self.formatbar.setVisible(not state)
  14.  
  15. def toggleStatusbar(self):
  16.  
  17.     state = self.statusbar.isVisible()
  18.  
  19.     # Set the visibility to its inverse
  20.     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!

Author: Peter Goldsborough

Scroll to Top