Building a Text Editor with PyQt: Part 1

I’ve always enjoyed building beautiful Graphical User Interfaces (GUIs) to the back-end computations, number-crunching and algorithms of my programs. For Python, my GUI library of choice is the Python binding for Qt, PyQt. This tutorial will guide you through the process of using PyQt to build a simple but useful rich-text editor. The first part of the tutorial will focus on the core features and skeleton of the editor. In part two, we’ll take care of text-formatting. And in part three, we’ll add some useful extensions like a find-and-replace dialog, support for tables and more. This is what it’ll look like at the end of this tutorial series:

writer

Before we get started, there are two things to square away:

  • You can find and download the finished source code on GitHub.
  • If you don’t already have PyQt installed, you can go grab it from the official website.

Once you’re set up and ready to go, we can embark on our journey to create a totally awesome text editor.

An empty canvas

We start out with an empty canvas, a bare-minimum PyQt application:

  1. import sys
  2. from PyQt4 import QtGui, QtCore
  3. from PyQt4.QtCore import Qt
  4.  
  5. class Main(QtGui.QMainWindow):
  6.  
  7.     def __init__(self, parent = None):
  8.         QtGui.QMainWindow.__init__(self,parent)
  9.  
  10.         self.initUI()
  11.  
  12.     def initUI(self):
  13.  
  14.         # x and y coordinates on the screen, width, height
  15.         self.setGeometry(100,100,1030,800)
  16.  
  17.         self.setWindowTitle("Writer")
  18.  
  19. def main():
  20.  
  21.     app = QtGui.QApplication(sys.argv)
  22.  
  23.     main = Main()
  24.     main.show()
  25.  
  26.     sys.exit(app.exec_())
  27.  
  28. if __name__ == "__main__":
  29.     main()

The first thing we need to do is import the sys module, which PyQt needs to start our application, as well as all the necessary modules from the PyQt4 package (PyQt5 if you have the newer version). We’ll call our class Main and let it inherit from PyQt’s QMainWindow class. In the __init__method, we initialize the parent class as well as all the UI settings for our application. The latter we just pack into the initUI() method. At the moment, the only settings we need are those concerning position on the screen, the size of the window and the window’s title. We can set the first two using the setGeometry() method, which lets us set the x and y coordinates of the window on the screen, the width and the height. We also set our application’s window title using the setWindowTitle()method. For simplicity, we’ll just call our text editor Writer.

Lastly, we need a main function that takes care of instantiating and displaying our window. We do so by creating a new Main object and calling its show() method.

And then there was text

Now that we have a basic PyQt application up and running, we can start making it look more like a text editor:

  1. def initToolbar(self):
  2.  
  3.   self.toolbar = self.addToolBar("Options")
  4.  
  5.   # Makes the next toolbar appear underneath this one
  6.   self.addToolBarBreak()
  7.  
  8. def initFormatbar(self):
  9.  
  10.   self.formatbar = self.addToolBar("Format")
  11.  
  12. def initMenubar(self):
  13.  
  14.   menubar = self.menuBar()
  15.  
  16.   file = menubar.addMenu("File")
  17.   edit = menubar.addMenu("Edit")
  18.   view = menubar.addMenu("View")
  19.  
  20. def initUI(self):
  21.  
  22.     self.text = QtGui.QTextEdit(self)
  23.     self.setCentralWidget(self.text)
  24.  
  25.     self.initToolbar()
  26.     self.initFormatbar()
  27.     self.initMenubar()
  28.  
  29.     # Initialize a statusbar for the window
  30.     self.statusbar = self.statusBar()
  31.  
  32.     # x and y coordinates on the screen, width, height
  33.     self.setGeometry(100,100,1030,800)
  34.  
  35.     self.setWindowTitle("Writer")

I left out everything that stayed unchanged from the previous code. As you can see in the initUI()function, we first create a QTextEdit object and set it to our window’s “central widget”. This makes the QTextEdit object take up the window’s entire space. Next up, we need to create three more methods: initToolbar()initFormatbar() and initMenubar(). The first two methods create toolbars that will appear at the top of our window and contain our text editor’s features, such as those concerning file management (opening a file, saving a file etc.) or text-formatting. The last method, initMenubar(), creates a set of drop-down menus at the top of the screen.

