Skip to content

suggestion to add an undo/redo system to widgets #701

@antrrax

Description

@antrrax

Is your feature request related to a problem? Please describe.

It would be interesting to have a global system linked to ttkbootstrap to control undo / redo when necessary. The suggested code was created by IA, but it is functional. I don't know if it needs any kind of correction or modification, so I'm posting it here and not as a pr

Describe the solution you'd like

The code below works with Entry, Spinbox, DateEntry, Text, tk ScrolledText and ttk ScrolledText, I don't know if there is any other widget that requires undo/redo:

undo_redo_manager.py

import tkinter as tk
from typing import List, Tuple

import ttkbootstrap as ttk
from ttkbootstrap.scrolled import ScrolledText
from ttkbootstrap.widgets import DateEntry


class UndoRedoManager:
    def __init__(self, widget, max_history: int = 20):
        """Initialize the undo/redo manager for a widget."""
        self.widget = widget
        self.max_history = max_history
        self.history: List[Tuple[str, int]] = []  # Stores (content, cursor_position)
        self.current_index = -1  # Current position in history
        self.is_undoing = False  # Flag to prevent recursion during undo/redo
        self._save_pending = False  # Flag for scheduled saves
        self._last_content = ""  # Used to detect actual changes

        self._save_state()
        self._bind_events()

    def _get_inner_widget(self):
        """Get the actual inner widget for different widget types."""
        if isinstance(self.widget, DateEntry):
            return self.widget.entry
        elif isinstance(self.widget, ScrolledText):
            return self.widget.text
        return self.widget

    def _get_content(self) -> str:
        """Get the current content of the widget."""
        w = self._get_inner_widget()
        try:
            if isinstance(w, (ttk.Entry, ttk.Spinbox, tk.Entry)):
                return w.get()
            elif isinstance(w, tk.Text):
                return w.get("1.0", "end-1c")
        except tk.TclError:
            pass
        return ""

    def _set_content(self, content: str):
        """Set the content of the widget."""
        w = self._get_inner_widget()
        try:
            if isinstance(w, (ttk.Entry, ttk.Spinbox, tk.Entry)):
                w.delete(0, "end")
                w.insert(0, content)
            elif isinstance(w, tk.Text):
                w.delete("1.0", "end")
                w.insert("1.0", content)
        except tk.TclError:
            pass

    def _get_cursor_pos(self) -> int:
        """Get the current cursor position."""
        w = self._get_inner_widget()
        try:
            if isinstance(w, (ttk.Entry, ttk.Spinbox, tk.Entry)):
                return w.index("insert")
            elif isinstance(w, tk.Text):
                return len(w.get("1.0", "insert"))
        except tk.TclError:
            pass
        return 0

    def _set_cursor_pos(self, pos: int):
        """Set the cursor position."""
        w = self._get_inner_widget()
        content = self._get_content()
        pos = max(0, min(pos, len(content)))

        try:
            if isinstance(w, (ttk.Entry, ttk.Spinbox, tk.Entry)):
                w.icursor(pos)
            elif isinstance(w, tk.Text):
                lines = content[:pos].split("\n")
                if lines:
                    line_num = len(lines)
                    col_num = len(lines[-1]) if lines else 0
                    index = f"{line_num}.{col_num}"
                    w.mark_set("insert", index)
        except tk.TclError:
            pass

    def _save_state(self):
        """Save the current state to history."""
        if self.is_undoing or self._save_pending:
            return

        content = self._get_content()
        cursor_pos = self._get_cursor_pos()

        # Don't save if content hasn't changed
        if (
            self.history
            and self.current_index >= 0
            and self.history[self.current_index][0] == content
        ):
            return

        # Truncate future history if we're in the middle
        self.history = self.history[: self.current_index + 1]
        self.history.append((content, cursor_pos))
        self.current_index = len(self.history) - 1

        # Keep only max history
        if len(self.history) > self.max_history:
            self.history = self.history[-self.max_history :]
            self.current_index = len(self.history) - 1

        # Update last known content
        self._last_content = content

    def _bind_events(self):
        """Bind events to widgets."""
        w = self._get_inner_widget()

        # Common events
        common_events = {
            "<KeyRelease>": self._on_change,
            "<ButtonRelease>": self._on_change,
            "<Control-v>": self._on_paste,
            "<Control-V>": self._on_paste,
            "<Control-z>": self._undo,
            "<Control-y>": self._redo,
            "<Control-Z>": self._undo,
            "<Control-Y>": self._redo,
        }

        for event, callback in common_events.items():
            # Use add=True to not overwrite existing bindings
            w.bind(event, callback, add=True)

        # Capture paste events (including right-click)
        paste_events = ["<<Paste>>", "<<Selection>>"]
        for event in paste_events:
            try:
                w.bind(event, self._on_paste_event, add=True)
            except tk.TclError:
                pass

        # Spinbox-specific events
        if isinstance(self.widget, ttk.Spinbox):
            for evt in ("<MouseWheel>", "<Button-4>", "<Button-5>"):
                self.widget.bind(evt, self._on_mousewheel, add=True)

        # DateEntry-specific events
        if isinstance(self.widget, DateEntry):
            self.widget.bind(
                "<<DateEntrySelected>>",
                lambda e: self.widget.after(10, self._save_state),
                add=True,
            )
            self.widget.bind("<FocusIn>", lambda e: self._save_state(), add=True)

        # Periodically monitor changes to capture right-click paste
        self._monitor_changes()

    def _monitor_changes(self):
        """Monitor content changes periodically."""
        if not self.is_undoing:
            current_content = self._get_content()
            if current_content != self._last_content:
                self._save_state()
                self._last_content = current_content

        # Schedule next check
        self.widget.after(100, self._monitor_changes)

    def _on_paste_event(self, event=None):
        """Handler for automatic paste events."""
        # Schedule save after paste is processed
        self.widget.after(10, self._save_state)
        return None

    def _on_paste(self, event=None):
        """Handler for paste events via Ctrl+V."""
        w = self._get_inner_widget()

        try:
            if isinstance(w, (ttk.Entry, ttk.Spinbox, tk.Entry)):
                if w.select_present():
                    start = w.index("sel.first")
                    end = w.index("sel.last")
                    w.delete(start, end)
                    w.icursor(start)
            elif isinstance(w, tk.Text):
                if w.tag_ranges("sel"):
                    start = w.index("sel.first")
                    end = w.index("sel.last")
                    w.delete(start, end)
                    w.mark_set("insert", start)
        except tk.TclError:
            pass

        # Schedule save after paste
        self.widget.after(10, self._save_state)
        return None

    def _on_change(self, event=None):
        """Handler for content changes."""
        if self.is_undoing:
            return

        # Ignore Ctrl+A (select all)
        if event and event.keysym.lower() == "a" and (event.state & 0x4):
            return

        self._schedule_save()

    def _on_mousewheel(self, event=None):
        """Handler for mouse wheel events in Spinbox."""
        if not self.is_undoing:
            self.widget.focus_set()
            self._schedule_save()

    def _schedule_save(self):
        """Schedule state save to avoid multiple operations."""
        if not self._save_pending:
            self._save_pending = True
            self.widget.after_idle(self._perform_save)

    def _perform_save(self):
        """Perform the actual state save."""
        self._save_pending = False
        if not self.is_undoing:
            self._save_state()

    def _undo(self, event=None):
        """Undo the last operation."""
        if self.current_index > 0:
            self.is_undoing = True
            try:
                self.current_index -= 1
                content, cursor_pos = self.history[self.current_index]
                self._set_content(content)
                self._set_cursor_pos(cursor_pos)
                self._last_content = content  # Update last known content
            finally:
                self.is_undoing = False
        return "break"

    def _redo(self, event=None):
        """Redo the next operation."""
        if self.current_index < len(self.history) - 1:
            self.is_undoing = True
            try:
                self.current_index += 1
                content, cursor_pos = self.history[self.current_index]
                self._set_content(content)
                self._set_cursor_pos(cursor_pos)
                self._last_content = content  # Update last known content
            finally:
                self.is_undoing = False
        return "break"

    def clear_history(self):
        """Clear undo/redo history."""
        self.history.clear()
        self.current_index = -1
        self._save_state()

    def can_undo(self) -> bool:
        """Check if undo is possible."""
        return self.current_index > 0

    def can_redo(self) -> bool:
        """Check if redo is possible."""
        return self.current_index < len(self.history) - 1

    def get_history_info(self) -> dict:
        """Return information about the history."""
        return {
            "total_states": len(self.history),
            "current_index": self.current_index,
            "can_undo": self.can_undo(),
            "can_redo": self.can_redo(),
            "max_history": self.max_history,
        }


