Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Multi-Select Support for 'Categories' #5376

Merged
merged 8 commits into from Apr 4, 2024
21 changes: 17 additions & 4 deletions lutris/game_actions.py
Expand Up @@ -157,6 +157,21 @@ def on_remove_game(self, *_args):
dlg = application.show_window(UninstallDialog, parent=self.window)
dlg.add_games(game_ids)

def on_edit_game_categories(self, _widget):
"""Edit game categories"""
games = self.get_games()
if len(games) == 1:
# Individual games get individual separate windows
self.application.show_window(EditGameCategoriesDialog, game=games[0], parent=self.window)
else:

def add_games(window):
window.add_games(self.get_games())

# Multi-select means a common categories window for all of them; we can wind
# up adding games to it if it's already open
self.application.show_window(EditGameCategoriesDialog, update_function=add_games, parent=self.window)


class MultiGameActions(GameActions):
"""This actions class handles actions on multiple games together, and only iof they
Expand All @@ -174,6 +189,7 @@ def get_game_actions(self):
return [
("stop", _("Stop"), self.on_game_stop),
(None, "-", None),
("category", _("Categories"), self.on_edit_game_categories),
("favorite", _("Add to favorites"), self.on_add_favorite_game),
("deletefavorite", _("Remove from favorites"), self.on_delete_favorite_game),
("hide", _("Hide game from library"), self.on_hide_game),
Expand All @@ -185,6 +201,7 @@ def get_game_actions(self):
def get_displayed_entries(self):
return {
"stop": self.is_game_running,
"category": True,
"favorite": any(g for g in self.games if not g.is_favorite),
"deletefavorite": any(g for g in self.games if g.is_favorite),
"hide": any(g for g in self.games if g.is_installed and not g.is_hidden),
Expand Down Expand Up @@ -309,10 +326,6 @@ def on_edit_game_configuration(self, _widget):
"""Edit game preferences"""
self.application.show_window(EditGameConfigDialog, game=self.game, parent=self.window)

def on_edit_game_categories(self, _widget):
"""Edit game categories"""
self.application.show_window(EditGameCategoriesDialog, game=self.game, parent=self.window)

def on_browse_files(self, _widget):
"""Callback to open a game folder in the file browser"""
path = self.game.get_browse_dir()
Expand Down
10 changes: 8 additions & 2 deletions lutris/gui/application.py
Expand Up @@ -361,11 +361,12 @@ def get_window_key(self, **kwargs):
return kwargs["game"].id
return str(kwargs)

def show_window(self, window_class, **kwargs):
def show_window(self, window_class, /, update_function=None, **kwargs):
"""Instantiate a window keeping 1 instance max

Params:
window_class (Gtk.Window): class to create the instance from
update_function (Callable): Function to initialize or update the window (if possible before being shown)
kwargs (dict): Additional arguments to pass to the instanciated window

Returns:
Expand All @@ -374,7 +375,10 @@ def show_window(self, window_class, **kwargs):
window_key = str(window_class.__name__) + self.get_window_key(**kwargs)
if self.app_windows.get(window_key):
self.app_windows[window_key].present()
return self.app_windows[window_key]
window_inst = self.app_windows[window_key]
if update_function:
update_function(window_inst)
return window_inst
if issubclass(window_class, Gtk.Dialog):
if "parent" in kwargs:
window_inst = window_class(**kwargs)
Expand All @@ -384,6 +388,8 @@ def show_window(self, window_class, **kwargs):
else:
window_inst = window_class(application=self, **kwargs)
window_inst.connect("destroy", self.on_app_window_destroyed, self.get_window_key(**kwargs))
if update_function:
update_function(window_inst)
self.app_windows[window_key] = window_inst
logger.debug("Showing window %s", window_key)
window_inst.show()
Expand Down
7 changes: 4 additions & 3 deletions lutris/gui/config/edit_category_games.py
Expand Up @@ -7,6 +7,7 @@
from lutris.database import games as games_db
from lutris.game import Game
from lutris.gui.dialogs import QuestionDialog, SavableModelessDialog
from lutris.util.strings import get_natural_sort_key


class EditCategoryGamesDialog(SavableModelessDialog):
Expand All @@ -17,9 +18,9 @@ def __init__(self, parent, category):

