From 5cf856069bfd038789ed1629f30b4588745af326 Mon Sep 17 00:00:00 2001 From: chrome-storm-c442 Date: Tue, 24 Feb 2026 06:13:27 -0500 Subject: [PATCH] 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 <> to by keysym which fails on non-Latin layouts - New handler intercepts 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 --- gui/app.py | 123 ++++++++++++++++++++++++++++++++- gui/tabs/terminal_tab.py | 4 +- gui/widgets/entry_undo.py | 10 --- gui/widgets/terminal_widget.py | 16 +++++ version.py | 2 +- 5 files changed, 141 insertions(+), 14 deletions(-) diff --git a/gui/app.py b/gui/app.py index 1998688..fc96751 100644 --- a/gui/app.py +++ b/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 <> to 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_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 <> {} + bind Entry <> {} + ''') + tkinter.Misc.bind_class(self, "Entry", "", _save_state, "+") + tkinter.Misc.bind_class(self, "Entry", "<>", _on_undo) + tkinter.Misc.bind_class(self, "Entry", "<>", _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 <> to by keysym. When the keyboard + layout is Russian, pressing Ctrl+V produces which doesn't + match. This fix intercepts 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: "<>", # V + 67: "<>", # C + 88: "<>", # X + 65: "<>", # A + 90: "<>", # Z + 89: "<>", # 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, "", _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() diff --git a/gui/tabs/terminal_tab.py b/gui/tabs/terminal_tab.py index 4c15088..3b3ff4b 100644 --- a/gui/tabs/terminal_tab.py +++ b/gui/tabs/terminal_tab.py @@ -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( diff --git a/gui/widgets/entry_undo.py b/gui/widgets/entry_undo.py index 3b40f2f..73a5a04 100644 --- a/gui/widgets/entry_undo.py +++ b/gui/widgets/entry_undo.py @@ -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("", _snapshot, add="+") entry.bind("", _undo) entry.bind("", _redo) - entry.bind("", _on_ctrl_key) diff --git a/gui/widgets/terminal_widget.py b/gui/widgets/terminal_widget.py index ed0a969..19a6930 100644 --- a/gui/widgets/terminal_widget.py +++ b/gui/widgets/terminal_widget.py @@ -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 diff --git a/version.py b/version.py index 9793cb0..f895481 100644 --- a/version.py +++ b/version.py @@ -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"