Added maximize window and free scaling support.
This commit is contained in:
@@ -5,6 +5,7 @@ from __future__ import annotations
|
|||||||
import argparse
|
import argparse
|
||||||
import ctypes
|
import ctypes
|
||||||
import ctypes.util
|
import ctypes.util
|
||||||
|
import select
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
@@ -24,21 +25,36 @@ WINDOW_SURFACE_REFRESH_DELAY_SECONDS = 0.15
|
|||||||
WINDOW_NUDGE_CONFIRM_TIMEOUT_SECONDS = 0.15
|
WINDOW_NUDGE_CONFIRM_TIMEOUT_SECONDS = 0.15
|
||||||
WINDOW_STATE_CONFIRM_TIMEOUT_SECONDS = 0.75
|
WINDOW_STATE_CONFIRM_TIMEOUT_SECONDS = 0.75
|
||||||
WINDOW_GEOMETRY_CONFIRM_TIMEOUT_SECONDS = 0.5
|
WINDOW_GEOMETRY_CONFIRM_TIMEOUT_SECONDS = 0.5
|
||||||
|
WINDOW_ACTION_CONFIRM_TIMEOUT_SECONDS = 0.75
|
||||||
|
WINDOW_SCALING_EVENT_TIMEOUT_SECONDS = 0.5
|
||||||
|
WINDOW_SCALING_REPAIR_INTERVAL_SECONDS = 0.05
|
||||||
WINDOW_CONFIRM_POLL_SECONDS = 0.025
|
WINDOW_CONFIRM_POLL_SECONDS = 0.025
|
||||||
WINDOW_FOCUS_DELAY_SECONDS = 0.2
|
WINDOW_FOCUS_DELAY_SECONDS = 0.2
|
||||||
WINDOW_STARTUP_GUARD_SECONDS = 12.0
|
WINDOW_STARTUP_GUARD_SECONDS = 12.0
|
||||||
WINDOW_STARTUP_POLL_SECONDS = 0.1
|
WINDOW_STARTUP_POLL_SECONDS = 0.1
|
||||||
WINDOW_ACTIVATION_DELAY_SECONDS = 0.2
|
WINDOW_ACTIVATION_DELAY_SECONDS = 0.2
|
||||||
WINDOW_STARTUP_RECOVERY_LIMIT = 3
|
WINDOW_STARTUP_RECOVERY_LIMIT = 3
|
||||||
|
WINDOW_SCALING_REPAIR_LIMIT = 3
|
||||||
|
WINDOW_SCALING_EVENT_BATCH_LIMIT = 64
|
||||||
_X_REVERT_TO_PARENT = 2
|
_X_REVERT_TO_PARENT = 2
|
||||||
_X_CURRENT_TIME = 0
|
_X_CURRENT_TIME = 0
|
||||||
_X_CLIENT_MESSAGE = 33
|
_X_CLIENT_MESSAGE = 33
|
||||||
|
_X_PROPERTY_NOTIFY = 28
|
||||||
|
_X_PROPERTY_CHANGE_MASK = 1 << 22
|
||||||
_X_SUBSTRUCTURE_NOTIFY_MASK = 1 << 19
|
_X_SUBSTRUCTURE_NOTIFY_MASK = 1 << 19
|
||||||
_X_SUBSTRUCTURE_REDIRECT_MASK = 1 << 20
|
_X_SUBSTRUCTURE_REDIRECT_MASK = 1 << 20
|
||||||
_X_WM_STATE_ICONIC = 3
|
_X_WM_STATE_ICONIC = 3
|
||||||
_NET_WM_STATE_REMOVE = 0
|
_NET_WM_STATE_REMOVE = 0
|
||||||
_NET_WM_STATE_SOURCE_APPLICATION = 1
|
_NET_WM_STATE_SOURCE_APPLICATION = 1
|
||||||
|
_P_MIN_SIZE = 1 << 4
|
||||||
|
_P_MAX_SIZE = 1 << 5
|
||||||
|
_MOTIF_HINTS_FUNCTIONS = 1 << 0
|
||||||
_MOTIF_HINTS_DECORATIONS = 1 << 1
|
_MOTIF_HINTS_DECORATIONS = 1 << 1
|
||||||
|
_MOTIF_ALL = 1 << 0
|
||||||
|
_MOTIF_FUNC_RESIZE = 1 << 1
|
||||||
|
_MOTIF_FUNC_MAXIMIZE = 1 << 4
|
||||||
|
_MOTIF_DECOR_RESIZE = 1 << 2
|
||||||
|
_MOTIF_DECOR_MAXIMIZE = 1 << 6
|
||||||
_X_ERROR_HANDLER = ctypes.CFUNCTYPE(ctypes.c_int, ctypes.c_void_p, ctypes.c_void_p)
|
_X_ERROR_HANDLER = ctypes.CFUNCTYPE(ctypes.c_int, ctypes.c_void_p, ctypes.c_void_p)
|
||||||
|
|
||||||
|
|
||||||
@@ -77,6 +93,60 @@ def _protocols_without(
|
|||||||
return tuple(protocol for protocol in protocols if protocol != blocked_protocol)
|
return tuple(protocol for protocol in protocols if protocol != blocked_protocol)
|
||||||
|
|
||||||
|
|
||||||
|
def _motif_value_with_enabled_action(value: int, action: int) -> int:
|
||||||
|
"""Enable one Motif action while preserving its inclusive/exclusive mode."""
|
||||||
|
|
||||||
|
if value & _MOTIF_ALL:
|
||||||
|
return value & ~action
|
||||||
|
return value | action
|
||||||
|
|
||||||
|
|
||||||
|
def _motif_hints_with_scaling(hints: Sequence[int]) -> tuple[int, ...]:
|
||||||
|
"""Add native resize/maximize actions while retaining every other hint."""
|
||||||
|
|
||||||
|
updated = list(hints)
|
||||||
|
if len(updated) < 3:
|
||||||
|
return tuple(updated)
|
||||||
|
flags = updated[0]
|
||||||
|
if flags & _MOTIF_HINTS_FUNCTIONS:
|
||||||
|
for action in (_MOTIF_FUNC_RESIZE, _MOTIF_FUNC_MAXIMIZE):
|
||||||
|
updated[1] = _motif_value_with_enabled_action(updated[1], action)
|
||||||
|
if flags & _MOTIF_HINTS_DECORATIONS:
|
||||||
|
for action in (_MOTIF_DECOR_RESIZE, _MOTIF_DECOR_MAXIMIZE):
|
||||||
|
updated[2] = _motif_value_with_enabled_action(updated[2], action)
|
||||||
|
return tuple(updated)
|
||||||
|
|
||||||
|
|
||||||
|
def _normal_hints_without_fixed_size(
|
||||||
|
hints: Sequence[int],
|
||||||
|
current_size: tuple[int, int],
|
||||||
|
) -> tuple[int, ...]:
|
||||||
|
"""Relax Wine's exact fixed-size pair while retaining unrelated constraints."""
|
||||||
|
|
||||||
|
updated = list(hints)
|
||||||
|
if len(updated) < 9:
|
||||||
|
return tuple(updated)
|
||||||
|
current_width, current_height = current_size
|
||||||
|
has_minimum = updated[0] & _P_MIN_SIZE != 0
|
||||||
|
has_maximum = updated[0] & _P_MAX_SIZE != 0
|
||||||
|
has_fixed_pair = (
|
||||||
|
has_minimum
|
||||||
|
and has_maximum
|
||||||
|
and updated[5] == updated[7]
|
||||||
|
and updated[6] == updated[8]
|
||||||
|
)
|
||||||
|
maximum_is_current = (
|
||||||
|
has_maximum
|
||||||
|
and updated[7] == current_width
|
||||||
|
and updated[8] == current_height
|
||||||
|
)
|
||||||
|
if has_fixed_pair:
|
||||||
|
updated[0] &= ~(_P_MIN_SIZE | _P_MAX_SIZE)
|
||||||
|
elif maximum_is_current:
|
||||||
|
updated[0] &= ~_P_MAX_SIZE
|
||||||
|
return tuple(updated)
|
||||||
|
|
||||||
|
|
||||||
class GameboxRuntimeError(RuntimeError):
|
class GameboxRuntimeError(RuntimeError):
|
||||||
"""Raised when the game command or X11 mapper cannot be started."""
|
"""Raised when the game command or X11 mapper cannot be started."""
|
||||||
|
|
||||||
@@ -119,10 +189,24 @@ class _XClientMessageEvent(ctypes.Structure):
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class _XPropertyEvent(ctypes.Structure):
|
||||||
|
_fields_ = [
|
||||||
|
("type", ctypes.c_int),
|
||||||
|
("serial", ctypes.c_ulong),
|
||||||
|
("send_event", ctypes.c_int),
|
||||||
|
("display", ctypes.c_void_p),
|
||||||
|
("window", ctypes.c_ulong),
|
||||||
|
("atom", ctypes.c_ulong),
|
||||||
|
("time", ctypes.c_ulong),
|
||||||
|
("state", ctypes.c_int),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
class _XEvent(ctypes.Union):
|
class _XEvent(ctypes.Union):
|
||||||
_fields_ = [
|
_fields_ = [
|
||||||
("type", ctypes.c_int),
|
("type", ctypes.c_int),
|
||||||
("client_message", _XClientMessageEvent),
|
("client_message", _XClientMessageEvent),
|
||||||
|
("property", _XPropertyEvent),
|
||||||
("padding", ctypes.c_long * 24),
|
("padding", ctypes.c_long * 24),
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -352,6 +436,183 @@ class _X11:
|
|||||||
)
|
)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
def enable_window_scaling(self, window: int, *, confirm: bool = True) -> bool:
|
||||||
|
"""Expose native resize/maximize actions without changing the game binary.
|
||||||
|
|
||||||
|
Wine marks this game with identical current-size ``PMinSize`` and
|
||||||
|
``PMaxSize`` constraints and excludes resize/maximize from its Motif hints.
|
||||||
|
Remove only that exact fixed-size pair and preserve unrelated aspect,
|
||||||
|
increment, and geometry hints. Initial setup is retained only when the
|
||||||
|
window manager confirms resize plus both maximize actions. Event-driven
|
||||||
|
maintenance can skip the confirmation because support was already proven.
|
||||||
|
"""
|
||||||
|
|
||||||
|
current_size = self.window_size(window)
|
||||||
|
normal_hints = self._long_property(window, b"WM_NORMAL_HINTS", 18)
|
||||||
|
motif_hints = self._long_property(window, b"_MOTIF_WM_HINTS", 5)
|
||||||
|
updated_normal_hints = (
|
||||||
|
_normal_hints_without_fixed_size(normal_hints, current_size)
|
||||||
|
if normal_hints is not None and current_size is not None
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
updated_motif_hints = (
|
||||||
|
_motif_hints_with_scaling(motif_hints)
|
||||||
|
if motif_hints is not None
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
normal_changed = (
|
||||||
|
normal_hints is not None
|
||||||
|
and updated_normal_hints is not None
|
||||||
|
and updated_normal_hints != normal_hints
|
||||||
|
)
|
||||||
|
motif_changed = motif_hints is not None and updated_motif_hints != motif_hints
|
||||||
|
if not normal_changed and not motif_changed:
|
||||||
|
return not confirm or self.window_allows_scaling(window)
|
||||||
|
|
||||||
|
confirmed = False
|
||||||
|
try:
|
||||||
|
if normal_changed and updated_normal_hints is not None:
|
||||||
|
self._replace_long_property(
|
||||||
|
window,
|
||||||
|
b"WM_NORMAL_HINTS",
|
||||||
|
b"WM_SIZE_HINTS",
|
||||||
|
updated_normal_hints,
|
||||||
|
)
|
||||||
|
if motif_changed and updated_motif_hints is not None:
|
||||||
|
self._replace_long_property(
|
||||||
|
window,
|
||||||
|
b"_MOTIF_WM_HINTS",
|
||||||
|
b"_MOTIF_WM_HINTS",
|
||||||
|
updated_motif_hints,
|
||||||
|
)
|
||||||
|
if not confirm:
|
||||||
|
self.library.XFlush(self.display)
|
||||||
|
confirmed = True
|
||||||
|
return True
|
||||||
|
self.library.XSync(self.display, 0)
|
||||||
|
confirmed = self._wait_for_scaling_actions(window)
|
||||||
|
return confirmed
|
||||||
|
finally:
|
||||||
|
if not confirmed:
|
||||||
|
restore_errors: list[Exception] = []
|
||||||
|
if motif_changed and motif_hints is not None:
|
||||||
|
try:
|
||||||
|
self._replace_long_property(
|
||||||
|
window,
|
||||||
|
b"_MOTIF_WM_HINTS",
|
||||||
|
b"_MOTIF_WM_HINTS",
|
||||||
|
motif_hints,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
restore_errors.append(exc)
|
||||||
|
if normal_changed and normal_hints is not None:
|
||||||
|
try:
|
||||||
|
self._replace_long_property(
|
||||||
|
window,
|
||||||
|
b"WM_NORMAL_HINTS",
|
||||||
|
b"WM_SIZE_HINTS",
|
||||||
|
normal_hints,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
restore_errors.append(exc)
|
||||||
|
try:
|
||||||
|
self.library.XSync(self.display, 0)
|
||||||
|
except Exception as exc:
|
||||||
|
restore_errors.append(exc)
|
||||||
|
if restore_errors:
|
||||||
|
raise GameboxRuntimeError(
|
||||||
|
"could not fully restore the original game-window hints"
|
||||||
|
) from restore_errors[0]
|
||||||
|
|
||||||
|
def window_allows_scaling(self, window: int) -> bool:
|
||||||
|
actions = self._long_property(window, b"_NET_WM_ALLOWED_ACTIONS", 64)
|
||||||
|
if not actions:
|
||||||
|
return False
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
return resize != 0 and horizontal != 0 and vertical != 0 and {
|
||||||
|
resize,
|
||||||
|
horizontal,
|
||||||
|
vertical,
|
||||||
|
}.issubset(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):
|
||||||
|
remaining = deadline - time.monotonic()
|
||||||
|
if remaining <= 0:
|
||||||
|
return False
|
||||||
|
time.sleep(min(WINDOW_CONFIRM_POLL_SECONDS, remaining))
|
||||||
|
return True
|
||||||
|
|
||||||
|
def select_scaling_hint_events(self, window: int) -> None:
|
||||||
|
"""Subscribe this X11 connection to client-property changes."""
|
||||||
|
|
||||||
|
if self.library.XSelectInput(
|
||||||
|
self.display,
|
||||||
|
window,
|
||||||
|
_X_PROPERTY_CHANGE_MASK,
|
||||||
|
) == 0:
|
||||||
|
raise GameboxRuntimeError(
|
||||||
|
"could not subscribe to game-window property changes"
|
||||||
|
)
|
||||||
|
self.library.XSync(self.display, 0)
|
||||||
|
|
||||||
|
def wait_for_scaling_hint_change(
|
||||||
|
self,
|
||||||
|
window: int,
|
||||||
|
*,
|
||||||
|
timeout_seconds: float = WINDOW_SCALING_EVENT_TIMEOUT_SECONDS,
|
||||||
|
) -> bool:
|
||||||
|
"""Wait efficiently for Wine to replace either maintained hint property."""
|
||||||
|
|
||||||
|
if self.library.XPending(self.display) == 0:
|
||||||
|
connection = self.library.XConnectionNumber(self.display)
|
||||||
|
if connection < 0:
|
||||||
|
raise GameboxRuntimeError("the X11 display connection is unavailable")
|
||||||
|
try:
|
||||||
|
readable, _, _ = select.select(
|
||||||
|
(connection,),
|
||||||
|
(),
|
||||||
|
(),
|
||||||
|
timeout_seconds,
|
||||||
|
)
|
||||||
|
except InterruptedError:
|
||||||
|
return False
|
||||||
|
if not readable:
|
||||||
|
return False
|
||||||
|
|
||||||
|
watched_atoms = {
|
||||||
|
self._intern_atom(b"WM_NORMAL_HINTS", only_if_exists=False),
|
||||||
|
self._intern_atom(b"_MOTIF_WM_HINTS", only_if_exists=False),
|
||||||
|
}
|
||||||
|
changed = False
|
||||||
|
events_read = 0
|
||||||
|
while (
|
||||||
|
events_read < WINDOW_SCALING_EVENT_BATCH_LIMIT
|
||||||
|
and self.library.XPending(self.display) > 0
|
||||||
|
):
|
||||||
|
event = _XEvent()
|
||||||
|
self.library.XNextEvent(self.display, ctypes.byref(event))
|
||||||
|
events_read += 1
|
||||||
|
if (
|
||||||
|
event.type == _X_PROPERTY_NOTIFY
|
||||||
|
and int(event.property.window) == window
|
||||||
|
and int(event.property.atom) in watched_atoms
|
||||||
|
):
|
||||||
|
changed = True
|
||||||
|
return changed
|
||||||
|
|
||||||
def _screen_filling_state_atoms(self) -> tuple[int, int, int]:
|
def _screen_filling_state_atoms(self) -> tuple[int, int, int]:
|
||||||
return (
|
return (
|
||||||
self._intern_atom(b"_NET_WM_STATE_FULLSCREEN", only_if_exists=True),
|
self._intern_atom(b"_NET_WM_STATE_FULLSCREEN", only_if_exists=True),
|
||||||
@@ -413,6 +674,31 @@ class _X11:
|
|||||||
if value:
|
if value:
|
||||||
self.library.XFree(ctypes.cast(value, ctypes.c_void_p))
|
self.library.XFree(ctypes.cast(value, ctypes.c_void_p))
|
||||||
|
|
||||||
|
def _replace_long_property(
|
||||||
|
self,
|
||||||
|
window: int,
|
||||||
|
name: bytes,
|
||||||
|
property_type: bytes,
|
||||||
|
values: Sequence[int],
|
||||||
|
) -> None:
|
||||||
|
atom = self._intern_atom(name, only_if_exists=False)
|
||||||
|
type_atom = self._intern_atom(property_type, only_if_exists=False)
|
||||||
|
if atom == 0 or type_atom == 0:
|
||||||
|
raise GameboxRuntimeError("could not create an X11 window-property atom")
|
||||||
|
property_values = (ctypes.c_ulong * len(values))(
|
||||||
|
*(value & 0xFFFFFFFF for value in values)
|
||||||
|
)
|
||||||
|
self.library.XChangeProperty(
|
||||||
|
self.display,
|
||||||
|
window,
|
||||||
|
atom,
|
||||||
|
type_atom,
|
||||||
|
32,
|
||||||
|
0,
|
||||||
|
ctypes.cast(property_values, ctypes.POINTER(ctypes.c_ubyte)),
|
||||||
|
len(values),
|
||||||
|
)
|
||||||
|
|
||||||
def window_size(self, window: int) -> tuple[int, int] | None:
|
def window_size(self, window: int) -> tuple[int, int] | None:
|
||||||
"""Return the current X11 client dimensions."""
|
"""Return the current X11 client dimensions."""
|
||||||
|
|
||||||
@@ -784,6 +1070,21 @@ class _X11:
|
|||||||
self.library.XSetWMProtocols.restype = ctypes.c_int
|
self.library.XSetWMProtocols.restype = ctypes.c_int
|
||||||
self.library.XMapRaised.argtypes = [ctypes.c_void_p, ctypes.c_ulong]
|
self.library.XMapRaised.argtypes = [ctypes.c_void_p, ctypes.c_ulong]
|
||||||
self.library.XMapRaised.restype = ctypes.c_int
|
self.library.XMapRaised.restype = ctypes.c_int
|
||||||
|
self.library.XSelectInput.argtypes = [
|
||||||
|
ctypes.c_void_p,
|
||||||
|
ctypes.c_ulong,
|
||||||
|
ctypes.c_long,
|
||||||
|
]
|
||||||
|
self.library.XSelectInput.restype = ctypes.c_int
|
||||||
|
self.library.XConnectionNumber.argtypes = [ctypes.c_void_p]
|
||||||
|
self.library.XConnectionNumber.restype = ctypes.c_int
|
||||||
|
self.library.XPending.argtypes = [ctypes.c_void_p]
|
||||||
|
self.library.XPending.restype = ctypes.c_int
|
||||||
|
self.library.XNextEvent.argtypes = [
|
||||||
|
ctypes.c_void_p,
|
||||||
|
ctypes.POINTER(_XEvent),
|
||||||
|
]
|
||||||
|
self.library.XNextEvent.restype = ctypes.c_int
|
||||||
self.library.XGetGeometry.argtypes = [
|
self.library.XGetGeometry.argtypes = [
|
||||||
ctypes.c_void_p,
|
ctypes.c_void_p,
|
||||||
ctypes.c_ulong,
|
ctypes.c_ulong,
|
||||||
@@ -949,13 +1250,104 @@ def _focus_game_window(mapper: _X11, window: int) -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _enable_game_window_scaling(mapper: _X11, window: int) -> bool:
|
||||||
|
"""Enable confirmed resize/maximize actions without risking game startup."""
|
||||||
|
|
||||||
|
try:
|
||||||
|
enabled = mapper.enable_window_scaling(window)
|
||||||
|
except Exception as exc:
|
||||||
|
print(
|
||||||
|
f"Warning: could not enable game-window scaling ({exc}); "
|
||||||
|
"the fixed-size window will continue normally",
|
||||||
|
file=sys.stderr,
|
||||||
|
flush=True,
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
if enabled:
|
||||||
|
print(
|
||||||
|
"Game window is resizable with persistent maximize support.",
|
||||||
|
file=sys.stderr,
|
||||||
|
flush=True,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
print(
|
||||||
|
"Warning: the window manager did not confirm safe scaling actions; "
|
||||||
|
"the game remains fixed-size",
|
||||||
|
file=sys.stderr,
|
||||||
|
flush=True,
|
||||||
|
)
|
||||||
|
return enabled
|
||||||
|
|
||||||
|
|
||||||
|
def _maintain_game_window_scaling(
|
||||||
|
mapper: _X11,
|
||||||
|
process: subprocess.Popen[bytes],
|
||||||
|
window: int,
|
||||||
|
) -> int:
|
||||||
|
"""Keep host scaling hints across Wine maximize/restore transitions."""
|
||||||
|
|
||||||
|
try:
|
||||||
|
mapper.select_scaling_hint_events(window)
|
||||||
|
except Exception as exc:
|
||||||
|
print(
|
||||||
|
f"Warning: could not monitor game-window scaling hints ({exc}); "
|
||||||
|
"leaving the game window fixed-size for consistency",
|
||||||
|
file=sys.stderr,
|
||||||
|
flush=True,
|
||||||
|
)
|
||||||
|
return int(process.wait())
|
||||||
|
|
||||||
|
if not _enable_game_window_scaling(mapper, window):
|
||||||
|
return int(process.wait())
|
||||||
|
|
||||||
|
repair_failures = 0
|
||||||
|
next_repair_at = 0.0
|
||||||
|
while process.poll() is None:
|
||||||
|
try:
|
||||||
|
hints_changed = mapper.wait_for_scaling_hint_change(window)
|
||||||
|
except Exception as exc:
|
||||||
|
print(
|
||||||
|
f"Warning: game-window scaling monitoring stopped ({exc}); "
|
||||||
|
"the game will continue normally",
|
||||||
|
file=sys.stderr,
|
||||||
|
flush=True,
|
||||||
|
)
|
||||||
|
break
|
||||||
|
if not hints_changed:
|
||||||
|
continue
|
||||||
|
now = time.monotonic()
|
||||||
|
if now < next_repair_at:
|
||||||
|
time.sleep(next_repair_at - now)
|
||||||
|
try:
|
||||||
|
mapper.enable_window_scaling(window, confirm=False)
|
||||||
|
except Exception as exc:
|
||||||
|
repair_failures += 1
|
||||||
|
print(
|
||||||
|
f"Warning: could not restore game-window scaling hints ({exc})",
|
||||||
|
file=sys.stderr,
|
||||||
|
flush=True,
|
||||||
|
)
|
||||||
|
if repair_failures >= WINDOW_SCALING_REPAIR_LIMIT:
|
||||||
|
print(
|
||||||
|
"Warning: scaling maintenance was disabled after repeated failures; "
|
||||||
|
"the game will continue normally",
|
||||||
|
file=sys.stderr,
|
||||||
|
flush=True,
|
||||||
|
)
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
repair_failures = 0
|
||||||
|
next_repair_at = time.monotonic() + WINDOW_SCALING_REPAIR_INTERVAL_SECONDS
|
||||||
|
return int(process.wait())
|
||||||
|
|
||||||
|
|
||||||
def _guard_game_window_startup(
|
def _guard_game_window_startup(
|
||||||
mapper: _X11,
|
mapper: _X11,
|
||||||
process: subprocess.Popen[bytes],
|
process: subprocess.Popen[bytes],
|
||||||
window: int,
|
window: int,
|
||||||
*,
|
*,
|
||||||
borderless: bool,
|
borderless: bool,
|
||||||
) -> None:
|
) -> bool:
|
||||||
"""Recover Wine's login-screen window transition without hiding the client."""
|
"""Recover Wine's login-screen window transition without hiding the client."""
|
||||||
|
|
||||||
deadline = time.monotonic() + WINDOW_STARTUP_GUARD_SECONDS
|
deadline = time.monotonic() + WINDOW_STARTUP_GUARD_SECONDS
|
||||||
@@ -973,9 +1365,11 @@ def _guard_game_window_startup(
|
|||||||
file=sys.stderr,
|
file=sys.stderr,
|
||||||
flush=True,
|
flush=True,
|
||||||
)
|
)
|
||||||
return
|
return False
|
||||||
recovery_count = 0
|
recovery_count = 0
|
||||||
while process.poll() is None and time.monotonic() < deadline:
|
while process.poll() is None:
|
||||||
|
if time.monotonic() >= deadline:
|
||||||
|
return True
|
||||||
try:
|
try:
|
||||||
state = mapper.window_state(window)
|
state = mapper.window_state(window)
|
||||||
screen_filling = mapper.window_is_fullscreen_or_maximized(window)
|
screen_filling = mapper.window_is_fullscreen_or_maximized(window)
|
||||||
@@ -989,7 +1383,7 @@ def _guard_game_window_startup(
|
|||||||
file=sys.stderr,
|
file=sys.stderr,
|
||||||
flush=True,
|
flush=True,
|
||||||
)
|
)
|
||||||
return
|
return False
|
||||||
became_iconic = state == _X_WM_STATE_ICONIC
|
became_iconic = state == _X_WM_STATE_ICONIC
|
||||||
entered_screen_filling_mode = screen_filling is True
|
entered_screen_filling_mode = screen_filling is True
|
||||||
login_style_reset = borderless_was_applied and borderless_now is False
|
login_style_reset = borderless_was_applied and borderless_now is False
|
||||||
@@ -1035,7 +1429,7 @@ def _guard_game_window_startup(
|
|||||||
file=sys.stderr,
|
file=sys.stderr,
|
||||||
flush=True,
|
flush=True,
|
||||||
)
|
)
|
||||||
return
|
return False
|
||||||
if recovery_count >= WINDOW_STARTUP_RECOVERY_LIMIT:
|
if recovery_count >= WINDOW_STARTUP_RECOVERY_LIMIT:
|
||||||
print(
|
print(
|
||||||
"Warning: the game repeatedly changed its startup window; "
|
"Warning: the game repeatedly changed its startup window; "
|
||||||
@@ -1043,8 +1437,9 @@ def _guard_game_window_startup(
|
|||||||
file=sys.stderr,
|
file=sys.stderr,
|
||||||
flush=True,
|
flush=True,
|
||||||
)
|
)
|
||||||
return
|
return False
|
||||||
time.sleep(WINDOW_STARTUP_POLL_SECONDS)
|
time.sleep(WINDOW_STARTUP_POLL_SECONDS)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
def run_game_window(
|
def run_game_window(
|
||||||
@@ -1116,12 +1511,18 @@ def run_game_window(
|
|||||||
flush=True,
|
flush=True,
|
||||||
)
|
)
|
||||||
_focus_game_window(mapper, game_window)
|
_focus_game_window(mapper, game_window)
|
||||||
_guard_game_window_startup(
|
startup_stable = _guard_game_window_startup(
|
||||||
mapper,
|
mapper,
|
||||||
process,
|
process,
|
||||||
game_window,
|
game_window,
|
||||||
borderless=borderless,
|
borderless=borderless,
|
||||||
)
|
)
|
||||||
|
if startup_stable and not borderless:
|
||||||
|
return _maintain_game_window_scaling(
|
||||||
|
mapper,
|
||||||
|
process,
|
||||||
|
game_window,
|
||||||
|
)
|
||||||
return int(process.wait())
|
return int(process.wait())
|
||||||
time.sleep(0.1)
|
time.sleep(0.1)
|
||||||
if process.poll() is None:
|
if process.poll() is None:
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ from .paths import AppPaths
|
|||||||
SETTINGS_SCHEMA = 1
|
SETTINGS_SCHEMA = 1
|
||||||
_TITLE_FRAME_DEFAULT_MIGRATION_KEY = "title_frame_default_migrated"
|
_TITLE_FRAME_DEFAULT_MIGRATION_KEY = "title_frame_default_migrated"
|
||||||
UI_SCALE_CHOICES = frozenset({"auto", "100", "125", "150", "175", "200"})
|
UI_SCALE_CHOICES = frozenset({"auto", "100", "125", "150", "175", "200"})
|
||||||
_RETIRED_DISPLAY_SETTINGS = frozenset(
|
_RETIRED_SETTINGS = frozenset(
|
||||||
{
|
{
|
||||||
"width",
|
"width",
|
||||||
"height",
|
"height",
|
||||||
@@ -28,6 +28,7 @@ _RETIRED_DISPLAY_SETTINGS = frozenset(
|
|||||||
"fullscreen",
|
"fullscreen",
|
||||||
"scaler",
|
"scaler",
|
||||||
"filter",
|
"filter",
|
||||||
|
"maximize_button",
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -205,7 +206,7 @@ def _settings_need_migration(value: dict[str, object]) -> bool:
|
|||||||
value.get("schema") != SETTINGS_SCHEMA
|
value.get("schema") != SETTINGS_SCHEMA
|
||||||
or not _SETTINGS_FIELD_NAMES.issubset(value)
|
or not _SETTINGS_FIELD_NAMES.issubset(value)
|
||||||
or value.get(_TITLE_FRAME_DEFAULT_MIGRATION_KEY) is not True
|
or value.get(_TITLE_FRAME_DEFAULT_MIGRATION_KEY) is not True
|
||||||
or bool(_RETIRED_DISPLAY_SETTINGS.intersection(value))
|
or bool(_RETIRED_SETTINGS.intersection(value))
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -215,7 +216,7 @@ def _write_settings(
|
|||||||
existing: dict[str, object] | None,
|
existing: dict[str, object] | None,
|
||||||
) -> None:
|
) -> None:
|
||||||
payload = dict(existing or {})
|
payload = dict(existing or {})
|
||||||
for name in _RETIRED_DISPLAY_SETTINGS:
|
for name in _RETIRED_SETTINGS:
|
||||||
payload.pop(name, None)
|
payload.pop(name, None)
|
||||||
payload.update(asdict(settings))
|
payload.update(asdict(settings))
|
||||||
payload["schema"] = SETTINGS_SCHEMA
|
payload["schema"] = SETTINGS_SCHEMA
|
||||||
|
|||||||
@@ -301,8 +301,8 @@ def open_settings_dialog(
|
|||||||
ttk.Label(
|
ttk.Label(
|
||||||
display_card,
|
display_card,
|
||||||
text=(
|
text=(
|
||||||
"The launcher keeps the game window's title bar by default. Enable "
|
"Decorated game windows become freely resizable with persistent maximize "
|
||||||
"Borderless only when you want to remove its host decorations."
|
"support after startup. Borderless mode removes all host decorations."
|
||||||
),
|
),
|
||||||
style="SettingsHint.TLabel",
|
style="SettingsHint.TLabel",
|
||||||
wraplength=360,
|
wraplength=360,
|
||||||
|
|||||||
Reference in New Issue
Block a user