self.category = category["name"]
self.category_id = category["id"]
self.available_games = [
Game(x["id"]) for x in games_db.get_games(sorts=[("installed", "DESC"), ("name", "COLLATE NOCASE ASC")])
]
self.available_games = sorted(
[Game(x["id"]) for x in games_db.get_games()], key=lambda g: (g.is_installed, get_natural_sort_key(g.name))
)
self.category_games = [Game(x) for x in categories_db.get_game_ids_for_categories([self.category])]
self.grid = Gtk.Grid()

Expand Down
184 changes: 128 additions & 56 deletions lutris/gui/config/edit_game_categories.py
@@ -1,100 +1,172 @@
# pylint: disable=no-member
import locale
from gettext import gettext as _
from typing import Sequence

from gi.repository import Gtk

from lutris.database import categories as categories_db
from lutris.gui.dialogs import SavableModelessDialog
from lutris.database.categories import is_reserved_category
from lutris.game import Game
from lutris.gui.dialogs import QuestionDialog, SavableModelessDialog
from lutris.util.strings import get_natural_sort_key


class EditGameCategoriesDialog(SavableModelessDialog):
"""Game category edit dialog."""

def __init__(self, parent, game):
super().__init__(_("Categories - %s") % game.name, parent=parent, border_width=10)
def __init__(self, game=None, parent=None):
title = game.name if game else _("Categories")

self.game = game
self.game_id = game.id
self.game_categories = categories_db.get_categories_in_game(self.game_id)
super().__init__(title, parent=parent, border_width=10)
self.set_default_size(350, 250)

self.grid = Gtk.Grid()
self.category_checkboxes = {}
self.games = []
self.categories = sorted(
[c["name"] for c in categories_db.get_categories() if not is_reserved_category(c["name"])],
key=lambda c: get_natural_sort_key(c),
)

self.set_default_size(350, 250)
self.checkbox_grid = Gtk.Grid()

self.vbox.set_homogeneous(False)
self.vbox.set_spacing(10)
self.vbox.pack_start(self._create_category_checkboxes(), True, True, 0)
self.vbox.pack_start(self._create_add_category(), False, False, 0)

self.show_all()
if game:
self.add_games([game])

self.vbox.show_all()

def add_games(self, games: Sequence[Game]) -> None:
"""Adds games to the dialog; this is intended to be used when the dialog is for multiple games,
and can be used more than once to accumulate games."""

def mark_category_checkbox(checkbox, included):
# Checks or unchecks a textbox- but after the first game, this will
# compare against the current state and go to 'inconsistent' rather than
# reversing the checkbox.
if len(self.games) == 0:
checkbox.set_active(included)
elif not checkbox.get_inconsistent() and checkbox.get_active() != included:
checkbox.set_active(False)
checkbox.set_inconsistent(True)

def add_game(game):
# Adds a single game to the dialog, and checks or unchecks
# boxes as appropriate.
categories = categories_db.get_categories_in_game(game.id)
other_checkboxes = set(self.category_checkboxes.values())
for category in categories:
category_checkbox = self.category_checkboxes.get(category)
if category_checkbox:
other_checkboxes.discard(category_checkbox)
mark_category_checkbox(category_checkbox, included=True)

for category_checkbox in other_checkboxes:
mark_category_checkbox(category_checkbox, included=False)

self.games.append(game)

existing_game_ids = set(game.id for game in self.games)
for g in games:
if g.id not in existing_game_ids:
add_game(g)

if len(self.games) > 1:
subtitle = _("%d games") % len(self.games)
header_bar = self.get_header_bar()
if header_bar:
header_bar.set_subtitle(subtitle)

def _create_category_checkboxes(self):
"""Constructs a frame containing checkboxes for all known (non-special) categories."""
frame = Gtk.Frame()
# frame.set_label("Categories") # probably too much redundancy
sw = Gtk.ScrolledWindow()
row = Gtk.VBox()
categories = sorted(
[c for c in categories_db.get_categories() if c["name"] != "favorite"],
key=lambda c: locale.strxfrm(c["name"]),
)
for category in categories:
label = category["name"]
checkbutton_option = Gtk.CheckButton(label)
if label in self.game_categories:
checkbutton_option.set_active(True)
self.grid.attach_next_to(checkbutton_option, None, Gtk.PositionType.BOTTOM, 3, 1)

row.pack_start(self.grid, True, True, 0)
sw.add_with_viewport(row)
frame.add(sw)
scrolledwindow = Gtk.ScrolledWindow()

