Fixed issues with windowed mode and fullscreen.
This commit is contained in:
@@ -19,8 +19,12 @@ from .desktop_identity import (
|
||||
WINDOW_TITLE = APPLICATION_NAME
|
||||
WINDOW_DISCOVERY_TIMEOUT_SECONDS = 60.0
|
||||
WINDOW_READY_DELAY_SECONDS = 0.5
|
||||
WINDOW_BORDERLESS_SETTLE_SECONDS = 0.1
|
||||
WINDOW_SURFACE_SETTLE_SECONDS = 0.1
|
||||
WINDOW_SURFACE_REFRESH_DELAY_SECONDS = 0.15
|
||||
WINDOW_NUDGE_CONFIRM_TIMEOUT_SECONDS = 0.15
|
||||
WINDOW_STATE_CONFIRM_TIMEOUT_SECONDS = 0.75
|
||||
WINDOW_GEOMETRY_CONFIRM_TIMEOUT_SECONDS = 0.5
|
||||
WINDOW_CONFIRM_POLL_SECONDS = 0.025
|
||||
WINDOW_FOCUS_DELAY_SECONDS = 0.2
|
||||
WINDOW_STARTUP_GUARD_SECONDS = 12.0
|
||||
WINDOW_STARTUP_POLL_SECONDS = 0.1
|
||||
@@ -32,6 +36,8 @@ _X_CLIENT_MESSAGE = 33
|
||||
_X_SUBSTRUCTURE_NOTIFY_MASK = 1 << 19
|
||||
_X_SUBSTRUCTURE_REDIRECT_MASK = 1 << 20
|
||||
_X_WM_STATE_ICONIC = 3
|
||||
_NET_WM_STATE_REMOVE = 0
|
||||
_NET_WM_STATE_SOURCE_APPLICATION = 1
|
||||
_MOTIF_HINTS_DECORATIONS = 1 << 1
|
||||
_X_ERROR_HANDLER = ctypes.CFUNCTYPE(ctypes.c_int, ctypes.c_void_p, ctypes.c_void_p)
|
||||
|
||||
@@ -280,6 +286,88 @@ class _X11:
|
||||
return False
|
||||
return hints[2] == 0
|
||||
|
||||
def window_is_fullscreen_or_maximized(self, window: int) -> bool:
|
||||
"""Return whether EWMH currently marks the client as screen-filling."""
|
||||
|
||||
states = self._long_property(window, b"_NET_WM_STATE", 64)
|
||||
if not states:
|
||||
return False
|
||||
return any(
|
||||
atom != 0 and atom in states
|
||||
for atom in self._screen_filling_state_atoms()
|
||||
)
|
||||
|
||||
def force_windowed_state(self, window: int) -> bool:
|
||||
"""Remove fullscreen/maximized EWMH state and wait for WM acknowledgement.
|
||||
|
||||
The state property must not be edited directly on a mapped client. EWMH
|
||||
requires a client message to the root window so KWin/XWayland can restore
|
||||
the window's previous geometry before the render surface is refreshed.
|
||||
|
||||
Return ``True`` only when a screen-filling state was present and its removal
|
||||
was confirmed. Return ``False`` when the window was already normal.
|
||||
"""
|
||||
|
||||
states = set(self._long_property(window, b"_NET_WM_STATE", 64) or ())
|
||||
fullscreen, maximized_vertical, maximized_horizontal = (
|
||||
self._screen_filling_state_atoms()
|
||||
)
|
||||
maximized = tuple(
|
||||
atom
|
||||
for atom in (maximized_vertical, maximized_horizontal)
|
||||
if atom != 0 and atom in states
|
||||
)
|
||||
fullscreen_is_set = fullscreen != 0 and fullscreen in states
|
||||
if not maximized and not fullscreen_is_set:
|
||||
return False
|
||||
|
||||
state_atom = self._intern_atom(b"_NET_WM_STATE", only_if_exists=True)
|
||||
if state_atom == 0:
|
||||
raise GameboxRuntimeError("the window manager has no EWMH state atom")
|
||||
requests: list[tuple[int, int]] = []
|
||||
if maximized:
|
||||
requests.append(
|
||||
(maximized[0], maximized[1] if len(maximized) > 1 else 0)
|
||||
)
|
||||
if fullscreen_is_set:
|
||||
requests.append((fullscreen, 0))
|
||||
for first_state, second_state in requests:
|
||||
if not self._send_root_client_message(
|
||||
window,
|
||||
state_atom,
|
||||
(
|
||||
_NET_WM_STATE_REMOVE,
|
||||
first_state,
|
||||
second_state,
|
||||
_NET_WM_STATE_SOURCE_APPLICATION,
|
||||
0,
|
||||
),
|
||||
):
|
||||
raise GameboxRuntimeError(
|
||||
"the window manager rejected the windowed-state request"
|
||||
)
|
||||
if not self._wait_for_windowed_state(window):
|
||||
raise GameboxRuntimeError(
|
||||
"the window manager did not confirm normal windowed state"
|
||||
)
|
||||
return True
|
||||
|
||||
def _screen_filling_state_atoms(self) -> tuple[int, int, int]:
|
||||
return (
|
||||
self._intern_atom(b"_NET_WM_STATE_FULLSCREEN", only_if_exists=True),
|
||||
self._intern_atom(b"_NET_WM_STATE_MAXIMIZED_VERT", only_if_exists=True),
|
||||
self._intern_atom(b"_NET_WM_STATE_MAXIMIZED_HORZ", only_if_exists=True),
|
||||
)
|
||||
|
||||
def _wait_for_windowed_state(self, window: int) -> bool:
|
||||
deadline = time.monotonic() + WINDOW_STATE_CONFIRM_TIMEOUT_SECONDS
|
||||
while self.window_is_fullscreen_or_maximized(window):
|
||||
remaining = deadline - time.monotonic()
|
||||
if remaining <= 0:
|
||||
return False
|
||||
time.sleep(min(WINDOW_CONFIRM_POLL_SECONDS, remaining))
|
||||
return True
|
||||
|
||||
def _long_property(
|
||||
self,
|
||||
window: int,
|
||||
@@ -349,13 +437,49 @@ class _X11:
|
||||
return None
|
||||
return width.value, height.value
|
||||
|
||||
def _wait_for_window_size(
|
||||
self,
|
||||
window: int,
|
||||
expected_size: tuple[int, int],
|
||||
*,
|
||||
timeout_seconds: float = WINDOW_GEOMETRY_CONFIRM_TIMEOUT_SECONDS,
|
||||
) -> bool:
|
||||
"""Wait briefly for an asynchronous WM ConfigureRequest decision."""
|
||||
|
||||
deadline = time.monotonic() + timeout_seconds
|
||||
while self.window_size(window) != expected_size:
|
||||
remaining = deadline - time.monotonic()
|
||||
if remaining <= 0:
|
||||
return False
|
||||
time.sleep(min(WINDOW_CONFIRM_POLL_SECONDS, remaining))
|
||||
return True
|
||||
|
||||
def _resize_window_and_wait(
|
||||
self,
|
||||
window: int,
|
||||
target_size: tuple[int, int],
|
||||
*,
|
||||
timeout_seconds: float = WINDOW_GEOMETRY_CONFIRM_TIMEOUT_SECONDS,
|
||||
) -> bool:
|
||||
width, height = target_size
|
||||
self.library.XResizeWindow(self.display, window, width, height)
|
||||
self.library.XSync(self.display, 0)
|
||||
return self._wait_for_window_size(
|
||||
window,
|
||||
target_size,
|
||||
timeout_seconds=timeout_seconds,
|
||||
)
|
||||
|
||||
def map_window(
|
||||
self,
|
||||
window: int,
|
||||
*,
|
||||
borderless: bool,
|
||||
) -> tuple[int, int] | None:
|
||||
target_size = self.window_size(window) if borderless else None
|
||||
# Capture the real client size for both decorated and borderless windows.
|
||||
# Wine/DXVK can otherwise retain a desktop-sized render surface inside a
|
||||
# smaller decorated client on some window managers.
|
||||
target_size = self.window_size(window)
|
||||
if borderless:
|
||||
self._remove_decorations(window)
|
||||
self.library.XMapRaised(self.display, window)
|
||||
@@ -368,11 +492,11 @@ class _X11:
|
||||
*,
|
||||
target_size: tuple[int, int] | None = None,
|
||||
) -> bool:
|
||||
"""Nudge the mapped client size once after borderless styling.
|
||||
"""Nudge the mapped client size once after Wine creates the window.
|
||||
|
||||
Some Wine/DXVK combinations retain the host desktop-sized render surface
|
||||
after the top-level game window becomes borderless. A one-pixel resize followed
|
||||
by an exact restore makes Wine reconcile the surface with the real window.
|
||||
inside a smaller top-level game window. A one-pixel resize followed by an
|
||||
exact restore makes Wine reconcile the surface with the real client size.
|
||||
|
||||
Never iconify or unmap a Wine client for this refresh. Wine can process the
|
||||
resulting state change after startup and minimize the game at its login screen.
|
||||
@@ -392,26 +516,22 @@ class _X11:
|
||||
else:
|
||||
return False
|
||||
|
||||
self.library.XResizeWindow(
|
||||
self.display,
|
||||
window,
|
||||
nudged_width,
|
||||
nudged_height,
|
||||
)
|
||||
self.library.XSync(self.display, 0)
|
||||
try:
|
||||
time.sleep(WINDOW_SURFACE_REFRESH_DELAY_SECONDS)
|
||||
finally:
|
||||
# Always restore the exact starting dimensions if the short wait is
|
||||
# interrupted. The game remains mapped for the entire operation.
|
||||
self.library.XResizeWindow(
|
||||
self.display,
|
||||
nudge_confirmed = self._resize_window_and_wait(
|
||||
window,
|
||||
original_width,
|
||||
original_height,
|
||||
(nudged_width, nudged_height),
|
||||
timeout_seconds=WINDOW_NUDGE_CONFIRM_TIMEOUT_SECONDS,
|
||||
)
|
||||
self.library.XSync(self.display, 0)
|
||||
return True
|
||||
if nudge_confirmed:
|
||||
time.sleep(WINDOW_SURFACE_REFRESH_DELAY_SECONDS)
|
||||
finally:
|
||||
# Always request and verify the exact starting dimensions, including when
|
||||
# KWin declined the nudge or the short wait was interrupted.
|
||||
restore_confirmed = self._resize_window_and_wait(
|
||||
window,
|
||||
(original_width, original_height),
|
||||
)
|
||||
return nudge_confirmed and restore_confirmed
|
||||
|
||||
def focus_window(self, window: int) -> None:
|
||||
"""Raise the mapped game client and give it keyboard focus."""
|
||||
@@ -434,16 +554,32 @@ class _X11:
|
||||
)
|
||||
if active_window == 0:
|
||||
return False
|
||||
return self._send_root_client_message(
|
||||
window,
|
||||
active_window,
|
||||
(1, _X_CURRENT_TIME, 0, 0, 0),
|
||||
)
|
||||
|
||||
def _send_root_client_message(
|
||||
self,
|
||||
window: int,
|
||||
message_type: int,
|
||||
data: Sequence[int],
|
||||
) -> bool:
|
||||
"""Send one 32-bit EWMH client message to the root window."""
|
||||
|
||||
if len(data) != 5:
|
||||
raise ValueError("an X11 client message requires exactly five values")
|
||||
event = _XEvent()
|
||||
event.client_message.type = _X_CLIENT_MESSAGE
|
||||
event.client_message.serial = 0
|
||||
event.client_message.send_event = 1
|
||||
event.client_message.display = self.display
|
||||
event.client_message.window = window
|
||||
event.client_message.message_type = active_window
|
||||
event.client_message.message_type = message_type
|
||||
event.client_message.format = 32
|
||||
event.client_message.data.longs[0] = 1
|
||||
event.client_message.data.longs[1] = _X_CURRENT_TIME
|
||||
for index, value in enumerate(data):
|
||||
event.client_message.data.longs[index] = value
|
||||
status = self.library.XSendEvent(
|
||||
self.display,
|
||||
self.library.XDefaultRootWindow(self.display),
|
||||
@@ -724,10 +860,38 @@ def _prepare_game_window(
|
||||
flush=True,
|
||||
)
|
||||
target_size = mapper.map_window(window, borderless=borderless)
|
||||
if not borderless:
|
||||
return
|
||||
if target_size is not None:
|
||||
time.sleep(WINDOW_BORDERLESS_SETTLE_SECONDS)
|
||||
forced_windowed = False
|
||||
try:
|
||||
forced_windowed = mapper.force_windowed_state(window)
|
||||
except Exception as exc:
|
||||
print(
|
||||
f"Warning: could not force the game into normal windowed mode ({exc}); "
|
||||
"continuing with surface synchronization",
|
||||
file=sys.stderr,
|
||||
flush=True,
|
||||
)
|
||||
else:
|
||||
if forced_windowed:
|
||||
print(
|
||||
"Game window forced out of fullscreen/maximized mode.",
|
||||
file=sys.stderr,
|
||||
flush=True,
|
||||
)
|
||||
if target_size is not None or forced_windowed:
|
||||
time.sleep(WINDOW_SURFACE_SETTLE_SECONDS)
|
||||
if forced_windowed:
|
||||
# Let KWin finish restoring the pre-fullscreen client geometry before it
|
||||
# becomes the authoritative surface size.
|
||||
try:
|
||||
target_size = mapper.window_size(window)
|
||||
except Exception as exc:
|
||||
print(
|
||||
f"Warning: could not read the restored game-window size ({exc}); "
|
||||
"retrying during surface synchronization",
|
||||
file=sys.stderr,
|
||||
flush=True,
|
||||
)
|
||||
target_size = None
|
||||
try:
|
||||
if target_size is None:
|
||||
refreshed = mapper.refresh_render_surface(window)
|
||||
@@ -738,7 +902,7 @@ def _prepare_game_window(
|
||||
)
|
||||
except Exception as exc:
|
||||
print(
|
||||
f"Warning: could not synchronize the borderless game surface ({exc}); "
|
||||
f"Warning: could not synchronize the game surface ({exc}); "
|
||||
"continuing normally",
|
||||
file=sys.stderr,
|
||||
flush=True,
|
||||
@@ -746,13 +910,13 @@ def _prepare_game_window(
|
||||
else:
|
||||
if refreshed:
|
||||
print(
|
||||
"Borderless game surface synchronized with its window.",
|
||||
"Game render surface synchronized with its window.",
|
||||
file=sys.stderr,
|
||||
flush=True,
|
||||
)
|
||||
else:
|
||||
print(
|
||||
"Warning: the window manager declined the borderless surface "
|
||||
"Warning: the window manager declined the game-surface "
|
||||
"refresh; continuing normally",
|
||||
file=sys.stderr,
|
||||
flush=True,
|
||||
@@ -814,6 +978,7 @@ def _guard_game_window_startup(
|
||||
while process.poll() is None and time.monotonic() < deadline:
|
||||
try:
|
||||
state = mapper.window_state(window)
|
||||
screen_filling = mapper.window_is_fullscreen_or_maximized(window)
|
||||
borderless_now = (
|
||||
mapper.window_is_borderless(window) if borderless else None
|
||||
)
|
||||
@@ -826,13 +991,23 @@ def _guard_game_window_startup(
|
||||
)
|
||||
return
|
||||
became_iconic = state == _X_WM_STATE_ICONIC
|
||||
entered_screen_filling_mode = screen_filling is True
|
||||
login_style_reset = borderless_was_applied and borderless_now is False
|
||||
if not became_iconic and not login_style_reset:
|
||||
if (
|
||||
not became_iconic
|
||||
and not entered_screen_filling_mode
|
||||
and not login_style_reset
|
||||
):
|
||||
time.sleep(WINDOW_STARTUP_POLL_SECONDS)
|
||||
continue
|
||||
|
||||
recovery_count += 1
|
||||
reason = "was minimized" if became_iconic else "changed its window layout"
|
||||
if became_iconic:
|
||||
reason = "was minimized"
|
||||
elif entered_screen_filling_mode:
|
||||
reason = "entered fullscreen/maximized mode"
|
||||
else:
|
||||
reason = "changed its window layout"
|
||||
print(
|
||||
f"Game startup window {reason}; restoring and refocusing it.",
|
||||
file=sys.stderr,
|
||||
|
||||
Reference in New Issue
Block a user