From afffc4177edea8e5c8326616ee77071bf13b1505 Mon Sep 17 00:00:00 2001 From: chrome-storm-c442 Date: Tue, 24 Feb 2026 05:28:56 -0500 Subject: [PATCH] =?UTF-8?q?v1.8.12:=20fix=20Ctrl+Z=20undo=20=E2=80=94=20ma?= =?UTF-8?q?nual=20implementation=20for=20tk.Entry?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tk.Entry has no built-in undo (unlike tk.Text), so _entry.config(undo=True) crashed. Replaced with custom entry_undo.py that tracks history manually and binds Ctrl+Z/Ctrl+Y (+ Russian layout support). Co-Authored-By: Claude Opus 4.6 --- gui/server_dialog.py | 22 ++++++---------- gui/sidebar.py | 4 +-- gui/tabs/files_tab.py | 7 +++-- gui/tabs/totp_tab.py | 4 +-- gui/widgets/entry_undo.py | 54 +++++++++++++++++++++++++++++++++++++++ version.py | 2 +- 6 files changed, 70 insertions(+), 23 deletions(-) create mode 100644 gui/widgets/entry_undo.py diff --git a/gui/server_dialog.py b/gui/server_dialog.py index 5d5144e..034156b 100644 --- a/gui/server_dialog.py +++ b/gui/server_dialog.py @@ -5,6 +5,7 @@ Server add/edit dialog — modal window with all server fields. import customtkinter as ctk from core.server_store import SERVER_TYPES, DEFAULT_PORTS from core.i18n import t +from gui.widgets.entry_undo import enable_undo def _get_network_interfaces() -> list[tuple[str, str]]: @@ -47,15 +48,13 @@ class ServerDialog(ctk.CTkToplevel): ctk.CTkLabel(self, text=t("alias"), anchor="w").pack(fill="x", **pad) self.alias_entry = ctk.CTkEntry(self, placeholder_text=t("placeholder_alias")) self.alias_entry.pack(fill="x", **entry_pad) - # Enable undo functionality - self.alias_entry._entry.config(undo=True) + enable_undo(self.alias_entry) # IP ctk.CTkLabel(self, text=t("ip"), anchor="w").pack(fill="x", **pad) self.ip_entry = ctk.CTkEntry(self, placeholder_text=t("placeholder_ip")) self.ip_entry.pack(fill="x", **entry_pad) - # Enable undo functionality - self.ip_entry._entry.config(undo=True) + enable_undo(self.ip_entry) # Type + Port row row = ctk.CTkFrame(self, fg_color="transparent") @@ -76,8 +75,7 @@ class ServerDialog(ctk.CTkToplevel): ctk.CTkLabel(port_frame, text=t("port"), anchor="w").pack(fill="x") self.port_entry = ctk.CTkEntry(port_frame, placeholder_text=t("placeholder_port")) self.port_entry.pack(fill="x") - # Enable undo functionality - self.port_entry._entry.config(undo=True) + enable_undo(self.port_entry) # Network interface ctk.CTkLabel(self, text=t("network_interface"), anchor="w").pack(fill="x", **pad) @@ -97,8 +95,7 @@ class ServerDialog(ctk.CTkToplevel): ctk.CTkLabel(self, text=t("username"), anchor="w").pack(fill="x", **pad) self.user_entry = ctk.CTkEntry(self, placeholder_text=t("placeholder_user")) self.user_entry.pack(fill="x", **entry_pad) - # Enable undo functionality - self.user_entry._entry.config(undo=True) + enable_undo(self.user_entry) # Password ctk.CTkLabel(self, text=t("password"), anchor="w").pack(fill="x", **pad) @@ -106,8 +103,7 @@ class ServerDialog(ctk.CTkToplevel): pass_frame.pack(fill="x", padx=20, pady=(2, 5)) self.password_entry = ctk.CTkEntry(pass_frame, show="*", placeholder_text=t("placeholder_password")) self.password_entry.pack(side="left", fill="x", expand=True, padx=(0, 5)) - # Enable undo functionality - self.password_entry._entry.config(undo=True) + enable_undo(self.password_entry) self.show_pass = ctk.CTkButton(pass_frame, text=t("show"), width=60, command=self._toggle_password) self.show_pass.pack(side="right") self._pass_visible = False @@ -117,8 +113,7 @@ class ServerDialog(ctk.CTkToplevel): self.totp_entry = ctk.CTkEntry(self, placeholder_text=t("placeholder_totp_secret"), font=ctk.CTkFont(family="Consolas", size=12)) self.totp_entry.pack(fill="x", **entry_pad) - # Enable undo functionality - self.totp_entry._entry.config(undo=True) + enable_undo(self.totp_entry) # Skip status checks self.skip_check_var = ctk.BooleanVar(value=False) @@ -131,8 +126,7 @@ class ServerDialog(ctk.CTkToplevel): ctk.CTkLabel(self, text=t("notes"), anchor="w").pack(fill="x", **pad) self.notes_entry = ctk.CTkEntry(self, placeholder_text=t("placeholder_notes")) self.notes_entry.pack(fill="x", **entry_pad) - # Enable undo functionality - self.notes_entry._entry.config(undo=True) + enable_undo(self.notes_entry) # Buttons btn_frame = ctk.CTkFrame(self, fg_color="transparent") diff --git a/gui/sidebar.py b/gui/sidebar.py index c438b60..b432b0e 100644 --- a/gui/sidebar.py +++ b/gui/sidebar.py @@ -5,6 +5,7 @@ Sidebar — server list with search, add/edit/delete buttons. import customtkinter as ctk from core.i18n import t from gui.widgets.status_badge import StatusBadge +from gui.widgets.entry_undo import enable_undo class Sidebar(ctk.CTkFrame): @@ -29,8 +30,7 @@ class Sidebar(ctk.CTkFrame): self.search_var.trace_add("write", lambda *_: self._refresh_list()) self.search_entry = ctk.CTkEntry(self, placeholder_text=t("search"), textvariable=self.search_var) self.search_entry.pack(fill="x", padx=10, pady=(5, 10)) - # Enable undo functionality - self.search_entry._entry.config(undo=True) + enable_undo(self.search_entry) # Server list self.list_frame = ctk.CTkScrollableFrame(self, fg_color="transparent") diff --git a/gui/tabs/files_tab.py b/gui/tabs/files_tab.py index 347247c..f33ebfd 100644 --- a/gui/tabs/files_tab.py +++ b/gui/tabs/files_tab.py @@ -13,6 +13,7 @@ from tkinter import messagebox, filedialog import customtkinter as ctk from core.i18n import t +from gui.widgets.entry_undo import enable_undo from core.ssh_client import SFTPSession from gui.widgets.file_list import FileListWidget @@ -131,8 +132,7 @@ class FilesTab(ctk.CTkFrame): self._local_path_entry = ctk.CTkEntry(left_header, height=28) self._local_path_entry.pack(side="left", fill="x", expand=True, padx=(4, 0)) self._local_path_entry.bind("", lambda e: self._local_go_to_path()) - # Enable undo functionality - self._local_path_entry._entry.config(undo=True) + enable_undo(self._local_path_entry) self._local_list = FileListWidget( left_pane, @@ -179,8 +179,7 @@ class FilesTab(ctk.CTkFrame): self._remote_path_entry = ctk.CTkEntry(right_header, height=28) self._remote_path_entry.pack(side="left", fill="x", expand=True, padx=(4, 0)) self._remote_path_entry.bind("", lambda e: self._remote_go_to_path()) - # Enable undo functionality - self._remote_path_entry._entry.config(undo=True) + enable_undo(self._remote_path_entry) self._remote_list = FileListWidget( right_pane, diff --git a/gui/tabs/totp_tab.py b/gui/tabs/totp_tab.py index 08100db..bd38e13 100644 --- a/gui/tabs/totp_tab.py +++ b/gui/tabs/totp_tab.py @@ -6,6 +6,7 @@ Live countdown, one-click copy, per-server secrets. import threading import customtkinter as ctk from core.i18n import t +from gui.widgets.entry_undo import enable_undo class TOTPTab(ctk.CTkFrame): @@ -106,8 +107,7 @@ class TOTPTab(ctk.CTkFrame): font=ctk.CTkFont(family="Consolas", size=12) ) self.secret_entry.pack(side="left", fill="x", expand=True, padx=(0, 5)) - # Enable undo functionality - self.secret_entry._entry.config(undo=True) + enable_undo(self.secret_entry) self.show_secret_btn = ctk.CTkButton( entry_row, text=t("show"), width=70, diff --git a/gui/widgets/entry_undo.py b/gui/widgets/entry_undo.py new file mode 100644 index 0000000..73d43a6 --- /dev/null +++ b/gui/widgets/entry_undo.py @@ -0,0 +1,54 @@ +""" +Ctrl+Z / Ctrl+Y undo/redo for CTkEntry widgets. +tk.Entry has no built-in undo, so we track history manually. +""" + + +def enable_undo(ctk_entry): + """Add Ctrl+Z undo and Ctrl+Y redo to a CTkEntry widget.""" + entry = ctk_entry._entry + history = [entry.get()] + redo_stack = [] + _recording = [True] + + def _snapshot(*_args): + if not _recording[0]: + return + val = entry.get() + if not history or history[-1] != val: + history.append(val) + redo_stack.clear() + # Cap history size + if len(history) > 200: + del history[:100] + + def _undo(event): + if len(history) > 1: + redo_stack.append(history.pop()) + _recording[0] = False + entry.delete(0, "end") + entry.insert(0, history[-1]) + _recording[0] = True + elif history: + redo_stack.append(history.pop()) + _recording[0] = False + entry.delete(0, "end") + _recording[0] = True + return "break" + + def _redo(event): + if redo_stack: + val = redo_stack.pop() + history.append(val) + _recording[0] = False + entry.delete(0, "end") + entry.insert(0, val) + _recording[0] = True + return "break" + + entry.bind("", _snapshot, add="+") + entry.bind("", _undo) + entry.bind("", _redo) + # Support Russian keyboard layout (Cyrillic keycodes) + entry.bind("", _undo) # Ctrl+Я (z position on Russian layout) + entry.bind("", _redo) # Ctrl+Н (y position on Russian layout) diff --git a/version.py b/version.py index 532aa6c..d30a73b 100644 --- a/version.py +++ b/version.py @@ -1,6 +1,6 @@ """Version info for ServerManager.""" -__version__ = "1.8.11" +__version__ = "1.8.12" __app_name__ = "ServerManager" __author__ = "aibot777" __description__ = "Desktop GUI for managing remote servers"