mirror of
https://github.com/lutris/lutris
synced 2024-11-02 07:10:17 +00:00
Merge pull request #4926 from lutris/dj/missing_badge
Badge for missing games
This commit is contained in:
commit
3e346b7ec6
4 changed files with 119 additions and 34 deletions
|
@ -3,7 +3,7 @@
|
|||
from gi.repository import Gtk
|
||||
|
||||
from lutris import settings
|
||||
from lutris.gui.views import COL_INSTALLED, COL_MEDIA_PATH, COL_NAME, COL_PLATFORM
|
||||
from lutris.gui.views import COL_INSTALLED, COL_MEDIA_PATH, COL_NAME, COL_PLATFORM, COL_ID
|
||||
from lutris.gui.views.base import GameView
|
||||
from lutris.gui.widgets.cellrenderers import GridViewCellRendererImage, GridViewCellRendererText
|
||||
from lutris.util.log import logger
|
||||
|
@ -72,6 +72,7 @@ class GameGridView(Gtk.IconView, GameView):
|
|||
def _initialize_image_renderer_attributes(self):
|
||||
if self.image_renderer:
|
||||
self.clear_attributes(self.image_renderer)
|
||||
self.add_attribute(self.image_renderer, "game_id", COL_ID)
|
||||
self.add_attribute(self.image_renderer, "media_path", COL_MEDIA_PATH)
|
||||
if self.show_badges:
|
||||
self.add_attribute(self.image_renderer, "platform", COL_PLATFORM)
|
||||
|
|
|
@ -55,7 +55,7 @@ class StoreItem:
|
|||
@property
|
||||
def id(self): # pylint: disable=invalid-name
|
||||
"""Game internal ID"""
|
||||
# Return an unique identifier for the game.
|
||||
# Return a unique identifier for the game.
|
||||
# Since service games are not related to lutris, use the appid
|
||||
if "service_id" not in self._game_data:
|
||||
if "appid" in self._game_data:
|
||||
|
|
|
@ -1,14 +1,16 @@
|
|||
# pylint:disable=using-constant-test
|
||||
# pylint:disable=comparison-with-callable
|
||||
from math import floor
|
||||
from gettext import gettext as _
|
||||
|
||||
import cairo
|
||||
from gi.repository import GLib, GObject, Gtk, Pango
|
||||
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
|
||||
)
|
||||
from lutris.scanners.lutris import is_game_missing
|
||||
|
||||
|
||||
class GridViewCellRendererText(Gtk.CellRendererText):
|
||||
|
@ -92,6 +94,7 @@ class GridViewCellRendererImage(Gtk.CellRenderer):
|
|||
super().__init__(*args, **kwargs)
|
||||
self._media_width = 0
|
||||
self._media_height = 0
|
||||
self._game_id = None
|
||||
self._media_path = None
|
||||
self._platform = None
|
||||
self._is_installed = True
|
||||
|
@ -100,6 +103,10 @@ class GridViewCellRendererImage(Gtk.CellRenderer):
|
|||
self.cached_surfaces_loaded = 0
|
||||
self.cycle_cache_idle_id = None
|
||||
self.cached_surface_generation = 0
|
||||
self.badge_size = 0, 0
|
||||
self.badge_alpha = 0.6
|
||||
self.badge_fore_color = 1, 1, 1
|
||||
self.badge_back_color = 0, 0, 0
|
||||
|
||||
@GObject.Property(type=int, default=0)
|
||||
def media_width(self):
|
||||
|
@ -123,6 +130,15 @@ class GridViewCellRendererImage(Gtk.CellRenderer):
|
|||
self._media_height = value
|
||||
self.clear_cache()
|
||||
|
||||
@GObject.Property(type=str)
|
||||
def game_id(self):
|
||||
"""This is the path to the media file to be displayed."""
|
||||
return self._game_id
|
||||
|
||||
@game_id.setter
|
||||
def game_id(self, value):
|
||||
self._game_id = value
|
||||
|
||||
@GObject.Property(type=str)
|
||||
def media_path(self):
|
||||
"""This is the path to the media file to be displayed."""
|
||||
|
@ -171,13 +187,16 @@ class GridViewCellRendererImage(Gtk.CellRenderer):
|
|||
path = get_default_icon_path((media_width, media_height))
|
||||
surface = self.get_cached_surface_by_path(widget, path,
|
||||
preserve_aspect_ratio=False)
|
||||
|
||||
if surface:
|
||||
x, y = self.get_media_position(surface, cell_area)
|
||||
self.select_badge_metrics(surface)
|
||||
|
||||
if alpha >= 1:
|
||||
self.render_media(cr, widget, surface, x, y)
|
||||
self.render_platforms(cr, widget, surface, x, cell_area)
|
||||
|
||||
if self.game_id and is_game_missing(self.game_id):
|
||||
self.render_text_badge(cr, widget, _("Missing"), x, cell_area.y + cell_area.height)
|
||||
else:
|
||||
cr.push_group()
|
||||
self.render_media(cr, widget, surface, x, y)
|
||||
|
@ -190,6 +209,30 @@ class GridViewCellRendererImage(Gtk.CellRenderer):
|
|||
if not self.cycle_cache_idle_id:
|
||||
self.cycle_cache_idle_id = GLib.idle_add(self.cycle_cache)
|
||||
|
||||
def select_badge_metrics(self, surface):
|
||||
"""Updates fields holding data about the appearance of the badges;
|
||||
this sets self.badge_size to None if no badges should be shown at all."""
|
||||
|
||||
def get_badge_icon_size():
|
||||
"""Returns the size of the badge icons to render, or None to hide them. We check
|
||||
width for the smallest size because Dolphin has very thin banners, but we only hide
|
||||
badges for icons, not banners."""
|
||||
if self.media_width < 64:
|
||||
return None
|
||||
if self.media_height < 128:
|
||||
return 16, 16
|
||||
if self.media_height < 256:
|
||||
return 24, 24
|
||||
return 32, 32
|
||||
|
||||
self.badge_size = get_badge_icon_size()
|
||||
on_bright_surface = self.badge_size and GridViewCellRendererImage.is_bright_corner(surface, self.badge_size)
|
||||
|
||||
bright_color = 0.8, 0.8, 0.8
|
||||
dark_color = 0.2, 0.2, 0.2
|
||||
self.badge_fore_color = dark_color if on_bright_surface else bright_color
|
||||
self.badge_back_color = bright_color if on_bright_surface else dark_color
|
||||
|
||||
@staticmethod
|
||||
def is_bright_corner(surface, corner_size):
|
||||
"""Tests several pixels near the corner of the surface where the badges
|
||||
|
@ -242,18 +285,6 @@ class GridViewCellRendererImage(Gtk.CellRenderer):
|
|||
y = round(cell_area.y + cell_area.height - height) # at bottom of cell
|
||||
return x, y
|
||||
|
||||
def get_badge_icon_size(self):
|
||||
"""Returns the size of the badge icons to render, or None to hide them. We check
|
||||
width for the smallest size because Dolphin has very thin banners, but we only hide
|
||||
badges for icons, not banners."""
|
||||
if self.media_width < 64:
|
||||
return None
|
||||
if self.media_height < 128:
|
||||
return 16, 16
|
||||
if self.media_height < 256:
|
||||
return 24, 24
|
||||
return 32, 32
|
||||
|
||||
def render_media(self, cr, widget, surface, x, y):
|
||||
"""Renders the media itself, given the surface containing it
|
||||
and the position."""
|
||||
|
@ -268,8 +299,7 @@ class GridViewCellRendererImage(Gtk.CellRenderer):
|
|||
"""Renders the stack of platform icons. They appear lined up vertically to the
|
||||
right of 'media_right', if that will fit in 'cell_area'."""
|
||||
platform = self.platform
|
||||
icon_size = self.get_badge_icon_size()
|
||||
if platform and icon_size:
|
||||
if platform and self.badge_size:
|
||||
if "," in platform:
|
||||
platforms = platform.split(",") # pylint:disable=no-member
|
||||
else:
|
||||
|
@ -278,29 +308,25 @@ class GridViewCellRendererImage(Gtk.CellRenderer):
|
|||
icon_paths = [get_runtime_icon_path(p + "-symbolic") for p in platforms]
|
||||
icon_paths = [path for path in icon_paths if path]
|
||||
if icon_paths:
|
||||
self.render_badge_stack(cr, widget, surface, surface_x, icon_paths, icon_size, cell_area)
|
||||
self.render_badge_stack(cr, widget, surface, surface_x, icon_paths, cell_area)
|
||||
|
||||
def render_badge_stack(self, cr, widget, surface, surface_x, icon_paths, icon_size, cell_area):
|
||||
def render_badge_stack(self, cr, widget, surface, surface_x, icon_paths, cell_area):
|
||||
"""Renders a vertical stack of badges, placed at the edge of the media, off to the right
|
||||
of 'media_right' if this will fit in the 'cell_area'. The icons in icon_paths are drawn from
|
||||
top to bottom, and spaced to fit in 'cell_area', even if they overlap because of this."""
|
||||
|
||||
badge_width = icon_size[0]
|
||||
badge_height = icon_size[1]
|
||||
on_bright_surface = GridViewCellRendererImage.is_bright_corner(surface, (badge_width, badge_height))
|
||||
|
||||
alpha = 0.6
|
||||
bright_color = 0.8, 0.8, 0.8
|
||||
dark_color = 0.2, 0.2, 0.2
|
||||
back_color = bright_color if on_bright_surface else dark_color
|
||||
fore_color = dark_color if on_bright_surface else bright_color
|
||||
badge_width = self.badge_size[0]
|
||||
badge_height = self.badge_size[1]
|
||||
alpha = self.badge_alpha
|
||||
fore_color = self.badge_fore_color
|
||||
back_color = self.badge_back_color
|
||||
|
||||
def render_badge(badge_x, badge_y, path):
|
||||
cr.rectangle(badge_x, badge_y, icon_size[0], icon_size[0])
|
||||
cr.rectangle(badge_x, badge_y, badge_width, badge_height)
|
||||
cr.set_source_rgba(back_color[0], back_color[1], back_color[2], alpha)
|
||||
cr.fill()
|
||||
|
||||
icon = self.get_cached_surface_by_path(widget, path, size=icon_size)
|
||||
icon = self.get_cached_surface_by_path(widget, path, size=self.badge_size)
|
||||
cr.set_source_rgba(fore_color[0], fore_color[1], fore_color[2], alpha)
|
||||
cr.mask_surface(icon, badge_x, badge_y)
|
||||
|
||||
|
@ -316,6 +342,47 @@ class GridViewCellRendererImage(Gtk.CellRenderer):
|
|||
render_badge(x, y, icon_path)
|
||||
y = y + y_offset
|
||||
|
||||
def render_text_badge(self, cr, widget, text, left, bottom):
|
||||
"""Draws a short test in the lower left corner of the media, in the
|
||||
style of a badge."""
|
||||
def get_layout():
|
||||
"""Constructs a layout with the text to draw, but also returns its size
|
||||
in pixels. This is boldfaced, but otherwise in the default font."""
|
||||
lo = widget.create_pango_layout(text)
|
||||
font = lo.get_context().get_font_description()
|
||||
font.set_weight(Pango.Weight.BOLD)
|
||||
lo.set_font_description(font)
|
||||
_, text_bounds = lo.get_extents()
|
||||
return lo, text_bounds.width / Pango.SCALE, text_bounds.height / Pango.SCALE
|
||||
|
||||
if self.badge_size:
|
||||
alpha = self.badge_alpha
|
||||
fore_color = self.badge_fore_color
|
||||
back_color = self.badge_back_color
|
||||
|
||||
layout, text_width, text_height = get_layout()
|
||||
|
||||
cr.save()
|
||||
|
||||
# To get the text to be as tall as a badge, we'll scale it
|
||||
# with Cairo. Scaling the font size does not work; the font
|
||||
# size measures the wrong height for this.
|
||||
text_scale = self.badge_size[1] / text_height
|
||||
text_height = self.badge_size[1]
|
||||
text_width = round(text_width * text_scale)
|
||||
|
||||
cr.rectangle(left, bottom - text_height, text_width + 4, text_height)
|
||||
cr.set_source_rgba(back_color[0], back_color[1], back_color[2], alpha)
|
||||
cr.fill()
|
||||
|
||||
cr.translate(left + 2, bottom - text_height)
|
||||
cr.scale(text_scale, text_scale)
|
||||
cr.set_source_rgba(fore_color[0], fore_color[1], fore_color[2], alpha)
|
||||
PangoCairo.update_layout(cr, layout)
|
||||
PangoCairo.show_layout(cr, layout)
|
||||
|
||||
cr.restore()
|
||||
|
||||
def clear_cache(self):
|
||||
"""Discards all cached surfaces; used when some properties are changed."""
|
||||
self.cached_surfaces_old.clear()
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import json
|
||||
import os
|
||||
import time
|
||||
from functools import lru_cache
|
||||
|
||||
from lutris import settings
|
||||
from lutris.api import get_api_games, get_game_installers
|
||||
|
@ -150,6 +151,7 @@ def build_path_cache(recreate=False):
|
|||
game_paths = get_game_paths()
|
||||
json.dump(game_paths, cache_file, indent=2)
|
||||
end_time = time.time()
|
||||
get_path_cache.cache_clear()
|
||||
logger.debug("Game path cache built in %0.2f seconds", end_time - start_time)
|
||||
|
||||
|
||||
|
@ -160,25 +162,34 @@ def add_to_path_cache(game):
|
|||
if not path:
|
||||
logger.warning("No path for %s", game)
|
||||
return
|
||||
current_cache = get_path_cache()
|
||||
current_cache = read_path_cache()
|
||||
current_cache[game.id] = path
|
||||
with open(GAME_PATH_CACHE_PATH, "w", encoding="utf-8") as cache_file:
|
||||
json.dump(current_cache, cache_file, indent=2)
|
||||
get_path_cache.cache_clear()
|
||||
|
||||
|
||||
def remove_from_path_cache(game):
|
||||
logger.debug("Removing %s from path cache", game)
|
||||
current_cache = get_path_cache()
|
||||
current_cache = read_path_cache()
|
||||
if str(game.id) not in current_cache:
|
||||
logger.warning("Game %s (id=%s) not in cache path", game, game.id)
|
||||
return
|
||||
del current_cache[str(game.id)]
|
||||
with open(GAME_PATH_CACHE_PATH, "w", encoding="utf-8") as cache_file:
|
||||
json.dump(current_cache, cache_file, indent=2)
|
||||
get_path_cache.cache_clear()
|
||||
|
||||
|
||||
@lru_cache()
|
||||
def get_path_cache():
|
||||
"""Return the contents of the path cache file"""
|
||||
"""Return the contents of the path cache file; this
|
||||
dict is cached, so do not modify it."""
|
||||
return read_path_cache()
|
||||
|
||||
|
||||
def read_path_cache():
|
||||
"""Read the contents of the path cache file, and does not cache it."""
|
||||
with open(GAME_PATH_CACHE_PATH, encoding="utf-8") as cache_file:
|
||||
try:
|
||||
return json.load(cache_file)
|
||||
|
@ -194,3 +205,9 @@ def get_missing_game_ids():
|
|||
if not os.path.exists(path):
|
||||
missing_ids.append(game_id)
|
||||
return missing_ids
|
||||
|
||||
|
||||
def is_game_missing(game_id):
|
||||
cache = get_path_cache()
|
||||
path = cache.get(str(game_id))
|
||||
return path and not os.path.exists(path)
|
||||
|
|
Loading…
Reference in a new issue