example of use:

import locale
from tkinter.scrolledtext import ScrolledText as TkScrolledText

import ttkbootstrap as ttk
from ttkbootstrap.scrolled import ScrolledText
from ttkbootstrap.widgets import DateEntry
from undo_redo_manager import UndoRedoManager


class DemoApp:
    def __init__(self, root):
        self.root = root
        self.root.title("TTK Bootstrap Undo/Redo Demo")
        self.root.geometry("800x600")

        # Create main frame
        self.main_frame = ttk.Frame(self.root, padding=10)
        self.main_frame.pack(fill="both", expand=True)

        # Title and instructions
        self.title_label = ttk.Label(
            self.main_frame, text="Undo/Redo Demo", font=("Arial", 16, "bold")
        )
        self.title_label.pack(pady=(0, 10))

        self.instructions = ttk.Label(
            self.main_frame,
            text="Use Ctrl+Z to undo and Ctrl+Y to redo. All changes are tracked.",
            font=("Arial", 10),
        )
        self.instructions.pack(pady=(0, 15))

        # Entry widget
        self.entry_frame = ttk.LabelFrame(
            self.main_frame, text="Entry Widget", padding=5
        )
        self.entry_frame.pack(fill="x", pady=(0, 5))
        self.entry = ttk.Entry(self.entry_frame, font=("Arial", 12))
        self.entry.pack(fill="x", padx=5, pady=5)
        self.entry_manager = UndoRedoManager(self.entry)

        # Spinbox widget
        self.spinbox_frame = ttk.LabelFrame(
            self.main_frame, text="Spinbox Widget (use mouse wheel)", padding=5
        )
        self.spinbox_frame.pack(fill="x", pady=(0, 5))
        self.spinbox = ttk.Spinbox(
            self.spinbox_frame, from_=0, to=100, font=("Arial", 12)
        )
        self.spinbox.pack(fill="x", padx=5, pady=5)
        self.spinbox_manager = UndoRedoManager(self.spinbox)

        # DateEntry widget with system-native date format
        self.date_frame = ttk.LabelFrame(
            self.main_frame, text="DateEntry Widget", padding=10
        )
        self.date_frame.pack(fill="x", pady=(0, 10))

        try:
            locale.setlocale(locale.LC_ALL, "")
        except locale.Error:
            locale.setlocale(locale.LC_ALL, "C")

        self.date_entry = DateEntry(self.date_frame)
        self.date_entry.pack(fill="x")
        self.date_manager = UndoRedoManager(self.date_entry)

        # Text widget
        self.text_frame = ttk.LabelFrame(self.main_frame, text="Text Widget", padding=5)
        self.text_frame.pack(fill="x", pady=(0, 5))
        self.text_widget = ttk.Text(self.text_frame, height=2, font=("Arial", 12))
        self.text_widget.pack(fill="x", padx=5, pady=5)
        self.text_manager = UndoRedoManager(self.text_widget)

        # ScrolledText widget (ttkbootstrap)
        self.scrolled_frame = ttk.LabelFrame(
            self.main_frame, text="ScrolledText Widget (ttkbootstrap)", padding=5
        )
        self.scrolled_frame.pack(fill="x", pady=(0, 5))
        self.scrolled_text = ScrolledText(self.scrolled_frame, height=2, autohide=True)
        self.scrolled_text.pack(fill="x", padx=5, pady=5)
        self.scrolled_manager = UndoRedoManager(self.scrolled_text)

        # ScrolledText do Tkinter
        self.tk_scrolled_frame = ttk.LabelFrame(
            self.main_frame, text="ScrolledText Widget (Tkinter)", padding=5
        )
        self.tk_scrolled_frame.pack(fill="x", pady=(0, 5))

        self.tk_scrolled_text = TkScrolledText(
            self.tk_scrolled_frame, height=2, font=("Arial", 12)
        )
        self.tk_scrolled_text.pack(fill="x", padx=5, pady=5)
        self.tk_scrolled_manager = UndoRedoManager(self.tk_scrolled_text)


