Building a Text Editor with PyQt: Part 3

*Read part two here, or start from the beginning!*

In part one of this tutorial series on Building a text editor with PyQt, we built a basic text editor skeleton and already added features for file management, list insertion, undo/redo and more. In part two, we turned our program into a rich-text editor by adding actions for text-formatting. In the third (and subsequently fourth) part of this series, we’ll be adding some slick extensions to our text editor for:

  • Finding and replacing text
  • Inserting an image
  • Word and symbol count
  • Creating and managing tables
  • Inserting time and date

This part will deal with the first three extensions and in the fourth and final part I’ll discuss the remaining two.

Directory structure

For most of the above actions, we’ll be creating dialog classes in separate files, meaning we need a new folder for all of these new files. Create a folder in your working directory called “ext” (for extensions) and create an empty file called __init__.py within it. This will turn our folder into a Python package.

Your working directory should look somewhat like this now:

  1. writer.py
  2. icons/
  3.   lots of icons
  4. ext/
  5.   __init__.py

Find-and-replace

First up, we’ll handle our find-and-replace dialog. PyQt unfortunately has no methods of its own for finding and replacing text in a QTextEdit, therefore we’ll be doing a lot ourselves for this one.

In your ext folder, create a new file called find.py:

  1. from PyQt4 import QtGui, QtCore
  2. from PyQt4.QtCore import Qt
  3.  
  4. import re
  5.  
  6. class Find(QtGui.QDialog):
  7.     def __init__(self, parent = None):
  8.  
  9.         QtGui.QDialog.__init__(self, parent)
  10.  
  11.         self.parent = parent
  12.  
  13.         self.lastMatch = None
  14.  
  15.         self.initUI()
  16.  
  17.     def initUI(self):
  18.  
  19.         # Button to search the document for something
  20.         findButton = QtGui.QPushButton("Find",self)
  21.         findButton.clicked.connect(self.find)
  22.  
  23.         # Button to replace the last finding
  24.         replaceButton = QtGui.QPushButton("Replace",self)
  25.         replaceButton.clicked.connect(self.replace)
  26.  
  27.         # Button to remove all findings
  28.         allButton = QtGui.QPushButton("Replace all",self)
  29.         allButton.clicked.connect(self.replaceAll)
  30.  
  31.         # Normal mode - radio button
  32.         self.normalRadio = QtGui.QRadioButton("Normal",self)
  33.         self.normalRadio.toggled.connect(self.normalMode)
  34.  
  35.         # Regular Expression Mode - radio button
  36.         self.regexRadio = QtGui.QRadioButton("RegEx",self)
  37.         self.regexRadio.toggled.connect(self.regexMode)
  38.  
  39.         # The field into which to type the query
  40.         self.findField = QtGui.QTextEdit(self)
  41.         self.findField.resize(250,50)
  42.  
  43.         # The field into which to type the text to replace the
  44.         # queried text
  45.         self.replaceField = QtGui.QTextEdit(self)
  46.         self.replaceField.resize(250,50)
  47.  
  48.         optionsLabel = QtGui.QLabel("Options: ",self)
  49.  
  50.         # Case Sensitivity option
  51.         self.caseSens = QtGui.QCheckBox("Case sensitive",self)
  52.  
  53.         # Whole Words option
  54.         self.wholeWords = QtGui.QCheckBox("Whole words",self)
  55.  
  56.         # Layout the objects on the screen
  57.         layout = QtGui.QGridLayout()
  58.  
  59.         layout.addWidget(self.findField,1,0,1,4)
  60.         layout.addWidget(self.normalRadio,2,2)
  61.         layout.addWidget(self.regexRadio,2,3)
  62.         layout.addWidget(findButton,2,0,1,2)
  63.  
  64.         layout.addWidget(self.replaceField,3,0,1,4)
  65.         layout.addWidget(replaceButton,4,0,1,2)
  66.         layout.addWidget(allButton,4,2,1,2)
  67.  
  68.         # Add some spacing
  69.         spacer = QtGui.QWidget(self)
  70.  
  71.         spacer.setFixedSize(0,10)
  72.  
  73.         layout.addWidget(spacer,5,0)
  74.  
  75.         layout.addWidget(optionsLabel,6,0)
  76.         layout.addWidget(self.caseSens,6,1)
  77.         layout.addWidget(self.wholeWords,6,2)
  78.  
  79.         self.setGeometry(300,300,360,250)
  80.         self.setWindowTitle("Find and Replace")
  81.         self.setLayout(layout)
  82.  
  83.         # By default the normal mode is activated
  84.         self.normalRadio.setChecked(True)
  85.  
  86.     def find(self):
  87.  
  88.         # Grab the parent's text
  89.         text = self.parent.text.toPlainText()
  90.  
  91.         # And the text to find
  92.         query = self.findField.toPlainText()
  93.  
  94.         # If the 'Whole Words' checkbox is checked, we need to append
  95.         # and prepend a non-alphanumeric character
  96.         if self.wholeWords.isChecked():
  97.             query = r'\W' + query + r'\W'
  98.  
  99.         # By default regexes are case sensitive but usually a search isn't
  100.         # case sensitive by default, so we need to switch this around here
  101.         flags = 0 if self.caseSens.isChecked() else re.I
  102.  
  103.         # Compile the pattern
  104.         pattern = re.compile(query,flags)
  105.  
  106.         # If the last match was successful, start at position after the last
  107.         # match's start, else at 0
  108.         start = self.lastMatch.start() + 1 if self.lastMatch else 0
  109.  
  110.         # The actual search
  111.         self.lastMatch = pattern.search(text,start)
  112.  
  113.         if self.lastMatch:
  114.  
  115.             start = self.lastMatch.start()
  116.             end = self.lastMatch.end()
  117.  
  118.             # If 'Whole words' is checked, the selection would include the two
  119.             # non-alphanumeric characters we included in the search, which need
  120.             # to be removed before marking them.
  121.             if self.wholeWords.isChecked():
  122.                 start += 1
  123.                 end -= 1
  124.  
  125.             self.moveCursor(start,end)
  126.  
  127.         else:
  128.  
  129.             # We set the cursor to the end if the search was unsuccessful
  130.             self.parent.text.moveCursor(QtGui.QTextCursor.End)
  131.  
  132.     def replace(self):
  133.  
  134.         # Grab the text cursor
  135.         cursor = self.parent.text.textCursor()
  136.  
  137.         # Security
  138.         if self.lastMatch and cursor.hasSelection():
  139.  
  140.             # We insert the new text, which will override the selected
  141.             # text
  142.             cursor.insertText(self.replaceField.toPlainText())
  143.  
  144.             # And set the new cursor
  145.             self.parent.text.setTextCursor(cursor)
  146.  
  147.     def replaceAll(self):
  148.  
  149.         # Set lastMatch to None so that the search
  150.         # starts from the beginning of the document
  151.         self.lastMatch = None
  152.  
  153.         # Initial find() call so that lastMatch is
  154.         # potentially not None anymore
  155.         self.find()
  156.  
  157.         # Replace and find until find is None again
  158.         while self.lastMatch:
  159.             self.replace()
  160.             self.find()
  161.  
  162.     def regexMode(self):
  163.  
  164.         # First uncheck the checkboxes
  165.         self.caseSens.setChecked(False)
  166.         self.wholeWords.setChecked(False)
  167.  
  168.         # Then disable them (gray them out)
  169.         self.caseSens.setEnabled(False)
  170.         self.wholeWords.setEnabled(False)
  171.  
  172.     def normalMode(self):
  173.  
  174.         # Enable checkboxes (un-gray them)
  175.         self.caseSens.setEnabled(True)
  176.         self.wholeWords.setEnabled(True)
  177.  
  178.     def moveCursor(self,start,end):
  179.  
  180.         # We retrieve the QTextCursor object from the parent's QTextEdit
  181.         cursor = self.parent.text.textCursor()
  182.  
  183.         # Then we set the position to the beginning of the last match
  184.         cursor.setPosition(start)
  185.  
  186.         # Next we move the Cursor by over the match and pass the KeepAnchor parameter
  187.         # which will make the cursor select the the match's text
  188.         cursor.movePosition(QtGui.QTextCursor.Right,QtGui.QTextCursor.KeepAnchor,end - start)
  189.  
  190.         # And finally we set this new cursor as the parent's
  191.         self.parent.text.setTextCursor(cursor)

