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

Labels

enhancementNew feature or requestv2ttkbootstrap v2

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions