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 <noreply@anthropic.com>
This commit is contained in:
Daniel Johnson
2026-05-03 19:16:40 -04:00
parent 2af95f8291
commit 9c7bbddde7
12 changed files with 99 additions and 81 deletions

View File

@@ -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)

View File

@@ -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:

View File

@@ -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]

View File

@@ -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")

View File

@@ -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,

View File

@@ -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."),
},
{

View File

@@ -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")

View File

@@ -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\n<b>Custom Resolutions:</b> (width)x(height)"),

View File

@@ -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."""

View File

@@ -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:

View File

@@ -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()

View File

@@ -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,