And insert this line in ext/__init__.py:

  1. __all__ = ["find"]

Back to writer.py. At the top of the file:

  1.   from ext import *

In initToolbar():

  1. self.findAction = QtGui.QAction(QtGui.QIcon("icons/find.png"),"Find and replace",self)
  2. self.findAction.setStatusTip("Find and replace words in your document")
  3. self.findAction.setShortcut("Ctrl+F")
  4. self.findAction.triggered.connect(find.Find(self).show)

Further below:

  1. self.toolbar.addSeparator()
  2.  
  3. self.toolbar.addAction(self.findAction)

In initMenubar():

  1. edit.addAction(self.findAction)

Woah! That was a lot! No worries, I’ll explain everything.

First, the easy stuff. In ext/__init__.py, we inserted the only line this file will ever contain: __all__ = ["find"]. This enables us to import from our ext package using the asterix symbol (*), which imports all modules that are inside __all__. Therefore, at the top of writer.py, we can now write from ext import *, which is currently equivalent to from ext import find, but will be a lot more efficient once we have more modules in our package.

Further down in writer.py, more precisely in our toolbar initialization method, initToolbar(), we — as we’ve done many times for our text editor — create a QAction, set up a status tip as well as a shortcut, and connect the triggered signal to a slot function. In this case, all we need to do is create an instance of the Find class (which I’ll get to in a bit) and call its show() method. Fortunately, this all fits into one line and doesn’t require us to create a separate method. In initMenubar(), we add this action to the edit menu.

