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:
123
gui/app.py
123
gui/app.py
@@ -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()
|
||||
|
||||
@@ -123,6 +123,8 @@ class TerminalTab(ctk.CTkFrame):
|
||||
self._terminal.set_status(
|
||||
t("term_connected").format(alias=alias), "#44cc44"
|
||||
)
|
||||
# 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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user