def main():
    root = ttk.Window(themename="cosmo")
    app = DemoApp(root)
    root.mainloop() 

if __name__ == "__main__":
    main()


Describe alternatives you've considered

another usage example, with context menu - example_advanced_context_menu.py:

import tkinter as tk

import ttkbootstrap as ttk
from undo_redo_manager import UndoRedoManager


class DemoApp:
    def __init__(self, root):
        self.root = root
        self.root.title("Advanced Context Menu")
        self.root.geometry("500x200")

        self.entry = ttk.Entry(self.root, font=("Arial", 12))
        self.entry.pack(fill="x", padx=20, pady=50)

        self.undo_manager = UndoRedoManager(self.entry)
        self.context_menu = tk.Menu(self.root, tearoff=0)
        self.setup_events()

    def setup_events(self):
        self.entry.bind("<Button-3>", self.show_context_menu)
        self.entry.bind("<Key>", self.on_key_press)

        # Modificado para forçar atualização do undo/redo
        self.entry.bind("<<Paste>>", self.handle_paste)
        self.entry.bind("<<Cut>>", lambda e: self.close_context_menu())
        self.entry.bind("<<Clear>>", lambda e: self.close_context_menu())

        self.root.bind("<Button-1>", lambda e: self.close_context_menu())
        self.root.bind("<FocusOut>", lambda e: self.close_context_menu())
        self.root.bind("<Unmap>", lambda e: self.close_context_menu())

    def handle_paste(self, event):
        """Handler especial para colagem com atualização do estado"""
        self.close_context_menu()
        self.entry.after(10, self.force_undo_update)

    def force_undo_update(self):
        """Força atualização do estado do undo/redo"""
        if hasattr(self.undo_manager, "_save_state"):
            self.undo_manager._save_state()

    def on_key_press(self, event):
        if event.keysym not in [
            "Shift_L",
            "Shift_R",
            "Control_L",
            "Control_R",
            "Alt_L",
            "Alt_R",
            "Tab",
            "Up",
            "Down",
            "Left",
            "Right",
        ]:
            self.close_context_menu()

    def show_context_menu(self, event):
        # Verificação imediata do estado
        can_undo = self.undo_manager.current_index > 0
        can_redo = self.undo_manager.current_index < len(self.undo_manager.history) - 1

        self.context_menu.delete(0, "end")
        commands = [
            ("Desfazer (Ctrl+Z)", self.undo, can_undo),
            ("Refazer (Ctrl+Y)", self.redo, can_redo),
            ("Recortar (Ctrl+X)", self.cut, self.entry.selection_present()),
            ("Copiar (Ctrl+C)", self.copy, self.entry.selection_present()),
            ("Colar (Ctrl+V)", self.paste, self.check_clipboard()),
            ("Excluir", self.delete, self.entry.selection_present()),
            ("Selecionar Tudo (Ctrl+A)", self.select_all, bool(self.entry.get())),
        ]

        for i, (label, cmd, condition) in enumerate(commands):
            if i == 2:
                self.context_menu.add_separator()
            self.context_menu.add_command(
                label=label, command=cmd, state="normal" if condition else "disabled"
            )

        self.context_menu.post(event.x_root, event.y_root)

    def check_clipboard(self):
        try:
            return bool(self.root.clipboard_get())
        except tk.TclError:
            return False

    def close_context_menu(self, event=None):
        if self.context_menu.winfo_ismapped():
            self.context_menu.unpost()

    # Métodos de comandos (mantidos originais)
    def undo(self):
        self.undo_manager._undo()

    def redo(self):
        self.undo_manager._redo()

    def cut(self):
        self.entry.event_generate("<<Cut>>")

    def copy(self):
        self.entry.event_generate("<<Copy>>")

    def paste(self):
        self.entry.event_generate("<<Paste>>")

    def delete(self):
        if self.entry.selection_present():
            self.entry.delete(tk.SEL_FIRST, tk.SEL_LAST)

    def select_all(self):
        self.entry.select_range(0, tk.END)
        self.entry.icursor(tk.END)


if __name__ == "__main__":
    root = ttk.Window(themename="cosmo")
    app = DemoApp(root)
    root.mainloop()

Additional context

No response

Metadata

Metadata

Assignees

No one assigned

    Labels

    backlogAccepted for future developmentenhancementNew feature or request

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions