-
Notifications
You must be signed in to change notification settings - Fork 458
Open
Labels
Description
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