# pylint: disable=no-member
import os
from gettext import gettext as _
from gettext import ngettext
from typing import Callable, Iterable, List
from gi.repository import GObject, Gtk
from lutris import settings
from lutris.database.games import get_game_by_field, get_games
from lutris.game import Game
from lutris.gui.dialogs import QuestionDialog
from lutris.gui.widgets.gi_composites import GtkTemplate
from lutris.gui.widgets.utils import get_required_main_window, get_widget_children
from lutris.util import datapath
from lutris.util.jobs import AsyncCall
from lutris.util.library_sync import LibrarySyncer
from lutris.util.log import logger
from lutris.util.path_cache import remove_from_path_cache
from lutris.util.strings import get_natural_sort_key, gtk_safe, human_size
from lutris.util.system import get_disk_size, is_removeable
@GtkTemplate(ui=os.path.join(datapath.get(), "ui", "uninstall-dialog.ui"))
class UninstallDialog(Gtk.Dialog):
"""A dialog to uninstall and remove games. It lists the games and offers checkboxes to delete
the game files, and to remove from the library."""
__gtype_name__ = "UninstallDialog"
header_bar: Gtk.HeaderBar = GtkTemplate.Child()
message_label: Gtk.Label = GtkTemplate.Child()
uninstall_game_list: Gtk.ListBox = GtkTemplate.Child()
cancel_button: Gtk.Button = GtkTemplate.Child()
uninstall_button: Gtk.Button = GtkTemplate.Child()
delete_all_files_checkbox: Gtk.CheckButton = GtkTemplate.Child()
remove_all_games_checkbox: Gtk.CheckButton = GtkTemplate.Child()
def __init__(self, parent: Gtk.Window, **kwargs):
super().__init__(parent=parent, **kwargs)
self.parent = parent
self._setting_all_checkboxes = False
self.games: List[Game] = []
self.any_shared = False
self.any_protected = False
self.init_template()
self.show_all()
def get_game_removal_rows(self) -> List["GameRemovalRow"]:
return get_widget_children(self.uninstall_game_list, GameRemovalRow)
def add_games(self, game_ids: Iterable[str]) -> None:
new_game_ids = set(game_ids) - set(g.id for g in self.games)
new_games = [Game(game_id) for game_id in new_game_ids]
new_games.sort(key=lambda g: get_natural_sort_key(g.name))
self.games += new_games
new_rows = []
for game in new_games:
row = GameRemovalRow(game)
self.uninstall_game_list.add(row)
new_rows.append(row)
self.update_deletability()
self.update_folder_sizes(new_games)
self.update_subtitle()
self.update_message()
self.update_all_checkboxes()
self.update_uninstall_button()
self.uninstall_game_list.show_all()
# Defer the connection until all checkboxes are updated
for row in new_rows:
row.connect("row-updated", self.on_row_updated)
def update_deletability(self) -> None:
"""Updates the can_delete_files property on each row; adding new rows can set this on existing rows
(they might no longer violate the 'can't delete shared directory' rule). This also sets flags that
are used by later update methods, so this must be called first."""
self.any_shared = False
self.any_protected = False
def is_shared(directory: str) -> bool:
dir_users = set(str(g["id"]) for g in get_games(filters={"directory": directory, "installed": 1}))
for g in self.games:
dir_users.discard(g.id)
return bool(dir_users)
for row in self.get_game_removal_rows():
game = row.game
if game.is_installed and game.directory:
if game.config and is_removeable(game.directory, game.config.system_config):
shared_dir = is_shared(game.directory)
self.any_shared = self.any_shared or shared_dir
row.can_delete_files = not shared_dir
else:
row.can_delete_files = False
self.any_protected = True
else:
row.can_delete_files = False
def update_folder_sizes(self, new_games: List[Game]) -> None:
"""Starts fetching folder sizes for new games added to the dialog; we only
do this for the games given in 'new_games', however."""
folders_to_size = []
folders_seen = set()
for row in self.get_game_removal_rows():
game = row.game
if game in new_games and game.is_installed and game.directory:
if game.directory not in folders_seen:
folders_seen.add(game.directory)
folders_to_size.append(game.directory)
row.show_folder_size_spinner()
if folders_to_size:
AsyncCall(
self._get_next_folder_size,
self._get_next_folder_size_cb,
folders_to_size,
)
def update_subtitle(self) -> None:
subtitle = self.build_subtitle()
self.header_bar.set_subtitle(subtitle)
def build_subtitle(self) -> str:
"""Updates the dialog subtitle according to what games are being removed."""
to_uninstall = [g for g in self.games if g.is_installed]
to_remove = [g for g in self.games if not g.is_installed]
count_uninstall = len(to_uninstall)
count_remove = len(to_remove)
if not to_remove:
if count_uninstall == 1:
return _("Uninstall %s") % gtk_safe(to_uninstall[0].name)
return ngettext("Uninstall %d game", "Uninstall %d games", count_uninstall) % count_uninstall
if not to_uninstall:
if count_remove == 1:
return _("Remove %s") % gtk_safe(to_remove[0].name)
return ngettext("Remove %d game", "Remove %d games", count_remove) % count_remove
uninstall_part = ngettext("Uninstall %d game", "Uninstall %d games", count_uninstall) % count_uninstall
remove_part = ngettext("remove %d game", "remove %d games", count_remove) % count_remove
return _("%s and %s") % (uninstall_part, remove_part)
def update_message(self) -> None:
"""Updates the message label at the top of the dialog."""
to_uninstall = [g for g in self.games if g.is_installed]
messages = []
if to_uninstall:
messages.append(_("After you uninstall these games, you won't be able play them in Lutris."))
messages.append(
_(
"Uninstalled games that you remove from the library will no longer appear in the "
"'Games' view, but those that remain will retain their playtime data."
)
)
else:
messages.append(_("After you remove these games, they will no longer appear in the 'Games' view."))
if self.any_shared:
messages.append(
_(
"Some of the game directories cannot be removed because they are shared "
"with other games that you are not removing."
)
)
if self.any_protected:
messages.append(_("Some of the game directories cannot be removed because they are protected."))
if messages:
self.message_label.set_markup("\n\n".join(messages))
self.message_label.show()
else:
self.message_label.hide()
def on_row_updated(self, row) -> None:
directory = row.game.directory
if directory and row.can_delete_files:
for r in self.get_game_removal_rows():
if row != r and r.game.directory == directory and r.can_delete_files:
r.delete_files = row.delete_files
self.update_all_checkboxes()
def update_all_checkboxes(self) -> None:
"""Sets the state of the checkboxes at the button that are used to control all
settings together. While we are actually updating these checkboxes en-mass,
this method does nothing at all."""
def update(checkbox, is_candidate, is_set):
set_count = 0
unset_count = 0
for row in self.get_game_removal_rows():
if is_candidate(row):
if is_set(row):
set_count += 1
else:
unset_count += 1
checkbox.set_active(set_count > 0)
checkbox.set_inconsistent(set_count > 0 and unset_count > 0)
checkbox.set_visible((set_count + unset_count) > 1 and (set_count > 0 or unset_count > 0))
if not self._setting_all_checkboxes:
self._setting_all_checkboxes = True
try:
update(
self.delete_all_files_checkbox,
lambda row: row.can_delete_files,
lambda row: row.delete_files,
)
update(
self.remove_all_games_checkbox,
lambda row: True,
lambda row: row.remove_from_library,
)
finally:
self._setting_all_checkboxes = False
def update_uninstall_button(self) -> None:
if any(g for g in self.games if g.is_installed):
self.uninstall_button.set_label(_("Uninstall"))
@GtkTemplate.Callback
def on_delete_all_files_checkbox_toggled(self, _widget):
def update_row(row, active):
if row.can_delete_files:
row.delete_files = active
self._apply_all_checkbox(self.delete_all_files_checkbox, update_row)
@GtkTemplate.Callback
def on_remove_all_games_checkbox_toggled(self, _widget):
def update_row(row, active):
row.remove_from_library = active
self._apply_all_checkbox(self.remove_all_games_checkbox, update_row)
def _apply_all_checkbox(self, checkbox, row_updater: Callable[["GameRemovalRow", bool], None]):
"""Sets the state of the checkboxes on all rows to agree with 'checkbox';
the actual change is performed by row_updater, so this can be used for
either checkbox."""
if not self._setting_all_checkboxes and checkbox.get_visible():
active = checkbox.get_active()
self._setting_all_checkboxes = True
for row in self.get_game_removal_rows():
row_updater(row, active)
self._setting_all_checkboxes = False
self.update_all_checkboxes()
@GtkTemplate.Callback
def on_cancel_button_clicked(self, _widget) -> None:
self.destroy()
@GtkTemplate.Callback
def on_remove_button_clicked(self, _widget) -> None:
rows = list(self.get_game_removal_rows())
dirs_to_delete = list(set(row.game.directory for row in rows if row.delete_files))
if dirs_to_delete:
if len(dirs_to_delete) == 1:
question = _("Please confirm.\nEverything under %s\nwill be moved to the trash.") % gtk_safe(
dirs_to_delete[0]
)
else:
question = _("Please confirm.\nAll the files for %d games will be moved to the trash.") % len(
dirs_to_delete
)
dlg = QuestionDialog(
{
"parent": self,
"question": question,
"title": _("Permanently delete files?"),
}
)
if dlg.result != Gtk.ResponseType.YES:
return
library_sync_enabled = settings.read_bool_setting("library_sync_enabled", True)
games_removed_from_library = []
library_syncer = LibrarySyncer() if library_sync_enabled else None
for row in rows:
if library_syncer and row.remove_from_library:
games_removed_from_library.append(get_game_by_field(row.game._id, "id"))
row.perform_removal()
if library_syncer and games_removed_from_library:
def sync_local_library():
library_syncer.sync_local_library()
library_syncer.delete_from_remote_library(games_removed_from_library)
AsyncCall(sync_local_library, None)
get_required_main_window().on_game_removed()
self.destroy()
def on_response(self, _dialog, response: Gtk.ResponseType) -> None:
if response in (
Gtk.ResponseType.DELETE_EVENT,
Gtk.ResponseType.CANCEL,
Gtk.ResponseType.OK,
):
self.destroy()
@staticmethod
def _get_next_folder_size(directories):
"""This runs on a thread and computes the size of the first directory in
directories; the _get_next_folder_size_cb will run this again if required,
until all directories have been sized."""
directory = directories.pop(0)
size = get_disk_size(directory)
return directory, size, directories
def _get_next_folder_size_cb(self, result, error):
if error:
logger.error(error)
return
directory, size, remaining_directories = result
if remaining_directories:
AsyncCall(
self._get_next_folder_size,
self._get_next_folder_size_cb,
remaining_directories,
)
for row in self.get_game_removal_rows():
if directory == row.game.directory:
row.show_folder_size(size)
class GameRemovalRow(Gtk.ListBoxRow):
__gsignals__ = {
"row-updated": (GObject.SIGNAL_RUN_FIRST, None, ()),
}
def __init__(self, game: Game):
super().__init__(activatable=False)
self.game = game
self._can_delete_files = False
self.delete_files_checkbox: Gtk.CheckButton = None
self.folder_size_spinner: Gtk.Spinner = None
self.directory_label: Gtk.Label = None
hbox = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6)
vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
vbox.pack_start(hbox, False, False, 0)
label = Gtk.Label(label=game.name, selectable=True)
hbox.pack_start(label, False, False, 0)
self.remove_from_library_checkbox = Gtk.CheckButton(label=_("Remove from Library"), halign=Gtk.Align.START)
self.remove_from_library_checkbox.set_active(False)
self.remove_from_library_checkbox.connect("toggled", self.on_checkbox_toggled)
hbox.pack_end(self.remove_from_library_checkbox, False, False, 0)
if game.is_installed and self.game.directory:
self.delete_files_checkbox = Gtk.CheckButton(label=_("Delete Files"))
self.delete_files_checkbox.set_sensitive(False)
self.delete_files_checkbox.set_active(False)
self.delete_files_checkbox.set_tooltip_text(self.game.directory)
self.delete_files_checkbox.connect("toggled", self.on_checkbox_toggled)
hbox.pack_end(self.delete_files_checkbox, False, False, 0)
dir_box = Gtk.Box(
orientation=Gtk.Orientation.HORIZONTAL,
spacing=6,
margin_left=6,
margin_right=6,
height_request=16,
)
self.directory_label = Gtk.Label(halign=Gtk.Align.START, selectable=True, valign=Gtk.Align.START)
self.directory_label.set_markup(self._get_directory_markup())
dir_box.pack_start(self.directory_label, False, False, 0)
self.folder_size_spinner = Gtk.Spinner(visible=False, no_show_all=True)
dir_box.pack_start(self.folder_size_spinner, False, False, 0)
vbox.pack_start(dir_box, False, False, 0)
self.add(vbox)
def _get_directory_markup(self, folder_size: int = None):
if not self.game.directory or not self.game.is_installed:
return ""
markup = gtk_safe(self.game.directory)
if folder_size is not None:
markup += f" ({human_size(folder_size)})"
return "%s" % markup
def on_checkbox_toggled(self, _widget):
self.emit("row-updated")
def show_folder_size_spinner(self):
if self.folder_size_spinner:
self.folder_size_spinner.start()
self.folder_size_spinner.show()
def show_folder_size(self, folder_size: int) -> None:
"""Called to stop the spinner and show the size of the game folder."""
if self.directory_label:
self.directory_label.set_markup(self._get_directory_markup(folder_size))
if self.folder_size_spinner:
self.folder_size_spinner.stop()
self.folder_size_spinner.hide()
@property
def delete_files(self) -> bool:
"""True if the game files should be deleted."""
return bool(
self.game.is_installed
and self.game.directory
and self.delete_files_checkbox
and self.delete_files_checkbox.get_active()
)
@delete_files.setter
def delete_files(self, active: bool) -> None:
self.delete_files_checkbox.set_active(active)
@property
def can_delete_files(self):
return self._can_delete_files
@can_delete_files.setter
def can_delete_files(self, can_delete):
if self._can_delete_files != can_delete and self.delete_files_checkbox:
self._can_delete_files = can_delete
self.delete_files_checkbox.set_sensitive(can_delete)
self.delete_files_checkbox.set_active(can_delete)
@property
def remove_from_library(self) -> bool:
"""True if the game should be removed from the database."""
return bool(self.remove_from_library_checkbox.get_active())
@remove_from_library.setter
def remove_from_library(self, active: bool) -> None:
self.remove_from_library_checkbox.set_active(active)
def perform_removal(self) -> None:
"""Performs the actions this row describes, uninstalling or deleting a game."""
# We uninstall installed games, and delete games where self.remove_from_library is true
if self.game.is_installed:
remove_from_path_cache(self.game)
if self.remove_from_library:
self.game.uninstall(delete_files=self.delete_files)
self.game.delete()
else:
self.game.uninstall(delete_files=self.delete_files)
elif self.remove_from_library:
self.game.delete()