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