Initializing the UI

Now to our Find class in find.py. We start out like we did for our main window. First, we import the necessary modules from PyQt as well as the re module, which we’ll use for text search. Next, we create a class and let it inherit from one of PyQt’s GUI windows. In this case, we’re going to inherit from QDialog instead of from QMainWindow, because, well, it’s a dialog and not our main window. In the constructor, __init__(), we make the parent object a member (we pass Find‘s constructor self in Main.initToolbar()). Moreover, we need another class member, self.lastMatch, which will store the last found match (more about it soon).

In initUI(), we take care of the graphical part of our find-and-replace dialog. We’ll create three push-buttons, one for finding text, one for replacing a single occurrence and a last one for replacing all occurrence. We create non-member instances of our buttons and connect their clicked signals to slot functions that we’ll discuss in a bit:

  1. # Button to search the document for something
  2. findButton = QtGui.QPushButton("Find",self)
  3. findButton.clicked.connect(self.find)
  4.  
  5. # Button to replace the last finding
  6. replaceButton = QtGui.QPushButton("Replace",self)
  7. replaceButton.clicked.connect(self.replace)
  8.  
  9. # Button to remove all findings
  10. allButton = QtGui.QPushButton("Replace all",self)
  11. allButton.clicked.connect(self.replaceAll)

Next, we create two radio buttons that’ll enable the user to switch between regular expression finding mode and normal, plain-text, finding mode. We make them class members, because we need to access their states later on, and connect their toggled signals to slot functions, as for the buttons above:

  1. # Normal mode - radio button
  2. self.normalRadio = QtGui.QRadioButton("Normal",self)
  3. self.normalRadio.toggled.connect(self.normalMode)
  4.  
  5. # Regular Expression Mode - radio button
  6. self.regexRadio = QtGui.QRadioButton("RegEx",self)
  7. self.regexRadio.toggled.connect(self.regexMode)

Then, we create two text fields. One where the user inputs text that he or she would like to find and another for the text the user’d like to replace occurences with. We resize both text fields to 250×50 pixels:

  1. # The field into which to type the query
  2. self.findField = QtGui.QTextEdit(self)
  3. self.findField.resize(250,50)
  4.  
  5. # The field into which to type the text to replace the
  6. # queried text
  7. self.replaceField = QtGui.QTextEdit(self)
  8. self.replaceField.resize(250,50)