As of now, the methods only contain the code necessary to make them visible. For the initToolbar() and initFormatbar() methods, this means creating a new toolbar object by calling our window’s addToolBar() method and passing it the name of the toolbar we’re creating. Note that in the initToolbar() method, we need to also call the addToolBarBreak() method. This makes the next toolbar, the format bar, appear underneath this toolbar. In case of the menu bar, we call the window’s menuBar() method and add three menus to it, “File”, “Edit” and “View”. We’ll populate all of these toolbars and menus in a bit.

Lastly, in the initUI() method, we also create a status bar object. This will create a status bar at the bottom of our window.

An icon is worth a thousand words

Before we start injecting some life into our text editor, we’re going to need some icons for its various features. If you had a look at the GitHub repository, you might have noticed that it contains a folder full of icons. I recommend that you download the repo (if you haven’t yet) and copy the icons folder into your working directory. The icons are from iconmonstr, completely free and require no attribution.

File management

Now that we have a basic text editor skeleton in place, we can add some meat to the bone. We’ll start with the functions concerning file management.

__init__():

  1. def __init__(self, parent = None):
  2.     QtGui.QMainWindow.__init__(self,parent)
  3.  
  4.     self.filename = ""
  5.  
  6.     self.initUI()

initToolbar():

  1. def initToolbar(self):
  2.  
  3.   self.newAction = QtGui.QAction(QtGui.QIcon("icons/new.png"),"New",self)
  4.   self.newAction.setStatusTip("Create a new document from scratch.")
  5.   self.newAction.setShortcut("Ctrl+N")
  6.   self.newAction.triggered.connect(self.new)
  7.  
  8.   self.openAction = QtGui.QAction(QtGui.QIcon("icons/open.png"),"Open file",self)
  9.   self.openAction.setStatusTip("Open existing document")
  10.   self.openAction.setShortcut("Ctrl+O")
  11.   self.openAction.triggered.connect(self.open)
  12.  
  13.   self.saveAction = QtGui.QAction(QtGui.QIcon("icons/save.png"),"Save",self)
  14.   self.saveAction.setStatusTip("Save document")
  15.   self.saveAction.setShortcut("Ctrl+S")
  16.   self.saveAction.triggered.connect(self.save)
  17.  
  18.   self.toolbar = self.addToolBar("Options")
  19.  
  20.   self.toolbar.addAction(self.newAction)
  21.   self.toolbar.addAction(self.openAction)
  22.   self.toolbar.addAction(self.saveAction)
  23.  
  24.   self.toolbar.addSeparator()
  25.  
  26.   # Makes the next toolbar appear underneath this one
  27.   self.addToolBarBreak()

initMenubar():

  1. file.addAction(self.newAction)
  2. file.addAction(self.openAction)
  3. file.addAction(self.saveAction)

Below the initUI() method:

  1. def new(self):
  2.  
  3.     spawn = Main(self)
  4.     spawn.show()
  5.  
  6. def open(self):
  7.  
  8.     # Get filename and show only .writer files
  9.     self.filename = QtGui.QFileDialog.getOpenFileName(self, 'Open File',".","(*.writer)")
  10.  
  11.     if self.filename:
  12.         with open(self.filename,"rt") as file:
  13.             self.text.setText(file.read())
  14.  
  15. def save(self):
  16.  
  17.     # Only open dialog if there is no filename yet
  18.     if not self.filename:
  19.         self.filename = QtGui.QFileDialog.getSaveFileName(self, 'Save File')
  20.  
  21.     # Append extension if not there yet
  22.     if not self.filename.endswith(".writer"):
  23.         self.filename += ".writer"
  24.  
  25.     # We just store the contents of the text file along with the
  26.     # format in html, which Qt does in a very nice way for us
  27.     with open(self.filename,"wt") as file:
  28.         file.write(self.text.toHtml())

As you might have noticed, all the actions we’ll be creating for our text editor follow the same code pattern:

  • Create a QAction and pass it an icon and a name
  • Create a status tip, which will display a message in the status bar (and a tool tip if you hover the action)
  • Create a shortcut
  • Connect the QAction’s triggered signal to a slot function

Once you’ve done this for the “new”, “open” and “save” actions, you can add them to the toolbar, using the toolbar’s addAction() method. Make sure you also call the addSeparator() method, which inserts a separator line between toolbar actions. Because these three actions are responsible for file management, we want to add a separator here. Also, we want to add these three actions to the “file” menu, so in the initMenubar() method, we add the three actions to the appropriate menu.

