Fixed more issues with Cinnamon window resizing and popups.

This commit is contained in:
2026-09-08 22:46:53 +02:00
parent 1a120bbf86
commit 76bef94757
3 changed files with 257 additions and 99 deletions

View File

@@ -5,11 +5,13 @@ from __future__ import annotations
import argparse
import ctypes
import ctypes.util
import os
from pathlib import Path
import select
import subprocess
import sys
import time
from collections.abc import Collection, Iterable, Sequence
from collections.abc import Collection, Iterable, Mapping, Sequence
from .desktop_identity import (
APPLICATION_NAME,
@@ -44,12 +46,15 @@ _X_PROPERTY_CHANGE_MASK = 1 << 22
_X_SUBSTRUCTURE_NOTIFY_MASK = 1 << 19
_X_SUBSTRUCTURE_REDIRECT_MASK = 1 << 20
_X_WM_STATE_WITHDRAWN = 0
_X_WM_STATE_NORMAL = 1
_X_WM_STATE_ICONIC = 3
_NET_WM_STATE_REMOVE = 0
_NET_WM_STATE_SOURCE_APPLICATION = 1
_NET_WM_BYPASS_COMPOSITOR_PREFER_COMPOSITING = 2
WINDOW_FOCUS_REPAIR_INTERVAL_SECONDS = 1.0
GAMESCOPE_FOCUS_REPAIR_INTERVAL_SECONDS = 0.25
GAMESCOPE_HOST_FOCUS_STATE_ENV = "PTLINUX_GAMESCOPE_HOST_FOCUS_STATE"
GAMESCOPE_HOST_FOCUS_STATE_MAX_AGE_SECONDS = 0.6
_P_MIN_SIZE = 1 << 4
_P_MAX_SIZE = 1 << 5
_P_RESIZE_INC = 1 << 6
@@ -64,6 +69,14 @@ _MOTIF_DECOR_MAXIMIZE = 1 << 6
_X_ERROR_HANDLER = ctypes.CFUNCTYPE(ctypes.c_int, ctypes.c_void_p, ctypes.c_void_p)
def _process_exit_status(returncode: int) -> int:
"""Convert Popen's negative signal code before passing it through SystemExit."""
if returncode < 0:
return 128 + min(-returncode, 127)
return min(returncode, 255)
def _ignore_x11_error(_display: object, _event: object) -> int:
"""Keep a stale X11 window race from terminating the mapper process."""
return 0
@@ -555,21 +568,14 @@ class _X11:
and updated_normal_hints != normal_hints
)
motif_changed = motif_hints is not None and updated_motif_hints != motif_hints
allowed_actions = self._long_property(window, b"_NET_WM_ALLOWED_ACTIONS", 64)
allowed_actions_with_scaling = self._allowed_actions_with_scaling(
allowed_actions,
)
allowed_actions_changed = (
allowed_actions is not None
and allowed_actions_with_scaling is not None
and allowed_actions_with_scaling != allowed_actions
)
if (
not normal_changed
and not motif_changed
and not allowed_actions_changed
):
return not confirm or self.window_allows_scaling(window)
if not normal_changed and not motif_changed:
if not confirm:
return True
# ``_NET_WM_ALLOWED_ACTIONS`` belongs to the window manager. Wait for
# its answer even when the client hints were already resizable; Muffin
# can publish the property shortly after the SDL window is discovered.
self.library.XSync(self.display, 0)
return self._wait_for_scaling_actions(window)
confirmed = False
try:
@@ -587,13 +593,6 @@ class _X11:
b"_MOTIF_WM_HINTS",
updated_motif_hints,
)
if allowed_actions_changed and allowed_actions_with_scaling is not None:
self._replace_long_property(
window,
b"_NET_WM_ALLOWED_ACTIONS",
b"_ATOM",
allowed_actions_with_scaling,
)
if not confirm:
self.library.XFlush(self.display)
confirmed = True
@@ -624,16 +623,6 @@ class _X11:
)
except Exception as exc:
restore_errors.append(exc)
if allowed_actions_changed and allowed_actions is not None:
try:
self._replace_long_property(
window,
b"_NET_WM_ALLOWED_ACTIONS",
b"_ATOM",
allowed_actions,
)
except Exception as exc:
restore_errors.append(exc)
try:
self.library.XSync(self.display, 0)
except Exception as exc:
@@ -665,36 +654,6 @@ class _X11:
vertical,
}.issubset(actions)
def _allowed_actions_with_scaling(
self,
actions: tuple[int, ...] | None,
) -> tuple[int, ...] | None:
if actions is None:
return None
resize = self._intern_atom(
b"_NET_WM_ACTION_RESIZE",
only_if_exists=True,
)
horizontal = self._intern_atom(
b"_NET_WM_ACTION_MAXIMIZE_HORZ",
only_if_exists=True,
)
vertical = self._intern_atom(
b"_NET_WM_ACTION_MAXIMIZE_VERT",
only_if_exists=True,
)
if resize == 0 or horizontal == 0 or vertical == 0:
return actions
updated_actions = list(actions)
known_actions = set(actions)
for atom in (resize, horizontal, vertical):
if atom not in known_actions:
updated_actions.append(atom)
known_actions.add(atom)
return tuple(updated_actions)
def _wait_for_scaling_actions(self, window: int) -> bool:
deadline = time.monotonic() + WINDOW_ACTION_CONFIRM_TIMEOUT_SECONDS
while not self.window_allows_scaling(window):
@@ -1004,6 +963,14 @@ class _X11:
active = self._long_property(root, b"_NET_ACTIVE_WINDOW", 1)
return bool(active and active[0] == window)
def minimize_window(self, window: int) -> bool:
"""Ask the host window manager to keep a remapped client minimized."""
screen = self.library.XDefaultScreen(self.display)
accepted = self.library.XIconifyWindow(self.display, window, screen) != 0
self.library.XSync(self.display, 0)
return accepted
def activate_window(self, window: int) -> bool:
"""Ask the window manager to restore and activate one client."""
@@ -1174,6 +1141,14 @@ class _X11:
self.library.XOpenDisplay.restype = ctypes.c_void_p
self.library.XDefaultRootWindow.argtypes = [ctypes.c_void_p]
self.library.XDefaultRootWindow.restype = ctypes.c_ulong
self.library.XDefaultScreen.argtypes = [ctypes.c_void_p]
self.library.XDefaultScreen.restype = ctypes.c_int
self.library.XIconifyWindow.argtypes = [
ctypes.c_void_p,
ctypes.c_ulong,
ctypes.c_int,
]
self.library.XIconifyWindow.restype = ctypes.c_int
self.library.XQueryTree.argtypes = [
ctypes.c_void_p,
ctypes.c_ulong,
@@ -1457,6 +1432,8 @@ def _repair_game_window_focus(mapper: _X11, window: int) -> bool:
def _repair_gamescope_game_window_focus(mapper: _X11, window: int) -> bool:
"""Restore nested focus without asking the desktop to activate Gamescope."""
if not _gamescope_host_focus_is_allowed():
return False
try:
if mapper.window_state(window) == _X_WM_STATE_ICONIC:
return False
@@ -1477,6 +1454,31 @@ def _repair_gamescope_game_window_focus(mapper: _X11, window: int) -> bool:
return False
def _gamescope_host_focus_is_allowed(
*,
environment: Mapping[str, str] | None = None,
now: float | None = None,
) -> bool:
"""Honor the outer X11 window's current focus without trusting stale state."""
env = os.environ if environment is None else environment
state_path = env.get(GAMESCOPE_HOST_FOCUS_STATE_ENV, "").strip()
if not state_path:
# Direct invocations and native Wayland Gamescope have no X11 host-state
# bridge. Retain their existing nested focus behavior.
return True
try:
fields = Path(state_path).read_text(encoding="ascii").split()
if len(fields) != 2 or fields[0] != "1":
return False
published_at = float(fields[1])
except (OSError, UnicodeError, ValueError):
return False
observed_at = time.monotonic() if now is None else now
age = observed_at - published_at
return 0.0 <= age <= GAMESCOPE_HOST_FOCUS_STATE_MAX_AGE_SECONDS
def _resolve_gamescope_game_window(
mapper: _X11,
window: int,
@@ -1851,7 +1853,8 @@ def run_game_window(
file=sys.stderr,
flush=True,
)
_focus_game_window(mapper, game_window)
if _gamescope_host_focus_is_allowed():
_focus_game_window(mapper, game_window)
print(
"Game presentation is isolated; Gamescope handles "
"aspect-preserving outer-window resizing.",
@@ -1944,13 +1947,15 @@ def main(argv: Sequence[str] | None = None) -> int:
if command and command[0] == "--":
command = command[1:]
try:
return run_game_window(
command,
arguments.window_title,
borderless=arguments.borderless,
allow_window_resizing=arguments.allow_window_resizing,
gamescope_scaling=arguments.gamescope_scaling,
suppress_wm_ping=arguments.suppress_wm_ping,
return _process_exit_status(
run_game_window(
command,
arguments.window_title,
borderless=arguments.borderless,
allow_window_resizing=arguments.allow_window_resizing,
gamescope_scaling=arguments.gamescope_scaling,
suppress_wm_ping=arguments.suppress_wm_ping,
)
)
except (GameboxRuntimeError, OSError) as exc:
print(f"Error: {exc}", file=sys.stderr)