diff --git a/docking/platform/backends/base.py b/docking/platform/backends/base.py index 037052c1..7a7ae8f2 100644 --- a/docking/platform/backends/base.py +++ b/docking/platform/backends/base.py @@ -26,10 +26,9 @@ from enum import Enum from typing import TYPE_CHECKING, Protocol -from docking.platform.running import RunningAppInfo - if TYPE_CHECKING: from docking.platform.model import DockModel + from docking.platform.running import RunningAppInfo class DisplayServer(Enum): diff --git a/docking/platform/backends/x11/__init__.py b/docking/platform/backends/x11/__init__.py new file mode 100644 index 00000000..c6794587 --- /dev/null +++ b/docking/platform/backends/x11/__init__.py @@ -0,0 +1,18 @@ +# Author: Eduardo Mucelli Rezende Oliveira +# E-mail: edumucelli@gmail.com +# +# This program 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. +# +# This program 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. + +"""X11 backend services.""" + +from docking.platform.backends.x11.windows import X11WindowService + +__all__ = ["X11WindowService"] diff --git a/docking/platform/backends/x11/windows.py b/docking/platform/backends/x11/windows.py new file mode 100644 index 00000000..a24217b0 --- /dev/null +++ b/docking/platform/backends/x11/windows.py @@ -0,0 +1,111 @@ +# Author: Eduardo Mucelli Rezende Oliveira +# E-mail: edumucelli@gmail.com +# +# This program 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. +# +# This program 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. + +"""X11 window-service facade backed by the existing Wnck WindowTracker.""" + +from __future__ import annotations + +from docking.platform.backends.base import ActionResult, WindowId +from docking.platform.window_tracker import WindowTracker + + +class X11WindowService(WindowTracker): + """WindowService adapter for the current X11/Wnck window tracker. + + The inherited methods keep the old UI-facing API alive while this facade + exposes backend-neutral actions for the future session backend. Runtime + startup still constructs ``WindowTracker`` directly until later PRs switch + application wiring to session services. + """ + + def bind_model(self, model) -> None: + """Attach a model after construction for backend-service callers.""" + self._model = model + + def start(self) -> None: + """Start X11 screen tracking if it has not already been initialized.""" + if self._screen is None: + self._init_screen() + + def stop(self) -> None: + """Release service state owned by the facade. + + Wnck signal handles are currently owned by the existing tracker path and + are not disconnected here; later session-backend PRs can make lifecycle + ownership explicit without changing this adapter contract. + """ + self._screen = None + + def activate(self, window_id: WindowId) -> ActionResult: + """Activate one X11 window by backend-neutral window ID.""" + xid = self._xid_from_window_id(window_id) + if xid is None: + return ActionResult.UNSUPPORTED + window = self._window_for_xid(xid=xid) + if window is None: + return ActionResult.NOT_FOUND + self.activate_window(window=window) + return ActionResult.OK + + def activate_most_recent(self, desktop_id: str) -> ActionResult: + """Activate the most recent window for a desktop ID.""" + if self._screen is None: + return ActionResult.UNSUPPORTED + if not self._get_windows_for(desktop_id=desktop_id): + return ActionResult.NOT_FOUND + return super().activate_most_recent(desktop_id=desktop_id) + + def cycle(self, desktop_id: str) -> ActionResult: + """Cycle windows for a desktop ID using the existing X11 policy.""" + if self._screen is None: + return ActionResult.UNSUPPORTED + if not self._get_windows_for(desktop_id=desktop_id): + return ActionResult.NOT_FOUND + super().cycle_windows(desktop_id=desktop_id) + return ActionResult.OK + + def minimize_all(self, desktop_id: str) -> ActionResult: + """Minimize all known windows for a desktop ID.""" + if self._screen is None: + return ActionResult.UNSUPPORTED + if not self._get_windows_for(desktop_id=desktop_id): + return ActionResult.NOT_FOUND + super().minimize_windows(desktop_id=desktop_id) + return ActionResult.OK + + def close(self, window_id: WindowId) -> ActionResult: + """Close one X11 window by backend-neutral window ID.""" + xid = self._xid_from_window_id(window_id) + if xid is None: + return ActionResult.UNSUPPORTED + if self._window_for_xid(xid=xid) is None: + return ActionResult.NOT_FOUND + super().close_xid(xid=xid) + return ActionResult.OK + + def close_all(self, desktop_id: str) -> ActionResult: + """Close all known windows for a desktop ID.""" + if self._screen is None: + return ActionResult.UNSUPPORTED + if not self._get_windows_for(desktop_id=desktop_id): + return ActionResult.NOT_FOUND + return super().close_all(desktop_id=desktop_id) + + @staticmethod + def _xid_from_window_id(window_id: WindowId) -> int | None: + if window_id.backend != "x11": + return None + try: + return int(window_id.value) + except (TypeError, ValueError): + return None diff --git a/docking/platform/running.py b/docking/platform/running.py index d82ba689..ff89a10b 100644 --- a/docking/platform/running.py +++ b/docking/platform/running.py @@ -19,6 +19,8 @@ from dataclasses import dataclass from typing import Any +from docking.platform.backends.base import WindowId + @dataclass(frozen=True) class RunningWindowInfo: @@ -32,6 +34,9 @@ class RunningWindowInfo: # focus actions use XIDs as a stable handoff between scans. Wnck objects # themselves can become stale at any time. xid: int + # Backend-neutral ID for future non-X11 window services. X11 callers should + # keep using xid until the UI is migrated to WindowService methods. + window_id: WindowId # These are per-window booleans. RunningAppInfo folds them with any(), so one # active or urgent window makes the app active or urgent. active: bool @@ -56,6 +61,7 @@ class RunningAppInfo: # UI consumers. windows: tuple[Any, ...] = () xids: tuple[int, ...] = () + window_ids: tuple[WindowId, ...] = () @classmethod def from_windows(cls, windows: Iterable[RunningWindowInfo]) -> RunningAppInfo: @@ -70,4 +76,5 @@ def from_windows(cls, windows: Iterable[RunningWindowInfo]) -> RunningAppInfo: urgent=any(snapshot.urgent for snapshot in snapshots), windows=tuple(snapshot.window for snapshot in snapshots), xids=tuple(snapshot.xid for snapshot in snapshots), + window_ids=tuple(snapshot.window_id for snapshot in snapshots), ) diff --git a/docking/platform/window_tracker.py b/docking/platform/window_tracker.py index 0e1b9ab3..396244bb 100644 --- a/docking/platform/window_tracker.py +++ b/docking/platform/window_tracker.py @@ -151,7 +151,6 @@ from __future__ import annotations from collections.abc import Iterable -from itertools import chain, pairwise from typing import TYPE_CHECKING, Any import gi @@ -161,6 +160,7 @@ from gi.repository import GLib, Gtk, Wnck from docking.log import get_logger, with_context +from docking.platform.backends.base import ActionResult, Rect, WindowId, WindowSnapshot from docking.platform.launcher import DESKTOP_SUFFIX, GNOME_APP_PREFIX from docking.platform.running import RunningAppInfo, RunningWindowInfo @@ -169,6 +169,7 @@ _RECOVERABLE_ERRORS: tuple[type[BaseException], ...] = (TypeError,) if isinstance(GLib.Error, type) and issubclass(GLib.Error, BaseException): _RECOVERABLE_ERRORS = (TypeError, GLib.Error) +_GEOMETRY_ERRORS: tuple[type[BaseException], ...] = (ValueError, *_RECOVERABLE_ERRORS) log = with_context(get_logger(name="window_tracker")) @@ -278,7 +279,10 @@ def _resolve_desktop_candidates( class_lower=class_lower, class_group=class_group ) ] - for desktop_id, next_candidate in pairwise(chain(candidate_ids, [None])): + for index, desktop_id in enumerate(candidate_ids): + next_candidate = ( + candidate_ids[index + 1] if index + 1 < len(candidate_ids) else None + ) if desktop_id in self._missed_desktop_candidates: continue info = self._launcher.resolve(desktop_id=desktop_id, log_failures=False) @@ -367,6 +371,7 @@ def __init__( # Preview/toggle paths use this cache to avoid rematching WM_CLASS # during hover-time UI events. self._running_xids_by_desktop: dict[str, list[int]] = {} + self._last_running: dict[str, RunningAppInfo] = {} self._cycle_index: dict[str, int] = {} self._cycle_order_by_desktop: dict[str, list[int]] = {} @@ -424,6 +429,7 @@ def _update_running(self) -> None: snapshots_by_desktop[desktop_id].append(snapshot) running = self._aggregate_running(windows_by_desktop=snapshots_by_desktop) + self._last_running = dict(running) self._running_xids_by_desktop = { desktop_id: list(info.xids) for desktop_id, info in running.items() } @@ -498,6 +504,7 @@ def _window_snapshot( return RunningWindowInfo( desktop_id=desktop_id, xid=xid, + window_id=WindowId.x11(xid), active=xid == active_xid, urgent=urgent, window=window, @@ -528,6 +535,24 @@ def get_xids_for(self, desktop_id: str) -> list[int]: """Get current window XIDs for a desktop_id from the latest scan.""" return list(self._running_xids_by_desktop.get(desktop_id, [])) + def snapshot_running(self) -> dict[str, RunningAppInfo]: + """Return the latest running-app aggregate for backend-service callers.""" + return dict(getattr(self, "_last_running", {})) + + def list_windows(self, desktop_id: str) -> list[WindowSnapshot]: + """Return backend-neutral snapshots for current windows of a desktop ID.""" + active_xid = self._active_xid() + snapshots: list[WindowSnapshot] = [] + for window in self._get_windows_for(desktop_id=desktop_id): + snapshot = self._window_service_snapshot( + window=window, + desktop_id=desktop_id, + active_xid=active_xid, + ) + if snapshot is not None: + snapshots.append(snapshot) + return snapshots + def icon_name_for_desktop(self, desktop_id: str) -> str: """Return icon name for desktop_id from the current dock model.""" for item in self._model.visible_items(): @@ -535,6 +560,179 @@ def icon_name_for_desktop(self, desktop_id: str) -> str: return item.icon_name or "application-x-executable" return "application-x-executable" + def _window_service_snapshot( + self, *, window: Wnck.Window, desktop_id: str, active_xid: int + ) -> WindowSnapshot | None: + """Convert one live Wnck window into the backend-neutral service shape.""" + try: + xid = int(window.get_xid()) + except _RECOVERABLE_ERRORS as exc: + log.bind(action="service_window_xid").warning( + f"Skipping window service snapshot: failed to read xid: {exc}" + ) + return None + + title = self._read_window_title(window=window, xid=xid) + app_id = self._read_window_string( + window=window, + method_name="get_class_group_name", + action="service_app_id", + xid=xid, + ) + wm_class = self._read_window_string( + window=window, + method_name="get_class_instance_name", + action="service_wm_class", + xid=xid, + ) + return WindowSnapshot( + id=WindowId.x11(xid), + desktop_id=desktop_id, + title=title, + app_id=app_id, + wm_class=wm_class or app_id, + active=xid == active_xid, + urgent=self._read_window_bool( + window=window, + method_name="needs_attention", + action="service_urgent", + xid=xid, + default=False, + ), + minimized=self._read_window_optional_bool( + window=window, + method_name="is_minimized", + action="service_minimized", + xid=xid, + ), + maximized=self._read_window_optional_bool( + window=window, + method_name="is_maximized", + action="service_maximized", + xid=xid, + ), + fullscreen=self._read_window_optional_bool( + window=window, + method_name="is_fullscreen", + action="service_fullscreen", + xid=xid, + ), + geometry=self._read_window_geometry(window=window, xid=xid), + workspace_id=self._read_window_workspace_id(window=window, xid=xid), + can_activate=True, + can_minimize=True, + can_close=True, + can_preview=True, + ) + + @staticmethod + def _read_window_title(*, window: Wnck.Window, xid: int) -> str: + try: + title = window.get_name() + except _RECOVERABLE_ERRORS as exc: + log.bind(action="service_title", xid=str(xid)).warning( + f"Failed to read window title: {exc}" + ) + return "Window" + return title or "Window" + + @staticmethod + def _read_window_string( + *, window: Wnck.Window, method_name: str, action: str, xid: int + ) -> str | None: + method = getattr(window, method_name, None) + if method is None: + return None + try: + value = method() + except _RECOVERABLE_ERRORS as exc: + log.bind(action=action, xid=str(xid)).warning( + f"Failed to read window string property: {exc}" + ) + return None + return value or None + + @staticmethod + def _read_window_bool( + *, + window: Wnck.Window, + method_name: str, + action: str, + xid: int, + default: bool, + ) -> bool: + method = getattr(window, method_name, None) + if method is None: + return default + try: + return bool(method()) + except _RECOVERABLE_ERRORS as exc: + log.bind(action=action, xid=str(xid)).warning( + f"Failed to read window boolean property: {exc}" + ) + return default + + @classmethod + def _read_window_optional_bool( + cls, *, window: Wnck.Window, method_name: str, action: str, xid: int + ) -> bool | None: + method = getattr(window, method_name, None) + if method is None: + return None + return cls._read_window_bool( + window=window, + method_name=method_name, + action=action, + xid=xid, + default=False, + ) + + @staticmethod + def _read_window_geometry(*, window: Wnck.Window, xid: int) -> Rect | None: + get_geometry = getattr(window, "get_geometry", None) + if get_geometry is None: + return None + try: + x, y, width, height = get_geometry() + except _GEOMETRY_ERRORS as exc: + log.bind(action="service_geometry", xid=str(xid)).warning( + f"Failed to read window geometry: {exc}" + ) + return None + return Rect(x=int(x), y=int(y), width=int(width), height=int(height)) + + @staticmethod + def _read_window_workspace_id(*, window: Wnck.Window, xid: int) -> str | None: + get_workspace = getattr(window, "get_workspace", None) + if get_workspace is None: + return None + try: + workspace = get_workspace() + except _RECOVERABLE_ERRORS as exc: + log.bind(action="service_workspace", xid=str(xid)).warning( + f"Failed to read window workspace: {exc}" + ) + return None + if workspace is None: + return None + get_number = getattr(workspace, "get_number", None) + if get_number is not None: + try: + return str(get_number()) + except _RECOVERABLE_ERRORS as exc: + log.bind(action="service_workspace_number", xid=str(xid)).warning( + f"Failed to read window workspace number: {exc}" + ) + get_name = getattr(workspace, "get_name", None) + if get_name is not None: + try: + return get_name() or None + except _RECOVERABLE_ERRORS as exc: + log.bind(action="service_workspace_name", xid=str(xid)).warning( + f"Failed to read window workspace name: {exc}" + ) + return None + def get_window_title_for_xid(self, xid: int) -> str: """Get a best-effort window title for an XID.""" window = self._window_for_xid(xid=xid) @@ -588,22 +786,23 @@ def toggle_focus(self, desktop_id: str) -> None: # Activate the most recent window self.activate_window(window=windows[0]) - def activate_most_recent(self, desktop_id: str) -> None: + def activate_most_recent(self, desktop_id: str) -> ActionResult: """Focus the MRU window for desktop_id, or minimize if already active.""" if self._screen is None: - return + return ActionResult.UNSUPPORTED windows = self._get_windows_for(desktop_id=desktop_id) if not windows: - return + return ActionResult.NOT_FOUND active_window = self._screen.get_active_window() if active_window and active_window in windows: self.minimize_windows(desktop_id=desktop_id) - return + return ActionResult.OK target = self._most_recent_window(windows=windows) self.activate_window(window=target) + return ActionResult.OK def _most_recent_window(self, windows: list[Wnck.Window]) -> Wnck.Window: """Return the topmost window in stacking order from the candidates.""" @@ -690,16 +889,22 @@ def close_focused(self, desktop_id: str) -> None: f"Failed to close window: {exc}" ) - def close_all(self, desktop_id: str) -> None: + def close_all(self, desktop_id: str) -> ActionResult: """Close all windows for a desktop_id.""" timestamp = Gtk.get_current_event_time() or 0 - for w in self._get_windows_for(desktop_id=desktop_id): + windows = self._get_windows_for(desktop_id=desktop_id) + if not windows: + return ActionResult.NOT_FOUND + result = ActionResult.OK + for w in windows: try: w.close(timestamp) except _RECOVERABLE_ERRORS as exc: log.bind(action="close_all", desktop_id=desktop_id).warning( f"Failed to close window: {exc}" ) + result = ActionResult.FAILED + return result def close_xid(self, xid: int) -> None: """Close a specific window by XID, if still present.""" diff --git a/docs/WAYLAND.md b/docs/WAYLAND.md index 39339685..300863a8 100644 --- a/docs/WAYLAND.md +++ b/docs/WAYLAND.md @@ -3063,23 +3063,107 @@ The safest first moves are still X11-preserving refactors: 1. Define `WindowId`, `WindowSnapshot`, `ActionResult`, and `WindowService`. 2. Add an X11 adapter around the current `WindowTracker` without changing - behavior. -3. Add `window_ids` alongside existing XIDs in running-state dataclasses. + behavior, and add `window_ids` alongside existing XIDs in running-state + dataclasses. This is the combined PR 2 + PR 3 step currently under review. +3. Wire the X11 window service into startup behind an X11-only backend/factory + path while preserving a temporary legacy fallback if needed. 4. Convert `MenuHandler` from Wnck windows/XIDs to `WindowSnapshot`. 5. Convert `PreviewPopup` from XID lists to `WindowSnapshot` plus `PreviewService`. -6. Add `SessionBackend` and wire the current X11 services through it. -7. Move dodge creation behind `VisibilityService`. -8. Move struts/barriers/blur/input-region ownership behind `SurfaceService`. -9. Add a `NullSessionBackend` or reduced backend and verify Docking can run +6. Move dodge creation behind `VisibilityService`. +7. Move struts/barriers/blur/input-region ownership behind `SurfaceService`. +8. Add a `NullSessionBackend` or reduced backend and verify Docking can run without Wnck task powers. -10. Only after that, start layer-shell and Wayland toplevel implementation. +9. Only after that, start layer-shell and Wayland toplevel implementation. The key test before real Wayland code is: Docking should be able to run with a backend that intentionally lacks taskbar, preview, workspace, and overlap powers. That flushes out hidden X11 assumptions before compositor protocols are involved. +### X11 Window-Service Migration Shape + +Current runtime path: + +```text +docking.app + | + +--> WindowTracker + | + +--> Wnck.Screen / Wnck.Window + | + +--> DockModel.update_running(RunningAppInfo with xids + Wnck windows) + | + +--> UI direct compatibility calls + | + +--> DockWindow click actions: + | cycle_windows(), activate_most_recent(), minimize_windows(), + | close_focused(), toggle_focus() + | + +--> Menu: + | get_windows_for(), get_window_title_for_xid(), + | activate_xid(), close_xid(), close_all() + | + +--> Preview: + get_xids_for(), get_window_title_for_xid(), activate_xid() +``` + +Proposed X11 service path before any Wayland backend is selected: + +```text +docking.app + | + +--> X11 backend/factory path + | + +--> X11WindowService + | + +--> existing WindowTracker/Wnck implementation internally + | + +--> DockModel.update_running(unchanged X11 aggregate) + | + +--> backend-neutral service methods + | + +--> list_windows() -> WindowSnapshot + WindowId.x11(xid) + +--> activate(WindowId), close(WindowId) + +--> snapshot_running() + | + +--> temporary compatibility methods + | + +--> get_xids_for(), get_windows_for(), activate_xid(), + close_xid(), cycle_windows(), close_all() +``` + +The current PR is the combined PR 2 + PR 3 step: it introduces the X11 window +facade and publishes neutral `WindowId` values alongside existing XIDs, but it +does not switch production startup away from direct `WindowTracker` construction. + +The next X11 migration PR should still be X11-only. It should prove that +`X11WindowService` can replace direct `WindowTracker` construction without +changing current X11 behavior, and it should not migrate menu or preview callers +in the same step. + +An optional temporary fallback can make that step safer: + +```text +DOCKING_X11_WINDOW_SERVICE=legacy -> construct WindowTracker directly +DOCKING_X11_WINDOW_SERVICE=service -> construct X11WindowService +``` + +If added, the environment switch should live at one construction point only and +should be removed after the X11 service path has been the default for a few PRs. +Do not thread environment checks through menu, preview, applets, or lower-level +backend code. + +Before `X11WindowService` is used in startup, make `_init_screen()` idempotent. +`WindowTracker.__init__()` schedules `_init_screen()` with `GLib.idle_add`, and +`X11WindowService.start()` can also call it directly; without a guard, the same +Wnck screen signals could be connected twice. + +Also keep the boundary clear: `snapshot_running()` is useful during migration, +but `RunningAppInfo.windows` currently carries live Wnck objects. Backend-neutral +consumers should use `WindowSnapshot` / `WindowId`, or `snapshot_running()` must +strip live Wnck objects before non-X11 services depend on it. + ### Proposed PR Order The PR sequence should keep every step reviewable and preserve the existing X11 @@ -3114,10 +3198,12 @@ Exit criteria: - pure unit tests for dataclasses/capabilities pass - no runtime behavior changes -#### PR 2: X11 Window Adapter Facade +#### PR 2 + PR 3: X11 Window Adapter Facade + Neutral Running IDs Wrap the current `WindowTracker` behavior behind an X11 `WindowService` while -keeping compatibility methods alive. +keeping compatibility methods alive. This combined PR also absorbs the neutral +running-ID step because it is still non-behavioral terrain prep: it only +publishes `WindowId` beside existing XIDs, without moving any UI caller yet. Scope: @@ -3128,120 +3214,219 @@ Scope: - add `WindowId` mapping from XID to live Wnck window internally - preserve `get_xids_for()`, `get_windows_for()`, `activate_xid()`, and `close_xid()` temporarily +- add `window_id` to `RunningWindowInfo` +- add `window_ids` to `RunningAppInfo` +- keep `xid` and `xids` as the active compatibility path for current X11 UI + code +- update the X11 scan path so every valid XID snapshot also carries + `WindowId.x11(xid)` +- make `docking/platform/backends/base.py` avoid runtime imports from + `docking.platform.running` so `running.py` can import `WindowId` without a + circular import Do not: - convert preview/menu yet - remove XID fields +- change `DockModel.update_running()` semantics +- change `docking.app` startup wiring +- add Wayland or reduced-backend selection Exit criteria: - current window-tracker tests pass -- new adapter tests prove snapshots preserve title, active, urgent, count, and - XID identity - -#### PR 3: Neutral Running State IDs - -Make running-state data capable of carrying non-XID window IDs. - -Scope: - -- add `window_id` to `RunningWindowInfo` -- add `window_ids` to `RunningAppInfo` -- keep `xid` and `xids` for X11 compatibility -- update X11 tracker/adapter to populate both -- update tests to assert both where useful +- new adapter tests prove snapshots preserve title, active, urgent, geometry, + workspace, capabilities, and XID identity +- running-state tests prove `xids` and `window_ids` are populated in the same + order +- import smoke tests prove `docking.platform.running` and + `docking.platform.backends.base` do not create a circular import +- current X11 callers still use the old compatibility methods, so user-visible + behavior is unchanged + +Implementation notes: + +- Prefer an additive adapter over a rewrite. `WindowTracker` remains the + current runtime object until the X11 runtime service-wiring PR. +- Keep live Wnck objects inside the X11 implementation. Only + `WindowSnapshot`, `RunningAppInfo`, and `WindowId` should cross the new + backend-facing API. +- When a Wnck property read races with a disappearing X11 window, preserve the + existing failure policy: skip only the unstable read/window and keep the scan + convergent. +- `WindowId.value` for X11 is the integer XID. Future Wayland backends must not + expose protocol handles there; they should use backend-owned opaque IDs. + +Validation commands: + +- `.venv/bin/ruff check docking/platform/backends/base.py docking/platform/running.py docking/platform/window_tracker.py docking/platform/backends/x11 tests/platform/test_backend_contracts.py tests/platform/test_window_tracker_integration.py tests/platform/test_x11_window_service.py` +- `python3 -m compileall -q docking/platform/backends/base.py docking/platform/running.py docking/platform/window_tracker.py docking/platform/backends/x11 tests/platform/test_backend_contracts.py tests/platform/test_window_tracker_integration.py tests/platform/test_x11_window_service.py` +- `python3 -c "from docking.platform.running import RunningAppInfo, RunningWindowInfo; from docking.platform.backends.base import WindowId; print(WindowId.x11(1))"` +- When the local environment has pytest installed: + `.venv/bin/pytest tests/platform/test_backend_contracts.py tests/platform/test_window_tracker.py tests/platform/test_window_tracker_integration.py tests/platform/test_x11_window_service.py` + +#### PR 4: X11 Runtime Service Wiring + +Switch production X11 construction from direct `WindowTracker` ownership to the +new X11 service path, without moving any menu, preview, applet, visibility, or +surface caller yet. + +Start here: + +- add a narrow X11 backend/factory construction point +- make `docking.app` construct `X11WindowService` through that path +- keep current X11 behavior and current UI compatibility methods available +- make `WindowTracker._init_screen()` idempotent before calling service startup +- optionally add a temporary `DOCKING_X11_WINDOW_SERVICE=legacy|service` switch + at the construction point only +- update startup/factory tests so both the default service path and optional + legacy fallback are covered if the fallback exists Do not: -- remove `xids` -- change UI callers yet +- migrate menu or preview callers yet +- add native Wayland or reduced-backend selection +- change `DockModel.update_running()` semantics +- thread environment checks beyond the construction point Exit criteria: -- `DockModel.update_running()` behavior is unchanged -- existing X11 tests still pass - -#### PR 4: Menu Window Rows Use Snapshots - -Remove Wnck/XID assumptions from open-window menu rows. - -Scope: - -- change `MenuHandler._append_open_windows()` to use `WindowService.list_windows` -- sort by `WindowSnapshot.title` -- activate/close by `WindowId` -- keep thumbnails optional -- leave compatibility fallback only if required during transition +- X11 startup uses `X11WindowService` by default, or can be forced to it with the + temporary environment switch +- the old `WindowTracker` path can still be selected temporarily if the fallback + is included +- current X11 UI tests still pass unchanged +- no duplicate Wnck signal connection is possible + +#### PR 5: Menu Window Rows Use Snapshots + +Remove Wnck/XID assumptions from open-window menu rows. This is the first +planned UI consumer migration after the combined PR 2 + PR 3 and the X11 runtime +service-wiring PR, even though X11 behavior should remain visually identical. + +Start here: + +- inspect `docking/ui/menu.py`, especially open-window row creation, row + activation, and close-button handlers +- inject or pass the window service that already has `list_windows()`, + `activate(WindowId)`, and `close(WindowId)` +- keep the compatibility tracker available until all callers have moved; do not + delete `get_windows_for()`, `activate_xid()`, or `close_xid()` +- use `WindowSnapshot.title` for labels and `WindowSnapshot.id` for actions +- preserve current sorting, empty-state behavior, close-button visibility, and + menu teardown behavior +- update tests in `tests/ui/test_menu_integration.py` so they assert + `WindowSnapshot` and `WindowId` usage instead of fake Wnck objects where + possible +- add a focused regression test that an X11 snapshot with XID 7 still calls the + X11 adapter close/activate path for `WindowId.x11(7)` Do not: -- change preview popup yet -- remove old XID methods from the X11 adapter +- touch preview popup behavior +- remove XID fields from running state +- change app startup/backend selection +- add native Wayland-specific menu behavior Exit criteria: -- menu tests no longer need fake Wnck windows for window rows +- menu tests no longer need live/fake Wnck windows for open-window rows - close/activate menu behavior remains unchanged on X11 - -#### PR 5: Preview Popup Uses `PreviewService` - -Remove direct `GdkX11`/`Wnck` usage from backend-neutral preview UI. - -Scope: - -- add X11 `PreviewService` using current XID capture internally -- change `PreviewPopup` to consume `WindowSnapshot` and `PreviewService` -- move `capture_xid()` / `capture_window()` into X11 preview implementation or - mark them transitional -- support icon/title fallback when `can_preview` is false +- unsupported or empty `list_windows()` produces the current no-window menu + behavior rather than a crash + +#### PR 6: Preview Popup Uses `PreviewService` + +Remove direct `GdkX11`/`Wnck` usage from backend-neutral preview UI while +keeping the X11 capture path internally unchanged. + +Start here: + +- inspect `docking/ui/preview.py` and identify every XID, Wnck, and GdkX11 + boundary +- add `docking/platform/backends/x11/previews.py` +- move current X11 capture logic behind `PreviewService.capture(WindowId, ...)` +- keep any low-level X11 pixbuf/window lookup helpers private to the X11 + preview service +- make `PreviewPopup` consume `WindowSnapshot` or `WindowId` instead of raw XID + lists +- preserve icon/title fallback behavior for windows where capture returns + `None` +- keep X11 screenshot/capture error handling defensive; disappearing windows + should drop a preview, not crash hover UI +- update `tests/ui/test_preview_popup_integration.py` and visual preview cases + to use snapshots/service fakes Do not: -- add native Wayland capture +- add native Wayland capture portals yet +- change thumbnail sizing or timing policy unless required by the service + boundary +- delete `get_xids_for()` until no callers remain Exit criteria: - `docking/ui/preview.py` no longer imports `GdkX11` or `Wnck` - preview behavior remains the same on X11 - -#### PR 6: Session Backend Selection With X11 Only - -Introduce `SessionBackend` and a backend factory, but still select only the X11 -backend in production. - -Scope: - -- add `docking/platform/backends/selection.py` -- add `X11SessionBackend` -- make `docking.app` construct a session backend -- pass backend/services into `build_dock_window` -- keep direct X11 behavior through the X11 services +- tests cover stale/not-found `WindowId` handling + +#### PR 7: Complete Session Backend Shape With X11 Only + +Complete the X11 `SessionBackend` shape after the first runtime service wiring +and after menu/preview can consume services. Production still selects only the +X11 backend. + +Start here: + +- add or expand `docking/platform/backends/x11/session.py` +- add or expand `docking/platform/backends/selection.py` +- complete `X11SessionBackend` with: + - `name == "x11"` + - `display_server == DisplayServer.X11` + - X11 `WindowService` + - X11 `PreviewService` once PR 6 exists + - placeholder `None` or transitional services for surface/visibility until + their PRs land +- make `docking.app` pass the service objects needed by migrated UI callers into + `build_dock_window` +- keep imports lazy enough that native Wayland startup does not import X11-only + modules before backend selection in later PRs +- update `tests/test_app.py`, `tests/ui/test_factory.py`, and smoke tests to + build/fake a session backend Do not: - add native Wayland backend - change applets yet +- make `GDK_BACKEND=wayland` claim support Exit criteria: - startup behavior is unchanged on X11 - tests can construct a fake/null session backend for UI wiring +- `docking.app` no longer directly decides individual X11 services -#### PR 7: Visibility Service +#### PR 8: Visibility Service Move dodge monitor creation behind the session backend. -Scope: +Start here: -- add X11 `VisibilityService` wrapping current `WindowDodgeMonitor` -- change `docking.ui.factory` to request a monitor from `backend.visibility` -- support "no visibility monitor" cleanly -- add capability checks for hide modes +- inspect `docking/platform/dodge.py`, `docking/platform/dodge_monitor.py`, and + `docking/ui/factory.py` +- add `docking/platform/backends/x11/visibility.py` +- wrap current `WindowDodgeMonitor` construction behind `VisibilityService` +- teach the factory to request a visibility monitor from + `backend.visibility.create_monitor(...)` +- support `None` cleanly for unsupported backends or unsupported hide modes +- map hide-mode support to `PlatformCapabilities` rather than probing X11 in UI +- preserve all current X11 hide-mode semantics and signal timing Do not: -- change hide-mode semantics on X11 +- change dodge math +- add Wayland overlap protocols +- change surface placement or struts Exit criteria: @@ -3249,76 +3434,96 @@ Exit criteria: - X11 dodge tests still pass - unsupported visibility service can run without crashing -#### PR 8: Surface Service +#### PR 9: Surface Service Move struts, barriers, blur hints, and platform surface hooks behind `SurfaceService`. -Scope: +Start here: -- add X11 `SurfaceService` -- move X11 strut/barrier/blur calls behind service methods -- make `DockPlacementController` coordinate placement but delegate platform - edge integration +- inspect `docking/platform/struts.py`, pointer barriers, blur helpers, and + `DockPlacementController` +- add `docking/platform/backends/x11/surface.py` +- move X11 strut/barrier/blur calls behind service methods while keeping + placement math in the existing placement controller - make input-region support capability-driven +- keep X11-specific imports under the X11 backend or explicit transitional + shims +- add tests that unsupported reservation/input-region operations are no-ops, + not crashes Do not: - add layer-shell yet - rewrite placement math unless required to preserve behavior +- change always-visible/autohide semantics Exit criteria: - raw `GdkX11` checks are confined to X11 backend code or transitional shims - X11 placement, strut, barrier, and blur tests still pass -#### PR 9: Applet Service Extraction +#### PR 10: Applet Service Extraction Move Wnck/X11 applet dependencies behind applet-facing services. -Scope: +Start here: -- add `WorkspaceService`, `DesktopActionService`, `WindowPickService`, - `IdleService`, and `ScreenCaptureService` implementations for X11 where - applicable -- migrate Desktop, Workspaces, Window Killer, Desk Presence, and Color Picker - toward service use -- add unsupported-service behavior for applets +- inventory X11/Wnck usage in Desktop, Workspaces, Window Killer, + Desk Presence, Color Picker, Screenshot, Caffeine, and any applet that shells + out to X11 tools +- add X11 implementations for `WorkspaceService`, `DesktopActionService`, + `WindowPickService`, `IdleService`, and `ScreenCaptureService` only where the + current applet really needs them +- migrate one applet family at a time, starting with the smallest one +- add explicit unsupported states so applets can hide actions, disable buttons, + or show a concise unavailable state instead of crashing +- keep service APIs narrow; do not expose raw Wnck windows to applets Do not: - attempt full native Wayland applet parity +- make applet UI depend on compositor names directly +- remove X11 helper paths until each applet has tests Exit criteria: - applets keep current X11 behavior - backend-neutral applet loading can skip or disable unsupported actions +- tests cover at least one unsupported-service path -#### PR 10: Null / Reduced Backend +#### PR 11: Null / Reduced Backend Add a backend with no taskbar powers to validate that X11 assumptions are no longer leaking through normal UI. -Scope: +Start here: -- add `NullSessionBackend` or `ReducedSessionBackend` +- add `docking/platform/backends/null/session.py` or + `docking/platform/backends/reduced/session.py` - provide no-op services for windows, previews, visibility, workspaces, and applet-specific platform actions -- add a test/dev entry point or environment flag to force it -- verify pinned launchers, rendering, menus without window rows, and - backend-neutral applets still work +- add a test/dev selection path, preferably an environment variable such as + `DOCKING_BACKEND=null`, but keep production auto-detection unchanged +- verify pinned launchers, rendering, menus without window rows, no-preview + hover behavior, and backend-neutral applets +- assert `PlatformCapabilities` is honest: false for taskbar powers, true only + for what the reduced backend actually supports Do not: - ship it as user-facing native Wayland support yet unless explicitly labeled reduced/experimental +- import X11 modules from the null backend +- silently pretend taskbar/window actions succeeded Exit criteria: - Docking can start without Wnck task powers - unsupported features degrade intentionally +- reduced-backend tests prove no accidental X11 imports -#### PR 11: Native Wayland Detection Stub +#### PR 12: Native Wayland Detection Stub Only after the reduced backend works, add native Wayland detection that selects a reduced/no-op backend when unsupported. @@ -3329,42 +3534,55 @@ Scope: - avoid importing X11-only backend modules for native Wayland - log protocol/dependency limitations clearly - select reduced backend on GNOME/Mutter native Wayland +- select X11 backend for X11 and XWayland sessions exactly as today Do not: - add layer-shell or foreign-toplevel yet +- run X11 fallback code in native Wayland unless explicitly under XWayland +- claim current-open-app support on compositors without a toplevel protocol Exit criteria: -- native GTK Wayland unsupported sessions do not crash -- X11 and `GDK_BACKEND=x11` XWayland still select X11 backend +- Docking does not crash on native Wayland startup +- X11 remains unchanged +- logs and capability flags explain reduced mode -#### PR 12: Layer-Shell Surface Backend +#### PR 13: Layer-Shell Surface Backend -Add the first real native Wayland dock-surface capability. +Add native Wayland dock-surface placement for compositors that support +layer-shell. -Scope: +Start here: - optional `gtk-layer-shell` import inside Wayland surface backend - configure layer-shell before first map - implement anchors, monitor targeting, layer choice, and exclusive zones - keep fallback when layer-shell is unavailable +- map the existing Docking position/config concepts onto layer-shell anchors + without changing X11 placement code +- keep GNOME/Mutter native Wayland in reduced mode because it does not expose + the required third-party layer-shell path +- add capability flags for layer-shell and exclusive-zone reservation Do not: - add taskbar/window tracking yet +- make layer-shell a hard dependency +- import layer-shell modules in X11 sessions Exit criteria: - native Wayland dock can reserve edge space on a supported layer-shell compositor - GNOME native Wayland remains reduced/unsupported with a clear log +- X11 placement tests remain unchanged -#### PR 13: Generic Foreign-Toplevel Window Service +#### PR 14: Generic Foreign-Toplevel Window Service Add wlroots-style opened-app context. -Scope: +Start here: - bind `zwlr_foreign_toplevel_manager_v1` - track app ID, title, active/minimized/maximized/fullscreen, parent, and close @@ -3372,59 +3590,81 @@ Scope: - implement activate/close/minimize where supported - publish `RunningAppInfo` through neutral window IDs - use Wayland-aware app ID matching +- store protocol handles only inside the Wayland backend; expose only + `WindowId`/`WindowSnapshot` to UI +- handle protocol absence by keeping the reduced backend active +- keep geometry, stacking, and workspace capability flags false unless the + compositor protocol actually provides them Do not: - claim geometry/dodge/workspace parity +- add compositor-specific Plasma or COSMIC code here +- change X11 matching behavior Exit criteria: - running indicators and basic window actions work on at least one supported wlroots compositor +- unsupported compositors continue reduced mode with clear logs -#### PR 14: KWin / Plasma Backend +#### PR 15: KWin / Plasma Backend Add the richest parity backend. -Scope: +Start here: - bind Plasma window-management and virtual desktop protocols - track UUIDs, app IDs, titles, state, attention, skip-taskbar, geometry, PID, virtual desktops, and stacking order where available - implement richer actions and show-desktop/workspace services - enable more hide modes when geometry/workspace capabilities are present +- add Plasma-specific `WindowService`, `WorkspaceService`, and + `DesktopActionService` pieces behind capability flags +- prefer Plasma protocol data over generic foreign-toplevel data when both are + present +- keep protocol selection deterministic and logged Exit criteria: - KWin Wayland reaches closest behavior to current X11 for taskbar, actions, workspace, and dodge features +- non-Plasma Wayland backends are unaffected -#### PR 15: COSMIC / Optional Compositor Extras +#### PR 16: COSMIC / Optional Compositor Extras Add compositor-specific backends after the generic and KWin paths are stable. -Scope: +Start here: - COSMIC toplevel/workspace/overlap protocols - optional Wayfire IPC extensions - optional Niri overview integration +- add each compositor integration behind explicit detection and capability + flags +- keep each optional backend isolated so a missing dependency or unsupported + compositor cannot affect X11, generic wlroots, or Plasma Exit criteria: - each compositor extension is capability-gated and does not affect X11 or other Wayland backends -#### PR 16: Cleanup Transitional X11 APIs +#### PR 17: Cleanup Transitional X11 APIs Remove compatibility methods only after all UI callers and tests use neutral backend APIs. -Scope: +Start here: - remove or deprecate `get_xids_for`, `activate_xid`, `close_xid` from backend-neutral call paths - keep XID internals inside X11 backend where still useful - update documentation and support language +- search the repo for `get_xids_for`, `get_windows_for`, `activate_xid`, + `close_xid`, raw `Wnck.Window`, and raw XID usage before deleting anything +- preserve any X11-only internals that the X11 backend still needs for previews + or diagnostics Exit criteria: diff --git a/tests/platform/test_backend_contracts.py b/tests/platform/test_backend_contracts.py index 42f4cc97..9b34e32e 100644 --- a/tests/platform/test_backend_contracts.py +++ b/tests/platform/test_backend_contracts.py @@ -7,6 +7,7 @@ WindowId, WindowSnapshot, ) +from docking.platform.running import RunningAppInfo, RunningWindowInfo class TestWindowId: @@ -59,6 +60,40 @@ def test_defaults_are_safe_for_unsupported_actions(self): assert snapshot.geometry is None +class TestRunningAppInfo: + def test_preserves_xids_and_window_ids(self): + first = object() + second = object() + + running = RunningAppInfo.from_windows( + [ + RunningWindowInfo( + desktop_id="firefox.desktop", + xid=1, + window_id=WindowId.x11(1), + active=False, + urgent=False, + window=first, + ), + RunningWindowInfo( + desktop_id="firefox.desktop", + xid=2, + window_id=WindowId.x11(2), + active=True, + urgent=True, + window=second, + ), + ] + ) + + assert running.count == 2 + assert running.active is True + assert running.urgent is True + assert running.windows == (first, second) + assert running.xids == (1, 2) + assert running.window_ids == (WindowId.x11(1), WindowId.x11(2)) + + class TestActionResult: def test_only_ok_succeeds(self): assert ActionResult.OK.succeeded diff --git a/tests/platform/test_window_tracker_integration.py b/tests/platform/test_window_tracker_integration.py index eeb05824..112d794e 100644 --- a/tests/platform/test_window_tracker_integration.py +++ b/tests/platform/test_window_tracker_integration.py @@ -17,6 +17,7 @@ sys.modules.setdefault("gi.repository", gi_mock.repository) import docking.platform.window_tracker as window_tracker_mod +from docking.platform.backends.base import WindowId from docking.platform.launcher import DesktopInfo from docking.platform.model import DockItem @@ -207,7 +208,12 @@ def test_update_running_aggregates_windows(self, tracker_env): assert running["firefox.desktop"].active is True assert running["firefox.desktop"].urgent is True assert running["firefox.desktop"].xids == (1, 2) + assert running["firefox.desktop"].window_ids == ( + WindowId.x11(1), + WindowId.x11(2), + ) assert running["code.desktop"].count == 1 + assert running["code.desktop"].window_ids == (WindowId.x11(3),) assert tracker._running_xids_by_desktop == { "firefox.desktop": [1, 2], "code.desktop": [3], @@ -264,6 +270,7 @@ def get_window_type(self): model.update_running.assert_called_once() running = model.update_running.call_args.kwargs["running"] assert running["firefox.desktop"].xids == (10,) + assert running["firefox.desktop"].window_ids == (WindowId.x11(10),) def test_update_running_preserves_matched_app_when_xid_read_fails( self, tracker_env @@ -283,6 +290,7 @@ def get_xid(self) -> int: # type: ignore[override] running = model.update_running.call_args.kwargs["running"] assert running["firefox.desktop"].count == 0 assert running["firefox.desktop"].xids == () + assert running["firefox.desktop"].window_ids == () class TestWindowMatching: diff --git a/tests/platform/test_x11_window_service.py b/tests/platform/test_x11_window_service.py new file mode 100644 index 00000000..4f2a5a3a --- /dev/null +++ b/tests/platform/test_x11_window_service.py @@ -0,0 +1,223 @@ +"""Tests for the X11 WindowService facade.""" + +from __future__ import annotations + +import sys +from types import SimpleNamespace +from unittest.mock import MagicMock + +try: + import gi # noqa: F401 +except ModuleNotFoundError: # pragma: no cover - fallback for non-GI environments + gi_mock = MagicMock() + gi_mock.require_version = MagicMock() + sys.modules.setdefault("gi", gi_mock) + sys.modules.setdefault("gi.repository", gi_mock.repository) + +import docking.platform.window_tracker as tracker_mod +from docking.platform.backends.base import ActionResult, WindowId +from docking.platform.backends.x11.windows import X11WindowService +from docking.platform.running import RunningAppInfo + + +class FakeWorkspace: + def __init__(self, number: int) -> None: + self._number = number + + def get_number(self) -> int: + return self._number + + +class FakeWindow: + def __init__( + self, + xid: int, + *, + title: str = "Window", + urgent: bool = False, + minimized: bool = False, + maximized: bool = False, + fullscreen: bool = False, + workspace: FakeWorkspace | None = None, + ) -> None: + self._xid = xid + self._title = title + self._urgent = urgent + self._minimized = minimized + self._maximized = maximized + self._fullscreen = fullscreen + self._workspace = workspace + self.activated_with: list[int] = [] + self.closed_with: list[int] = [] + self.minimize_calls = 0 + + def get_window_type(self) -> int: + return 0 + + def is_skip_tasklist(self) -> bool: + return False + + def get_xid(self) -> int: + return self._xid + + def get_name(self) -> str: + return self._title + + def get_class_group_name(self) -> str: + return "Firefox" + + def get_class_instance_name(self) -> str: + return "firefox" + + def needs_attention(self) -> bool: + return self._urgent + + def is_minimized(self) -> bool: + return self._minimized + + def is_maximized(self) -> bool: + return self._maximized + + def is_fullscreen(self) -> bool: + return self._fullscreen + + def get_geometry(self) -> tuple[int, int, int, int]: + return (10, 20, 800, 600) + + def get_workspace(self) -> FakeWorkspace | None: + return self._workspace + + def activate(self, timestamp: int) -> None: + self.activated_with.append(timestamp) + + def close(self, timestamp: int) -> None: + self.closed_with.append(timestamp) + + def minimize(self) -> None: + self.minimize_calls += 1 + self._minimized = True + + +class FakeScreen: + def __init__(self, windows: list[FakeWindow], active_window: FakeWindow | None): + self._windows = windows + self._active_window = active_window + + def get_windows(self) -> list[FakeWindow]: + return list(self._windows) + + def get_active_window(self) -> FakeWindow | None: + return self._active_window + + +def make_service( + windows: list[FakeWindow], *, active: FakeWindow | None = None +) -> X11WindowService: + service = X11WindowService.__new__(X11WindowService) + service._screen = FakeScreen(windows=windows, active_window=active) + service._config = None + service._model = MagicMock() + service._launcher = MagicMock() + service._running_xids_by_desktop = { + "firefox.desktop": [window.get_xid() for window in windows] + } + service._last_running = { + "firefox.desktop": RunningAppInfo( + count=len(windows), + xids=tuple(window.get_xid() for window in windows), + window_ids=tuple(WindowId.x11(window.get_xid()) for window in windows), + ) + } + service._cycle_index = {} + service._cycle_order_by_desktop = {} + return service + + +def test_list_windows_returns_backend_snapshots(monkeypatch): + monkeypatch.setattr( + tracker_mod.Wnck, + "WindowType", + SimpleNamespace(DESKTOP=1, DOCK=2), + raising=False, + ) + + first = FakeWindow(10, title="Firefox") + second = FakeWindow( + 20, + title="Private Window", + urgent=True, + minimized=True, + maximized=True, + workspace=FakeWorkspace(2), + ) + service = make_service([first, second], active=second) + + snapshots = service.list_windows("firefox.desktop") + + assert len(snapshots) == 2 + assert snapshots[0].id == WindowId.x11(10) + assert snapshots[0].title == "Firefox" + assert snapshots[0].active is False + assert snapshots[0].urgent is False + assert snapshots[0].geometry is not None + assert snapshots[0].geometry.width == 800 + assert snapshots[1].id == WindowId.x11(20) + assert snapshots[1].title == "Private Window" + assert snapshots[1].active is True + assert snapshots[1].urgent is True + assert snapshots[1].minimized is True + assert snapshots[1].maximized is True + assert snapshots[1].fullscreen is False + assert snapshots[1].workspace_id == "2" + assert snapshots[1].can_activate is True + assert snapshots[1].can_close is True + + +def test_snapshot_running_returns_copy(): + service = make_service([FakeWindow(10)]) + + running = service.snapshot_running() + running.clear() + + assert service.snapshot_running()["firefox.desktop"].count == 1 + assert service.snapshot_running()["firefox.desktop"].xids == (10,) + assert service.snapshot_running()["firefox.desktop"].window_ids == ( + WindowId.x11(10), + ) + + +def test_activate_uses_x11_window_id(monkeypatch): + monkeypatch.setattr(tracker_mod.Gtk, "get_current_event_time", lambda: 123) + window = FakeWindow(10) + service = make_service([window]) + + result = service.activate(WindowId.x11(10)) + + assert result is ActionResult.OK + assert window.activated_with == [123] + + +def test_activate_reports_not_found_for_stale_xid(): + service = make_service([FakeWindow(10)]) + + assert service.activate(WindowId.x11(99)) is ActionResult.NOT_FOUND + + +def test_activate_rejects_non_x11_window_id(): + service = make_service([FakeWindow(10)]) + + assert ( + service.activate(WindowId(backend="wayland", value="window-1")) + is ActionResult.UNSUPPORTED + ) + + +def test_close_uses_x11_window_id(monkeypatch): + monkeypatch.setattr(tracker_mod.Gtk, "get_current_event_time", lambda: 456) + window = FakeWindow(10) + service = make_service([window]) + + result = service.close(WindowId.x11(10)) + + assert result is ActionResult.OK + assert window.closed_with == [456]