Previous | Tutorial index | Next

Tutorial 8: Develop a Complete GUI Application – Simple Text Editor

Learning Objectives

1. Introduction – A Significant Milestone

Building a text editor is a classic rite of passage for GUI developers. It consolidates everything you have learned so far:

In this expanded tutorial, we will not only build the editor but also add professional features:

2. Architecture and State Management

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.

3. Deep Dive: The Text Widget

Before we code, let's understand the Text widget’s indexing system.

3.1 Index Formats

3.2 Key Methods Used

3.3 Important Options

4. Building the Enhanced Text Editor – Step by Step

4.1 The Class Constructor (__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)

4.2 Creating the Menus

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

4.3 Keyboard Shortcuts

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

4.4 The Status Bar Update

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

4.5 Tracking Modifications

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

4.6 File Operations (New, Open, Save, Save As)

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

4.7 Clipboard Operations (Cut, Copy, Paste)

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)

4.8 Closing Protocol – Unsaved Changes Check

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

4.9 Context Menu (Right‑click)

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

5. Complete Final Code

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

6. Check Your Understanding (Quizzes)

Quiz 1: Multiple Choice

  1. Which Text widget index represents the very first character in the document?
AnswerB – `"1.0"` is line 1, column 0.
  1. What does the accelerator option in a menu command do?
AnswerB – It only shows the shortcut; you must bind it separately (though some toolkits do auto-binding, Tkinter does not).
  1. Which method is used to clear the clipboard and add new text to it?
AnswerB – Clear then append is the standard sequence.
  1. What is the purpose of self.text_area.edit_reset() after loading a file?
AnswerA – It resets the undo/redo stack.
  1. Which event is used to detect when a key is released in the Text widget?
AnswerB – ``.

Quiz 2: True or False

  1. True / False: The Text widget's undo option, when True, automatically provides Undo/Redo functionality without any extra code.
AnswerTrue – The built-in undo/redo stack works.
  1. True / False: The filedialog.askopenfilename() function automatically reads the file content for you.
AnswerFalse – It only returns the file path; you must read it yourself.
  1. True / False: The messagebox.askyesnocancel() returns True for "Yes", False for "No", and None for "Cancel".
AnswerTrue – Yes/No/Cancel maps to True/False/None.
  1. True / False: To catch a clipboard error when no text is copied, you should catch KeyError.
AnswerFalse – Catch `tk.TclError` (or `tkinter.TclError`).
  1. True / False: The protocol("WM_DELETE_WINDOW", callback) method allows you to override the window close button behavior.
AnswerTrue – This is how you intercept the close event.

Quiz 3: Fill in the Blanks

  1. The method to get the current cursor position is self.text_area.index("________").
Answer`"insert"`
  1. The file dialog used for saving a new file for the first time is filedialog.____________().
Answer`asksaveasfilename`
  1. To update the window title with a modification asterisk, we use self.root.________("New Title").
Answer`title`
  1. To retrieve text from the clipboard, we use self.root.____________().
Answer`clipboard_get`
  1. The right‑click context menu on Windows is bound to event <________-3>.
Answer`Button`

7. Hands‑On Lab (In‑Class Exercises)

Exercise 1: Add a "Word Count" Feature

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.

Sample Solution ```python def word_count(self): text = self.text_area.get(1.0, tk.END).strip() chars = len(text) words = len(text.split()) lines = len(text.splitlines()) messagebox.showinfo("Word Count", f"Characters: {chars}\nWords: {words}\nLines: {lines}") # Add to Edit menu: edit_menu.add_command(label="Word Count", command=self.word_count) ```

Exercise 2: Change Font Family and Size

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

Sample Solution ```python def change_font_size(self, size): self.text_area.config(font=("Consolas", size)) # In menu creation: format_menu = tk.Menu(menubar, tearoff=0) menubar.add_cascade(label="Format", menu=format_menu) size_submenu = tk.Menu(format_menu, tearoff=0) format_menu.add_cascade(label="Font Size", menu=size_submenu) for s in [10, 12, 14, 16, 18]: size_submenu.add_command(label=str(s), command=lambda sz=s: self.change_font_size(sz)) ```

Exercise 3: Find and Replace (Dialogue)

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.

Sample Outline - Create a Toplevel with an Entry and a Button. - The button callback uses `self.text_area.search(term, start, stopindex=tk.END)` and if found, uses `self.text_area.tag_add("found", pos, pos+len(term))` and `self.text_area.see(pos)`.

Exercise 4: Line Numbers (Bonus)

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

Sample Outline - Create a `Text` widget for line numbers with width=4, state='disabled'. - Pack it to the left of the main text area. - Bind `` and `` to update the line numbers. - Use `self.text_area.index("end")` to get total lines and insert numbers accordingly.

8. Homework Assignment

Objective

Extend the text editor to include robust search, line numbering, and persistence of user preferences (font size, window geometry).

Part A: Implement "Search and Replace" Dialog (15 points)

Build a search/replace dialogue using a Toplevel window. The dialog should have:

Hint: Use self.text_area.search(pattern, start_index, stopindex=tk.END) for searching.

Sample Solution Outline - Create a Toplevel with grid layout. - Store the current search position. - `find_next`: clear previous tag, use `search` to find next, apply tag "found" to the match, update position. - `replace`: if there is a selection with tag "found", delete and insert replacement, then call find_next. - `replace_all`: loop until no more matches, using a counter and `search` with start index.

Part B: Window Preferences Persistence (10 points)

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.

Sample Solution Outline - In `__init__`, load settings from JSON if exists, apply geometry, font size, and last_dir. - In `on_closing`, before destroying, save current geometry (`root.geometry()`), font size, and last_dir to JSON.

Part C: Code Analysis – Critical Bug (10 points)

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
Answers 1. `self.is_modified = True` – after saving, the document should be unmodified (False). Setting it to True incorrectly marks it as modified. 2. `except:` with only a `print` – the error is not shown to the user; it should use `messagebox.showerror` to inform the user. Also, catching all exceptions is too broad; better to catch specific exceptions like `OSError` or `IOError`.

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

Part D: Reflect on 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.

Sample Answer Using `event_generate` is simpler and relies on the system's built‑in clipboard handling, but it may not work consistently across all platforms or in all Tkinter versions, and it gives you no control over the process. Manual implementation gives you full control—you can validate the selection, modify the clipboard content, and handle errors explicitly. It also works reliably on all platforms because it uses Tkinter's own clipboard functions. Additionally, manual operations allow you to add custom behaviour, such as logging or transforming the text before pasting. For a professional application, manual clipboard handling is safer and more predictable.

Part E: Add a "Recent Files" List (5 points)

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.

Sample Solution Outline - In settings JSON, store a list `recent_files`. - In `open_file` and `save_as_file`, add the file path to the recent list (if not already present, and limit to 5). - Create a menu item "Recent Files" that dynamically builds a submenu with the list, calling `open_file_path(path)` for each. - Update the menu whenever the recent list changes.

9. Summary of Key Terms (Glossary)

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.

10. Further Resources for Self‑Study

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.

Previous | Tutorial index | Next