Previous | Tutorial index | Next
filedialog, messagebox).Building a text editor is a classic rite of passage for GUI developers. It consolidates everything you have learned so far:
Text, Label, Menu.pack() with expand and fill.<Control-s>), menu commands.filedialog for open/save, messagebox for confirmations.In this expanded tutorial, we will not only build the editor but also add professional features:
When building an editor, you need to track the state of the application. We will add two crucial instance variables to our TextEditor class:
| Variable | Purpose |
|---|---|
self.current_file_path |
Stores the full path of the opened file, or None if it is a new unsaved document. |
self.is_modified |
Boolean flag that is True if the text has been changed since the last save. |
We will also add a Status Bar at the bottom to display the current line and column of the text cursor, and a title indicator (*) when the file is modified.
Text WidgetBefore we code, let's understand the Text widget’s indexing system.
"1.0" – Line 1, Column 0 (the very first character)."end" – The position just after the last character."{line}.{column}" – e.g., "5.12" is line 5, column 12."insert" – The current cursor position."sel.first" and "sel.last" – The start and end of the currently selected text..get(start, end) – retrieve text..insert(index, string) – add text..delete(start, end) – remove text..event_generate(virtual_event) – triggers built-in virtual events (like <<Cut>>, <<Copy>>, <<Paste>>)..index(index) – converts an index to a canonical form (useful for status bar)..see(index) – scrolls the view to make the given index visible.wrap="word" – wraps text at word boundaries.undo=True – enables built‑in undo/redo (Ctrl+Z / Ctrl+Y).__init__)class TextEditor:
def __init__(self, root):
self.root = root
self.root.title("Untitled - Simple Editor")
self.root.geometry("800x600")
# State variables
self.current_file_path = None
self.is_modified = False
# Main Text Area
self.text_area = tk.Text(root, wrap="word", undo=True, font=("Consolas", 12))
self.text_area.pack(expand=True, fill="both")
self.text_area.focus_set()
# Status Bar
self.status_bar = tk.Label(root, text="Line: 1 | Col: 1", anchor="w", relief="sunken")
self.status_bar.pack(side=tk.BOTTOM, fill=tk.X)
# Events for modified flag and status bar
self.text_area.bind("<KeyRelease>", self.on_text_change)
self.text_area.bind("<ButtonRelease-1>", self.update_status_bar)
self.text_area.bind("<KeyRelease>", self.update_status_bar)
# Build Menu
self.create_menus()
# Bind keyboard shortcuts
self.bind_shortcuts()
# Protocol for closing window (check unsaved changes)
self.root.protocol("WM_DELETE_WINDOW", self.on_closing)
We will use a separate method for clarity.
def create_menus(self):
self.menu_bar = tk.Menu(self.root)
self.root.config(menu=self.menu_bar)
# ---------- File Menu ----------
file_menu = tk.Menu(self.menu_bar, tearoff=0)
self.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="Exit", command=self.on_closing)
# ---------- Edit Menu ----------
edit_menu = tk.Menu(self.menu_bar, tearoff=0)
self.menu_bar.add_cascade(label="Edit", menu=edit_menu)
edit_menu.add_command(label="Undo", command=self.text_area.edit_undo, accelerator="Ctrl+Z")
edit_menu.add_command(label="Redo", command=self.text_area.edit_redo, accelerator="Ctrl+Y")
edit_menu.add_separator()
edit_menu.add_command(label="Cut", command=self.cut_text, accelerator="Ctrl+X")
edit_menu.add_command(label="Copy", command=self.copy_text, accelerator="Ctrl+C")
edit_menu.add_command(label="Paste", command=self.paste_text, accelerator="Ctrl+V")
edit_menu.add_separator()
edit_menu.add_command(label="Select All", command=self.select_all, accelerator="Ctrl+A")
def bind_shortcuts(self):
self.root.bind("<Control-n>", lambda e: self.new_file())
self.root.bind("<Control-o>", lambda e: self.open_file())
self.root.bind("<Control-s>", lambda e: self.save_file())
self.root.bind("<Control-Shift-S>", lambda e: self.save_as_file())
self.root.bind("<Control-a>", lambda e: self.select_all())
# Undo/Redo are built into the Text widget, but we bind them just in case
self.root.bind("<Control-z>", lambda e: self.text_area.edit_undo())
self.root.bind("<Control-y>", lambda e: self.text_area.edit_redo())
We need to update the status bar whenever the cursor moves or text changes.
def update_status_bar(self, event=None):
"""Update the status bar with line and column numbers."""
try:
line, col = self.text_area.index("insert").split(".")
self.status_bar.config(text=f"Line: {line} | Col: {int(col) + 1}") # Column is 0-indexed in Tkinter
except:
pass
We bind <KeyRelease> to mark the document as modified. We also update the window title.
def on_text_change(self, event=None):
"""Set the modified flag to True and update the title."""
if not self.is_modified:
self.is_modified = True
self.update_title()
def update_title(self):
"""Update the window title with file name and modification indicator."""
base = "Untitled"
if self.current_file_path:
base = os.path.basename(self.current_file_path)
mod = "* " if self.is_modified else ""
self.root.title(f"{mod}{base} - Simple Editor")
new_file()Checks for unsaved changes before clearing the text area.
def new_file(self):
"""Create a new file. Prompt to save if current document is modified."""
if self.is_modified:
response = messagebox.askyesnocancel("New File", "Do you want to save the current file?")
if response is None: # Cancel clicked
return
if response: # Yes clicked
self.save_file()
if self.is_modified: # If save was cancelled or failed, abort
return
self.text_area.delete(1.0, tk.END)
self.current_file_path = None
self.is_modified = False
self.update_title()
self.text_area.focus_set()
open_file()def open_file(self):
if self.is_modified:
response = messagebox.askyesnocancel("Open File", "Do you want to save the current file?")
if response is None:
return
if response:
self.save_file()
if self.is_modified:
return
file_path = filedialog.askopenfilename(
defaultextension=".txt",
filetypes=[("Text files", "*.txt"), ("Python files", "*.py"), ("All files", "*.*")]
)
if file_path:
try:
with open(file_path, "r", encoding="utf-8") as f:
content = f.read()
self.text_area.delete(1.0, tk.END)
self.text_area.insert(1.0, content)
self.current_file_path = file_path
self.is_modified = False
self.update_title()
self.text_area.edit_reset() # Reset undo history after loading
except Exception as e:
messagebox.showerror("Error", f"Could not open file:\n{e}")
save_file()If there is no current path, call save_as_file(). Otherwise, write directly.
def save_file(self):
if self.current_file_path is None:
return self.save_as_file()
try:
with open(self.current_file_path, "w", encoding="utf-8") as f:
f.write(self.text_area.get(1.0, tk.END).rstrip("\n"))
self.is_modified = False
self.update_title()
return True
except Exception as e:
messagebox.showerror("Error", f"Could not save file:\n{e}")
return False
save_as_file()def save_as_file(self):
file_path = filedialog.asksaveasfilename(
defaultextension=".txt",
filetypes=[("Text files", "*.txt"), ("Python files", "*.py"), ("All files", "*.*")]
)
if file_path:
self.current_file_path = file_path
return self.save_file()
return False
You have two options: use event_generate (simpler) or manually manipulate the clipboard.
Method 1 (Manual – More Explicit):
def cut_text(self):
try:
selected = self.text_area.get("sel.first", "sel.last")
if selected:
self.root.clipboard_clear()
self.root.clipboard_append(selected)
self.text_area.delete("sel.first", "sel.last")
except tk.TclError: # No selection
pass
def copy_text(self):
try:
selected = self.text_area.get("sel.first", "sel.last")
if selected:
self.root.clipboard_clear()
self.root.clipboard_append(selected)
except tk.TclError:
pass
def paste_text(self):
try:
clipboard_text = self.root.clipboard_get()
self.text_area.insert("insert", clipboard_text)
except tk.TclError:
pass
Method 2 (using event_generate – Relies on system bindings):
def cut_text(self):
self.text_area.event_generate("<<Cut>>")
We will use the manual method in our final code for maximum cross‑platform reliability and educational clarity.
select_all()def select_all(self):
self.text_area.tag_add("sel", 1.0, tk.END)
def on_closing(self):
if self.is_modified:
response = messagebox.askyesnocancel("Save Changes", "Do you want to save the current file?")
if response is None:
return
if response:
if not self.save_file():
return # Save cancelled or failed
self.root.destroy()
To make editing easier, we add a right‑click context menu:
def create_context_menu(self):
self.context_menu = tk.Menu(self.root, tearoff=0)
self.context_menu.add_command(label="Cut", command=self.cut_text)
self.context_menu.add_command(label="Copy", command=self.copy_text)
self.context_menu.add_command(label="Paste", command=self.paste_text)
self.context_menu.add_separator()
self.context_menu.add_command(label="Select All", command=self.select_all)
def show_context(event):
self.context_menu.post(event.x_root, event.y_root)
self.text_area.bind("<Button-3>", show_context) # Right-click on Windows/Linux
self.text_area.bind("<Control-Button-1>", show_context) # Right-click equivalent on macOS
import tkinter as tk
from tkinter import filedialog, messagebox
import os
class TextEditor:
def __init__(self, root):
self.root = root
self.root.title("Untitled - Simple Editor")
self.root.geometry("800x600")
# State
self.current_file_path = None
self.is_modified = False
# Text Area
self.text_area = tk.Text(root, wrap="word", undo=True, font=("Consolas", 12))
self.text_area.pack(expand=True, fill="both")
self.text_area.focus_set()
# Status Bar
self.status_bar = tk.Label(root, text="Line: 1 | Col: 1", anchor="w", relief="sunken")
self.status_bar.pack(side=tk.BOTTOM, fill=tk.X)
# Events
self.text_area.bind("<KeyRelease>", self.on_text_change)
self.text_area.bind("<KeyRelease>", self.update_status_bar)
self.text_area.bind("<ButtonRelease-1>", self.update_status_bar)
# Menus
self.create_menus()
self.create_context_menu()
# Shortcuts
self.bind_shortcuts()
# Closing protocol
self.root.protocol("WM_DELETE_WINDOW", self.on_closing)
def update_title(self):
base = "Untitled"
if self.current_file_path:
base = os.path.basename(self.current_file_path)
mod = "* " if self.is_modified else ""
self.root.title(f"{mod}{base} - Simple Editor")
def update_status_bar(self, event=None):
try:
line, col = self.text_area.index("insert").split(".")
self.status_bar.config(text=f"Line: {line} | Col: {int(col) + 1}")
except:
pass
def on_text_change(self, event=None):
if not self.is_modified:
self.is_modified = True
self.update_title()
def create_menus(self):
menubar = tk.Menu(self.root)
self.root.config(menu=menubar)
# File
file_menu = tk.Menu(menubar, tearoff=0)
menubar.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="Exit", command=self.on_closing)
# Edit
edit_menu = tk.Menu(menubar, tearoff=0)
menubar.add_cascade(label="Edit", menu=edit_menu)
edit_menu.add_command(label="Undo", command=self.text_area.edit_undo, accelerator="Ctrl+Z")
edit_menu.add_command(label="Redo", command=self.text_area.edit_redo, accelerator="Ctrl+Y")
edit_menu.add_separator()
edit_menu.add_command(label="Cut", command=self.cut_text, accelerator="Ctrl+X")
edit_menu.add_command(label="Copy", command=self.copy_text, accelerator="Ctrl+C")
edit_menu.add_command(label="Paste", command=self.paste_text, accelerator="Ctrl+V")
edit_menu.add_separator()
edit_menu.add_command(label="Select All", command=self.select_all, accelerator="Ctrl+A")
def create_context_menu(self):
self.context_menu = tk.Menu(self.root, tearoff=0)
self.context_menu.add_command(label="Cut", command=self.cut_text)
self.context_menu.add_command(label="Copy", command=self.copy_text)
self.context_menu.add_command(label="Paste", command=self.paste_text)
self.context_menu.add_separator()
self.context_menu.add_command(label="Select All", command=self.select_all)
def show_context(event):
self.context_menu.post(event.x_root, event.y_root)
self.text_area.bind("<Button-3>", show_context)
self.text_area.bind("<Control-Button-1>", show_context) # macOS
def bind_shortcuts(self):
self.root.bind("<Control-n>", lambda e: self.new_file())
self.root.bind("<Control-o>", lambda e: self.open_file())
self.root.bind("<Control-s>", lambda e: self.save_file())
self.root.bind("<Control-Shift-S>", lambda e: self.save_as_file())
self.root.bind("<Control-a>", lambda e: self.select_all())
def new_file(self):
if self.is_modified:
response = messagebox.askyesnocancel("New File", "Do you want to save the current file?")
if response is None:
return
if response:
if not self.save_file():
return
self.text_area.delete(1.0, tk.END)
self.current_file_path = None
self.is_modified = False
self.update_title()
self.text_area.focus_set()
def open_file(self):
if self.is_modified:
response = messagebox.askyesnocancel("Open File", "Do you want to save the current file?")
if response is None:
return
if response:
if not self.save_file():
return
file_path = filedialog.askopenfilename(
defaultextension=".txt",
filetypes=[("Text files", "*.txt"), ("Python files", "*.py"), ("All files", "*.*")]
)
if file_path:
try:
with open(file_path, "r", encoding="utf-8") as f:
content = f.read()
self.text_area.delete(1.0, tk.END)
self.text_area.insert(1.0, content)
self.current_file_path = file_path
self.is_modified = False
self.update_title()
self.text_area.edit_reset()
except Exception as e:
messagebox.showerror("Error", f"Could not open file:\n{e}")
def save_file(self):
if self.current_file_path is None:
return self.save_as_file()
try:
with open(self.current_file_path, "w", encoding="utf-8") as f:
f.write(self.text_area.get(1.0, tk.END).rstrip("\n"))
self.is_modified = False
self.update_title()
return True
except Exception as e:
messagebox.showerror("Error", f"Could not save file:\n{e}")
return False
def save_as_file(self):
file_path = filedialog.asksaveasfilename(
defaultextension=".txt",
filetypes=[("Text files", "*.txt"), ("Python files", "*.py"), ("All files", "*.*")]
)
if file_path:
self.current_file_path = file_path
return self.save_file()
return False
# Clipboard Operations (Manual)
def cut_text(self):
try:
selected = self.text_area.get("sel.first", "sel.last")
if selected:
self.root.clipboard_clear()
self.root.clipboard_append(selected)
self.text_area.delete("sel.first", "sel.last")
except tk.TclError:
pass
def copy_text(self):
try:
selected = self.text_area.get("sel.first", "sel.last")
if selected:
self.root.clipboard_clear()
self.root.clipboard_append(selected)
except tk.TclError:
pass
def paste_text(self):
try:
clipboard_text = self.root.clipboard_get()
self.text_area.insert("insert", clipboard_text)
except tk.TclError:
pass
def select_all(self):
self.text_area.tag_add("sel", 1.0, tk.END)
self.text_area.focus_set()
def on_closing(self):
if self.is_modified:
response = messagebox.askyesnocancel("Save Changes", "Do you want to save the current file?")
if response is None:
return
if response:
if not self.save_file():
return
self.root.destroy()
if __name__ == "__main__":
root = tk.Tk()
app = TextEditor(root)
root.mainloop()
Text widget index represents the very first character in the document?
"0.0""1.0""start""0,0"accelerator option in a menu command do?
root.clipboard_set()root.clipboard_clear() and root.clipboard_append()root.clipboard_copy()root.set_clipboard()self.text_area.edit_reset() after loading a file?
Text widget?
<Key><KeyRelease><ButtonPress><Enter>Text widget's undo option, when True, automatically provides Undo/Redo functionality without any extra code.filedialog.askopenfilename() function automatically reads the file content for you.messagebox.askyesnocancel() returns True for "Yes", False for "No", and None for "Cancel".KeyError.protocol("WM_DELETE_WINDOW", callback) method allows you to override the window close button behavior.self.text_area.index("________").filedialog.____________().self.root.________("New Title").self.root.____________().<________-3>.Add a menu item under "Edit" called "Word Count" that opens a messagebox.showinfo showing the number of characters, words, and lines in the document.
Hint: Use len(text_area.get(1.0, tk.END).strip().split()) for words.
Add a new menu "Format" with a submenu "Font Size" (e.g., 10, 12, 14, 16, 18). When a size is selected, change the font of the entire Text widget using .config(font=("Consolas", size)).
Implement a "Find" dialog that opens a new Toplevel window with an Entry to type the search term and a "Find Next" button that highlights the next occurrence in the text.
Add a line number display on the left side of the Text widget. Hint: Use a separate Text or Canvas widget adjacent to the main text area, using pack(side=tk.LEFT).
Extend the text editor to include robust search, line numbering, and persistence of user preferences (font size, window geometry).
Build a search/replace dialogue using a Toplevel window. The dialog should have:
Entry for "Find what".Entry for "Replace with".tag_add with a custom tag like "found").Hint: Use self.text_area.search(pattern, start_index, stopindex=tk.END) for searching.
Save the user's preferences (window size, last opened file directory, font size) to a JSON file (settings.json) when the application exits. Load them when the application starts.
geometry (e.g., "800x600"), font_size (e.g., 12), last_dir (string).os.path.expanduser("~") to store the file in the user's home directory.The following code snippet is supposed to save a file, but it contains two logical bugs. Identify them and explain why they are problematic. Provide the corrected code.
def save_file(self):
if self.current_file_path is None:
return self.save_as_file()
try:
with open(self.current_file_path, "w") as f:
f.write(self.text_area.get(1.0, "end"))
self.is_modified = True # Bug 1
except:
print("Error saving") # Bug 2
return False
self.update_title()
return True
Corrected code:
def save_file(self):
if self.current_file_path is None:
return self.save_as_file()
try:
with open(self.current_file_path, "w", encoding="utf-8") as f:
f.write(self.text_area.get(1.0, tk.END).rstrip("\n"))
self.is_modified = False # Fix
self.update_title()
return True
except Exception as e:
messagebox.showerror("Error", f"Could not save file:\n{e}")
return False
event_generate vs Manual Clipboard (5 points)Write a short paragraph (5-7 sentences) explaining the advantages of implementing Cut/Copy/Paste manually (using clipboard_get, clipboard_append, and direct delete/insert) over using text_area.event_generate("<<Cut>>"). Mention platform reliability and control over the operation.
Add a "Recent Files" submenu under the File menu. Keep a list of the last 5 files opened/saved. When clicked, the file should be opened (with the same unsaved changes check). Store this list in the settings.json file for persistence.
| Term | Definition |
|---|---|
| Text Index | A string like "1.0" or "end" used to refer to positions in the Text widget. |
| Virtual Event | A synthetic event like <<Cut>> that invokes built-in functionality. |
| File Dialog | A native window (askopenfilename, asksaveasfilename) for selecting files. |
| Modified Flag | A boolean variable that indicates whether the document has unsaved changes. |
| Protocol Handler | A function attached to WM_DELETE_WINDOW to intercept the window close event. |
| Clipboard | A system buffer for cut/copy/paste operations. |
| Context Menu | A popup menu triggered by right-click (or Ctrl+click on macOS). |
| Undo/Redo Stack | A history of changes, managed automatically by the Text widget when undo=True. |
encoding="utf-8" and handling different file encodings.filedialog and messagebox options for customising icons and buttons.Text tags) – a classic challenge.This tutorial is designed to take approximately 3.5 hours of study, lab work, and homework. Building this text editor will give you the confidence to tackle any medium‑sized GUI application in Tkinter.