Almost done. We want to also provide the user with some search options, namely case-sensitivity control and a “whole word” flag, which only highlights occurrence that have non-alphanumeric characters to their left and right. For example, I like cat soup would pass the “whole word” check for the word cat because the word cat is not part of another word. In I greatly enjoy concatenating strings, the string “cat” would be highlighted if the “whole words” flag is unchecked, but would be ignored if the user only wants “whole words”. The code for this is very simple, the only important things is that these QCheckBoxes are class members so we can check their states later on. Also, we create a QLabel that will hold the string “Options:”, just for visual clarity:

  1. optionsLabel = QtGui.QLabel("Options: ",self)
  2.  
  3. # Case Sensitivity option
  4. self.caseSens = QtGui.QCheckBox("Case sensitive",self)
  5.  
  6. # Whole Words option
  7. self.wholeWords = QtGui.QCheckBox("Whole words",self)

Now we need to order all of these widgets on our dialog. We do so by creating a QGridLayout and adding the widgets we just created using the QGridLayout‘s addWidget() method, which takes the widget to add, the row, column, row-span and column-span in the layout as its arguments. Note that I create a “spacer” widget which is just a plain QWidget with a fixed size of 0 by 10 pixels. We insert this spacer to add some distance between the replace buttons and our options:

  1. # Layout the objects on the screen
  2. layout = QtGui.QGridLayout()
  3.  
  4. layout.addWidget(self.findField,1,0,1,4)
  5. layout.addWidget(self.normalRadio,2,2)
  6. layout.addWidget(self.regexRadio,2,3)
  7. layout.addWidget(findButton,2,0,1,2)
  8.  
  9. layout.addWidget(self.replaceField,3,0,1,4)
  10. layout.addWidget(replaceButton,4,0,1,2)
  11. layout.addWidget(allButton,4,2,1,2)
  12.  
  13. # Add some spacing
  14. spacer = QtGui.QWidget(self)
  15.  
  16. spacer.setFixedSize(0,10)
  17.  
  18. layout.addWidget(spacer,5,0)
  19.  
  20. layout.addWidget(optionsLabel,6,0)
  21. layout.addWidget(self.caseSens,6,1)
  22. layout.addWidget(self.wholeWords,6,2)

Lastly, some window settings. We set our dialog’s geometry settings, give it a window title and set our newly created layout as the dialog’s layout. Also, we want to activate our normalRadio checkbox initially:

  1. self.setGeometry(300,300,360,250)
  2. self.setWindowTitle("Find and Replace")
  3. self.setLayout(layout)
  4.  
  5. # By default the normal mode is activated
  6. self.normalRadio.setChecked(True)

Raiders of the lost text

Now that we have an interface, we can make our dialog… do something. As a start, I’ll discuss find()line by line. The first thing this method needs to do is get the text in which we’ll look for queries, our main window’s QTextEdit, and find out what text the user wants to find, which we get from our findField:

  1. # Grab the parent's text
  2. text = self.parent.text.toPlainText()
  3.  
  4. # And the text to find
  5. query = self.findField.toPlainText()

Then, we need to check whether the user has ticked any options. Note that we will use Python’s regular expression engine to do our searching. If the user wants only whole words, we append and prepend a '\W' character, which matches any non-alphanumeric character such as a space or any form of punctuation. After checking for the case-sensitivy flag, we compile our regular expression.