Next up, we need to create the three slot functions that we connected to our actions in the initToolbar() method. The new() method is very easy, all it does is create a new instance of our window and call its show() method to display it.

Before we create the last two methods, let me mention that we’ll use “.writer” as our text files’ extensions. Now, for open(), we need to create PyQt’s getOpenFileName dialog. This opens a file dialog which returns the name of the file the user opens. We also pass this method a title for the file dialog, in this case “Open File”, the directory to open initially, “.” (current directory) and finally a file filter, so that we only show “.writer” files. If the user didn’t close or cancel the file dialog, we open the file and set its text to our editor’s current text.

Lastly, the save() method. We first check whether the current file already has a file name associated with it, either because it was opened with the open() method or already saved before, in the case of a new text file. If this isn’t the case, we open a getSaveFileName dialog, which will again return a filename for us, given the user doesn’t cancel or close the file dialog. Once we have a file name, we need to check whether the user already entered our extension when saving the file. If not, we add the extension. Finally, we save our file in HTML format (which stores style as well), using the QTextEdit’s toHTML() method.

Printing

Next, we’ll create some actions for printing and previewing our document.

initToolbar():

  1. self.printAction = QtGui.QAction(QtGui.QIcon("icons/print.png"),"Print document",self)
  2. self.printAction.setStatusTip("Print document")
  3. self.printAction.setShortcut("Ctrl+P")
  4. self.printAction.triggered.connect(self.print)
  5.  
  6. self.previewAction = QtGui.QAction(QtGui.QIcon("icons/preview.png"),"Page view",self)
  7. self.previewAction.setStatusTip("Preview page before printing")
  8. self.previewAction.setShortcut("Ctrl+Shift+P")
  9. self.previewAction.triggered.connect(self.preview)

Further below:

  1. self.toolbar.addAction(self.printAction)
  2. self.toolbar.addAction(self.previewAction)
  3.  
  4. self.toolbar.addSeparator()

initMenubar():

  1. file.addAction(self.printAction)
  2. file.addAction(self.previewAction)

Below the initUI() method:

  1. def preview(self):
  2.  
  3.     # Open preview dialog
  4.     preview = QtGui.QPrintPreviewDialog()
  5.  
  6.     # If a print is requested, open print dialog
  7.     preview.paintRequested.connect(lambda p: self.text.print_(p))
  8.  
  9.     preview.exec_()
  10.  
  11. def print(self):
  12.  
  13.     # Open printing dialog
  14.     dialog = QtGui.QPrintDialog()
  15.  
  16.     if dialog.exec_() == QtGui.QDialog.Accepted:
  17.         self.text.document().print_(dialog.printer())

We create the actions following the same scheme as we did for the file management actions and add them to our toolbar as well as the “file” menu. The preview() method opens a QPrintPreviewDialog and optionally prints the document, if the user wishes to do so. The print() method opens a QPrintDialog and prints the document if the user accepts.

Copy and paste – undo and redo

These actions will let us copy, cut and paste text as well as undo/redo actions:

initToolbar():

  1. self.cutAction = QtGui.QAction(QtGui.QIcon("icons/cut.png"),"Cut to clipboard",self)
  2. self.cutAction.setStatusTip("Delete and copy text to clipboard")
  3. self.cutAction.setShortcut("Ctrl+X")
  4. self.cutAction.triggered.connect(self.text.cut)
  5.  
  6. self.copyAction = QtGui.QAction(QtGui.QIcon("icons/copy.png"),"Copy to clipboard",self)
  7. self.copyAction.setStatusTip("Copy text to clipboard")
  8. self.copyAction.setShortcut("Ctrl+C")
  9. self.copyAction.triggered.connect(self.text.copy)
  10.  
  11. self.pasteAction = QtGui.QAction(QtGui.QIcon("icons/paste.png"),"Paste from clipboard",self)
  12. self.pasteAction.setStatusTip("Paste text from clipboard")
  13. self.pasteAction.setShortcut("Ctrl+V")
  14. self.pasteAction.triggered.connect(self.text.paste)
  15.  
  16. self.undoAction = QtGui.QAction(QtGui.QIcon("icons/undo.png"),"Undo last action",self)
  17. self.undoAction.setStatusTip("Undo last action")
  18. self.undoAction.setShortcut("Ctrl+Z")
  19. self.undoAction.triggered.connect(self.text.undo)
  20.  
  21. self.redoAction = QtGui.QAction(QtGui.QIcon("icons/redo.png"),"Redo last undone thing",self)
  22. self.redoAction.setStatusTip("Redo last undone thing")
  23. self.redoAction.setShortcut("Ctrl+Y")
  24. self.redoAction.triggered.connect(self.text.redo)

