# -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2025 sezanzeb # # This file is part of input-remapper. # # input-remapper is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # input-remapper is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with input-remapper. If not, see . """Focus backend for X11 / XWayland using python-xlib. The root window is watched for ``_NET_ACTIVE_WINDOW`` property changes. The X connection's file descriptor is integrated into the GLib main loop so no extra thread is needed. """ from __future__ import annotations import os from typing import Any, Optional import gi from inputremapper.focus.focus_backend import FocusBackend, FocusEvent, OnFocus from inputremapper.logging.logger import logger gi.require_version("GLib", "2.0") from gi.repository import GLib # noqa: E402 class XorgFocusBackend(FocusBackend): """Detects focus changes via _NET_ACTIVE_WINDOW on the X root window.""" name = "xorg" def __init__(self) -> None: self._display: Any = None self._root: Any = None self._watch_id: Optional[int] = None self._on_focus: Optional[OnFocus] = None self._net_active_window: Any = None self._net_wm_name: Any = None self._utf8_string: Any = None @staticmethod def is_available() -> bool: if not os.environ.get("DISPLAY"): return False try: from Xlib import display as xdisplay except ImportError: logger.debug("python-xlib is not installed") return False try: connection = xdisplay.Display() connection.close() except Exception as error: # pragma: no cover - depends on environment logger.debug("Could not open X display: %s", error) return False return True def start(self, on_focus: OnFocus) -> None: from Xlib import X, display as xdisplay self._on_focus = on_focus self._display = xdisplay.Display() self._root = self._display.screen().root self._net_active_window = self._display.intern_atom("_NET_ACTIVE_WINDOW") self._net_wm_name = self._display.intern_atom("_NET_WM_NAME") self._utf8_string = self._display.intern_atom("UTF8_STRING") self._root.change_attributes(event_mask=X.PropertyChangeMask) self._display.flush() self._watch_id = GLib.io_add_watch( self._display.fileno(), GLib.IO_IN | GLib.IO_HUP | GLib.IO_ERR, self._on_readable, ) logger.debug("Xorg focus backend watching root window") def _on_readable(self, _fd: int, condition: int) -> bool: from Xlib import X if condition & (GLib.IO_HUP | GLib.IO_ERR): logger.error("X connection lost") return False try: pending = self._display.pending_events() for _ in range(pending): event = self._display.next_event() if ( event.type == X.PropertyNotify and event.atom == self._net_active_window ): self._emit_current() except Exception as error: # pragma: no cover - depends on environment logger.error("Error while reading X events: %s", error) return False return True def _emit_current(self) -> None: event = self.get_current() if event is not None and self._on_focus is not None: self._on_focus(event) def _active_window(self) -> Any: prop = self._root.get_full_property(self._net_active_window, 0) if prop is None or not prop.value: return None window_id = prop.value[0] if not window_id: return None return self._display.create_resource_object("window", window_id) def _read_title(self, window: Any) -> str: from Xlib import X try: prop = window.get_full_property(self._net_wm_name, self._utf8_string) if prop and prop.value: value = prop.value if isinstance(value, bytes): return value.decode(errors="replace") return str(value) prop = window.get_full_property(X.XA_WM_NAME, X.AnyPropertyType) if prop and prop.value: value = prop.value if isinstance(value, bytes): return value.decode(errors="replace") return str(value) except Exception: # pragma: no cover - depends on environment pass return "" def get_current(self) -> Optional[FocusEvent]: if self._display is None: return None from Xlib.error import XError try: window = self._active_window() if window is None: return FocusEvent(app_id="", title="", backend=self.name) wm_class = window.get_wm_class() # get_wm_class returns (instance, class); the class is the canonical id app_id = wm_class[1] if wm_class else "" title = self._read_title(window) except XError as error: logger.debug("X error while reading active window: %s", error) return None return FocusEvent(app_id=app_id or "", title=title, backend=self.name) def stop(self) -> None: if self._watch_id is not None: GLib.source_remove(self._watch_id) self._watch_id = None if self._display is not None: try: self._display.close() except Exception: # pragma: no cover - defensive pass self._display = None self._root = None