fix(hotkeys): prefer evdev on GNOME X11 and skip redundant IBus engine restore (#963)

Fixes #954.

GNOME X11: mutter never delivers physical key events to the XRecord extension pynput relies on, so the listener started cleanly but shortcuts stayed dead. Prefer the evdev backend there (same path as Wayland), falling back to pynput when /dev/input is unusable.

Shutdown: `ibus engine <name>` rewrites the whole X server XKB map, and the unconditional restore collapsed GNOME's multi-layout map and broke layout switching. The daemon check now uses _query_active_engine() (no GNOME gsettings fallback) so a cleared engine reads as None; the restore write is skipped when a real engine is live or the target is an xkb:* source GNOME owns, and still runs for real IM engines (#558).
This commit is contained in:
devin-ai-integration[bot]
2026-10-16 09:48:28 -07:00
committed by GitHub
parent ed0ed8ba49
commit e866cb85e4
6 changed files with 244 additions and 17 deletions
+48 -9
View File
@@ -553,6 +553,28 @@ def get_current_engine() -> Optional[str]:
return None
def _query_active_engine() -> Optional[str]:
"""
Query the engine ibus-daemon actually reports, with no GNOME fallbacks.
Unlike :func:`get_current_engine`, this never consults gsettings, so it
can distinguish a really-active engine from a cleared global engine —
``None`` means either "No global engine" or that the query failed.
"""
try:
result = subprocess.run(
["ibus", "engine"], capture_output=True, text=True, timeout=5, env=host_env()
)
stderr_lower = result.stderr.lower() if result.stderr else ""
if "no global engine" in stderr_lower:
return None
if result.returncode == 0:
return result.stdout.strip() or None
except (subprocess.SubprocessError, FileNotFoundError):
pass
return None
def _ibus_im_env_configured() -> bool:
"""True if the process env says IBus is the session input method."""
gtk_im = os.environ.get("GTK_IM_MODULE", "").lower()
@@ -1588,15 +1610,32 @@ class IBusTextInjector:
stop_engine_process()
if restore_engine:
logger.info(f"Restoring previous engine after teardown: {restore_engine}")
restored = False
for attempt in range(3):
if switch_engine(restore_engine):
restored = True
break
time.sleep(0.15 * (attempt + 1))
if not restored:
logger.error(f"Failed to restore IBus engine after teardown: {restore_engine}")
# `ibus engine <name>` applies that engine's XKB map server-wide.
# Re-check the daemon (not the GNOME fallback) after teardown and
# skip the write when a real engine is already active — rewriting
# it collapses GNOME's multi-layout map (#954). The same skip
# applies when teardown left the engine cleared but the restore
# target is an xkb:* source on GNOME: gnome-shell owns that map
# and the write would only collapse it. A cleared global engine
# for a real IM engine still gets restored (#558).
live = _query_active_engine()
if live and live != ENGINE_NAME:
logger.info(f"Skipping engine restore — live engine already active: {live}")
elif live is None and _is_gnome_session() and restore_engine.startswith("xkb:"):
logger.info(
"Skipping engine restore — engine cleared and GNOME owns "
f"the XKB map; not rewriting {restore_engine}"
)
else:
logger.info(f"Restoring previous engine after teardown: {restore_engine}")
restored = False
for attempt in range(3):
if switch_engine(restore_engine):
restored = True
break
time.sleep(0.15 * (attempt + 1))
if not restored:
logger.error(f"Failed to restore IBus engine after teardown: {restore_engine}")
else:
logger.info(
"Skipping engine restoration — no restorable engine was available at shutdown"
@@ -82,6 +82,11 @@ class DesktopEnvironment:
return DesktopEnvironment.UNKNOWN
def _is_gnome_session() -> bool:
"""Check if the current session is running under GNOME."""
return "GNOME" in os.environ.get("XDG_CURRENT_DESKTOP", "").upper()
def create_backend(
preferred_backend: Optional[str] = None,
shortcut: str = DEFAULT_SHORTCUT,
@@ -152,6 +157,21 @@ def create_backend(
)
return None
# On GNOME's X11 session, mutter does not deliver physical key events
# through the XRecord extension that pynput's Listener relies on, so the
# listener starts cleanly but shortcuts stay dead (#954). evdev reads the
# kernel input devices directly, so prefer it here — but fall back to
# pynput when /dev/input is unusable rather than leaving shortcuts dead.
if env == DesktopEnvironment.X11 and _is_gnome_session() and EVDEV_AVAILABLE:
backend = EvdevKeyboardBackend(shortcut=shortcut, mode=mode) # type: ignore
if backend.is_available():
logger.info("Using evdev backend for global shortcuts (GNOME X11)")
return backend
hint = backend.get_permission_hint()
if hint:
logger.warning(f"Evdev backend not usable on GNOME X11: {hint}")
logger.warning("Falling back to pynput; global shortcuts may not respond on GNOME X11")
# Default to pynput for native X11 (or unknown)
if PYNPUT_AVAILABLE:
logger.info("Using pynput backend")