To find out where we need to start our search in the text, we check if the self.lastMatch object is not None. If it isn’t, we can use the last match’s starting position and increment it by one for our new search. If self.lastMatch is None, however, we re-start from index 0. Note that Python’s regex functions return None if no match was found for a regular expression, meaning that this way of resetting the search index to 0 will work in such a way that if the user searches the text to its end, the search starts all over again, which is great. Lastly, we do the actual search:

  1. # If the 'Whole Words' checkbox is checked, we need to append.
  2. # and prepend a non-alphanumeric character
  3. if self.wholeWords.isChecked():
  4.     query = r'\W' + query + r'\W'
  5.  
  6. # By default regexes are case sensitive, but usually a search isn't.
  7. # case sensitive by default, so we need to switch this around here
  8. flags = 0 if self.caseSens.isChecked() else re.I
  9.  
  10. # Compile the pattern
  11. pattern = re.compile(query,flags)
  12.  
  13. # If the last match was successful, start at position after the last.
  14. # match's start, else at 0
  15. start = self.lastMatch.start() + 1 if self.lastMatch else 0
  16.  
  17. # The actual search
  18. self.lastMatch = pattern.search(text,start)

If the search was successful, we need to highlight the match. We have to do this manually using our main window’s QTextEdit‘s QTextCursor again, but more about that in a bit. If the user had the “whole word” flag checked, this means that the match also includes the two non-alphanumeric characters that we included in the search. Would we leave the indices like this, replacing the matched text would mean also replacing the spaces or punctuation around the actual matched text, which would make our users frustrated and make them hate us, which in turn would make us very sad. To keep everyone happy and loving, we increment the starting position and decrement the ending index of our match. If the search was unsuccessful, we set the cursor to the end of the text:

  1. if self.lastMatch:
  2.  
  3.     start = self.lastMatch.start()
  4.     end = self.lastMatch.end()
  5.  
  6.     # If 'Whole words' is checked, the selection would include the two
  7.     # non-alphanumeric characters we included in the search, which need
  8.     # to be removed before marking them.
  9.     if self.wholeWords.isChecked():
  10.         start += 1
  11.         end -= 1
  12.  
  13.     self.moveCursor(start,end)
  14.  
  15. else:
  16.  
  17.     # We set the cursor to the end if the search was unsuccessful
  18.     self.parent.text.moveCursor(QtGui.QTextCursor.End)

Highlights

Because we just used the self.moveCursor() method in find(), I’ll talk about that next. As commented, We retrieve the QTextCursor object from the parent’s QTextEdit and Then we set the position to the beginning of the last matchNext we move the Cursor over the match and pass the KeepAnchor parameter which will make the cursor select the match’s textAnd finally we set this new cursor as the parent’s:

  1. def moveCursor(self,start,end):
  2.  
  3.   # We retrieve the QTextCursor object from the parent's QTextEdit
  4.   cursor = self.parent.text.textCursor()
  5.  
  6.   # Then we set the position to the beginning of the last match
  7.   cursor.setPosition(start)
  8.  
  9.   # Next we move the Cursor over the match and pass the KeepAnchor parameter
  10.   # which will make the cursor select the match's text
  11.   cursor.movePosition(QtGui.QTextCursor.Right,QtGui.QTextCursor.KeepAnchor,end - start)
  12.  
  13.   # And finally we set this new cursor as the parent's
  14.   self.parent.text.setTextCursor(cursor)

Replacing

Now that we managed to find text and highlight it, we’ll want to also handle our slot functions that take care of replacing the matched text. In replace(), we again grab our parent’s QTextCursorobject. Then, we ensure

  1. That the last match was successful and self.lastMatch is not None.
  2. The cursor currently has a selection.

If those two conditions are met, we can use the cursor’s insertText() method and retrieve the text we want to replace our match with from the replace field. Because the cursor has a selection, it will replace the selected text with the new text. Finally, we reset our cursor:

  1.     def replace(self):
  2.  
  3.         # Grab the text cursor
  4.         cursor = self.parent.text.textCursor()
  5.  
  6.         # Security
  7.         if self.lastMatch and cursor.hasSelection():
  8.  
  9.             # We insert the new text, which will override the selected
  10.             # text
  11.             cursor.insertText(self.replaceField.toPlainText())
  12.  
  13.             # And set the new cursor
  14.             self.parent.text.setTextCursor(cursor)

Replace ALL the occurences!