Further below:

  1. self.toolbar.addAction(self.cutAction)
  2. self.toolbar.addAction(self.copyAction)
  3. self.toolbar.addAction(self.pasteAction)
  4. self.toolbar.addAction(self.undoAction)
  5. self.toolbar.addAction(self.redoAction)
  6.  
  7. self.toolbar.addSeparator()

initMenubar():

  1. edit.addAction(self.undoAction)
  2. edit.addAction(self.redoAction)
  3. edit.addAction(self.cutAction)
  4. edit.addAction(self.copyAction)
  5. edit.addAction(self.pasteAction)

As you can see, we don’t need any separate slot functions for these actions, as our QTextEdit object already has very handy methods for all of these actions. Note that in the initMenubar() method, we add these actions to the “Edit” menu and not the “File” menu.

Lists

Finally, we’ll add two actions for inserting lists. One for numbered lists and one for bulleted lists:

initToolbar():

  1. bulletAction = QtGui.QAction(QtGui.QIcon("icons/bullet.png"),"Insert bullet List",self)
  2. bulletAction.setStatusTip("Insert bullet list")
  3. bulletAction.setShortcut("Ctrl+Shift+B")
  4. bulletAction.triggered.connect(self.bulletList)
  5.  
  6. numberedAction = QtGui.QAction(QtGui.QIcon("icons/number.png"),"Insert numbered List",self)
  7. numberedAction.setStatusTip("Insert numbered list")
  8. numberedAction.setShortcut("Ctrl+Shift+L")
  9. numberedAction.triggered.connect(self.numberList)

Further below:

  1. self.toolbar.addAction(bulletAction)
  2. self.toolbar.addAction(numberedAction)

Below the initUI() method:

  1. def bulletList(self):
  2.  
  3.     cursor = self.text.textCursor()
  4.  
  5.     # Insert bulleted list
  6.     cursor.insertList(QtGui.QTextListFormat.ListDisc)
  7.  
  8. def numberList(self):
  9.  
  10.     cursor = self.text.textCursor()
  11.  
  12.     # Insert list with numbers
  13.     cursor.insertList(QtGui.QTextListFormat.ListDecimal)

As you can see, we don’t make these actions class members because we don’t need to access them anywhere else in our class. We only need to create and use them within the scope of initToolbar().

Concerning the slot functions, we retrieve our QTextEdit‘s QTextCursor, which has a lot of very useful methods, such as insertList(), which, well, does what it’s supposed to do. In case of bulletList(), we insert a list with the QTextListFormat set to ListDisc. For numberList(), we insert a list with ListDecimal format.

Final changes

To finish off this part of Building a text editor with PyQT, let’s make some final changes in the initUI() method.

Because PyQt’s tab width is very strange, I recommend that you reset the QTextEdit‘s tab stop width. In my case, 8 spaces is around 33 pixels (this may differ for you):

  1. self.text.setTabStopWidth(33)

Now that we have icons, we can add an icon for our window:

  1. self.setWindowIcon(QtGui.QIcon("icons/icon.png"))

By connecting our QTextEdit‘s cursorPositionChanged signal to a function, we can display the cursor’s current line and column number in the status bar:

  1. self.text.cursorPositionChanged.connect(self.cursorPosition)

Here is the corresponding slot function for the cursorPositionChanged() signal, below initUI():

  1. def cursorPosition(self):
  2.  
  3.     cursor = self.text.textCursor()
  4.  
  5.     # Mortals like 1-indexed things
  6.     line = cursor.blockNumber() + 1
  7.     col = cursor.columnNumber()
  8.  
  9.     self.statusbar.showMessage("Line: {} | Column: {}".format(line,col))

We first retrieve our QTextEdit‘s QTextCursor, then grab the cursor’s column and block/line number and finally display these numbers in the status bar.

That’ll be it for the first part of the series. Subscribe below to receive updates on new tutorials. See you next week for part two, and onward to text formatting!

Read part two here!

Author: Peter Goldsborough