Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions sshpilot/keyboard_utils.py
Original file line number Diff line number Diff line change
@@ -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
11 changes: 7 additions & 4 deletions sshpilot/split_view.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)

Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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

Expand Down
46 changes: 44 additions & 2 deletions sshpilot/terminal.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()

Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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)
Expand Down
42 changes: 42 additions & 0 deletions sshpilot/window.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand All @@ -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:
Expand Down