To replace all the occurences of a query in the text, we need to first reset our self.lastMatchmember to None and call find(), so that the search will begin from the start of the text. Then, if the first match was successful, we enter a loop that will replace and find occurences as long as self.lastMatch is not None, so as long as the search doesn’t hit the end of the text.

  1.     def replaceAll(self):
  2.  
  3.         # Set lastMatch to None so that the search
  4.         # starts from the beginning of the document
  5.         self.lastMatch = None
  6.  
  7.         # Initial find() call so that lastMatch is
  8.         # potentially not None anymore
  9.         self.find()
  10.  
  11.         # Replace and find until find is None again
  12.         while self.lastMatch:
  13.             self.replace()
  14.             self.find()

Some last slots

The last two functions we need for our Find class are the handlers for the search mode (normal or regex):

  1.     def regexMode(self):
  2.  
  3.         # First uncheck the checkboxes
  4.         self.caseSens.setChecked(False)
  5.         self.wholeWords.setChecked(False)
  6.  
  7.         # Then disable them (gray them out)
  8.         self.caseSens.setEnabled(False)
  9.         self.wholeWords.setEnabled(False)
  10.  
  11.     def normalMode(self):
  12.  
  13.         # Enable checkboxes (un-gray them)
  14.         self.caseSens.setEnabled(True)
  15.         self.wholeWords.setEnabled(True)

Regex mode means that the search flags are unnecessary, since the user will want to input flags using regular expressions him- or herself. Therefore, we uncheck the check boxes and also disable them, which will “gray” them out.

For normalMode(), we simply re-enable the check boxes.

So much for our find-and-replace dialog! Next up:

Image insertion

Image insertion does not require a class of its own, so we’ll stick around writer.py for this one. In fact, all we need is a QAction in initToolbar():

  1. imageAction = QtGui.QAction(QtGui.QIcon("icons/image.png"),"Insert image",self)
  2. imageAction.setStatusTip("Insert image")
  3. imageAction.setShortcut("Ctrl+Shift+I")
  4. imageAction.triggered.connect(self.insertImage)
  5.  
  6. self.toolbar.addAction(imageAction)

And a slot function, self.insertImage(). In it, we open a getOpenFileName dialog like we did for opening a .writer file in the very beginning, from which we retrieve a file name. For the file dialog’s filter, we include common image formats. If we got a file name, we create a QImage and, if it was loadable (isNull == False), we insert it using our QTextCursor‘s insertImage() method. If it wasn’t loadable, we pop up a QMessageBox. The constructor of this QMessageBox requires an icon from the QMessageBox namespace (either a question, information, warning or “critical” icon), a window title, the message to display, a set of buttons to show and lastly a parent object:

  1. def insertImage(self):
  2.  
  3.     # Get image file name
  4.     filename = QtGui.QFileDialog.getOpenFileName(self, 'Insert image',".","Images (*.png *.xpm *.jpg *.bmp *.gif)")
  5.  
  6.     # Create image object
  7.     image = QtGui.QImage(filename)
  8.  
  9.     # Error if unloadable
  10.     if image.isNull():
  11.  
  12.         popup = QtGui.QMessageBox(QtGui.QMessageBox.Critical,
  13.                                   "Image load error",
  14.                                   "Could not load image file!",
  15.                                   QtGui.QMessageBox.Ok,
  16.                                   self)
  17.         popup.show()
  18.  
  19.     else:
  20.  
  21.         cursor = self.text.textCursor()
  22.  
  23.         cursor.insertImage(image,filename)

Counting words

For the next extension, a word-count dialog that’ll display the number of words and symbols in the document’s selected and total text, we’ll create a new class in a separate file again. So, in

