diff --git a/sshpilot/keyboard_utils.py b/sshpilot/keyboard_utils.py new file mode 100644 index 00000000..0fac7390 --- /dev/null +++ b/sshpilot/keyboard_utils.py @@ -0,0 +1,22 @@ +"""Keyboard layout utilities for GTK4 shortcut handling.""" +from __future__ import annotations + +from gi.repository import Gdk + + +def get_latin_keyval(keycode: int, state) -> int | None: + """Return the keyval for *keycode* as if the Latin (group-0) layout were active. + + GTK4 passes the layout-specific keyval to key handlers, so pressing 'C' with + a Russian layout yields KEY_Cyrillic_es instead of KEY_c. Translating the + hardware keycode to group 0 gives the Latin equivalent and lets shortcuts work + regardless of the active keyboard layout. + + Returns None when translation fails; callers should fall back to the original + keyval in that case. + """ + display = Gdk.Display.get_default() + if display is None: + return None + ok, keyval, *_ = display.translate_key(keycode, state, 0) + return keyval if ok else None diff --git a/sshpilot/split_view.py b/sshpilot/split_view.py index 83ea2213..eaa47aca 100644 --- a/sshpilot/split_view.py +++ b/sshpilot/split_view.py @@ -6,6 +6,7 @@ from gi.repository import Gtk, Gdk, GObject, GLib, Adw from gettext import gettext as _ +from .keyboard_utils import get_latin_keyval logger = logging.getLogger(__name__) @@ -1596,7 +1597,7 @@ def get_focused_terminal(self): _RESIZE_STEP = 50 # pixels per Ctrl+Alt+Shift+HJKL keypress - def _on_key_pressed(self, _ctrl, keyval, _keycode, state) -> bool: + def _on_key_pressed(self, _ctrl, keyval, keycode, state) -> bool: # Guard: only act when the focused widget is inside THIS SplitViewTab. # Adw.TabView keeps all tab-page children realized, so GTK4 may invoke # our CAPTURE handler even when a sibling widget (e.g. the connection @@ -1611,6 +1612,8 @@ def _on_key_pressed(self, _ctrl, keyval, _keycode, state) -> bool: else: return False + effective = get_latin_keyval(keycode, state) or keyval + mods = state & ( Gdk.ModifierType.CONTROL_MASK | Gdk.ModifierType.ALT_MASK @@ -1631,8 +1634,8 @@ def _on_key_pressed(self, _ctrl, keyval, _keycode, state) -> bool: Gdk.KEY_l: 'right', Gdk.KEY_L: 'right', } - if keyval in HJKL_NAV: - d = HJKL_NAV[keyval] + if effective in HJKL_NAV: + d = HJKL_NAV[effective] if mods == CTRL_ALT: self._navigate_pane(d) return True @@ -1649,7 +1652,7 @@ def _on_key_pressed(self, _ctrl, keyval, _keycode, state) -> bool: self.set_layout_mode(self.VERTICAL) return True # Ctrl+Shift+N — add pane (Ctrl+Shift+T is taken by local-terminal action) - if keyval in (Gdk.KEY_N, Gdk.KEY_n) and mods == CTRL_SH: + if effective in (Gdk.KEY_N, Gdk.KEY_n) and mods == CTRL_SH: self.add_pane() return True diff --git a/sshpilot/terminal.py b/sshpilot/terminal.py index 2d0f25f0..f2c00b52 100644 --- a/sshpilot/terminal.py +++ b/sshpilot/terminal.py @@ -23,6 +23,7 @@ from typing import Optional, List from .port_utils import get_port_checker from .platform_utils import is_flatpak, is_macos, get_sshpass_path +from .keyboard_utils import get_latin_keyval from .terminal_backends import BaseTerminalBackend, VTETerminalBackend, PyXtermTerminalBackend from .ssh_connection_builder import build_ssh_connection, ConnectionContext from .plugins.api import PluginContext, ProtocolError @@ -3422,6 +3423,35 @@ def _cb_reset_zoom(widget, *args): self.terminal_widget.add_controller(controller) self._shortcut_controller = controller + if getattr(self, '_layout_shortcut_controller', None) is None: + def _on_layout_key(ctrl, keyval, keycode, state): + latin = get_latin_keyval(keycode, state) + if latin is None or latin == keyval: + return False + mods = state & Gtk.accelerator_get_default_mod_mask() + shift = bool(mods & Gdk.ModifierType.SHIFT_MASK) + ctrl_held = bool(mods & Gdk.ModifierType.CONTROL_MASK) + meta = bool(mods & Gdk.ModifierType.META_MASK) + mac = is_macos() + edit = (meta and not shift) if mac else (ctrl_held and shift) + zoom = meta if mac else ctrl_held + if edit and latin in (Gdk.KEY_c, Gdk.KEY_C): return _cb_copy(None) + if edit and latin in (Gdk.KEY_v, Gdk.KEY_V): return _cb_paste(None) + if edit and latin in (Gdk.KEY_a, Gdk.KEY_A): return _cb_select_all(None) + if zoom and latin in (Gdk.KEY_equal, Gdk.KEY_plus): return _cb_zoom_in(None) + if zoom and latin == Gdk.KEY_minus: return _cb_zoom_out(None) + if zoom and latin == Gdk.KEY_0: return _cb_reset_zoom(None) + return False + + layout_ctrl = Gtk.EventControllerKey() + layout_ctrl.set_propagation_phase(Gtk.PropagationPhase.CAPTURE) + layout_ctrl.connect('key-pressed', _on_layout_key) + if self.vte is not None: + self.vte.add_controller(layout_ctrl) + elif self.terminal_widget is not None: + self.terminal_widget.add_controller(layout_ctrl) + self._layout_shortcut_controller = layout_ctrl + if getattr(self, '_shortcut_controller', None) is not None: self._setup_mouse_wheel_zoom() @@ -3499,18 +3529,20 @@ def _ensure_search_key_controller(self): def _on_vte_search_key_pressed(self, controller, keyval, keycode, state): """Handle global terminal search shortcuts on the VTE widget.""" try: + effective = get_latin_keyval(keycode, state) or keyval + shift = bool(state & Gdk.ModifierType.SHIFT_MASK) primary = bool(state & Gdk.ModifierType.CONTROL_MASK) meta = bool(state & Gdk.ModifierType.META_MASK) - if keyval in (Gdk.KEY_f, Gdk.KEY_F) and (primary or meta): + if effective in (Gdk.KEY_f, Gdk.KEY_F) and (primary or meta): if hasattr(self, 'search_revealer') and self.search_revealer.get_reveal_child(): self._hide_search_overlay() else: self._show_search_overlay(select_all=True) return True - if keyval in (Gdk.KEY_g, Gdk.KEY_G) and (primary or meta): + if effective in (Gdk.KEY_g, Gdk.KEY_G) and (primary or meta): if shift: self._on_search_previous() else: @@ -3556,6 +3588,16 @@ def _remove_custom_shortcut_controllers(self): finally: self._search_key_controller = None + layout_ctrl = getattr(self, '_layout_shortcut_controller', None) + if layout_ctrl is not None: + try: + if hasattr(self.vte, 'remove_controller'): + self.vte.remove_controller(layout_ctrl) + except Exception as exc: + logger.debug("Failed to remove layout shortcut controller: %s", exc) + finally: + self._layout_shortcut_controller = None + def _apply_pass_through_mode(self, enabled: bool): """Enable or disable custom shortcut handling based on configuration.""" enabled = bool(enabled) diff --git a/sshpilot/window.py b/sshpilot/window.py index fd0e26e5..83466621 100644 --- a/sshpilot/window.py +++ b/sshpilot/window.py @@ -43,6 +43,7 @@ from gettext import gettext as _ from .connection_manager import ConnectionManager, Connection, ConnectionState +from .keyboard_utils import get_latin_keyval from .terminal import TerminalWidget from .terminal_manager import TerminalManager from .config import Config @@ -1962,6 +1963,8 @@ def _accel_label(accel): self.toast_overlay.set_child(main_box) self.set_content(self.toast_overlay) + self._install_layout_shortcut_handler() + # The window UI now exists (tab_view, toast_overlay, the plugins menu # section). Bind the plugin host so plugin pages, toasts, events, and # terminal control go live. Idempotent: only the first window binds. @@ -1971,6 +1974,45 @@ def _accel_label(accel): except Exception: logger.exception("Plugin host bind_window failed") + def _install_layout_shortcut_handler(self): + """Activate app/win actions by Latin keyval so shortcuts work with any keyboard layout.""" + def _on_key(ctrl, keyval, keycode, state): + try: + latin = get_latin_keyval(keycode, state) + if latin is None or latin == keyval: + return False + + mods = state & Gtk.accelerator_get_default_mod_mask() + if not mods: + return False + + app = self.get_application() + if app is None: + return False + + for action_desc in app.list_action_descriptions(): + for accel in app.get_accels_for_action(action_desc): + ok, parsed_keyval, parsed_mods = Gtk.accelerator_parse(accel) + if ok and Gdk.keyval_to_lower(parsed_keyval) == Gdk.keyval_to_lower(latin) and parsed_mods == mods: + prefix, _, name = action_desc.partition('.') + if prefix == 'app': + action = app.lookup_action(name) + elif prefix == 'win': + action = self.lookup_action(name) + else: + continue + if action and action.get_enabled(): + action.activate(None) + return True + except Exception as exc: + logger.debug("Layout shortcut handler error: %s", exc) + return False + + key_ctrl = Gtk.EventControllerKey() + key_ctrl.set_propagation_phase(Gtk.PropagationPhase.CAPTURE) + key_ctrl.connect('key-pressed', _on_key) + self.add_controller(key_ctrl) + def _set_sidebar_widget(self, widget: Gtk.Widget) -> None: if HAS_OVERLAY_SPLIT: try: