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.
|
Main application window — sidebar + tabview layout.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import tkinter
|
||||||
import customtkinter as ctk
|
import customtkinter as ctk
|
||||||
from tkinter import messagebox
|
from tkinter import messagebox
|
||||||
|
|
||||||
@@ -46,6 +47,14 @@ class App(ctk.CTk):
|
|||||||
self.checker.start()
|
self.checker.start()
|
||||||
self.checker.check_all_now()
|
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
|
# Cleanup on close
|
||||||
self.protocol("WM_DELETE_WINDOW", self._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))
|
self.about_btn.pack(side="right", padx=(5, 5))
|
||||||
|
|
||||||
# Tabview
|
# 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)
|
self.tabview.pack(fill="both", expand=True, padx=10, pady=10)
|
||||||
|
|
||||||
# Tab names stored for language updates
|
# Tab names stored for language updates
|
||||||
@@ -204,7 +213,7 @@ class App(ctk.CTk):
|
|||||||
self.tabview.destroy()
|
self.tabview.destroy()
|
||||||
|
|
||||||
# Create new tabview with translated names
|
# 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)
|
self.tabview.pack(fill="both", expand=True, padx=10, pady=10)
|
||||||
|
|
||||||
for key in self._tab_keys:
|
for key in self._tab_keys:
|
||||||
@@ -255,6 +264,116 @@ class App(ctk.CTk):
|
|||||||
# Update sidebar
|
# Update sidebar
|
||||||
self.sidebar.update_language()
|
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):
|
def _on_close(self):
|
||||||
# Disconnect all sessions before closing
|
# Disconnect all sessions before closing
|
||||||
self.session_pool.disconnect_all()
|
self.session_pool.disconnect_all()
|
||||||
|
|||||||
@@ -123,7 +123,9 @@ class TerminalTab(ctk.CTkFrame):
|
|||||||
self._terminal.set_status(
|
self._terminal.set_status(
|
||||||
t("term_connected").format(alias=alias), "#44cc44"
|
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)
|
self.after(0, _set_session)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.after(0, lambda: self._terminal.set_status(
|
self.after(0, lambda: self._terminal.set_status(
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ def enable_undo(ctk_entry):
|
|||||||
if not history or history[-1] != val:
|
if not history or history[-1] != val:
|
||||||
history.append(val)
|
history.append(val)
|
||||||
redo_stack.clear()
|
redo_stack.clear()
|
||||||
# Cap history size
|
|
||||||
if len(history) > 200:
|
if len(history) > 200:
|
||||||
del history[:100]
|
del history[:100]
|
||||||
|
|
||||||
@@ -46,15 +45,6 @@ def enable_undo(ctk_entry):
|
|||||||
_recording[0] = True
|
_recording[0] = True
|
||||||
return "break"
|
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("<KeyRelease>", _snapshot, add="+")
|
||||||
entry.bind("<Control-z>", _undo)
|
entry.bind("<Control-z>", _undo)
|
||||||
entry.bind("<Control-y>", _redo)
|
entry.bind("<Control-y>", _redo)
|
||||||
entry.bind("<Control-Key>", _on_ctrl_key)
|
|
||||||
|
|||||||
@@ -251,6 +251,9 @@ class TerminalWidget(tk.Frame):
|
|||||||
self._selecting = False
|
self._selecting = False
|
||||||
self._last_ctrl_c: float = 0.0
|
self._last_ctrl_c: float = 0.0
|
||||||
|
|
||||||
|
# ── Keyboard enable/disable (for modal dialogs) ──
|
||||||
|
self._keyboard_enabled = True
|
||||||
|
|
||||||
# ── Flash status state ──
|
# ── Flash status state ──
|
||||||
self._flash_after_id = None
|
self._flash_after_id = None
|
||||||
self._saved_status_text = ""
|
self._saved_status_text = ""
|
||||||
@@ -360,6 +363,14 @@ class TerminalWidget(tk.Frame):
|
|||||||
def focus_terminal(self):
|
def focus_terminal(self):
|
||||||
self._text.focus_set()
|
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]:
|
def get_size(self) -> tuple[int, int]:
|
||||||
return self._cols, self._rows
|
return self._cols, self._rows
|
||||||
|
|
||||||
@@ -581,6 +592,9 @@ class TerminalWidget(tk.Frame):
|
|||||||
# ── Keyboard handling ──────────────────────────────────────────────────
|
# ── Keyboard handling ──────────────────────────────────────────────────
|
||||||
|
|
||||||
def _on_key(self, event):
|
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
|
# Ignore modifier-only keys
|
||||||
if event.keysym in ("Shift_L", "Shift_R", "Control_L", "Control_R",
|
if event.keysym in ("Shift_L", "Shift_R", "Control_L", "Control_R",
|
||||||
"Alt_L", "Alt_R", "Meta_L", "Meta_R",
|
"Alt_L", "Alt_R", "Meta_L", "Meta_R",
|
||||||
@@ -708,6 +722,8 @@ class TerminalWidget(tk.Frame):
|
|||||||
|
|
||||||
def _on_ctrl_key(self, event):
|
def _on_ctrl_key(self, event):
|
||||||
"""Route Ctrl+key by physical keycode (layout-independent)."""
|
"""Route Ctrl+key by physical keycode (layout-independent)."""
|
||||||
|
if not self._keyboard_enabled:
|
||||||
|
return
|
||||||
handler_name = self._CTRL_KEYCODE_MAP.get(event.keycode)
|
handler_name = self._CTRL_KEYCODE_MAP.get(event.keycode)
|
||||||
if handler_name:
|
if handler_name:
|
||||||
# Check if Shift is also held
|
# Check if Shift is also held
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"""Version info for ServerManager."""
|
"""Version info for ServerManager."""
|
||||||
|
|
||||||
__version__ = "1.8.17"
|
__version__ = "1.8.23"
|
||||||
__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