ext/wordcount.py:

  1. from PyQt4 import QtGui, QtCore
  2. from PyQt4.QtCore import Qt
  3.  
  4. class WordCount(QtGui.QDialog):
  5.     def __init__(self,parent = None):
  6.         QtGui.QDialog.__init__(self, parent)
  7.  
  8.         self.parent = parent
  9.  
  10.         self.initUI()
  11.  
  12.     def initUI(self):
  13.  
  14.         # Word count in selection
  15.         currentLabel = QtGui.QLabel("Current selection",self)
  16.         currentLabel.setStyleSheet("font-weight:bold; font-size: 15px;")
  17.  
  18.         currentWordsLabel = QtGui.QLabel("Words: ", self)
  19.         currentSymbolsLabel = QtGui.QLabel("Symbols: ",self)
  20.  
  21.         self.currentWords = QtGui.QLabel(self)
  22.         self.currentSymbols = QtGui.QLabel(self)
  23.  
  24.         # Total word/symbol count
  25.         totalLabel = QtGui.QLabel("Total",self)
  26.         totalLabel.setStyleSheet("font-weight:bold; font-size: 15px;")
  27.  
  28.         totalWordsLabel = QtGui.QLabel("Words: ", self)
  29.         totalSymbolsLabel = QtGui.QLabel("Symbols: ",self)
  30.  
  31.         self.totalWords = QtGui.QLabel(self)
  32.         self.totalSymbols = QtGui.QLabel(self)
  33.  
  34.         # Layout
  35.  
  36.         layout = QtGui.QGridLayout(self)
  37.  
  38.         layout.addWidget(currentLabel,0,0)
  39.  
  40.         layout.addWidget(currentWordsLabel,1,0)
  41.         layout.addWidget(self.currentWords,1,1)
  42.  
  43.         layout.addWidget(currentSymbolsLabel,2,0)
  44.         layout.addWidget(self.currentSymbols,2,1)
  45.  
  46.         spacer = QtGui.QWidget()
  47.         spacer.setFixedSize(0,5)
  48.  
  49.         layout.addWidget(spacer,3,0)
  50.  
  51.         layout.addWidget(totalLabel,4,0)
  52.  
  53.         layout.addWidget(totalWordsLabel,5,0)
  54.         layout.addWidget(self.totalWords,5,1)
  55.  
  56.         layout.addWidget(totalSymbolsLabel,6,0)
  57.         layout.addWidget(self.totalSymbols,6,1)
  58.  
  59.         self.setWindowTitle("Word count")
  60.         self.setGeometry(300,300,200,200)
  61.         self.setLayout(layout)
  62.  
  63.     def getText(self):
  64.  
  65.         # Get the text currently in selection
  66.         text = self.parent.text.textCursor().selectedText()
  67.  
  68.         # Split the text to get the word count
  69.         words = str(len(text.split()))
  70.  
  71.         # And just get the length of the text for the symbols
  72.         # count
  73.         symbols = str(len(text))
  74.  
  75.         self.currentWords.setText(words)
  76.         self.currentSymbols.setText(symbols)
  77.  
  78.         # For the total count, same thing as above but for the
  79.         # total text
  80.  
  81.         text = self.parent.text.toPlainText()
  82.  
  83.         words = str(len(text.split()))
  84.         symbols = str(len(text))
  85.  
  86.         self.totalWords.setText(words)
  87.         self.totalSymbols.setText(symbols)

And in __init__.py:

  1. __all__ = ["find","wordcount"]

Back to writer.py. In initToolbar():

  1. wordCountAction = QtGui.QAction(QtGui.QIcon("icons/count.png"),"See word/symbol count",self)
  2. wordCountAction.setStatusTip("See word/symbol count")
  3. wordCountAction.setShortcut("Ctrl+W")
  4. wordCountAction.triggered.connect(self.wordCount)
  5.  
  6. self.toolbar.addAction(wordCountAction)

Below initUI():

  1. def wordCount(self):
  2.  
  3.     wc = wordcount.WordCount(self)
  4.  
  5.     wc.getText()
  6.  
  7.     wc.show()

