v1.8.23: fix Ctrl+V/C/X/A/Z for non-Latin keyboard layouts, add Entry undo/redo

- Global keycode-based handler for Ctrl shortcuts (works with Russian, Chinese, any layout)
- Tkinter maps <<Paste>> to <Control-v> by keysym which fails on non-Latin layouts
- New handler intercepts <Control-Key> at 'all' level, checks keycodes and generates correct virtual events
- Added Undo/Redo support for Entry widgets (tk.Entry has no built-in undo)
- Tab switch focus management: terminal releases focus when switching away

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
chrome-storm-c442
2026-02-24 06:13:27 -05:00
parent a8f3bd04e1
commit 5cf856069b
5 changed files with 141 additions and 14 deletions

View File

@@ -2,6 +2,7 @@
Main application window — sidebar + tabview layout.
"""
import tkinter
import customtkinter as ctk
from tkinter import messagebox
@@ -46,6 +47,14 @@ class App(ctk.CTk):
self.checker.start()
self.checker.check_all_now()
# Fix Ctrl+V/C/A/X for non-Latin keyboard layouts (e.g. Russian)
# Tkinter maps <<Paste>> to <Control-v> by keysym, which fails when
# the layout produces non-Latin characters. This fix uses keycodes instead.
self._setup_keyboard_layout_fix()
# Add Undo/Redo support for Entry widgets (tk.Entry has no built-in undo)
self._setup_entry_undo()
# Cleanup on close
self.protocol("WM_DELETE_WINDOW", self._on_close)
@@ -85,7 +94,7 @@ class App(ctk.CTk):
self.about_btn.pack(side="right", padx=(5, 5))
# Tabview
self.tabview = ctk.CTkTabview(main)
self.tabview = ctk.CTkTabview(main, command=self._on_tab_changed)
self.tabview.pack(fill="both", expand=True, padx=10, pady=10)
# Tab names stored for language updates
@@ -204,7 +213,7 @@ class App(ctk.CTk):
self.tabview.destroy()
# Create new tabview with translated names
self.tabview = ctk.CTkTabview(main)
self.tabview = ctk.CTkTabview(main, command=self._on_tab_changed)
self.tabview.pack(fill="both", expand=True, padx=10, pady=10)
for key in self._tab_keys:
@@ -255,6 +264,116 @@ class App(ctk.CTk):
# Update sidebar
self.sidebar.update_language()
def _setup_entry_undo(self):
"""Add Undo/Redo support for tk.Entry widgets (they have no built-in undo).
Tracks text changes per-widget and restores on <<Undo>>/<<Redo>>.
"""
undo_stacks: dict[str, list[str]] = {}
redo_stacks: dict[str, list[str]] = {}
def _save_state(event):
w = event.widget
wid = str(w)
try:
current = w.get()
except Exception:
return
stack = undo_stacks.setdefault(wid, [])
if not stack or stack[-1] != current:
stack.append(current)
if len(stack) > 100:
stack.pop(0)
# Clear redo on new input
redo_stacks.pop(wid, None)
def _on_undo(event):
w = event.widget
wid = str(w)
try:
current = w.get()
except Exception:
return "break"
stack = undo_stacks.get(wid, [])
# Pop current state
while stack and stack[-1] == current:
stack.pop()
if stack:
prev = stack[-1]
redo_stacks.setdefault(wid, []).append(current)
w.delete(0, "end")
w.insert(0, prev)
return "break"
def _on_redo(event):
w = event.widget
wid = str(w)
try:
current = w.get()
except Exception:
return "break"
stack = redo_stacks.get(wid, [])
if stack:
next_val = stack.pop()
undo_stacks.setdefault(wid, []).append(current)
w.delete(0, "end")
w.insert(0, next_val)
return "break"
# Add class-level bindings for Entry
self.tk.eval('''
bind Entry <<Undo>> {}
bind Entry <<Redo>> {}
''')
tkinter.Misc.bind_class(self, "Entry", "<Key>", _save_state, "+")
tkinter.Misc.bind_class(self, "Entry", "<<Undo>>", _on_undo)
tkinter.Misc.bind_class(self, "Entry", "<<Redo>>", _on_redo)
def _setup_keyboard_layout_fix(self):
"""Fix Ctrl+V/C/X/A/Z for non-Latin keyboard layouts (Russian, Chinese, etc.).
Tkinter binds <<Paste>> to <Control-v> by keysym. When the keyboard
layout is Russian, pressing Ctrl+V produces <Control-м> which doesn't
match. This fix intercepts <Control-Key> at the 'all' level by keycode
(layout-independent) and generates the correct virtual events.
"""
# Keycode → virtual event mapping (physical keys, layout-independent)
keycode_to_event = {
86: "<<Paste>>", # V
67: "<<Copy>>", # C
88: "<<Cut>>", # X
65: "<<SelectAll>>", # A
90: "<<Undo>>", # Z
89: "<<Redo>>", # Y
}
def _on_ctrl_key_global(event):
# Only act when Ctrl is held (not Ctrl+Shift, etc.)
if not (event.state & 0x4):
return
# Skip if keysym is already Latin (standard handling works)
if event.keysym in ('v', 'V', 'c', 'C', 'x', 'X', 'a', 'A',
'z', 'Z', 'y', 'Y'):
return
virtual = keycode_to_event.get(event.keycode)
if virtual:
event.widget.event_generate(virtual)
return "break"
# Bypass CTk's bind_all restriction by calling tkinter.Misc directly
tkinter.Misc.bind_all(self, "<Control-Key>", _on_ctrl_key_global, "+")
def _on_tab_changed(self):
"""Handle tab switch — manage terminal focus."""
try:
current = self.tabview.get()
if current == t("terminal"):
self.terminal_tab._terminal.focus_terminal()
else:
self.focus_set()
except Exception:
pass
def _on_close(self):
# Disconnect all sessions before closing
self.session_pool.disconnect_all()

View File

@@ -123,7 +123,9 @@ class TerminalTab(ctk.CTkFrame):
self._terminal.set_status(
t("term_connected").format(alias=alias), "#44cc44"
)
self._terminal.focus_terminal()
# Only grab focus if terminal tab is currently visible
if self._terminal.winfo_ismapped():
self._terminal.focus_terminal()
self.after(0, _set_session)
except Exception as e:
self.after(0, lambda: self._terminal.set_status(

View File

@@ -18,7 +18,6 @@ def enable_undo(ctk_entry):
if not history or history[-1] != val:
history.append(val)
redo_stack.clear()
# Cap history size
if len(history) > 200:
del history[:100]
@@ -46,15 +45,6 @@ def enable_undo(ctk_entry):
_recording[0] = True
return "break"
def _on_ctrl_key(event):
"""Route Ctrl+key by physical keycode (layout-independent)."""
if event.keycode == 90: # Z
return _undo(event)
if event.keycode == 89: # Y
return _redo(event)
return None
entry.bind("<KeyRelease>", _snapshot, add="+")
entry.bind("<Control-z>", _undo)
entry.bind("<Control-y>", _redo)
entry.bind("<Control-Key>", _on_ctrl_key)

View File

@@ -251,6 +251,9 @@ class TerminalWidget(tk.Frame):
self._selecting = False
self._last_ctrl_c: float = 0.0
# ── Keyboard enable/disable (for modal dialogs) ──
self._keyboard_enabled = True
# ── Flash status state ──
self._flash_after_id = None
self._saved_status_text = ""
@@ -360,6 +363,14 @@ class TerminalWidget(tk.Frame):
def focus_terminal(self):
self._text.focus_set()
def disable_keyboard(self):
"""Disable keyboard processing (e.g. when a modal dialog is open)."""
self._keyboard_enabled = False
def enable_keyboard(self):
"""Re-enable keyboard processing."""
self._keyboard_enabled = True
def get_size(self) -> tuple[int, int]:
return self._cols, self._rows
@@ -581,6 +592,9 @@ class TerminalWidget(tk.Frame):
# ── Keyboard handling ──────────────────────────────────────────────────
def _on_key(self, event):
# Don't intercept keys when keyboard is disabled (modal dialog open)
if not self._keyboard_enabled:
return
# Ignore modifier-only keys
if event.keysym in ("Shift_L", "Shift_R", "Control_L", "Control_R",
"Alt_L", "Alt_R", "Meta_L", "Meta_R",
@@ -708,6 +722,8 @@ class TerminalWidget(tk.Frame):
def _on_ctrl_key(self, event):
"""Route Ctrl+key by physical keycode (layout-independent)."""
if not self._keyboard_enabled:
return
handler_name = self._CTRL_KEYCODE_MAP.get(event.keycode)
if handler_name:
# Check if Shift is also held

View File

@@ -1,6 +1,6 @@
"""Version info for ServerManager."""
__version__ = "1.8.17"
__version__ = "1.8.23"
__app_name__ = "ServerManager"
__author__ = "aibot777"
__description__ = "Desktop GUI for managing remote servers"