v1.8.12: fix Ctrl+Z undo — manual implementation for tk.Entry
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 <noreply@anthropic.com>
This commit is contained in:
@@ -5,6 +5,7 @@ Server add/edit dialog — modal window with all server fields.
|
|||||||
import customtkinter as ctk
|
import customtkinter as ctk
|
||||||
from core.server_store import SERVER_TYPES, DEFAULT_PORTS
|
from core.server_store import SERVER_TYPES, DEFAULT_PORTS
|
||||||
from core.i18n import t
|
from core.i18n import t
|
||||||
|
from gui.widgets.entry_undo import enable_undo
|
||||||
|
|
||||||
|
|
||||||
def _get_network_interfaces() -> list[tuple[str, str]]:
|
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)
|
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 = ctk.CTkEntry(self, placeholder_text=t("placeholder_alias"))
|
||||||
self.alias_entry.pack(fill="x", **entry_pad)
|
self.alias_entry.pack(fill="x", **entry_pad)
|
||||||
# Enable undo functionality
|
enable_undo(self.alias_entry)
|
||||||
self.alias_entry._entry.config(undo=True)
|
|
||||||
|
|
||||||
# IP
|
# IP
|
||||||
ctk.CTkLabel(self, text=t("ip"), anchor="w").pack(fill="x", **pad)
|
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 = ctk.CTkEntry(self, placeholder_text=t("placeholder_ip"))
|
||||||
self.ip_entry.pack(fill="x", **entry_pad)
|
self.ip_entry.pack(fill="x", **entry_pad)
|
||||||
# Enable undo functionality
|
enable_undo(self.ip_entry)
|
||||||
self.ip_entry._entry.config(undo=True)
|
|
||||||
|
|
||||||
# Type + Port row
|
# Type + Port row
|
||||||
row = ctk.CTkFrame(self, fg_color="transparent")
|
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")
|
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 = ctk.CTkEntry(port_frame, placeholder_text=t("placeholder_port"))
|
||||||
self.port_entry.pack(fill="x")
|
self.port_entry.pack(fill="x")
|
||||||
# Enable undo functionality
|
enable_undo(self.port_entry)
|
||||||
self.port_entry._entry.config(undo=True)
|
|
||||||
|
|
||||||
# Network interface
|
# Network interface
|
||||||
ctk.CTkLabel(self, text=t("network_interface"), anchor="w").pack(fill="x", **pad)
|
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)
|
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 = ctk.CTkEntry(self, placeholder_text=t("placeholder_user"))
|
||||||
self.user_entry.pack(fill="x", **entry_pad)
|
self.user_entry.pack(fill="x", **entry_pad)
|
||||||
# Enable undo functionality
|
enable_undo(self.user_entry)
|
||||||
self.user_entry._entry.config(undo=True)
|
|
||||||
|
|
||||||
# Password
|
# Password
|
||||||
ctk.CTkLabel(self, text=t("password"), anchor="w").pack(fill="x", **pad)
|
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))
|
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 = ctk.CTkEntry(pass_frame, show="*", placeholder_text=t("placeholder_password"))
|
||||||
self.password_entry.pack(side="left", fill="x", expand=True, padx=(0, 5))
|
self.password_entry.pack(side="left", fill="x", expand=True, padx=(0, 5))
|
||||||
# Enable undo functionality
|
enable_undo(self.password_entry)
|
||||||
self.password_entry._entry.config(undo=True)
|
|
||||||
self.show_pass = ctk.CTkButton(pass_frame, text=t("show"), width=60, command=self._toggle_password)
|
self.show_pass = ctk.CTkButton(pass_frame, text=t("show"), width=60, command=self._toggle_password)
|
||||||
self.show_pass.pack(side="right")
|
self.show_pass.pack(side="right")
|
||||||
self._pass_visible = False
|
self._pass_visible = False
|
||||||
@@ -117,8 +113,7 @@ class ServerDialog(ctk.CTkToplevel):
|
|||||||
self.totp_entry = ctk.CTkEntry(self, placeholder_text=t("placeholder_totp_secret"),
|
self.totp_entry = ctk.CTkEntry(self, placeholder_text=t("placeholder_totp_secret"),
|
||||||
font=ctk.CTkFont(family="Consolas", size=12))
|
font=ctk.CTkFont(family="Consolas", size=12))
|
||||||
self.totp_entry.pack(fill="x", **entry_pad)
|
self.totp_entry.pack(fill="x", **entry_pad)
|
||||||
# Enable undo functionality
|
enable_undo(self.totp_entry)
|
||||||
self.totp_entry._entry.config(undo=True)
|
|
||||||
|
|
||||||
# Skip status checks
|
# Skip status checks
|
||||||
self.skip_check_var = ctk.BooleanVar(value=False)
|
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)
|
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 = ctk.CTkEntry(self, placeholder_text=t("placeholder_notes"))
|
||||||
self.notes_entry.pack(fill="x", **entry_pad)
|
self.notes_entry.pack(fill="x", **entry_pad)
|
||||||
# Enable undo functionality
|
enable_undo(self.notes_entry)
|
||||||
self.notes_entry._entry.config(undo=True)
|
|
||||||
|
|
||||||
# Buttons
|
# Buttons
|
||||||
btn_frame = ctk.CTkFrame(self, fg_color="transparent")
|
btn_frame = ctk.CTkFrame(self, fg_color="transparent")
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ Sidebar — server list with search, add/edit/delete buttons.
|
|||||||
import customtkinter as ctk
|
import customtkinter as ctk
|
||||||
from core.i18n import t
|
from core.i18n import t
|
||||||
from gui.widgets.status_badge import StatusBadge
|
from gui.widgets.status_badge import StatusBadge
|
||||||
|
from gui.widgets.entry_undo import enable_undo
|
||||||
|
|
||||||
|
|
||||||
class Sidebar(ctk.CTkFrame):
|
class Sidebar(ctk.CTkFrame):
|
||||||
@@ -29,8 +30,7 @@ class Sidebar(ctk.CTkFrame):
|
|||||||
self.search_var.trace_add("write", lambda *_: self._refresh_list())
|
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 = ctk.CTkEntry(self, placeholder_text=t("search"), textvariable=self.search_var)
|
||||||
self.search_entry.pack(fill="x", padx=10, pady=(5, 10))
|
self.search_entry.pack(fill="x", padx=10, pady=(5, 10))
|
||||||
# Enable undo functionality
|
enable_undo(self.search_entry)
|
||||||
self.search_entry._entry.config(undo=True)
|
|
||||||
|
|
||||||
# Server list
|
# Server list
|
||||||
self.list_frame = ctk.CTkScrollableFrame(self, fg_color="transparent")
|
self.list_frame = ctk.CTkScrollableFrame(self, fg_color="transparent")
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ from tkinter import messagebox, filedialog
|
|||||||
import customtkinter as ctk
|
import customtkinter as ctk
|
||||||
|
|
||||||
from core.i18n import t
|
from core.i18n import t
|
||||||
|
from gui.widgets.entry_undo import enable_undo
|
||||||
from core.ssh_client import SFTPSession
|
from core.ssh_client import SFTPSession
|
||||||
from gui.widgets.file_list import FileListWidget
|
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 = ctk.CTkEntry(left_header, height=28)
|
||||||
self._local_path_entry.pack(side="left", fill="x", expand=True, padx=(4, 0))
|
self._local_path_entry.pack(side="left", fill="x", expand=True, padx=(4, 0))
|
||||||
self._local_path_entry.bind("<Return>", lambda e: self._local_go_to_path())
|
self._local_path_entry.bind("<Return>", lambda e: self._local_go_to_path())
|
||||||
# Enable undo functionality
|
enable_undo(self._local_path_entry)
|
||||||
self._local_path_entry._entry.config(undo=True)
|
|
||||||
|
|
||||||
self._local_list = FileListWidget(
|
self._local_list = FileListWidget(
|
||||||
left_pane,
|
left_pane,
|
||||||
@@ -179,8 +179,7 @@ class FilesTab(ctk.CTkFrame):
|
|||||||
self._remote_path_entry = ctk.CTkEntry(right_header, height=28)
|
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.pack(side="left", fill="x", expand=True, padx=(4, 0))
|
||||||
self._remote_path_entry.bind("<Return>", lambda e: self._remote_go_to_path())
|
self._remote_path_entry.bind("<Return>", lambda e: self._remote_go_to_path())
|
||||||
# Enable undo functionality
|
enable_undo(self._remote_path_entry)
|
||||||
self._remote_path_entry._entry.config(undo=True)
|
|
||||||
|
|
||||||
self._remote_list = FileListWidget(
|
self._remote_list = FileListWidget(
|
||||||
right_pane,
|
right_pane,
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ Live countdown, one-click copy, per-server secrets.
|
|||||||
import threading
|
import threading
|
||||||
import customtkinter as ctk
|
import customtkinter as ctk
|
||||||
from core.i18n import t
|
from core.i18n import t
|
||||||
|
from gui.widgets.entry_undo import enable_undo
|
||||||
|
|
||||||
|
|
||||||
class TOTPTab(ctk.CTkFrame):
|
class TOTPTab(ctk.CTkFrame):
|
||||||
@@ -106,8 +107,7 @@ class TOTPTab(ctk.CTkFrame):
|
|||||||
font=ctk.CTkFont(family="Consolas", size=12)
|
font=ctk.CTkFont(family="Consolas", size=12)
|
||||||
)
|
)
|
||||||
self.secret_entry.pack(side="left", fill="x", expand=True, padx=(0, 5))
|
self.secret_entry.pack(side="left", fill="x", expand=True, padx=(0, 5))
|
||||||
# Enable undo functionality
|
enable_undo(self.secret_entry)
|
||||||
self.secret_entry._entry.config(undo=True)
|
|
||||||
|
|
||||||
self.show_secret_btn = ctk.CTkButton(
|
self.show_secret_btn = ctk.CTkButton(
|
||||||
entry_row, text=t("show"), width=70,
|
entry_row, text=t("show"), width=70,
|
||||||
|
|||||||
54
gui/widgets/entry_undo.py
Normal file
54
gui/widgets/entry_undo.py
Normal file
@@ -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("<KeyRelease>", _snapshot, add="+")
|
||||||
|
entry.bind("<Control-z>", _undo)
|
||||||
|
entry.bind("<Control-y>", _redo)
|
||||||
|
# Support Russian keyboard layout (Cyrillic keycodes)
|
||||||
|
entry.bind("<Control-\u044f>", _undo) # Ctrl+Я (z position on Russian layout)
|
||||||
|
entry.bind("<Control-\u043d>", _redo) # Ctrl+Н (y position on Russian layout)
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
"""Version info for ServerManager."""
|
"""Version info for ServerManager."""
|
||||||
|
|
||||||
__version__ = "1.8.11"
|
__version__ = "1.8.12"
|
||||||
__app_name__ = "ServerManager"
|
__app_name__ = "ServerManager"
|
||||||
__author__ = "aibot777"
|
__author__ = "aibot777"
|
||||||
__description__ = "Desktop GUI for managing remote servers"
|
__description__ = "Desktop GUI for managing remote servers"
|
||||||
|
|||||||
Reference in New Issue
Block a user