As mentioned, this dialog will show the user the number of words and symbols currently under selection (if there is a selection) and also the number of words and symbols in the entire document. The UI is fairly simple. We create labels that indicate whether the numbers shown are for the current selection or the whole text, currentLabel and totalLabel, as well as two labels each that hold the strings “Words:” and “Symbols:”, plus two labels each in which we’ll show the actual numbers (these must be class members):

  1.     # Word count in selection
  2.     currentLabel = QtGui.QLabel("Current selection",self)
  3.     currentLabel.setStyleSheet("font-weight:bold; font-size: 15px;")
  4.  
  5.     currentWordsLabel = QtGui.QLabel("Words: ", self)
  6.     currentSymbolsLabel = QtGui.QLabel("Symbols: ",self)
  7.  
  8.     self.currentWords = QtGui.QLabel(self)
  9.     self.currentSymbols = QtGui.QLabel(self)
  10.  
  11.     # Total word/symbol count
  12.     totalLabel = QtGui.QLabel("Total",self)
  13.     totalLabel.setStyleSheet("font-weight:bold; font-size: 15px;")
  14.  
  15.     totalWordsLabel = QtGui.QLabel("Words: ", self)
  16.     totalSymbolsLabel = QtGui.QLabel("Symbols: ",self)
  17.  
  18.     self.totalWords = QtGui.QLabel(self)
  19.     self.totalSymbols = QtGui.QLabel(self)

We put them into a layout and set the dialog’s geometry and window title:

  1.     # Layout
  2.  
  3.     layout = QtGui.QGridLayout(self)
  4.  
  5.     layout.addWidget(currentLabel,0,0)
  6.  
  7.     layout.addWidget(currentWordsLabel,1,0)
  8.     layout.addWidget(self.currentWords,1,1)
  9.  
  10.     layout.addWidget(currentSymbolsLabel,2,0)
  11.     layout.addWidget(self.currentSymbols,2,1)
  12.  
  13.     spacer = QtGui.QWidget()
  14.     spacer.setFixedSize(0,5)
  15.  
  16.     layout.addWidget(spacer,3,0)
  17.  
  18.     layout.addWidget(totalLabel,4,0)
  19.  
  20.     layout.addWidget(totalWordsLabel,5,0)
  21.     layout.addWidget(self.totalWords,5,1)
  22.  
  23.     layout.addWidget(totalSymbolsLabel,6,0)
  24.     layout.addWidget(self.totalSymbols,6,1)
  25.  
  26.     self.setWindowTitle("Word count")
  27.     self.setGeometry(300,300,200,200)
  28.     self.setLayout(layout)

The function that will count all of these words and symbols is getText(). First, we want to count the words and symbols of the selected text, which we get by grabbing our QTextEdit‘s QTextCursor and calling its selectedText() method. We use the retrieved string’s split()method to split the string into a list of individual words, of which we then get the length. The number of symbols is simply the length of the entire string. We then visualize the two numbers we just got using the respective labels’ setText() method. We repeat this process for the whole text and again set the counts we retrieved to the respective labels’ text:

  1.     def getText(self):
  2.  
  3.         # Get the text currently in selection
  4.         text = self.parent.text.textCursor().selectedText()
  5.  
  6.         # Split the text to get the word count
  7.         words = str(len(text.split()))
  8.  
  9.         # And just get the length of the text for the symbols
  10.         # count
  11.         symbols = str(len(text))
  12.  
  13.         self.currentWords.setText(words)
  14.         self.currentSymbols.setText(symbols)
  15.  
  16.         # For the total count, same thing as above but for the
  17.         # total text
  18.  
  19.         text = self.parent.text.toPlainText()
  20.  
  21.         words = str(len(text.split()))
  22.         symbols = str(len(text))
  23.  
  24.         self.totalWords.setText(words)
  25.         self.totalSymbols.setText(symbols)

In writer.py, we again create a QAction for our word count dialog and add it to the toolbar. In the slot function, self.wordCount(), we create an instance of our WordCount class, call its getText() method and finally show the dialog.

That’ll be it for this part of the series. In the next (and final!) part, we’ll be adding awesome extensions for inserting the current time and date into the text as well as a more sophisticated dialog for inserting tables. Moreover, I’ll show you how to enable custom context menus that will enable us to manipulate the tables we insert into the text (adding/deleting/merging rows and columns).

Don’t forget to check back to the project’s GitHub repository for any new updates or changes once in a while. See you next week!

Subscribe below to receive updates on new tutorials!

Continue this series with the fourth and final installment!

Author: Peter Goldsborough