Building a text editor in Python with Tkinter

Table of contents

While graphical desktop applications are all around us, building GUIs can be an unexpectedly complex task for beginners. To gain abetter understanding of how GUI programming works, we will write a basic text editor in python using the builtin tkinter library, complete with file handling, menus, shortcuts and safety features to prevent data loss.

A basic tkinter application

Every piece of software starts somewhere. For tkinter, that is the creation of the main widget representing the window, and calling it's mainloop() method to run the program until the window is closed:

import tkinter as tk


if __name__ == "__main__":
    window = tk.Tk()
    window.mainloop()

Running this will open a small blank window, ready for our text editor's content.

Creating a class for the editor

While it is perfectly possible to write a tkinter application without using object-oriented features, we chose to use a class here, mainly because it allows us to pass the widgets and state of the text editor between functions easily. State refers to implicit context that the text editor needs to keep in mind, for example if a file is currently open, or if the editor contains unsaved changes.

import tkinter as tk
from tkinter import scrolledtext

class TextEditor:
    def __init__(self, window):
        self.window = window
        self.text_widget = scrolledtext.ScrolledText(window, wrap=tk.WORD, undo=True, maxundo=-1)
        self.text_widget.pack(expand=True, fill='both')

if __name__ == "__main__":
    window = tk.Tk()
    window.mainloop()

We start off by importing the scrolledtext widget from the tkinter module, which gives us a nice editable text area with a scrollbar out of the box. In the __init__ method of the TextEditor class, we first attach the window widget as a class attribute, then create the ScrolledText widget as the main body of our editor. When first defining the widget, we already pass the arguments undo=True to enable the undo/redo functionality included in the ScrolledText widget, and set maxundo=-1 to allow an unlimited undo history. The widget's pack method places the widget within it's parent window, and the arguments define it as consuming any remaining space within, growing & shrinking with the window itself.

Managing state

In the previous part, we talked about state, now we create the foundations for it. We do this by declaring the current_file attribute on the TextEditor class, which may be either a str containing the path to the file currently being edited, or None if we are not working on a file at the moment. The second attribute last_saved_content is a str containing the editor contents when they were last saved, so we can check for unsaved changes. We also create a method to update the current window title to reflect the current state of the text editor. The format we chose for the window title is: Text Editor - {current file path or "untitled"}{an * if we have unsaved changes}. Lastly, we update the window title implicitly in every method that changes the application state, so the title visually reflects the state to the user:

class TextEditor:

    current_file: str|None = None

    last_saved_content: str = ""

    # __init__

    def get_content(self) -> str:
        content = self.text_widget.get(1.0, tk.END)
        if content.endswith('\n'):
            content = content[:len(content)-1]
        if content.endswith('\n'):
            content = content[:len(content)-1]
        return content

    def set_content(self, content: str) -> None:
        self.text_widget.delete(1.0, tk.END)
        self.text_widget.insert(tk.END, content)

    def has_unsaved_changes(self) -> bool:
        return self.last_saved_content != self.get_content()

    def clear_unsaved_changes(self) -> None:
        self.last_saved_content = self.get_content()
        self.update_title()

    def update_title(self) -> None:
        self.window.title(f"Text Editor - {self.current_file or 'Untitled'}{'*' if self.has_unsaved_changes() else ''}")

    def set_current_file(self, file_path: str|None) -> None:
        self.current_file = file_path
        self.update_title()

Note that get_content() strips up to two characters from the end of content before returning, if they are newline characters (\n). This is intentional, because tkinter text widgets implicitly add a newline characters to the content when initializing or inserting content. This may cause one or two newlines to be added to the widget content, without being part of the actual contents. While this makes the editor slightly incorrect in handling empty trailing lines within a file, it is necessary to ensure consistency when checking for unsaved changes.

Tracking changes made by the user