for category in self.categories:
label = category
checkbutton = Gtk.CheckButton(label)
checkbutton.connect("toggled", self.on_checkbutton_toggled)
self.checkbox_grid.attach_next_to(checkbutton, None, Gtk.PositionType.BOTTOM, 3, 1)
self.category_checkboxes[category] = checkbutton

scrolledwindow.add(self.checkbox_grid)
frame.add(scrolledwindow)
return frame

def _create_add_category(self):
"""Creates a box that carries the controls to add a new category."""

def on_add_category(*_args):
category_text = categories_db.strip_category_name(category_entry.get_text())
if not categories_db.is_reserved_category(category_text):
for category_checkbox in self.grid.get_children():
if category_checkbox.get_label() == category_text:
return
category = categories_db.strip_category_name(category_entry.get_text())
if not categories_db.is_reserved_category(category) and category not in self.category_checkboxes:
category_entry.set_text("")
checkbutton_option = Gtk.CheckButton(category_text)
checkbutton_option.set_active(True)
self.grid.attach_next_to(checkbutton_option, None, Gtk.PositionType.TOP, 3, 1)
categories_db.add_category(category_text)
self.vbox.show_all()
checkbutton = Gtk.CheckButton(category, visible=True, active=True)
self.category_checkboxes[category] = checkbutton
self.checkbox_grid.attach_next_to(checkbutton, None, Gtk.PositionType.TOP, 3, 1)
categories_db.add_category(category)

hbox = Gtk.HBox()
hbox.set_spacing(10)
hbox = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10)

category_entry = Gtk.Entry()
category_entry.set_text("")
category_entry.connect("activate", on_add_category)
hbox.pack_start(category_entry, True, True, 0)

button = Gtk.Button.new_with_label(_("Add Category"))
button.connect("clicked", on_add_category)
button.set_tooltip_text(_("Adds the category to the list."))
hbox.pack_start(button, False, False, 0)
hbox.pack_end(button, False, False, 0)

return hbox

def on_save(self, _button):
"""Save game info and destroy widget."""
removed_categories = set()
added_categories = set()

for category_checkbox in self.grid.get_children():
label = category_checkbox.get_label()
@staticmethod
def on_checkbutton_toggled(checkbutton):
# If the user toggles a checkbox, it is no longer inconsistent.
checkbutton.set_inconsistent(False)

if label in self.game_categories:
if not category_checkbox.get_active():
removed_categories.add(label)
def on_save(self, _button):
"""Save category changes and destroy widget."""

changes = []

for game in self.games:
for category_checkbox in self.category_checkboxes.values():
removed_categories = set()
added_categories = set()

if not category_checkbox.get_inconsistent():
label = category_checkbox.get_label()
game_categories = categories_db.get_categories_in_game(game.id)
if label in game_categories:
if not category_checkbox.get_active():
removed_categories.add(label)
else:
if category_checkbox.get_active():
added_categories.add(label)

if added_categories or removed_categories:
changes.append((game, added_categories, removed_categories))

if changes and len(self.games) > 1:
if len(changes) == 1:
question = _("You are updating the categories on 1 game. Are you sure you want to change it?")
else:
if category_checkbox.get_active():
added_categories.add(label)

if added_categories or removed_categories:
self.game.update_game_categories(added_categories, removed_categories)
question = _(
"You are updating the categories on %d games. Are you sure you want to change them?"
) % len(changes)
dlg = QuestionDialog(
{
"parent": self,
"question": question,
"title": _("Changing Categories"),
}
)
if dlg.result != Gtk.ResponseType.YES:
return

for game, added_categories, removed_categories in changes:
game.update_game_categories(added_categories, removed_categories)

self.destroy()
5 changes: 5 additions & 0 deletions lutris/gui/widgets/sidebar.py
Expand Up @@ -21,6 +21,7 @@
from lutris.services import SERVICES
from lutris.services.base import AuthTokenExpiredError, BaseService
from lutris.util.library_sync import LOCAL_LIBRARY_SYNCED, LOCAL_LIBRARY_SYNCING
from lutris.util.strings import get_natural_sort_key

TYPE = 0
SLUG = 1
Expand Down Expand Up @@ -253,6 +254,10 @@ def __init__(self, category, application):

self._sort_name = locale.strxfrm(category["name"])

@property
def sort_key(self):
return get_natural_sort_key(self.name)

def get_actions(self):
"""Return the definition of buttons to be added to the row"""
return [("applications-system-symbolic", _("Edit Games"), self.on_category_clicked, "manage-category-games")]
Expand Down