From 9c7bbddde76e98996e2bb011fc1944ade40eb8fa Mon Sep 17 00:00:00 2001 From: Daniel Johnson Date: Sun, 3 May 2026 19:16:40 -0400 Subject: [PATCH] Promote DisplayManager to an ABC and lazy-init the singleton Convert the unused stub DisplayManager class in lutris/util/display.py into an abstract base class declaring the five-method interface, and make MutterDisplayManager and LegacyDisplayManager inherit from it. This collapses the get_display_manager() return type from a three-way union to just DisplayManager. Replace the eager DISPLAY_MANAGER module-level singleton with a @cache_single-decorated get_display_manager() function. This breaks a latent import cycle (xrandr.py and displayconfig.py now top-level import the ABC; display.py no longer constructs the singleton at import time) and defers backend selection until first use. Co-Authored-By: Claude Opus 4.7 --- lutris/game.py | 10 +-- lutris/installer/interpreter.py | 4 +- lutris/runners/atari800.py | 6 +- lutris/runners/fsuae.py | 4 +- lutris/runners/mednafen.py | 4 +- lutris/runners/wine.py | 4 +- lutris/runners/zdoom.py | 2 +- lutris/sysoptions.py | 12 +-- lutris/util/display.py | 109 +++++++++++++++----------- lutris/util/graphics/displayconfig.py | 3 +- lutris/util/graphics/xrandr.py | 18 ++--- lutris/util/wine/prefix.py | 4 +- 12 files changed, 99 insertions(+), 81 deletions(-) diff --git a/lutris/game.py b/lutris/game.py index 7f318aac0..8522454dc 100644 --- a/lutris/game.py +++ b/lutris/game.py @@ -28,10 +28,10 @@ from lutris.runners import import_runner, is_valid_runner_name from lutris.runners.runner import Runner, kill_processes from lutris.util import busy, discord, extract, jobs, linux, strings, system, xdgshortcuts from lutris.util.display import ( - DISPLAY_MANAGER, SCREEN_SAVER_INHIBITOR, disable_compositing, enable_compositing, + get_display_manager, is_display_x11, ) from lutris.util.graphics.xrandr import turn_off_except @@ -560,7 +560,7 @@ class Game: return True def restrict_to_display(self, display: str) -> bool: - outputs = DISPLAY_MANAGER.get_config() + outputs = get_display_manager().get_config() if display == "primary": display = "" for output in outputs: @@ -746,7 +746,7 @@ class Game: system.set_keyboard_layout("us") # Display control - self.original_outputs = DISPLAY_MANAGER.get_config() + self.original_outputs = get_display_manager().get_config() if self.runner.system_config.get("disable_compositor"): self.set_desktop_compositing(False) @@ -759,7 +759,7 @@ class Game: resolution: str = self.runner.system_config.get("resolution") if resolution != "off": - DISPLAY_MANAGER.set_resolution(resolution) + get_display_manager().set_resolution(resolution) time.sleep(3) self.resolution_changed = True @@ -1094,7 +1094,7 @@ class Game: if self.resolution_changed or self.runner.system_config.get("reset_desktop"): if self.original_outputs: - DISPLAY_MANAGER.set_resolution(self.original_outputs) + get_display_manager().set_resolution(self.original_outputs) if self.compositor_disabled: self.set_desktop_compositing(True) diff --git a/lutris/installer/interpreter.py b/lutris/installer/interpreter.py index e71f37858..bee5db979 100644 --- a/lutris/installer/interpreter.py +++ b/lutris/installer/interpreter.py @@ -17,7 +17,7 @@ from lutris.installer.installer import LutrisInstaller from lutris.runners import NonInstallableRunnerError, RunnerInstallationError, steam, wine from lutris.services.lutris import download_lutris_media from lutris.util import system -from lutris.util.display import DISPLAY_MANAGER +from lutris.util.display import get_display_manager from lutris.util.jobs import AsyncCall from lutris.util.log import logger from lutris.util.strings import unpack_dependencies @@ -82,7 +82,7 @@ class ScriptInterpreter(GObject.Object, CommandsMixin): self.user_inputs = [] self.current_command = 0 # Current installer command when iterating through them self.runners_to_install = [] - self.current_resolution = DISPLAY_MANAGER.get_current_resolution() + self.current_resolution = get_display_manager().get_current_resolution() self.installer = LutrisInstaller(installer, self, service=self.service, appid=_appid) if not self.installer.script: diff --git a/lutris/runners/atari800.py b/lutris/runners/atari800.py index c6a47d840..7b75850ad 100644 --- a/lutris/runners/atari800.py +++ b/lutris/runners/atari800.py @@ -10,7 +10,9 @@ from lutris.util import display, extract, system def get_resolutions(): try: - screen_resolutions = [(resolution, resolution) for resolution in display.DISPLAY_MANAGER.get_resolutions()] + screen_resolutions = [ + (resolution, resolution) for resolution in display.get_display_manager().get_resolutions() + ] except OSError: screen_resolutions = [] screen_resolutions.insert(0, (_("Desktop resolution"), "desktop")) @@ -123,7 +125,7 @@ class atari800(Runner): resolution = self.runner_config.get("resolution") if resolution: if resolution == "desktop": - width, height = display.DISPLAY_MANAGER.get_current_resolution() + width, height = display.get_display_manager().get_current_resolution() else: width, height = resolution.split("x") arguments += ["-fs-width", "%s" % width, "-fs-height", "%s" % height] diff --git a/lutris/runners/fsuae.py b/lutris/runners/fsuae.py index 33ff0122c..4f4a9f5da 100644 --- a/lutris/runners/fsuae.py +++ b/lutris/runners/fsuae.py @@ -6,7 +6,7 @@ from typing import Any from lutris import settings from lutris.runners.runner import Runner from lutris.util import system -from lutris.util.display import DISPLAY_MANAGER +from lutris.util.display import get_display_manager AMIGAS: dict[str, Any] = { "A500": { @@ -426,7 +426,7 @@ class fsuae(Runner): params.append(param % option_value) if self.runner_config.get("gfx_fullscreen_amiga"): - width = DISPLAY_MANAGER.get_current_resolution()[0] + width = get_display_manager().get_current_resolution()[0] params.append("--fullscreen") # params.append("--fullscreen_mode=fullscreen-window") params.append("--fullscreen_mode=fullscreen") diff --git a/lutris/runners/mednafen.py b/lutris/runners/mednafen.py index 5a07f09c5..89e90461b 100644 --- a/lutris/runners/mednafen.py +++ b/lutris/runners/mednafen.py @@ -7,7 +7,7 @@ from lutris.exceptions import MissingGameExecutableError # Lutris Modules from lutris.runners.runner import Runner from lutris.util import system -from lutris.util.display import DISPLAY_MANAGER +from lutris.util.display import get_display_manager from lutris.util.joypad import get_controller_mappings from lutris.util.log import logger @@ -464,7 +464,7 @@ class mednafen(Runner): scaler = self.runner_config.get("scaler") or DEFAULT_MEDNAFEN_SCALER sound_device = self.runner_config.get("sound_device") - xres, yres = DISPLAY_MANAGER.get_current_resolution() + xres, yres = get_display_manager().get_current_resolution() options = [ "-fs", fullscreen, diff --git a/lutris/runners/wine.py b/lutris/runners/wine.py index bd878e078..8406a839e 100644 --- a/lutris/runners/wine.py +++ b/lutris/runners/wine.py @@ -37,7 +37,7 @@ from lutris.runners.commands.wine import ( # noqa: F401 pylint: disable=unused- ) from lutris.runners.runner import Runner from lutris.util import system -from lutris.util.display import DISPLAY_MANAGER, get_default_dpi, is_display_x11 +from lutris.util.display import get_default_dpi, get_display_manager, is_display_x11 from lutris.util.graphics import drivers, vkquery from lutris.util.linux import LINUX_SYSTEM from lutris.util.log import logger @@ -538,7 +538,7 @@ class wine(Runner): "visible": _is_pre_proton, "conditional_on": "Desktop", "advanced": True, - "choices": DISPLAY_MANAGER.get_resolutions, + "choices": get_display_manager().get_resolutions, "help": _("The size of the virtual desktop in pixels."), }, { diff --git a/lutris/runners/zdoom.py b/lutris/runners/zdoom.py index af0cfca71..3461b3042 100644 --- a/lutris/runners/zdoom.py +++ b/lutris/runners/zdoom.py @@ -101,7 +101,7 @@ class zdoom(Runner): resolution = self.runner_config.get("resolution") if resolution: if resolution == "desktop": - width, height = display.DISPLAY_MANAGER.get_current_resolution() + width, height = display.get_display_manager().get_current_resolution() else: width, height = resolution.split("x") command.append("-width") diff --git a/lutris/sysoptions.py b/lutris/sysoptions.py index fe680eb31..3a009a139 100644 --- a/lutris/sysoptions.py +++ b/lutris/sysoptions.py @@ -8,7 +8,7 @@ from typing import TYPE_CHECKING, Any, cast from lutris import runners from lutris.util import linux, system -from lutris.util.display import DISPLAY_MANAGER, SCREEN_SAVER_INHIBITOR, is_compositing_enabled, is_display_x11 +from lutris.util.display import SCREEN_SAVER_INHIBITOR, get_display_manager, is_compositing_enabled, is_display_x11 from lutris.util.graphics.gpu import get_gpus from lutris.util.sniper import get_sniper_run_command @@ -20,7 +20,7 @@ def get_resolution_choices() -> list[tuple[str, str]]: """Return list of available resolutions as label, value tuples suitable for inclusion in drop-downs. """ - resolutions = DISPLAY_MANAGER.get_resolutions() + resolutions = get_display_manager().get_resolutions() resolution_choices = list(zip(resolutions, resolutions)) resolution_choices.insert(0, (_("Keep current"), "off")) return resolution_choices @@ -61,7 +61,7 @@ def get_gpu_list() -> list[tuple[str, str]]: def get_output_choices() -> list[tuple[str, str]]: """Return list of outputs for drop-downs""" - displays = DISPLAY_MANAGER.get_display_names() + displays = get_display_manager().get_display_names() output_choices = list(zip(displays, displays)) output_choices.insert(0, (_("Off"), "off")) output_choices.insert(1, (_("Primary"), "primary")) @@ -73,7 +73,7 @@ def get_output_list() -> list[tuple[str, str]]: This is used to indicate to SDL 1.2 which monitor to use. """ choices = [(_("Off"), "off")] - displays = DISPLAY_MANAGER.get_display_names() + displays = get_display_manager().get_display_names() for index, output in enumerate(displays): # Display name can't be used because they might not be in the right order # Using DISPLAYS to get the number of connected monitors @@ -293,7 +293,7 @@ system_options: list[dict[str, Any]] = [ # pylint: disable=invalid-name "option": "gamescope_output_res", "type": "choice_with_entry", "label": _("Output Resolution"), - "choices": DISPLAY_MANAGER.get_resolutions, + "choices": get_display_manager().get_resolutions, "advanced": True, "conditional_on": "gamescope", "condition": system.can_find_executable("gamescope"), @@ -309,7 +309,7 @@ system_options: list[dict[str, Any]] = [ # pylint: disable=invalid-name "option": "gamescope_game_res", "type": "choice_with_entry", "label": _("Game Resolution"), - "choices": DISPLAY_MANAGER.get_resolutions, + "choices": get_display_manager().get_resolutions, "conditional_on": "gamescope", "condition": system.can_find_executable("gamescope"), "help": _("Set the maximum resolution used by the game.\n\nCustom Resolutions: (width)x(height)"), diff --git a/lutris/util/display.py b/lutris/util/display.py index 639db652c..119b2c82a 100644 --- a/lutris/util/display.py +++ b/lutris/util/display.py @@ -3,15 +3,14 @@ from __future__ import annotations # isort:skip_file +import abc import enum import os import subprocess -from collections.abc import Iterable from typing import TYPE_CHECKING, Any -# GnomeDesktop 3.0 requires GTK 3 and cannot coexist with GTK 4. -# The GnomeDesktop-based DisplayManager is no longer available; -# we fall through to MutterDisplayManager or LegacyDisplayManager instead. +# GnomeDesktop 3.0 requires GTK 3 and cannot coexist with GTK 4. The constants are +# kept (always falsy) for callers that branched on them before the GTK 4 port. LIB_GNOME_DESKTOP_AVAILABLE = False GnomeDesktop = None @@ -26,8 +25,6 @@ from gi.repository import Gdk, GLib, Gio, Gtk from lutris.util import cache_single from lutris.settings import DEFAULT_RESOLUTION_HEIGHT, DEFAULT_RESOLUTION_WIDTH -from lutris.util.graphics.displayconfig import MutterDisplayManager -from lutris.util.graphics.xrandr import LegacyDisplayManager, change_resolution, get_outputs, Output from lutris.util.log import logger if TYPE_CHECKING: @@ -57,51 +54,76 @@ def is_display_x11() -> bool: return "x11" in type(display).__name__.casefold() -class DisplayManager: - """Get display and resolution using XRandR (GnomeDesktop no longer available with GTK 4)""" +class DisplayManager(abc.ABC): + """Abstract interface implemented by the supported display backends: + MutterDisplayManager (DBus, Wayland-aware) and LegacyDisplayManager (xrandr, X11). - @staticmethod - def get_display_names() -> list[str]: - """Return names of connected displays""" - return [output.name for output in get_outputs()] + Default implementations of get_display_names and get_current_resolution use + GDK; the concrete backends override both to stay self-consistent with the + same data source they use for the abstract methods below.""" - @staticmethod - def get_resolutions() -> list[str]: - """Return available resolutions""" - resolutions = [] - for output in get_outputs(): - resolutions.append(output.mode) - if not resolutions: - logger.error("Failed to generate resolution list") - return ["%sx%s" % (DEFAULT_RESOLUTION_WIDTH, DEFAULT_RESOLUTION_HEIGHT)] - return sorted(set(resolutions), key=lambda x: int(x.split("x")[0]), reverse=True) + def get_display_names(self) -> list[str]: + """Return connector names of connected displays.""" + display = Gdk.Display.get_default() + if not display: + return [] + monitors = display.get_monitors() + names: list[str] = [] + for i in range(monitors.get_n_items()): + monitor = monitors.get_item(i) + if monitor and (connector := monitor.get_connector()): + names.append(connector) + return names - @staticmethod - def get_current_resolution() -> tuple[str, str]: - """Return the current resolution for the primary display""" - outputs = get_outputs() - primary = next((o for o in outputs if o.primary), None) or (outputs[0] if outputs else None) - if primary and primary.mode: - parts = primary.mode.split("x") - if len(parts) == 2: - return parts[0], parts[1] + @abc.abstractmethod + def get_resolutions(self) -> list[str]: + """Return available resolutions, formatted "WIDTHxHEIGHT".""" + + def get_current_resolution(self) -> tuple[str, str]: + """Return the current resolution (width, height) of the primary display. + + BUG: under fractional desktop scaling this reports the wrong number. + GDK 4.10 exposes only the integer scale_factor (Gdk.Monitor.get_scale, + a float, was added in GDK 4.14). For 125% scaling on a 1080p panel, + get_geometry returns a 1536x864 logical size and get_scale_factor + returns 1, so width*scale_factor = 1536x864 instead of the physical + 1920x1080. KDE/Wayland exposes the same bug from the other side via + XWayland's upscaled virtual canvas. Both concrete subclasses override + this with backend-specific logic that recovers the physical mode + (xrandr uses XWayland's EDID preferred_mode; Mutter pulls the current + mode list from DBus).""" + display = Gdk.Display.get_default() + if display: + monitors = display.get_monitors() + if monitors.get_n_items() > 0: + monitor = monitors.get_item(0) + if monitor: + geom = monitor.get_geometry() + scale = monitor.get_scale_factor() + return str(geom.width * scale), str(geom.height * scale) return str(DEFAULT_RESOLUTION_WIDTH), str(DEFAULT_RESOLUTION_HEIGHT) - @staticmethod - def set_resolution(resolution: str | Iterable[Output]) -> None: - """Set the resolution of one or more displays.""" - return change_resolution(resolution) + @abc.abstractmethod + def set_resolution(self, resolution: Any) -> None: + """Apply a resolution string to the primary display, or a backend-specific + config list (as returned by get_config) to all displays.""" - @staticmethod - def get_config() -> list[Output]: - """Return the current display configuration.""" - return get_outputs() + @abc.abstractmethod + def get_config(self) -> list[Any]: + """Return the current display configuration; can be passed back to set_resolution.""" -def get_display_manager() -> MutterDisplayManager | DisplayManager | LegacyDisplayManager: - """Return the appropriate display manager instance. - Defaults to Mutter if available. This is the only one to support Wayland. +@cache_single +def get_display_manager() -> DisplayManager: + """Return the appropriate display manager instance, lazily constructed and + cached. Defaults to Mutter if available — this is the only backend that + supports Wayland. """ + # Imported here to avoid a circular import: the concrete classes inherit from + # DisplayManager defined above, so they import this module at their top level. + from lutris.util.graphics.displayconfig import MutterDisplayManager + from lutris.util.graphics.xrandr import LegacyDisplayManager + if DBUS_AVAILABLE: try: return MutterDisplayManager() @@ -115,9 +137,6 @@ def get_display_manager() -> MutterDisplayManager | DisplayManager | LegacyDispl return LegacyDisplayManager() -DISPLAY_MANAGER = get_display_manager() - - class DesktopEnvironment(enum.Enum): """Enum of desktop environments.""" diff --git a/lutris/util/graphics/displayconfig.py b/lutris/util/graphics/displayconfig.py index 29ba171f2..ed53f4c89 100644 --- a/lutris/util/graphics/displayconfig.py +++ b/lutris/util/graphics/displayconfig.py @@ -6,6 +6,7 @@ from typing import Any import dbus from lutris.settings import DEFAULT_RESOLUTION_HEIGHT, DEFAULT_RESOLUTION_WIDTH +from lutris.util.display import DisplayManager from lutris.util.log import logger DisplayConfig = namedtuple("DisplayConfig", ("monitors", "name", "position", "transform", "primary", "scale")) @@ -621,7 +622,7 @@ class MutterDisplayConfig: self.interface.ApplyMonitorsConfig(self.current_state.serial, self.TEMPORARY_METHOD, monitors_config, {}) -class MutterDisplayManager: +class MutterDisplayManager(DisplayManager): """Manage displays using the DBus Mutter interface""" def __init__(self) -> None: diff --git a/lutris/util/graphics/xrandr.py b/lutris/util/graphics/xrandr.py index 73c1c569b..af82d05a1 100644 --- a/lutris/util/graphics/xrandr.py +++ b/lutris/util/graphics/xrandr.py @@ -7,6 +7,7 @@ from collections.abc import Iterable from lutris.settings import DEFAULT_RESOLUTION_HEIGHT, DEFAULT_RESOLUTION_WIDTH from lutris.util import cache_single +from lutris.util.display import DisplayManager from lutris.util.linux import LINUX_SYSTEM from lutris.util.log import logger from lutris.util.system import read_process_output @@ -201,23 +202,20 @@ def change_resolution(resolution: str | Iterable[Output]) -> None: xrandr.communicate() -class LegacyDisplayManager: # pylint: disable=too-few-public-methods +class LegacyDisplayManager(DisplayManager): """Legacy XrandR based display manager. Does not work on Wayland. """ - @staticmethod - def get_display_names() -> list[str]: + def get_display_names(self) -> list[str]: """Return output names from XrandR""" return [output.name for output in get_outputs()] - @staticmethod - def get_resolutions() -> list[str]: + def get_resolutions(self) -> list[str]: """Return available resolutions""" return get_resolutions() - @staticmethod - def get_current_resolution() -> tuple[str, str]: + def get_current_resolution(self) -> tuple[str, str]: """Return the current resolution for the desktop""" outputs = get_outputs() if not outputs: @@ -235,12 +233,10 @@ class LegacyDisplayManager: # pylint: disable=too-few-public-methods mode = primary.preferred_mode return tuple(mode.split("x")) - @staticmethod - def set_resolution(resolution: str | Iterable[Output]) -> None: + def set_resolution(self, resolution: str | Iterable[Output]) -> None: """Change the current resolution""" change_resolution(resolution) - @staticmethod - def get_config() -> list[Output]: + def get_config(self) -> list[Output]: """Return the current display configuration""" return get_outputs() diff --git a/lutris/util/wine/prefix.py b/lutris/util/wine/prefix.py index c15a64a15..8ca0f5c26 100644 --- a/lutris/util/wine/prefix.py +++ b/lutris/util/wine/prefix.py @@ -4,7 +4,7 @@ import os from lutris.settings import get_lutris_directory_settings, set_lutris_directory_settings from lutris.util import joypad, system -from lutris.util.display import DISPLAY_MANAGER +from lutris.util.display import get_display_manager from lutris.util.log import logger from lutris.util.wine.registry import WineRegistry from lutris.util.xdgshortcuts import get_xdg_entry @@ -258,7 +258,7 @@ class WinePrefixManager: path = self.hkcu_prefix + "/Software/Wine/Explorer" if enabled: self.set_registry_key(path, "Desktop", "WineDesktop") - default_resolution = "x".join(DISPLAY_MANAGER.get_current_resolution()) + default_resolution = "x".join(get_display_manager().get_current_resolution()) logger.debug( "Enabling wine virtual desktop with default resolution of %s", default_resolution,