While the text editor now tracks changes it made itself, such as setting the current_file or clearing unsaved changes, it does not yet update the window title when the user edits the text area. Tkinter allows us to listen for arbitrary events using the bind() method of the ScrolledText widget. In our case, we want to update the window title every time the user releases a button, so we will bind an event handler to the <KeyRelease> event. Doing this may present a slight issue: An event callback is passed the event that caused it to be called, but our update_title() method doesn't take any arguments. We could modify it to take an optional event, but it doesn't need or use it, so we instead wrap the callback in a lambda that takes the event instead:

class TextEditor:

    # ...

    def __init__(self, window):

        # ...

        self.set_current_file(None)
        self.text_widget.bind("<KeyRelease>", lambda event: self.update_title())

    # ...

You may notice that we also called set_current_file(None) from __init__() here. This has two reasons: first, we want to set the current editor content as the last saved version (since our empty editor does not need to be saved, nothing is lost if it isn't). Secondly, it will initially update the window title, so we don't have to call that method manually.

Adding common editor shortcuts

When using a text editor, we automatically expect some functionality to be present. Among those are the common shortcuts to cut, copy, paste, undo, redo and select all text. We can bind callbacks to those keyboard shortcuts the same way we listened to keys being released previously:

class TextEditor:

    # ...

    def __init__(self, window):
        
        # ...

        self.text_widget.bind("<Control-z>", lambda event: self.undo())
        self.text_widget.bind("<Control-y>", lambda event: self.redo())
        self.text_widget.bind("<Control-x>", lambda event: self.cut())
        self.text_widget.bind("<Control-c>", lambda event: self.copy())
        self.text_widget.bind("<Control-v>", lambda event: self.paste())
        self.text_widget.bind("<Control-a>", lambda event: self.select_all())

    # ...

    def undo(self):
        self.text_widget.event_generate("<<Undo>>")
        return "break"

    def redo(self):
        self.text_widget.event_generate("<<Redo>>")
        return "break"

    def cut(self):
        self.text_widget.event_generate("<<Cut>>")
        return "break"

    def copy(self):
        self.text_widget.event_generate("<<Copy>>")
        return "break"

    def paste(self):
        self.text_widget.event_generate("<<Paste>>")
        return "break"

    def select_all(self):
        self.text_widget.tag_add(tk.SEL, 1.0, tk.END)
        return "break"

Almost all callbacks use the builtin features or the ScrolledText widget to carry out the actual task in response to the event. The method undo() for example, simply generates the <<Undo>> virtual event on the ScrolledText widget, which has a builtin handler for this purpose. The only exception to this is the select_all() method, which does not have a corresponding virtual event available. For this one, we instead use tag_add() to "tag" the text section from start to end as "selected" using the tk.SEL tag.

Open and close files

To be a real text editor, our program must be able to deal with files. We will start by adding features to create a new (empty file), overriding the current contents (if any), open an existing file and close the current file. We will need to import the filedialog component from tkinter to give the user a visual dialog window to choose files. As an added requirement, we also want to ensure we are warned if there are unsaved changes that would be lost if we created a new file or closed the current one. For this confirmation dialog and information popups, we need to import the messagebox component from the tkinter module.

import tkinter as tk
from tkinter import scrolledtext, messagebox, filedialog


class TextEditor:

    # ...

    def __init__(self, window):
        
        # ...

        self.text_widget.bind("<Control-n>", lambda event: self.new_file())
        self.text_widget.bind("<Control-o>", lambda event: self.open_file())
        self.text_widget.bind("<Control-w>", lambda event: self.close_file())

    def new_file(self):
        if self.has_unsaved_changes():
            result = messagebox.askyesnocancel("Unsaved Changes", "Changes to the current file will be lost if you create a new file. Create new file anyway?")
            if result is not True:
                return  # user cancelled or pressed no
        self.set_content("")
        self.set_current_file(None)


    def open_file(self):
        if self.has_unsaved_changes():
            result = messagebox.askyesnocancel("Unsaved Changes", "Changes to the current file will be lost if you open a new file. Open new file anyway?")
            if result is not True:
                return  # user cancelled or pressed no
        file_path = filedialog.askopenfilename(defaultextension=".txt", filetypes=[("Text files", "*.txt"), ("All files", "*.*")])
        if file_path:
            with open(file_path, "r") as file:
                content = file.read()
                self.set_content(content)
                self.set_current_file(file_path)


    def close_file(self):
        if self.has_unsaved_changes():
            result = messagebox.askyesnocancel("Unsaved Changes", "Changes to the current file will be lost if you close it. Close anyway?")
            if result is not True:
                return  # user cancelled or pressed noif self.current_file is None:
            self.window.destroy()
        else:
            self.set_content("")
            self.set_current_file(None)



if __name__ == "__main__":
    window = tk.Tk()
    TextEditor(window)
    window.mainloop()

Our new shortcuts allow us to open new or existing files, and close them when we are done. They also ensure we don't accidentally close or override text we have not yet saved.

Saving files

To complete the core functionality of our editor, we need the ability to save editor contents to a file. This should behave differently, depending on context: If we did not previously open a file, we need a dialog that let's the user select a target file to save into. This can be achieved using the same filedioalog component we used to open a file previously. But if we already opened a file (or saved to one), we should be able to save without needing to specify the same file again.

This should be set up as two distinct shortcuts, where ctrl+shift+s would always call the "save as" dialog, and ctrl+s would try to reuse a previously opened/saved file and fall back to "save as" if none can be found:

import tkinter as tk
from tkinter import scrolledtext, messagebox, filedialog


class TextEditor:

    # ...

    def __init__(self, window):

        # ...

        self.text_widget.bind("<Control-s>", lambda event: self.save_file())
        self.text_widget.bind("<Control-S>", lambda event: self.save_as_file())

    # ...
    
    def save_file(self):
        if self.current_file is None:
            self.save_as_file()
        else:
            with open(self.current_file, "w") as file:
                content = self.text_widget.get(1.0, tk.END)
                file.write(content)
            self.clear_unsaved_changes()
            messagebox.showinfo("Saved", "File saved successfully.")

    def save_as_file(self):
        file_path = filedialog.asksaveasfilename(defaultextension=".txt", filetypes=[("Text files", "*.txt"), ("All files", "*.*")])
        if file_path:
            with open(file_path, "w") as file:
                content = self.text_widget.get(1.0, tk.END)
                file.write(content)
            self.set_current_file(file_path)
            messagebox.showinfo("Saved", "File saved successfully.")

if __name__ == "__main__":
    window = tk.Tk()
    TextEditor(window)
    window.mainloop()

Note that the shortcuts we used in the bind() method look almost identical. Shortcuts in tkinter are case-sensitive, so the uppercase S in <Control-S> will not be triggered when pressing ctrl+s, but only when using shift as well, so ctrl+shift+s.

Adding menus

Now that we have all the functionality we want from a basic text editor, we are still missing one last piece. How does the user know what features our text editor supports? The common way to communicate this within a GUI application is a simple menu bar, containing categories of functions that can be used. Tkinter includes a menu component that can be used to quickly create such menus:

# ...

class TextEditor:

    # ...

    def __init__(self, window):

        # ...

        menu_bar = tk.Menu(window)
        window.config(menu=menu_bar)

        file_menu = tk.Menu(menu_bar, tearoff=0)
        menu_bar.add_cascade(label="File", menu=file_menu)
        file_menu.add_command(label="New", command=self.new_file, accelerator="Ctrl+N")
        file_menu.add_command(label="Open", command=self.open_file, accelerator="Ctrl+O")
        file_menu.add_command(label="Save", command=self.save_file, accelerator="Ctrl+S")
        file_menu.add_command(label="Save As", command=self.save_as_file, accelerator="Ctrl+Shift+S")
        file_menu.add_separator()
        file_menu.add_command(label="Close", command=self.close_file, accelerator="Ctrl+W")

        edit_menu = tk.Menu(menu_bar, tearoff=0)
        menu_bar.add_cascade(label="Edit", menu=edit_menu)
        edit_menu.add_command(label="Undo", command=self.undo, accelerator="Ctrl+Z")
        edit_menu.add_command(label="Redo", command=self.redo, accelerator="Ctrl+Y")
        edit_menu.add_separator()
        edit_menu.add_command(label="Cut", command=self.cut, accelerator="Ctrl+X")
        edit_menu.add_command(label="Copy", command=self.copy, accelerator="Ctrl+C")
        edit_menu.add_command(label="Paste", command=self.paste, accelerator="Ctrl+V")
        edit_menu.add_separator()
        edit_menu.add_command(label="Select All", command=self.select_all, accelerator="Ctrl+A")

    # ...

Menus are composed of commands and separators, allowing visual separation between logically grouped commands within a category. Each command must have a label with a human-readable title and specify a callback function as it's command argument. We chose to include the optional accelerator argument, to quickly hint to the user that this feature can also be used with a keyboard shortcut.




And just like that, we have built a fully-featured text editor in python! Building graphical desktop applications with tkinter is simple and productive, allowing you to focus on business logic rather than managing complex UI libraries. Tkinter has many more features that we didn't even touch on, such as more advanced layout managers, themes and widget styling, and even custom widgets.

If you are interested in the final source code of the editor, this is the complete script:

import tkinter as tk
from tkinter import scrolledtext, messagebox, filedialog


class TextEditor:

    current_file: str|None = None

    last_saved_content: str = ""

    def __init__(self, window):
        self.window = window
        self.text_widget = scrolledtext.ScrolledText(window, wrap=tk.WORD, undo=True, maxundo=-1)
        self.text_widget.pack(expand=True, fill='both')

        self.set_current_file(None)
        self.text_widget.bind("<KeyRelease>", lambda event: self.update_title())

        self.text_widget.bind("<Control-z>", lambda event: self.undo())
        self.text_widget.bind("<Control-y>", lambda event: self.redo())
        self.text_widget.bind("<Control-x>", lambda event: self.cut())
        self.text_widget.bind("<Control-c>", lambda event: self.copy())
        self.text_widget.bind("<Control-v>", lambda event: self.paste())
        self.text_widget.bind("<Control-a>", lambda event: self.select_all())
        self.text_widget.bind("<Control-n>", lambda event: self.new_file())
        self.text_widget.bind("<Control-o>", lambda event: self.open_file())
        self.text_widget.bind("<Control-w>", lambda event: self.close_file())
        self.text_widget.bind("<Control-s>", lambda event: self.save_file())
        self.text_widget.bind("<Control-S>", lambda event: self.save_as_file())

        menu_bar = tk.Menu(window)
        window.config(menu=menu_bar)

        file_menu = tk.Menu(menu_bar, tearoff=0)
        menu_bar.add_cascade(label="File", menu=file_menu)
        file_menu.add_command(label="New", command=self.new_file, accelerator="Ctrl+N")
        file_menu.add_command(label="Open", command=self.open_file, accelerator="Ctrl+O")
        file_menu.add_command(label="Save", command=self.save_file, accelerator="Ctrl+S")
        file_menu.add_command(label="Save As", command=self.save_as_file, accelerator="Ctrl+Shift+S")
        file_menu.add_separator()
        file_menu.add_command(label="Close", command=self.close_file, accelerator="Ctrl+W")

        edit_menu = tk.Menu(menu_bar, tearoff=0)
        menu_bar.add_cascade(label="Edit", menu=edit_menu)
        edit_menu.add_command(label="Undo", command=self.undo, accelerator="Ctrl+Z")
        edit_menu.add_command(label="Redo", command=self.redo, accelerator="Ctrl+Y")
        edit_menu.add_separator()
        edit_menu.add_command(label="Cut", command=self.cut, accelerator="Ctrl+X")
        edit_menu.add_command(label="Copy", command=self.copy, accelerator="Ctrl+C")
        edit_menu.add_command(label="Paste", command=self.paste, accelerator="Ctrl+V")
        edit_menu.add_separator()
        edit_menu.add_command(label="Select All", command=self.select_all, accelerator="Ctrl+A")

    def get_content(self) -> str:
        content = self.text_widget.get(1.0, tk.END)
        if content.endswith('\n'):
            content = content[:len(content)-1]
        if content.endswith('\n'):
            content = content[:len(content)-1]
        return content

    def set_content(self, content: str) -> None:
        self.text_widget.delete(1.0, tk.END)
        self.text_widget.insert(tk.END, content)

    def has_unsaved_changes(self) -> bool:
        return self.last_saved_content != self.get_content()

    def clear_unsaved_changes(self) -> None:
        self.last_saved_content = self.get_content()
        self.update_title()

    def update_title(self) -> None:
        self.window.title(f"Text Editor - {self.current_file or 'Untitled'}{'*' if self.has_unsaved_changes() else ''}")

    def set_current_file(self, file_path: str|None) -> None:
        self.current_file = file_path
        self.clear_unsaved_changes()

    def undo(self):
        self.text_widget.event_generate("<<Undo>>")
        return "break"

    def redo(self):
        self.text_widget.event_generate("<<Redo>>")
        return "break"

    def cut(self):
        self.text_widget.event_generate("<<Cut>>")
        return "break"

    def copy(self):
        self.text_widget.event_generate("<<Copy>>")
        return "break"

    def paste(self):
        self.text_widget.event_generate("<<Paste>>")
        return "break"

    def select_all(self):
        self.text_widget.tag_add(tk.SEL, 1.0, tk.END)
        return "break"

    def new_file(self):
        if self.has_unsaved_changes():
            result = messagebox.askyesnocancel("Unsaved Changes", "Changes to the current file will be lost if you create a new file. Create new file anyway?")
            if result is not True:
                return  # user cancelled or pressed no
        self.set_content("")
        self.set_current_file(None)

    def open_file(self):
        if self.has_unsaved_changes():
            result = messagebox.askyesnocancel("Unsaved Changes", "Changes to the current file will be lost if you open a new file. Open new file anyway?")
            if result is not True:
                return  # user cancelled or pressed no
        file_path = filedialog.askopenfilename(defaultextension=".txt", filetypes=[("Text files", "*.txt"), ("All files", "*.*")])
        if file_path:
            with open(file_path, "r") as file:
                content = file.read()
                self.set_content(content)
                self.set_current_file(file_path)

    def close_file(self):
        if self.has_unsaved_changes():
            result = messagebox.askyesnocancel("Unsaved Changes", "Changes to the current file will be lost if you close it. Close anyway?")
            if result is not True:
                return  # user cancelled or pressed no
        if self.current_file is None:
            self.window.destroy()
        else:
            self.set_content("")
            self.set_current_file(None)

    def save_file(self):
        if self.current_file is None:
            self.save_as_file()
        else:
            with open(self.current_file, "w") as file:
                content = self.text_widget.get(1.0, tk.END)
                file.write(content)
            self.clear_unsaved_changes()
            messagebox.showinfo("Saved", "File saved successfully.")

    def save_as_file(self):
        file_path = filedialog.asksaveasfilename(defaultextension=".txt", filetypes=[("Text files", "*.txt"), ("All files", "*.*")])
        if file_path:
            with open(file_path, "w") as file:
                content = self.text_widget.get(1.0, tk.END)
                file.write(content)
            self.set_current_file(file_path)
            messagebox.showinfo("Saved", "File saved successfully.")


if __name__ == "__main__":
    window = tk.Tk()
    TextEditor(window)
    window.mainloop()

More articles

Managing virtual environments in python

Managing dependencies in python projects

Generator functions in python

Generating sequences on the fly, one by one

Understanding  Go Closures

When two funcs aren't quite the same

The downsides of source-available software licenses

And how it differs from real open-source licenses

Configure linux debian to boot into a fullscreen application

Running kiosk-mode applications with confidence

How to use ansible with vagrant environments

Painlessly connect vagrant infrastructure and ansible playbooks