diff --git a/lutris/gui/config/game_common.py b/lutris/gui/config/game_common.py index 4d2326794..d933fe865 100644 --- a/lutris/gui/config/game_common.py +++ b/lutris/gui/config/game_common.py @@ -17,7 +17,7 @@ from lutris.gui.dialogs.delegates import DialogInstallUIDelegate from lutris.gui.widgets.common import Label, NumberEntry, SlugEntry from lutris.gui.widgets.notifications import send_notification from lutris.gui.widgets.scaled_image import ScaledImage -from lutris.gui.widgets.utils import get_image_file_format, invalidate_media_caches +from lutris.gui.widgets.utils import MEDIA_CACHE_INVALIDATED, get_image_file_format from lutris.runners import import_runner from lutris.services.lutris import LutrisBanner, LutrisCoverart, LutrisIcon, download_lutris_media from lutris.util.jobs import AsyncCall @@ -725,7 +725,7 @@ class GameDialogCommon(SavableModelessDialog, DialogInstallUIDelegate): # JPEG encoding looks rather better at high quality; # PNG encoding just ignores this option. pixbuf.savev(dest_path, file_format, ["quality"], ["100"]) - invalidate_media_caches() + MEDIA_CACHE_INVALIDATED.fire() return image_type def refresh_image(self, image_type): @@ -737,7 +737,7 @@ class GameDialogCommon(SavableModelessDialog, DialogInstallUIDelegate): if os.path.isfile(dest_path): os.remove(dest_path) download_lutris_media(self.game.slug) - invalidate_media_caches() + MEDIA_CACHE_INVALIDATED.fire() return image_type def image_refreshed_cb(self, image_type, _error): diff --git a/lutris/gui/lutriswindow.py b/lutris/gui/lutriswindow.py index 24e41b37d..e60f4599a 100644 --- a/lutris/gui/lutriswindow.py +++ b/lutris/gui/lutriswindow.py @@ -770,11 +770,15 @@ class LutrisWindow(Gtk.ApplicationWindow, regenerates it. This is used to update view settings that can only be set during view construction, and not updated later.""" if view_type in self.views: + view = self.views[view_type] scrolledwindow = self.games_stack.get_child_by_name(view_type) - scrolledwindow.remove(self.views[view_type]) - del self.views["grid"] + scrolledwindow.remove(view) + del self.views[view_type] if self.current_view_type == view_type: self.redraw_view() + # Because the view has hooks and such hooked up, it must be explicitly + # destroyed to disconnect everything. + view.destroy() def update_view_settings(self): if self.current_view and self.current_view_type == "grid": diff --git a/lutris/gui/views/base.py b/lutris/gui/views/base.py index aeb004f14..25b605454 100644 --- a/lutris/gui/views/base.py +++ b/lutris/gui/views/base.py @@ -8,6 +8,7 @@ from lutris.database.services import ServiceGameCollection from lutris.game import Game from lutris.game_actions import BaseGameActions, get_game_actions from lutris.gui.widgets.contextual_menu import ContextualMenu +from lutris.gui.widgets.utils import MEDIA_CACHE_INVALIDATED from lutris.util.log import logger @@ -20,18 +21,27 @@ class GameView: def __init__(self, service): self.service = service + self.cache_notification_id = None self.game_start_hook_id = None self.image_renderer = None def connect_signals(self): """Signal handlers common to all views""" + self.cache_notification_id = MEDIA_CACHE_INVALIDATED.register(self.on_media_cache_invalidated) + self.connect("destroy", self.on_destroy) self.connect("button-press-event", self.popup_contextual_menu) self.connect("key-press-event", self.handle_key_press) self.game_start_hook_id = GObject.add_emission_hook(Game, "game-start", self.on_game_start) + def on_media_cache_invalidated(self): + self.queue_draw() + def on_destroy(self, _widget): + if self.cache_notification_id: + MEDIA_CACHE_INVALIDATED.unregister(self.cache_notification_id) + if self.game_start_hook_id: GObject.remove_emission_hook(Game, "game-start", self.game_start_hook_id) diff --git a/lutris/gui/views/media_loader.py b/lutris/gui/views/media_loader.py index addfa4de2..a76bb6397 100644 --- a/lutris/gui/views/media_loader.py +++ b/lutris/gui/views/media_loader.py @@ -1,7 +1,7 @@ """Loads game media in parallel""" import concurrent.futures -from lutris.gui.widgets.utils import invalidate_media_caches +from lutris.gui.widgets.utils import MEDIA_CACHE_INVALIDATED from lutris.util import system from lutris.util.log import logger @@ -30,5 +30,5 @@ def download_media(media_urls, service_media): icons[slug] = path if icons: - invalidate_media_caches() + MEDIA_CACHE_INVALIDATED.fire() return icons diff --git a/lutris/gui/widgets/__init__.py b/lutris/gui/widgets/__init__.py index e69de29bb..6809bda17 100644 --- a/lutris/gui/widgets/__init__.py +++ b/lutris/gui/widgets/__init__.py @@ -0,0 +1,49 @@ +from typing import Callable + +from lutris.util.jobs import schedule_at_idle + + +class Notification: + """A class to inform interested code of changes in a global, like a signal but not attached to any + object.""" + + def __init__(self): + self._generation_number = 0 + self._callbacks = {} + self._next_callback_id = 1 + self._scheduled_callbacks = set() + + def fire(self) -> None: + """Signals that the thing, whatever it is, has happened. This increments the generation number, + and schedules the callbacks to run (if they are not scheduled already).""" + self._generation_number += 1 + self._scheduled_callbacks.update(self._callbacks.values()) + schedule_at_idle(self._notify) + + @property + def generation_number(self) -> int: + """Returns a number that is incremented on each call to fire(). This can be polled + passively, when registering a callback is inappropriate.""" + return self._generation_number + + def register(self, callback: Callable[[], None]) -> int: + """Registers a callback to be called after the thing, whatever it is, has happened; + fire() schedules callbacks to be called at idle time on the main thread. + + Note that a callback will be kept alive until unregistered, and this can keep + large objects alive until then. + + Returns an id number to use to unregister the callback.""" + callback_id = self._next_callback_id + self._callbacks[callback_id] = callback + self._next_callback_id += 1 + return callback_id + + def unregister(self, callback_id: int) -> None: + """Unregisters a callback that register() had registered.""" + self._callbacks.pop(callback_id, None) + + def _notify(self): + while self._scheduled_callbacks: + callback = self._scheduled_callbacks.pop() + callback() diff --git a/lutris/gui/widgets/cellrenderers.py b/lutris/gui/widgets/cellrenderers.py index 0ebe20688..3e446ff9e 100644 --- a/lutris/gui/widgets/cellrenderers.py +++ b/lutris/gui/widgets/cellrenderers.py @@ -11,8 +11,7 @@ import cairo from gi.repository import GLib, GObject, Gtk, Pango, PangoCairo from lutris.gui.widgets.utils import ( - get_default_icon_path, get_media_generation_number, get_runtime_icon_path, get_scaled_surface_by_path, - get_surface_size + MEDIA_CACHE_INVALIDATED, get_default_icon_path, get_runtime_icon_path, get_scaled_surface_by_path, get_surface_size ) from lutris.scanners.lutris import is_game_missing @@ -472,8 +471,8 @@ class GridViewCellRendererImage(Gtk.CellRenderer): in this render, but we'll clear that cache when the media generation number is changed, or certain properties are. We also age surfaces from the cache at idle time after rendering.""" - if self.cached_surface_generation != get_media_generation_number(): - self.cached_surface_generation = get_media_generation_number() + if self.cached_surface_generation != MEDIA_CACHE_INVALIDATED.generation_number: + self.cached_surface_generation = MEDIA_CACHE_INVALIDATED.generation_number self.clear_cache() key = widget, path, size, preserve_aspect_ratio diff --git a/lutris/gui/widgets/utils.py b/lutris/gui/widgets/utils.py index 50b04ab67..53337b7cf 100644 --- a/lutris/gui/widgets/utils.py +++ b/lutris/gui/widgets/utils.py @@ -6,8 +6,8 @@ import cairo from gi.repository import Gdk, GdkPixbuf, Gio, GLib, Gtk from lutris import settings +from lutris.gui.widgets import Notification from lutris.util import datapath, magic, system -from lutris.util.jobs import schedule_at_idle from lutris.util.log import logger try: @@ -17,8 +17,7 @@ except ImportError: ICON_SIZE = (32, 32) BANNER_SIZE = (184, 69) - -_surface_generation_number = 0 +MEDIA_CACHE_INVALIDATED = Notification() def get_main_window(widget): @@ -103,29 +102,6 @@ def get_scaled_surface_by_path(path, size, device_scale, preserve_aspect_ratio=T return surface -def get_media_generation_number(): - """Returns a number that is incremented whenever cached media may no longer - be valid. Caller can check to see if this has changed before using their own caches.""" - return _surface_generation_number - - -def invalidate_media_caches(): - """Increments the media generation number; this indicates that cached media - from earlier generations may be invalid and should be reloaded.""" - global _surface_generation_number - _surface_generation_number += 1 - schedule_at_idle(queue_draw_all_windows) - - -def queue_draw_all_windows(): - """Scans through all windows and queues a redraw for each one; - we trigger this when invalidate_media_caches() has been called.""" - application = Gio.Application.get_default() - if application: - for window in application.get_windows(): - window.queue_draw() - - def get_default_icon_path(size): """Returns the path to the default icon for the size given; it's a Lutris icon for a square size, and a gradient for other sizes."""