class LutrisWindow(Gtk.ApplicationWindow): # pylint: disable=too-many-public-methods """Handler class for main window signals.""" default_view_type = "grid" default_width = 800 default_height = 600 __gtype_name__ = "LutrisWindow" __gsignals__ = { "view-updated": (GObject.SIGNAL_RUN_FIRST, None, ()), } main_box = GtkTemplate.Child() games_scrollwindow = GtkTemplate.Child() sidebar_revealer = GtkTemplate.Child() sidebar_scrolled = GtkTemplate.Child() game_revealer = GtkTemplate.Child() search_entry = GtkTemplate.Child() search_toggle = GtkTemplate.Child() zoom_adjustment = GtkTemplate.Child() blank_overlay = GtkTemplate.Child() viewtype_icon = GtkTemplate.Child() def __init__(self, application, **kwargs): width = int(settings.read_setting("width") or self.default_width) height = int(settings.read_setting("height") or self.default_height) super().__init__(default_width=width, default_height=height, window_position=Gtk.WindowPosition.NONE, icon_name="lutris", application=application, **kwargs) self.application = application self.runtime_updater = RuntimeUpdater() self.threads_stoppers = [] self.icon_type = None self.service = None self.window_size = (width, height) self.maximized = settings.read_setting("maximized") == "True" icon_type = self.load_icon_type() self.service_media = self.get_service_media(icon_type) self.game_actions = GameActions(application=application, window=self) self.search_timer_id = None self.game_store = None self.view = Gtk.Box() self.connect("delete-event", self.on_window_delete) self.connect("map-event", self.on_load) if self.maximized: self.maximize() self.init_template() self._init_actions() self.set_dark_theme() self.set_viewtype_icon(self.view_type) lutris_icon = Gtk.Image.new_from_icon_name("lutris", Gtk.IconSize.MENU) lutris_icon.set_margin_right(3) self.selected_category = settings.read_setting("selected_category", default="runner:all") self.filters = self.load_filters() self.sidebar = LutrisSidebar(self.application, selected=self.selected_category) self.sidebar.set_size_request(250, -1) self.sidebar.connect("selected-rows-changed", self.on_sidebar_changed) self.sidebar_scrolled.add(self.sidebar) self.sidebar_revealer.set_reveal_child(self.left_side_panel_visible) self.sidebar_revealer.set_transition_duration(300) self.game_bar = None self.revealer_box = Gtk.HBox(visible=True) self.game_revealer.add(self.revealer_box) GObject.add_emission_hook(BaseService, "service-login", self.on_service_login) GObject.add_emission_hook(BaseService, "service-logout", self.on_service_logout) GObject.add_emission_hook(BaseService, "service-games-load", self.on_service_games_updating) GObject.add_emission_hook(BaseService, "service-games-loaded", self.on_service_games_updated) def _init_actions(self): Action = namedtuple( "Action", ("callback", "type", "enabled", "default", "accel")) Action.__new__.__defaults__ = (None, None, True, None, None) actions = { "add-game": Action(self.on_add_game_button_clicked), "preferences": Action(self.on_preferences_activate), "manage-runners": Action(self.on_manage_runners, ), "about": Action(self.on_about_clicked), "show-installed-only": Action( # delete? self.on_show_installed_state_change, type="b", default=self.filter_installed, accel="<Primary>h", ), "toggle-viewtype": Action(self.on_toggle_viewtype), "icon-type": Action(self.on_icontype_state_change, type="s", default=self.icon_type), "view-sorting": Action(self.on_view_sorting_state_change, type="s", default=self.view_sorting), "view-sorting-ascending": Action( self.on_view_sorting_direction_change, type="b", default=self.view_sorting_ascending, ), "use-dark-theme": Action(self.on_dark_theme_state_change, type="b", default=self.use_dark_theme), "show-tray-icon": Action(self.on_tray_icon_toggle, type="b", default=self.show_tray_icon), "show-left-side-panel": Action( self.on_left_side_panel_state_change, type="b", default=self.left_side_panel_visible, accel="F9", ), "show-hidden-games": Action( self.hidden_state_change, type="b", default=self.show_hidden_games, ), "open-forums": Action(lambda *x: open_uri("https://forums.lutris.net/")), "open-discord": Action(lambda *x: open_uri("https://discord.gg/Pnt5CuY")), "donate": Action(lambda *x: open_uri("https://lutris.net/donate")), } self.actions = {} app = self.props.application for name, value in actions.items(): if not value.type: action = Gio.SimpleAction.new(name) action.connect("activate", value.callback) else: default_value = None param_type = None if value.default is not None: default_value = GLib.Variant(value.type, value.default) if value.type != "b": param_type = default_value.get_type() action = Gio.SimpleAction.new_stateful(name, param_type, default_value) action.connect("change-state", value.callback) self.actions[name] = action if value.enabled is False: action.props.enabled = False self.add_action(action) if value.accel: app.add_accelerator(value.accel, "win." + name) def on_load(self, widget, data): self.game_store = GameStore(self.service_media) self.redraw_view() self._bind_zoom_adjustment() self.view.grab_focus() self.view.contextual_menu = ContextualMenu( self.game_actions.get_game_actions()) self.update_runtime() def load_filters(self): """Load the initial filters when creating the view""" category, value = self.selected_category.split(":") filters = { category: value } # Type of filter corresponding to the selected sidebar element filters["hidden"] = settings.read_setting( "show_hidden_games").lower() == "true" filters["installed"] = settings.read_setting( "filter_installed").lower() == "true" return filters def hidden_state_change(self, action, value): """Hides or shows the hidden games""" action.set_state(value) settings.write_setting("show_hidden_games", str(value).lower(), section="lutris") self.filters["hidden"] = value self.emit("view-updated") @property def current_view_type(self): """Returns which kind of view is currently presented (grid or list)""" return "grid" if isinstance(self.view, GameGridView) else "list" @property def filter_installed(self): return settings.read_setting("filter_installed").lower() == "true" @property def left_side_panel_visible(self): show_left_panel = ( settings.read_setting("left_side_panel_visible").lower() != "false") return show_left_panel or self.sidebar_visible @property def sidebar_visible(self): """Deprecated: For compability only""" return settings.read_setting("sidebar_visible") in [ "true", None, ] @property def use_dark_theme(self): """Return whether to use the dark theme variant (if the theme provides one)""" return settings.read_setting("dark_theme", default="false").lower() == "true" def on_tray_icon_toggle(self, action, value): """Callback for handling tray icon toggle""" action.set_state(value) settings.write_setting('show_tray_icon', value) self.application.set_tray_icon() @property def show_tray_icon(self): """Setting to hide or show status icon""" return settings.read_setting("show_tray_icon", default="false").lower() == "true" @property def view_sorting(self): value = settings.read_setting("view_sorting") or "name" if value.endswith("_text"): value = value[:-5] return value @property def view_sorting_ascending(self): return settings.read_setting( "view_sorting_ascending").lower() != "false" @property def show_hidden_games(self): return settings.read_setting("show_hidden_games").lower() == "true" @property def sort_params(self): _sort_params = [("installed", "DESC")] _sort_params.append((self.view_sorting, "ASC" if self.view_sorting_ascending else "DESC")) return _sort_params def get_running_games(self): """Return a list of currently running games""" return games_db.get_games_by_ids( [game.id for game in self.application.running_games]) def get_installed_games(self): """Return a list of currently running games""" searches, _filters, excludes = self.get_sql_filters() return games_db.get_games(searches=searches, filters={'installed': '1'}, excludes=excludes) def get_api_games(self): """Return games from the lutris API""" if not self.filters.get("text"): api_games = api.get_bundle("featured") else: api_games = api.search_games(self.filters["text"]) return api_games def game_matches(self, game): if self.filters.get("installed"): if game["appid"] not in games_db.get_service_games( self.service.id): return False if not self.filters.get("text"): return True return self.filters["text"] in game["name"].lower() def set_service(self, service_name): self.service = services.get_services()[service_name]() def unset_service(self): self.service = None def get_games_from_filters(self): service_name = self.filters.get("service") if service_name in services.get_services(): self.set_service(service_name) service_games = ServiceGameCollection.get_for_service(service_name) if service_games: return [ game for game in sorted(service_games, key=lambda game: game.get( self.view_sorting) or game["name"], reverse=not self.view_sorting_ascending) if self.game_matches(game) ] if self.service.online and not self.service.is_connected(): self.show_label( _("Connect your %s account to access your games") % self.service.name) return self.unset_service() dynamic_categories = { "running": self.get_running_games, "installed": self.get_installed_games } if self.filters.get("dynamic_category") in dynamic_categories: return dynamic_categories[self.filters["dynamic_category"]]() if self.filters.get("category"): game_ids = categories_db.get_game_ids_for_category( self.filters["category"]) return games_db.get_games_by_ids(game_ids) searches, filters, excludes = self.get_sql_filters() return games_db.get_games(searches=searches, filters=filters, excludes=excludes, sorts=self.sort_params) def on_service_games_updating(self, service): if not self.service or service.id != self.service.id: logger.warning("Wrong service %s", self.service) return self.show_spinner() def get_sql_filters(self): """Return the current filters for the view""" sql_filters = {} sql_excludes = {} if self.filters.get("runner"): sql_filters["runner"] = self.filters["runner"] if self.filters.get("platform"): sql_filters["platform"] = self.filters["platform"] if self.filters.get("installed"): sql_filters["installed"] = "1" if self.filters.get("text"): searches = {"name": self.filters["text"]} else: searches = None if not self.filters.get("hidden"): sql_excludes["hidden"] = 1 return searches, sql_filters, sql_excludes def get_service_media(self, icon_type): """Return the ServiceMedia class used for this view""" service = self.service if self.service else LutrisService medias = service.medias if icon_type in medias: return medias[icon_type]() return medias[service.default_format]() def update_revealer(self, game=None): if game: if self.game_bar: self.game_bar.destroy() self.game_bar = GameBar(game, self.game_actions) self.revealer_box.pack_start(self.game_bar, True, True, 0) elif self.game_bar: self.game_bar.destroy() if self.revealer_box.get_children(): self.game_revealer.set_reveal_child(True) else: self.game_revealer.set_reveal_child(False) def update_store(self, *_args, **_kwargs): self.game_store.store.clear() for child in self.blank_overlay.get_children(): child.destroy() games = self.get_games_from_filters() self.view.service = self.service.id if self.service else None self.reload_service_media() self.update_revealer() if games is None: return False for game in games: game["image_size"] = self.service_media.size self.game_store.add_game(game) if not games: if self.filters.get("text"): self.show_label( _("No games matching '%s' found ") % self.filters["text"]) else: self.show_label(_("No games found")) self.search_timer_id = None return False def set_dark_theme(self): """Enables or disables dark theme""" gtksettings = Gtk.Settings.get_default() gtksettings.set_property("gtk-application-prefer-dark-theme", self.use_dark_theme) def _bind_zoom_adjustment(self): """Bind the zoom slider to the supported banner sizes""" service = self.service if self.service else LutrisService media_services = list(service.medias.keys()) self.zoom_adjustment.set_lower(0) self.zoom_adjustment.set_upper(len(media_services) - 1) if self.icon_type in media_services: value = media_services.index(self.icon_type) else: value = 0 self.zoom_adjustment.props.value = value self.zoom_adjustment.connect("value-changed", self.on_zoom_changed) def on_zoom_changed(self, adjustment): """Handler for zoom modification""" media_index = round(adjustment.props.value) adjustment.props.value = media_index service = self.service if self.service else LutrisService media_services = list(service.medias.keys()) if len(media_services) <= media_index: media_index = media_services.index(service.default_format) icon_type = media_services[media_index] if icon_type != self.icon_type: self.save_icon_type(icon_type) self.reload_service_media() self.show_spinner() AsyncCall(self.game_store.load_icons, self.icons_loaded_cb) def show_label(self, message): """Display a label in the middle of the UI""" for child in self.blank_overlay.get_children(): child.destroy() label = Gtk.Label(message) self.blank_overlay.add(label) self.blank_overlay.props.visible = True def show_spinner(self): spinner = Gtk.Spinner(visible=True) spinner.start() for child in self.blank_overlay.get_children(): child.destroy() self.blank_overlay.add(spinner) self.blank_overlay.props.visible = True def hide_overlay(self): self.blank_overlay.props.visible = False for child in self.blank_overlay.get_children(): child.destroy() def icons_loaded_cb(self, result, error): if error: logger.debug("Failed to reload icons") self.hide_overlay() self.emit("view-updated") @property def view_type(self): """Return the type of view saved by the user""" view_type = settings.read_setting("view_type") if view_type in ["grid", "list"]: return view_type return self.default_view_type def do_key_press_event(self, event): # pylint: disable=arguments-differ if event.keyval == Gdk.KEY_Escape: self.search_toggle.set_active(False) return Gdk.EVENT_STOP # XXX: This block of code below is to enable searching on type. # Enabling this feature steals focus from other entries so it needs # some kind of focus detection before enabling library search. # Probably not ideal for non-english, but we want to limit # which keys actually start searching if ( # pylint: disable=too-many-boolean-expressions not Gdk.KEY_0 <= event.keyval <= Gdk.KEY_z or event.state & Gdk.ModifierType.CONTROL_MASK or event.state & Gdk.ModifierType.SHIFT_MASK or event.state & Gdk.ModifierType.META_MASK or event.state & Gdk.ModifierType.MOD1_MASK or self.search_entry.has_focus()): return Gtk.ApplicationWindow.do_key_press_event(self, event) self.search_toggle.set_active(True) self.search_entry.grab_focus() return self.search_entry.do_key_press_event(self.search_entry, event) def load_icon_type(self): """Return the icon style depending on the type of view.""" if self.view_type == "list": self.icon_type = settings.read_setting("icon_type_listview") else: self.icon_type = settings.read_setting("icon_type_gridview") return self.icon_type def save_icon_type(self, icon_type): self.icon_type = icon_type if self.current_view_type == "grid": settings.write_setting("icon_type_gridview", self.icon_type) elif self.current_view_type == "list": settings.write_setting("icon_type_listview", self.icon_type) self.redraw_view() def reload_service_media(self): self.game_store.set_service_media( self.get_service_media(self.load_icon_type())) def redraw_view(self): """Completely reconstruct the main view""" if self.view: self.view.destroy() self.reload_service_media() view_class = GameGridView if self.view_type == "grid" else GameListView self.view = view_class(self.game_store, self.game_store.service_media) self.view.connect("game-selected", self.on_game_selection_changed) self.view.connect("game-activated", self.on_game_activated) self.view.contextual_menu = ContextualMenu( self.game_actions.get_game_actions()) for child in self.games_scrollwindow.get_children(): child.destroy() self.games_scrollwindow.add(self.view) self.connect("view-updated", self.update_store) self.view.show_all() self.update_store() def set_viewtype_icon(self, view_type): self.viewtype_icon.set_from_icon_name("view-%s-symbolic" % view_type, Gtk.IconSize.BUTTON) def update_runtime(self): """Check that the runtime is up to date""" runtime_sync = AsyncCall(self.runtime_updater.update, None) self.threads_stoppers.append(runtime_sync.stop_request.set) def set_show_installed_state(self, filter_installed): """Shows or hide uninstalled games""" settings.write_setting("filter_installed", bool(filter_installed)) self.filters["installed"] = filter_installed self.emit("view-updated") def on_service_games_updated(self, service): AsyncCall(self.game_store.load_icons, None) self.emit("view-updated") def on_service_login(self, service): AsyncCall(service.load, None) def on_service_logout(self, service): self.emit("view-updated") def on_dark_theme_state_change(self, action, value): """Callback for theme switching action""" action.set_state(value) settings.write_setting("dark_theme", value.get_boolean()) self.set_dark_theme() @GtkTemplate.Callback def on_resize(self, widget, *_args): """Size-allocate signal. Updates stored window size and maximized state. """ if not widget.get_window(): return self.maximized = widget.is_maximized() if not self.maximized: self.window_size = widget.get_size() def on_window_delete(self, *_args): if self.application.running_games.get_n_items(): self.hide() return True @GtkTemplate.Callback def on_destroy(self, *_args): """Signal for window close.""" # Stop cancellable running threads for stopper in self.threads_stoppers: stopper() # Save settings width, height = self.window_size settings.write_setting("width", width) settings.write_setting("height", height) settings.write_setting("maximized", self.maximized) @GtkTemplate.Callback def on_preferences_activate(self, *_args): """Callback when preferences is activated.""" self.application.show_window(SystemConfigDialog) @GtkTemplate.Callback def on_manage_runners(self, *args): self.application.show_window(RunnersDialog, transient_for=self) def on_show_installed_state_change(self, action, value): """Callback to handle uninstalled game filter switch""" action.set_state(value) self.set_show_installed_state(value.get_boolean()) self.emit("view-updated") @GtkTemplate.Callback def on_search_entry_changed(self, entry): """Callback for the search input keypresses""" if self.search_timer_id: GLib.source_remove(self.search_timer_id) self.filters["text"] = entry.get_text().lower().strip() self.search_timer_id = GLib.timeout_add(350, self.update_store) @GtkTemplate.Callback def on_search_toggle(self, button): """Called when search bar is shown / hidden""" active = button.props.active if active: self.search_entry.show() self.search_entry.grab_focus() else: self.search_entry.props.text = "" self.search_entry.hide() @GtkTemplate.Callback def on_about_clicked(self, *_args): """Open the about dialog.""" dialogs.AboutDialog(parent=self) def on_game_error(self, game, error): """Called when a game has sent the 'game-error' signal""" logger.error("%s crashed", game) dialogs.ErrorDialog(error, parent=self) @GtkTemplate.Callback def on_add_game_button_clicked(self, *_args): """Add a new game manually with the AddGameDialog.""" if "runner" in self.filters: runner = self.filters["runner"] else: runner = None AddGameDialog(self, runner=runner) return True def on_toggle_viewtype(self, *args): view_type = "list" if self.current_view_type == "grid" else "grid" self.set_viewtype_icon(view_type) settings.write_setting("view_type", view_type) self.redraw_view() def on_icontype_state_change(self, action, value): action.set_state(value) self._set_icon_type(value.get_string()) def on_view_sorting_state_change(self, action, value): self.actions["view-sorting"].set_state(value) value = str(value).strip("'") settings.write_setting("view_sorting", value) self.emit("view-updated") def on_view_sorting_direction_change(self, action, value): self.actions["view-sorting-ascending"].set_state(value) settings.write_setting("view_sorting_ascending", bool(value)) self.emit("view-updated") def on_left_side_panel_state_change(self, action, value): """Callback to handle left side panel toggle""" action.set_state(value) left_side_panel_visible = value.get_boolean() settings.write_setting("left_side_panel_visible", bool(left_side_panel_visible)) self.sidebar_revealer.set_reveal_child(left_side_panel_visible) # Retrocompatibility with sidebar_visible : # if we change the new attribute, we must set the old one to false if self.sidebar_visible: settings.write_setting("sidebar_visible", "false") def on_sidebar_changed(self, widget): row = widget.get_selected_row() self.set_title("Lutris - %s" % row.name) self.selected_category = "%s:%s" % (row.type, row.id) for filter_type in ("category", "dynamic_category", "service", "runner", "platform"): if filter_type in self.filters: self.filters.pop(filter_type) if row: self.filters[row.type] = row.id self.emit("view-updated") def on_game_selection_changed(self, view, game_id): if not game_id: GLib.idle_add(self.update_revealer) return False if self.service: game = ServiceGameCollection.get_game(self.service.id, game_id) else: game = games_db.get_game_by_field(int(game_id), "id") GLib.idle_add(self.update_revealer, game) return False def on_game_activated(self, view, game_id): """Handles view activations (double click, enter press)""" if self.service: db_game = games_db.get_game_for_service(self.service.id, game_id) if db_game: game_id = db_game["id"] else: db_game = ServiceGameCollection.get_game( self.service.id, game_id) self.service.install(db_game) return game = Game(game_id) if game.is_installed: logger.info("Game is installed") game.emit("game-launch")
class LutrisWindow(Gtk.ApplicationWindow): # pylint: disable=too-many-public-methods """Handler class for main window signals.""" default_view_type = "grid" default_width = 800 default_height = 600 __gtype_name__ = "LutrisWindow" __gsignals__ = { "view-updated": (GObject.SIGNAL_RUN_FIRST, None, ()), } tabs_box = GtkTemplate.Child() games_scrollwindow = GtkTemplate.Child() sidebar_revealer = GtkTemplate.Child() sidebar_scrolled = GtkTemplate.Child() game_revealer = GtkTemplate.Child() search_entry = GtkTemplate.Child() zoom_adjustment = GtkTemplate.Child() blank_overlay = GtkTemplate.Child() viewtype_icon = GtkTemplate.Child() library_button = GtkTemplate.Child() website_button = GtkTemplate.Child() def __init__(self, application, **kwargs): width = int(settings.read_setting("width") or self.default_width) height = int(settings.read_setting("height") or self.default_height) super().__init__(default_width=width, default_height=height, window_position=Gtk.WindowPosition.NONE, name="lutris", icon_name="lutris", application=application, **kwargs) update_desktop_icons() load_icon_theme() self.application = application self.window_x = settings.read_setting("window_x") self.window_y = settings.read_setting("window_y") if self.window_x and self.window_y: self.move(int(self.window_x), int(self.window_y)) self.threads_stoppers = [] self.window_size = (width, height) self.maximized = settings.read_setting("maximized") == "True" self.service = None self.game_actions = GameActions(application=application, window=self) self.search_timer_id = None self.selected_category = settings.read_setting("selected_category", default="runner:all") self.filters = self.load_filters() self.set_service(self.filters.get("service")) self.icon_type = self.load_icon_type() self.game_store = GameStore(self.service, self.service_media) self.view = Gtk.Box() self.connect("delete-event", self.on_window_delete) self.connect("configure-event", self.on_window_configure) self.connect("realize", self.on_load) if self.maximized: self.maximize() self.init_template() self._init_actions() self.set_dark_theme() self.set_viewtype_icon(self.view_type) lutris_icon = Gtk.Image.new_from_icon_name("lutris", Gtk.IconSize.MENU) lutris_icon.set_margin_right(3) self.sidebar = LutrisSidebar(self.application, selected=self.selected_category) self.sidebar.connect("selected-rows-changed", self.on_sidebar_changed) self.sidebar_scrolled.add(self.sidebar) self.sidebar_revealer.set_reveal_child(self.side_panel_visible) self.sidebar_revealer.set_transition_duration(300) self.tabs_box.hide() self.game_bar = None self.revealer_box = Gtk.HBox(visible=True) self.game_revealer.add(self.revealer_box) self.connect("view-updated", self.update_store) GObject.add_emission_hook(BaseService, "service-login", self.on_service_login) GObject.add_emission_hook(BaseService, "service-logout", self.on_service_logout) GObject.add_emission_hook(BaseService, "service-games-load", self.on_service_games_updating) GObject.add_emission_hook(BaseService, "service-games-loaded", self.on_service_games_updated) GObject.add_emission_hook(Game, "game-updated", self.on_game_collection_changed) GObject.add_emission_hook(Game, "game-removed", self.on_game_collection_changed) def _init_actions(self): Action = namedtuple( "Action", ("callback", "type", "enabled", "default", "accel")) Action.__new__.__defaults__ = (None, None, True, None, None) actions = { "add-game": Action(self.on_add_game_button_clicked), "preferences": Action(self.on_preferences_activate), "manage-runners": Action(self.on_manage_runners, ), "about": Action(self.on_about_clicked), "show-installed-only": Action( # delete? self.on_show_installed_state_change, type="b", default=self.filter_installed, accel="<Primary>h", ), "toggle-viewtype": Action(self.on_toggle_viewtype), "icon-type": Action(self.on_icontype_state_change, type="s", default=self.icon_type), "view-sorting": Action(self.on_view_sorting_state_change, type="s", default=self.view_sorting), "view-sorting-ascending": Action( self.on_view_sorting_direction_change, type="b", default=self.view_sorting_ascending, ), "use-dark-theme": Action(self.on_dark_theme_state_change, type="b", default=self.use_dark_theme), "show-side-panel": Action( self.on_side_panel_state_change, type="b", default=self.side_panel_visible, accel="F9", ), "show-hidden-games": Action( self.hidden_state_change, type="b", default=self.show_hidden_games, ), "open-forums": Action(lambda *x: open_uri("https://forums.lutris.net/")), "open-discord": Action(lambda *x: open_uri("https://discord.gg/Pnt5CuY")), "donate": Action(lambda *x: open_uri("https://lutris.net/donate")), } self.actions = {} app = self.props.application for name, value in actions.items(): if not value.type: action = Gio.SimpleAction.new(name) action.connect("activate", value.callback) else: default_value = None param_type = None if value.default is not None: default_value = GLib.Variant(value.type, value.default) if value.type != "b": param_type = default_value.get_type() action = Gio.SimpleAction.new_stateful(name, param_type, default_value) action.connect("change-state", value.callback) self.actions[name] = action if value.enabled is False: action.props.enabled = False self.add_action(action) if value.accel: app.add_accelerator(value.accel, "win." + name) @property def service_media(self): return self.get_service_media(self.load_icon_type()) def on_load(self, widget, data=None): """Finish initializing the view""" self.redraw_view() self._bind_zoom_adjustment() self.view.grab_focus() self.view.contextual_menu = ContextualMenu( self.game_actions.get_game_actions()) def load_filters(self): """Load the initial filters when creating the view""" category, value = self.selected_category.split(":") filters = { category: value } # Type of filter corresponding to the selected sidebar element filters["hidden"] = settings.read_setting( "show_hidden_games").lower() == "true" filters["installed"] = settings.read_setting( "filter_installed").lower() == "true" return filters def hidden_state_change(self, action, value): """Hides or shows the hidden games""" action.set_state(value) settings.write_setting("show_hidden_games", str(value).lower(), section="lutris") self.filters["hidden"] = value self.emit("view-updated") @property def current_view_type(self): """Returns which kind of view is currently presented (grid or list)""" return settings.read_setting("view_type") or "grid" @property def filter_installed(self): return settings.read_setting("filter_installed").lower() == "true" @property def side_panel_visible(self): return settings.read_setting("side_panel_visible").lower() != "false" @property def use_dark_theme(self): """Return whether to use the dark theme variant (if the theme provides one)""" return settings.read_setting("dark_theme", default="false").lower() == "true" @property def show_tray_icon(self): """Setting to hide or show status icon""" return settings.read_setting("show_tray_icon", default="false").lower() == "true" @property def view_sorting(self): value = settings.read_setting("view_sorting") or "name" if value.endswith("_text"): value = value[:-5] return value @property def view_sorting_ascending(self): return settings.read_setting( "view_sorting_ascending").lower() != "false" @property def show_hidden_games(self): return settings.read_setting("show_hidden_games").lower() == "true" @property def sort_params(self): _sort_params = [("installed", "COLLATE NOCASE DESC")] _sort_params.append( (self.view_sorting, "COLLATE NOCASE ASC" if self.view_sorting_ascending else "COLLATE NOCASE DESC")) return _sort_params def get_running_games(self): """Return a list of currently running games""" return games_db.get_games_by_ids( [game.id for game in self.application.running_games]) def get_recent_games(self): """Return a list of currently running games""" searches, _filters, excludes = self.get_sql_filters() games = games_db.get_games(searches=searches, filters={'installed': '1'}, excludes=excludes) return sorted(games, key=lambda game: max(game["installed_at"] or 0, game[ "lastplayed"] or 0), reverse=True) def get_api_games(self): """Return games from the lutris API""" if not self.filters.get("text"): return [] api_games = api.search_games(self.filters["text"]) if "icon" in self.icon_type: GLib.idle_add(self.load_icons, {g["slug"]: g["icon_url"] for g in api_games}, LutrisIcon) else: GLib.idle_add(self.load_icons, {g["slug"]: g["banner_url"] for g in api_games}, LutrisBanner) return api_games def load_icons(self, media_urls, service_media): self.game_store.media_loader.download_icons(media_urls, service_media()) def game_matches(self, game): if self.filters.get("installed"): if game["appid"] not in games_db.get_service_games( self.service.id): return False if not self.filters.get("text"): return True return self.filters["text"] in game["name"].lower() def set_service(self, service_name): if self.service and self.service.id == service_name: return self.service if not service_name: self.service = None return try: self.service = services.get_services()[service_name]() except KeyError: logger.error("Non existent service '%s'", service_name) self.service = None return self.service @staticmethod def combine_games(service_game, lutris_game): """Inject lutris game information into a service game""" if lutris_game and service_game["appid"] == lutris_game["service_id"]: for field in ("platform", "runner", "year", "installed_at", "lastplayed", "playtime", "installed"): service_game[field] = lutris_game[field] return service_game def get_service_games(self, service_name): """Switch the current service to service_name and return games if available""" service_games = ServiceGameCollection.get_for_service(service_name) if service_name == "lutris": lutris_games = {g["slug"]: g for g in games_db.get_games()} else: lutris_games = { g["service_id"]: g for g in games_db.get_games( filters={"service": self.service.id}) } def get_sort_value(game): sort_defaults = { "name": "", "year": 0, "lastplayed": 0.0, "installed_at": 0.0, "playtime": 0.0, } lutris_game = lutris_games.get(game["appid"]) if not lutris_game: return sort_defaults[self.view_sorting] value = lutris_game[self.view_sorting] if value: return value return sort_defaults[self.view_sorting] return [ self.combine_games(game, lutris_games.get(game["appid"])) for game in sorted(service_games, key=get_sort_value, reverse=not self.view_sorting_ascending) if self.game_matches(game) ] def get_games_from_filters(self): service_name = self.filters.get("service") self.tabs_box.hide() if service_name in services.get_services(): if service_name == "lutris": self.tabs_box.show( ) # Only the lutris service has the ability to search through all games. if self.website_button.props.active: return self.get_api_games() if self.service.online and not self.service.is_authenticated(): self.show_label( _("Connect your %s account to access your games") % self.service.name) return [] return self.get_service_games(service_name) dynamic_categories = { "recent": self.get_recent_games, "running": self.get_running_games, } if self.filters.get("dynamic_category") in dynamic_categories: return dynamic_categories[self.filters["dynamic_category"]]() if self.filters.get("category") and self.filters["category"] != "all": game_ids = categories_db.get_game_ids_for_category( self.filters["category"]) return games_db.get_games_by_ids(game_ids) searches, filters, excludes = self.get_sql_filters() return games_db.get_games(searches=searches, filters=filters, excludes=excludes, sorts=self.sort_params) def on_service_games_updating(self, service): if not self.service or service.id != self.service.id: return self.show_spinner() return True def get_sql_filters(self): """Return the current filters for the view""" sql_filters = {} sql_excludes = {} if self.filters.get("runner"): sql_filters["runner"] = self.filters["runner"] if self.filters.get("platform"): sql_filters["platform"] = self.filters["platform"] if self.filters.get("installed"): sql_filters["installed"] = "1" if self.filters.get("text"): searches = {"name": self.filters["text"]} else: searches = None if not self.filters.get("hidden"): sql_excludes["hidden"] = 1 return searches, sql_filters, sql_excludes def get_service_media(self, icon_type): """Return the ServiceMedia class used for this view""" service = self.service if self.service else LutrisService medias = service.medias if icon_type in medias: return medias[icon_type]() return medias[service.default_format]() def update_revealer(self, game=None): if game: if self.game_bar: self.game_bar.destroy() self.game_bar = GameBar(game, self.game_actions, self.application) self.revealer_box.pack_start(self.game_bar, True, True, 0) elif self.game_bar: # The game bar can't be destroyed here because the game gets unselected on Wayland # whenever the game bar is interacted with. Instead, we keep the current game bar open # when the game gets unselected, which is somewhat closer to what the intended behavior # should be anyway. Might require closing the game bar manually in some cases. pass # self.game_bar.destroy() if self.revealer_box.get_children(): self.game_revealer.set_reveal_child(True) else: self.game_revealer.set_reveal_child(False) def show_empty_label(self): """Display a label when the view is empty""" if self.filters.get("text"): self.show_label( _("No games matching '%s' found ") % self.filters["text"]) elif self.view.service == "lutris" and self.website_button.props.active: self.show_label(_("Use search to find games on lutris.net")) else: if self.filters.get("category") == "favorite": self.show_label( _("Add games to your favorites to see them here.")) elif self.filters.get("installed"): self.show_label( _("No installed games found. Press Ctrl+H so show all games." )) else: self.show_label(_("No games found")) def update_store(self, *_args, **_kwargs): self.game_store.store.clear() for child in self.blank_overlay.get_children(): child.destroy() games = self.get_games_from_filters() self.view.service = self.service.id if self.service else None GLib.idle_add(self.update_revealer) for game in games: self.game_store.add_game(game) if not games: self.show_empty_label() self.search_timer_id = None return False def set_dark_theme(self): """Enables or disables dark theme""" gtksettings = Gtk.Settings.get_default() gtksettings.set_property("gtk-application-prefer-dark-theme", self.use_dark_theme) def _bind_zoom_adjustment(self): """Bind the zoom slider to the supported banner sizes""" service = self.service if self.service else LutrisService media_services = list(service.medias.keys()) self.load_icon_type() self.zoom_adjustment.set_lower(0) self.zoom_adjustment.set_upper(len(media_services) - 1) if self.icon_type in media_services: value = media_services.index(self.icon_type) else: value = 0 self.zoom_adjustment.props.value = value self.zoom_adjustment.connect("value-changed", self.on_zoom_changed) def on_zoom_changed(self, adjustment): """Handler for zoom modification""" media_index = round(adjustment.props.value) adjustment.props.value = media_index service = self.service if self.service else LutrisService media_services = list(service.medias.keys()) if len(media_services) <= media_index: media_index = media_services.index(service.default_format) icon_type = media_services[media_index] if icon_type != self.icon_type: self.save_icon_type(icon_type) self.show_spinner() GLib.timeout_add(100, self._load_icons) def _load_icons(self): AsyncCall(self.game_store.load_icons, None) return False def show_label(self, message): """Display a label in the middle of the UI""" for child in self.blank_overlay.get_children(): child.destroy() label = Gtk.Label(message, visible=True) self.blank_overlay.add(label) self.blank_overlay.props.visible = True def show_spinner(self): spinner = Gtk.Spinner(visible=True) spinner.start() for child in self.blank_overlay.get_children(): child.destroy() self.blank_overlay.add(spinner) self.blank_overlay.props.visible = True def hide_overlay(self): self.blank_overlay.props.visible = False for child in self.blank_overlay.get_children(): child.destroy() @property def view_type(self): """Return the type of view saved by the user""" view_type = settings.read_setting("view_type") if view_type in ["grid", "list"]: return view_type return self.default_view_type def do_key_press_event(self, event): # pylint: disable=arguments-differ # XXX: This block of code below is to enable searching on type. # Enabling this feature steals focus from other entries so it needs # some kind of focus detection before enabling library search. # Probably not ideal for non-english, but we want to limit # which keys actually start searching if event.keyval == Gdk.KEY_Escape: self.search_entry.set_text("") self.view.grab_focus() return Gtk.ApplicationWindow.do_key_press_event(self, event) if ( # pylint: disable=too-many-boolean-expressions not Gdk.KEY_0 <= event.keyval <= Gdk.KEY_z or event.state & Gdk.ModifierType.CONTROL_MASK or event.state & Gdk.ModifierType.SHIFT_MASK or event.state & Gdk.ModifierType.META_MASK or event.state & Gdk.ModifierType.MOD1_MASK or self.search_entry.has_focus()): return Gtk.ApplicationWindow.do_key_press_event(self, event) self.search_entry.grab_focus() return self.search_entry.do_key_press_event(self.search_entry, event) def load_icon_type(self): """Return the icon style depending on the type of view.""" setting_key = "icon_type_%sview" % self.current_view_type if self.service and self.service.id != "lutris": setting_key += "_%s" % self.service.id self.icon_type = settings.read_setting(setting_key) return self.icon_type def save_icon_type(self, icon_type): self.icon_type = icon_type setting_key = "icon_type_%sview" % self.current_view_type if self.service and self.service.id != "lutris": setting_key += "_%s" % self.service.id settings.write_setting(setting_key, self.icon_type) self.redraw_view() def redraw_view(self): """Completely reconstruct the main view""" if not self.game_store: logger.error("No game store yet") return if self.view: self.view.destroy() self.game_store = GameStore(self.service, self.service_media) if self.view_type == "grid": self.view = GameGridView(self.game_store, self.game_store.service_media, hide_text=settings.read_setting( "hide_text_under_icons") == "True") else: self.view = GameListView(self.game_store, self.game_store.service_media) self.view.connect("game-selected", self.on_game_selection_changed) self.view.connect("game-activated", self.on_game_activated) self.view.contextual_menu = ContextualMenu( self.game_actions.get_game_actions()) for child in self.games_scrollwindow.get_children(): child.destroy() self.games_scrollwindow.add(self.view) self.view.show_all() self.view.grab_focus() GLib.idle_add(self.update_store) def set_viewtype_icon(self, view_type): self.viewtype_icon.set_from_icon_name("view-%s-symbolic" % view_type, Gtk.IconSize.BUTTON) def set_show_installed_state(self, filter_installed): """Shows or hide uninstalled games""" settings.write_setting("filter_installed", bool(filter_installed)) self.filters["installed"] = filter_installed def on_service_games_updated(self, service): """Request a view update when service games are loaded""" if self.service and service.id == self.service.id: self.emit("view-updated") return True def on_service_login(self, service): AsyncCall(service.load, None) return True def on_service_logout(self, service): if self.service and service.id == self.service.id: self.emit("view-updated") return True def on_dark_theme_state_change(self, action, value): """Callback for theme switching action""" action.set_state(value) settings.write_setting("dark_theme", value.get_boolean()) self.set_dark_theme() @GtkTemplate.Callback def on_resize(self, widget, *_args): """Size-allocate signal. Updates stored window size and maximized state. """ if not widget.get_window(): return self.maximized = widget.is_maximized() size = widget.get_size() if not self.maximized: self.window_size = size self.search_entry.set_size_request(min(max(50, size[0] - 470), 800), -1) def on_window_delete(self, *_args): if self.application.running_games.get_n_items(): self.hide() return True def on_window_configure(self, *_args): """Callback triggered when the window is moved, resized...""" self.window_x, self.window_y = self.get_position() @GtkTemplate.Callback def on_destroy(self, *_args): """Signal for window close.""" # Stop cancellable running threads for stopper in self.threads_stoppers: stopper() # Save settings width, height = self.window_size settings.write_setting("width", width) settings.write_setting("height", height) if self.window_x and self.window_y: settings.write_setting("window_x", self.window_x) settings.write_setting("window_y", self.window_y) settings.write_setting("maximized", self.maximized) @GtkTemplate.Callback def on_preferences_activate(self, *_args): """Callback when preferences is activated.""" self.application.show_window(SystemConfigDialog) @GtkTemplate.Callback def on_manage_runners(self, *args): self.application.show_window(RunnersDialog, transient_for=self) def on_show_installed_state_change(self, action, value): """Callback to handle uninstalled game filter switch""" action.set_state(value) self.set_show_installed_state(value.get_boolean()) self.emit("view-updated") @GtkTemplate.Callback def on_search_entry_changed(self, entry): """Callback for the search input keypresses""" if self.search_timer_id: GLib.source_remove(self.search_timer_id) self.filters["text"] = entry.get_text().lower().strip() if self.service and self.service.id == "lutris" and self.website_button.props.active: delay = 1250 # Big delay to make sure user has stopped typing before sending a search else: delay = 150 self.search_timer_id = GLib.timeout_add(delay, self.update_store) @GtkTemplate.Callback def on_search_entry_key_press(self, widget, event): if event.keyval == Gdk.KEY_Down: if self.current_view_type == 'grid': self.view.select_path( Gtk.TreePath('0')) # needed for gridview only # if game_bar is alive at this point it can mess grid item selection up # for some unknown reason, # it is safe to close it here, it will be reopened automatically. if self.game_bar: self.game_bar.destroy() # for gridview only self.view.set_cursor(Gtk.TreePath('0'), None, False) # needed for both view types self.view.grab_focus() @GtkTemplate.Callback def on_about_clicked(self, *_args): """Open the about dialog.""" dialogs.AboutDialog(parent=self) def on_game_error(self, game, error): """Called when a game has sent the 'game-error' signal""" logger.error("%s crashed", game) dialogs.ErrorDialog(error, parent=self) @GtkTemplate.Callback def on_add_game_button_clicked(self, *_args): """Add a new game manually with the AddGameDialog.""" if "runner" in self.filters: runner = self.filters["runner"] else: runner = None AddGameDialog(self, runner=runner) return True def on_toggle_viewtype(self, *args): view_type = "list" if self.current_view_type == "grid" else "grid" self.set_viewtype_icon(view_type) settings.write_setting("view_type", view_type) self.redraw_view() def on_icontype_state_change(self, action, value): action.set_state(value) self._set_icon_type(value.get_string()) def on_view_sorting_state_change(self, action, value): self.actions["view-sorting"].set_state(value) value = str(value).strip("'") settings.write_setting("view_sorting", value) self.emit("view-updated") def on_view_sorting_direction_change(self, action, value): self.actions["view-sorting-ascending"].set_state(value) settings.write_setting("view_sorting_ascending", bool(value)) self.emit("view-updated") def on_side_panel_state_change(self, action, value): """Callback to handle side panel toggle""" action.set_state(value) side_panel_visible = value.get_boolean() settings.write_setting("side_panel_visible", bool(side_panel_visible)) self.sidebar_revealer.set_reveal_child(side_panel_visible) def on_sidebar_changed(self, widget): row = widget.get_selected_row() self.selected_category = "%s:%s" % (row.type, row.id) for filter_type in ("category", "dynamic_category", "service", "runner", "platform"): if filter_type in self.filters: self.filters.pop(filter_type) if row: self.filters[row.type] = row.id service_name = self.filters.get("service") self.set_service(service_name) self._bind_zoom_adjustment() self.redraw_view() def on_game_selection_changed(self, view, selection): if not selection: GLib.idle_add(self.update_revealer) return False game_id = view.get_model().get_value(selection, COL_ID) if not game_id: GLib.idle_add(self.update_revealer) return False if self.service: game = ServiceGameCollection.get_game(self.service.id, game_id) else: game = games_db.get_game_by_field(int(game_id), "id") if not game: game = { "id": game_id, "appid": game_id, "name": view.get_model().get_value(selection, COL_NAME), "slug": game_id, "service": self.service.id if self.service else None, } GLib.idle_add(self.update_revealer, game) return False def on_game_collection_changed(self, _sender): """Simple method used to refresh the view""" logger.debug("Game collection changed") self.emit("view-updated") return True def on_game_activated(self, view, game_id): """Handles view activations (double click, enter press)""" if self.service: if self.service.id != "lutris": db_game = games_db.get_game_for_service( self.service.id, game_id) if db_game: game_id = db_game["id"] else: db_game = ServiceGameCollection.get_game( self.service.id, game_id) if db_game: game_id = self.service.install(db_game) else: game_id = self.service.install(game_id) else: db_game = games_db.get_game_by_field(game_id) if not db_game: self.service.install(game_id) return if db_game["installed"] != 1: self.service.install(game_id) return game_id = db_game["id"] if game_id: game = Game(game_id) game.emit("game-launch")
class LutrisWindow(Gtk.ApplicationWindow): # pylint: disable=too-many-public-methods """Handler class for main window signals.""" default_view_type = "grid" default_width = 800 default_height = 600 __gtype_name__ = "LutrisWindow" main_box = GtkTemplate.Child() games_scrollwindow = GtkTemplate.Child() sidebar_revealer = GtkTemplate.Child() sidebar_scrolled = GtkTemplate.Child() connection_label = GtkTemplate.Child() search_revealer = GtkTemplate.Child() search_entry = GtkTemplate.Child() search_toggle = GtkTemplate.Child() zoom_adjustment = GtkTemplate.Child() no_results_overlay = GtkTemplate.Child() connect_button = GtkTemplate.Child() disconnect_button = GtkTemplate.Child() register_button = GtkTemplate.Child() sync_button = GtkTemplate.Child() sync_label = GtkTemplate.Child() sync_spinner = GtkTemplate.Child() search_spinner = GtkTemplate.Child() add_popover = GtkTemplate.Child() viewtype_icon = GtkTemplate.Child() website_search_toggle = GtkTemplate.Child() def __init__(self, application, **kwargs): # pylint: disable=too-many-statements # TODO: refactor width = int(settings.read_setting("width") or self.default_width) height = int(settings.read_setting("height") or self.default_height) super().__init__( default_width=width, default_height=height, window_position=Gtk.WindowPosition.NONE, icon_name="lutris", application=application, **kwargs ) self.application = application self.runtime_updater = RuntimeUpdater() self.threads_stoppers = [] self.selected_runner = None self.selected_platform = None self.selected_category = None # Load settings self.window_size = (width, height) self.maximized = settings.read_setting("maximized") == "True" view_type = self.get_view_type() self.load_image_type_from_settings(view_type) # Window initialization self.game_actions = GameActions(application=application, window=self) self.search_terms = None self.search_timer_id = None self.image_type = ImageType.icon self.search_mode = "local" self.game_store = self.get_store() self.view = self.get_view(view_type) GObject.add_emission_hook(Game, "game-updated", self.on_game_updated) GObject.add_emission_hook(Game, "game-removed", self.on_game_updated) GObject.add_emission_hook(Game, "game-started", self.on_game_started) GObject.add_emission_hook(Game, "game-installed", self.on_game_installed) GObject.add_emission_hook(GenericPanel, "running-game-selected", self.game_selection_changed) self.connect("delete-event", self.on_window_delete) if self.maximized: self.maximize() self.init_template() self._init_actions() self._bind_zoom_adjustment() # Load view self.games_scrollwindow.add(self.view) self._connect_signals() # Set theme to dark if set in the settings self.set_dark_theme() self.set_viewtype_icon(view_type) # Add additional widgets lutris_icon = Gtk.Image.new_from_icon_name("lutris", Gtk.IconSize.MENU) lutris_icon.set_margin_right(3) self.website_search_toggle.set_image(lutris_icon) self.website_search_toggle.set_label(_("Search Lutris.net")) self.website_search_toggle.set_tooltip_text(_("Search Lutris.net")) self.sidebar_listbox = SidebarListBox(self.application) self.sidebar_listbox.set_size_request(250, -1) self.sidebar_listbox.connect("selected-rows-changed", self.on_sidebar_changed) self.sidebar_scrolled.add(self.sidebar_listbox) self.game_panel = GenericPanel(application=self.application) self.game_scrolled = Gtk.ScrolledWindow(visible=True) self.game_scrolled.set_size_request(320, -1) self.game_scrolled.get_style_context().add_class("game-scrolled") self.game_scrolled.set_policy(Gtk.PolicyType.EXTERNAL, Gtk.PolicyType.EXTERNAL) self.game_scrolled.add(self.game_panel) self.panel_revealer = Gtk.Revealer(visible=True) self.panel_revealer.set_transition_duration(300) self.panel_revealer.set_transition_type(Gtk.RevealerTransitionType.SLIDE_LEFT) self.panel_revealer.add(self.game_scrolled) self.main_box.pack_end(self.panel_revealer, False, False, 0) self.view.show() self.game_store.load() # Contextual menu self.view.contextual_menu = ContextualMenu(self.game_actions.get_game_actions()) # Left/Right Sidebar visibility self.sidebar_revealer.set_reveal_child(self.left_side_panel_visible) self.sidebar_revealer.set_transition_duration(300) self.panel_revealer.set_reveal_child(self.right_side_panel_visible) self.panel_revealer.set_transition_duration(300) self.update_runtime() # Connect account and/or sync credentials = api.read_api_key() if credentials: self.on_connect_success(None, credentials["username"]) else: self.toggle_connection(False) self.sync_library() self.sync_services() # steamapps_paths = steam.get_steamapps_paths(flat=True) # self.steam_watcher = SteamWatcher(steamapps_paths, self.on_steam_game_changed) def _init_actions(self): Action = namedtuple("Action", ("callback", "type", "enabled", "default", "accel")) Action.__new__.__defaults__ = (None, None, True, None, None) actions = { "browse-games": Action(lambda *x: open_uri("https://lutris.net/games/")), "register-account": Action(lambda *x: open_uri("https://lutris.net/user/register/")), "disconnect": Action(self.on_disconnect), "connect": Action(self.on_connect), "synchronize": Action(lambda *x: self.sync_library()), "sync-local": Action(lambda *x: self.open_sync_dialog()), "add-game": Action(self.on_add_game_button_clicked), "preferences": Action(self.on_preferences_activate), "manage-runners": Action(self.on_manage_runners, ), "about": Action(self.on_about_clicked), "show-installed-only": Action( self.on_show_installed_state_change, type="b", default=self.filter_installed, accel="<Primary>h", ), "show-installed-first": Action( self.on_show_installed_first_state_change, type="b", default=self.show_installed_first, ), "toggle-viewtype": Action(self.on_toggle_viewtype), "icon-type": Action(self.on_icontype_state_change, type="s", default=self.image_type._name_), "view-sorting": Action(self.on_view_sorting_state_change, type="s", default=self.view_sorting), "view-sorting-ascending": Action( self.on_view_sorting_direction_change, type="b", default=self.view_sorting_ascending, ), "use-dark-theme": Action(self.on_dark_theme_state_change, type="b", default=self.use_dark_theme), "show-tray-icon": Action(self.on_tray_icon_toggle, type="b", default=self.show_tray_icon), "show-left-side-panel": Action( self.on_left_side_panel_state_change, type="b", default=self.left_side_panel_visible, accel="F9", ), "show-hidden-games": Action( self.hidden_state_change, type="b", default=self.show_hidden_games, ), "show-right-side-panel": Action( self.on_right_side_panel_state_change, type="b", default=self.right_side_panel_visible, accel="F10", ), "open-forums": Action(lambda *x: open_uri("https://forums.lutris.net/")), "open-discord": Action(lambda *x: open_uri("https://discord.gg/Pnt5CuY")), "donate": Action(lambda *x: open_uri("https://lutris.net/donate")), } self.actions = {} app = self.props.application for name, value in actions.items(): if not value.type: action = Gio.SimpleAction.new(name) action.connect("activate", value.callback) else: default_value = None param_type = None if value.default is not None: default_value = GLib.Variant(value.type, value.default) if value.type != "b": param_type = default_value.get_type() action = Gio.SimpleAction.new_stateful(name, param_type, default_value) action.connect("change-state", value.callback) self.actions[name] = action if value.enabled is False: action.props.enabled = False self.add_action(action) if value.accel: app.add_accelerator(value.accel, "win." + name) def on_hide_game(self, _widget): """Add a game to the list of hidden games""" game = Game(self.view.selected_game) # Append the new hidden ID and save it ignores = pga.get_hidden_ids() + [game.id] pga.set_hidden_ids(ignores) # Update the GUI if not self.show_hidden_games: self.view.remove_game(game.id) def on_unhide_game(self, _widget): """Removes a game from the list of hidden games""" game = Game(self.view.selected_game) # Remove the ID to unhide and save it ignores = pga.get_hidden_ids() ignores.remove(game.id) pga.set_hidden_ids(ignores) def hidden_state_change(self, action, value): """Hides or shows the hidden games""" action.set_state(value) # Add or remove hidden games ignores = pga.get_hidden_ids() settings.write_setting("show_hidden_games", str(self.show_hidden_games).lower(), section="lutris") # If we have to show the hidden games now, we need to add them back to # the view. If we need to hide them, we just remove them from the view if value: self.game_store.add_games_by_ids(ignores) else: for game_id in ignores: self.game_store.remove_game(game_id) @property def current_view_type(self): """Returns which kind of view is currently presented (grid or list)""" return "grid" if isinstance(self.view, GameGridView) else "list" @property def filter_installed(self): return settings.read_setting("filter_installed").lower() == "true" @property def left_side_panel_visible(self): show_left_panel = (settings.read_setting("left_side_panel_visible").lower() != "false") return show_left_panel or self.sidebar_visible @property def right_side_panel_visible(self): show_right_panel = (settings.read_setting("right_side_panel_visible").lower() != "false") return show_right_panel or self.sidebar_visible @property def sidebar_visible(self): """Deprecated: For compability only""" return settings.read_setting("sidebar_visible") in [ "true", None, ] @property def use_dark_theme(self): """Return whether to use the dark theme variant (if the theme provides one)""" return settings.read_setting("dark_theme", default="false").lower() == "true" @property def show_installed_first(self): return settings.read_setting("show_installed_first", default="false").lower() == "true" def on_tray_icon_toggle(self, action, value): """Callback for handling tray icon toggle""" action.set_state(value) settings.write_setting('show_tray_icon', value) self.application.set_tray_icon() @property def show_tray_icon(self): """Setting to hide or show status icon""" return settings.read_setting("show_tray_icon", default="false").lower() == "true" @property def view_sorting(self): return settings.read_setting("view_sorting") or "name" @property def view_sorting_ascending(self): return settings.read_setting("view_sorting_ascending").lower() != "false" @property def show_hidden_games(self): return settings.read_setting("show_hidden_games").lower() == "true" def get_store(self, games=None): """Return an instance of GameStore""" games = games or pga.get_games(show_installed_first=self.show_installed_first) game_store = GameStore( games, self.image_type, self.filter_installed, self.view_sorting, self.view_sorting_ascending, self.show_hidden_games, self.show_installed_first, ) game_store.connect("sorting-changed", self.on_game_store_sorting_changed) return game_store def sync_services(self): """Sync local lutris library with current Steam games and desktop games""" def full_sync(syncer_cls): syncer = syncer_cls() games = syncer.load() return syncer.sync(games, full=True) def on_sync_complete(response, errors): """Callback to update the view on sync complete""" if errors: logger.error("Sync failed: %s", errors) return added_games, removed_games = response for game_id in added_games: self.game_store.add_or_update(game_id) for game_id in removed_games: self.remove_game_from_view(game_id) for service in get_services_synced_at_startup(): AsyncCall(full_sync, on_sync_complete, service.SYNCER) def on_steam_game_changed(self, operation, path): """Action taken when a Steam AppManifest file is updated""" appmanifest = steam.AppManifest(path) # if self.running_game and "steam" in self.running_game.runner_name: # self.running_game.notify_steam_game_changed(appmanifest) runner_name = appmanifest.get_runner_name() games = pga.get_games_where(steamid=appmanifest.steamid) if operation == Gio.FileMonitorEvent.DELETED: for game in games: if game["runner"] == runner_name: steam.mark_as_uninstalled(game) self.game_store.set_uninstalled(Game(game["id"])) break elif operation in (Gio.FileMonitorEvent.CHANGED, Gio.FileMonitorEvent.CREATED): if not appmanifest.is_installed(): return if runner_name == "winesteam": return game_info = None for game in games: if game["installed"] == 0: game_info = game else: # Game is already installed, don't do anything return if not game_info: game_info = {"name": appmanifest.name, "slug": appmanifest.slug} if steam in get_services_synced_at_startup(): game_id = steam.mark_as_installed(appmanifest.steamid, runner_name, game_info) self.game_store.update_game_by_id(game_id) def set_dark_theme(self): """Enables or disbales dark theme""" gtksettings = Gtk.Settings.get_default() gtksettings.set_property("gtk-application-prefer-dark-theme", self.use_dark_theme) def get_view(self, view_type): """Return the appropriate widget for the current view""" if view_type == "grid": return GameGridView(self.game_store) return GameListView(self.game_store) def _connect_signals(self): """Connect signals from the view with the main window. This must be called each time the view is rebuilt. """ self.view.connect("game-selected", self.game_selection_changed) self.view.connect("game-activated", self.on_game_activated) def _bind_zoom_adjustment(self): """Bind the zoom slider to the supported banner sizes""" image_sizes = list(IMAGE_SIZES.keys()) self.zoom_adjustment.props.value = image_sizes.index(self.image_type) self.zoom_adjustment.connect( "value-changed", lambda adj: self._set_image_type(image_sizes[int(adj.props.value)]), ) def get_view_type(self): """Return the type of view saved by the user""" view_type = settings.read_setting("view_type") if view_type in ["grid", "list"]: return view_type return self.default_view_type def do_key_press_event(self, event): # pylint: disable=arguments-differ if event.keyval == Gdk.KEY_Escape: self.search_toggle.set_active(False) return Gdk.EVENT_STOP # return Gtk.ApplicationWindow.do_key_press_event(self, event) # XXX: This block of code below is to enable searching on type. # Enabling this feature steals focus from other entries so it needs # some kind of focus detection before enabling library search. # Probably not ideal for non-english, but we want to limit # which keys actually start searching if ( # pylint: disable=too-many-boolean-expressions not Gdk.KEY_0 <= event.keyval <= Gdk.KEY_z or event.state & Gdk.ModifierType.CONTROL_MASK or event.state & Gdk.ModifierType.SHIFT_MASK or event.state & Gdk.ModifierType.META_MASK or event.state & Gdk.ModifierType.MOD1_MASK or self.search_entry.has_focus() ): return Gtk.ApplicationWindow.do_key_press_event(self, event) self.search_toggle.set_active(True) self.search_entry.grab_focus() return self.search_entry.do_key_press_event(self.search_entry, event) def load_image_type_from_settings(self, view_type): """Return the icon style depending on the type of view.""" if view_type == "list": try: self.image_type = ImageType[settings.read_setting("icon_type_listview")] # FIXME: IS THIS A KeyError OR ValueError ? except KeyError: self.image_type = ImageType.icon else: try: self.image_type = ImageType[settings.read_setting("icon_type_gridview")] # FIXME: IS THIS A KeyError OR ValueError ? except KeyError: self.image_type = ImageType.banner return self.image_type def switch_view(self, view_type): """Switch between grid view and list view.""" self.view.destroy() self.load_image_type_from_settings(view_type) self.game_store.set_image_type(ImageType[self.icon_type]) self.view = self.get_view(view_type) self.view.contextual_menu = ContextualMenu(self.game_actions.get_game_actions()) self._connect_signals() scrollwindow_children = self.games_scrollwindow.get_children() if scrollwindow_children: child = scrollwindow_children[0] child.destroy() self.games_scrollwindow.add(self.view) self.set_selected_filter(self.selected_runner, self.selected_platform, self.selected_category) self.set_show_installed_state(self.filter_installed) self.view.show_all() self.zoom_adjustment.props.value = list(IMAGE_SIZES.keys()).index(ImageType[self.icon_type]) self.set_viewtype_icon(view_type) settings.write_setting("view_type", view_type) def set_viewtype_icon(self, view_type): self.viewtype_icon.set_from_icon_name( "view-%s-symbolic" % ("list" if view_type == "grid" else "grid"), Gtk.IconSize.BUTTON, ) def sync_library(self): """Synchronize games with local stuff and server.""" def update_gui(result, error): self.sync_label.set_label(_("Synchronize library")) self.sync_spinner.props.active = False self.sync_button.set_sensitive(True) if error: if isinstance(error, http.UnauthorizedAccess): GLib.idle_add(self.show_invalid_credential_warning) else: GLib.idle_add(self.show_library_sync_error) return if result: added_ids, updated_ids = result self.game_store.add_games_by_ids(added_ids) for game_id in updated_ids.difference(added_ids): self.game_store.update_game_by_id(game_id) else: logger.error("No results returned when syncing the library") self.sync_label.set_label(_("Synchronizing…")) self.sync_spinner.props.active = True self.sync_button.set_sensitive(False) AsyncCall(sync_from_remote, update_gui) def open_sync_dialog(self): """Opens the service sync dialog""" self.add_popover.hide() self.application.show_window(SyncServiceWindow) def update_runtime(self): """Check that the runtime is up to date""" runtime_sync = AsyncCall(self.runtime_updater.update, None) self.threads_stoppers.append(runtime_sync.stop_request.set) def on_dark_theme_state_change(self, action, value): """Callback for theme switching action""" action.set_state(value) settings.write_setting("dark_theme", value.get_boolean()) self.set_dark_theme() @GtkTemplate.Callback def on_connect(self, *_args): """Callback when a user connects to his account.""" login_dialog = dialogs.ClientLoginDialog(self) login_dialog.connect("connected", self.on_connect_success) return True def on_connect_success(self, _dialog, username): """Callback for user connect success""" self.toggle_connection(True, username) self.sync_library() self.actions["synchronize"].props.enabled = True self.actions["register-account"].props.enabled = False def on_game_activated(self, _widget, game): self.game_selection_changed(None, game) if game.is_installed: self.application.launch(game) else: self.application.show_window(InstallerWindow, parent=self, game_slug=game.slug) @GtkTemplate.Callback def on_disconnect(self, *_args): """Callback from user disconnect""" dlg = dialogs.QuestionDialog({ "question": _("Do you want to log out from Lutris?"), "title": _("Log out?"), }) if dlg.result != Gtk.ResponseType.YES: return api.disconnect() self.toggle_connection(False) self.actions["synchronize"].props.enabled = False def toggle_connection(self, is_connected, username=None): """Sets or unset connected state for the current user""" self.connect_button.props.visible = not is_connected self.register_button.props.visible = not is_connected self.disconnect_button.props.visible = is_connected self.sync_button.props.visible = is_connected if is_connected: self.connection_label.set_text(username) logger.info("Connected to lutris.net as %s", username) @GtkTemplate.Callback def on_resize(self, widget, *_args): """Size-allocate signal. Updates stored window size and maximized state. """ if not widget.get_window(): return self.maximized = widget.is_maximized() if not self.maximized: self.window_size = widget.get_size() def on_window_delete(self, *_args): if self.application.running_games.get_n_items(): self.hide() return True @GtkTemplate.Callback def on_destroy(self, *_args): """Signal for window close.""" # Stop cancellable running threads for stopper in self.threads_stoppers: stopper() # self.steam_watcher = None # Save settings width, height = self.window_size settings.write_setting("width", width) settings.write_setting("height", height) settings.write_setting("maximized", self.maximized) @GtkTemplate.Callback def on_preferences_activate(self, *_args): """Callback when preferences is activated.""" self.application.show_window(SystemConfigDialog) @GtkTemplate.Callback def on_manage_runners(self, *args): self.application.show_window(RunnersDialog, transient_for=self) def invalidate_game_filter(self): """Refilter the game view based on current filters""" self.game_store.modelfilter.refilter() self.game_store.modelsort.clear_cache() self.game_store.sort_view(self.view_sorting, self.view_sorting_ascending) self.no_results_overlay.props.visible = not bool(self.game_store.games) def on_show_installed_first_state_change(self, action, value): """Callback to handle installed games first toggle""" action.set_state(value) self.set_show_installed_first_state(value.get_boolean()) def set_show_installed_first_state(self, show_installed_first): """Shows the installed games first in the view""" settings.write_setting("show_installed_first", bool(show_installed_first)) self.game_store.sort_view(show_installed_first) self.game_store.modelfilter.refilter() def on_show_installed_state_change(self, action, value): """Callback to handle uninstalled game filter switch""" action.set_state(value) self.set_show_installed_state(value.get_boolean()) def set_show_installed_state(self, filter_installed): """Shows or hide uninstalled games""" settings.write_setting("filter_installed", bool(filter_installed)) self.game_store.filters["installed"] = filter_installed self.invalidate_game_filter() @GtkTemplate.Callback def on_search_entry_changed(self, entry): """Callback for the search input keypresses""" if self.search_mode == "local": self.game_store.filters["text"] = entry.get_text() self.invalidate_game_filter() elif self.search_mode == "website": search_terms = entry.get_text().lower().strip() self.search_spinner.props.active = True if self.search_timer_id: GLib.source_remove(self.search_timer_id) self.search_timer_id = GLib.timeout_add(750, self.on_search_games_fire, search_terms) else: raise ValueError("Unsupported search mode %s" % self.search_mode) @GtkTemplate.Callback def on_search_toggle(self, button): """Called when search bar is shown / hidden""" active = button.props.active self.search_revealer.set_reveal_child(active) if active: self.search_entry.grab_focus() else: self.search_entry.props.text = "" @GtkTemplate.Callback def on_website_search_toggle_toggled(self, toggle_button): self.search_terms = self.search_entry.props.text if toggle_button.props.active: self.search_mode = "website" self.search_entry.set_placeholder_text(_("Search Lutris.net")) self.search_entry.set_icon_from_icon_name(Gtk.EntryIconPosition.PRIMARY, "folder-download-symbolic") self.game_store.search_mode = True self.search_games(self.search_terms) else: self.search_mode = "local" self.search_entry.set_placeholder_text(_("Filter the list of games")) self.search_entry.set_icon_from_icon_name(Gtk.EntryIconPosition.PRIMARY, "system-search-symbolic") self.search_games("") self.search_spinner.props.active = False @GtkTemplate.Callback def on_about_clicked(self, *_args): """Open the about dialog.""" dialogs.AboutDialog(parent=self) def on_game_error(self, game, error): """Called when a game has sent the 'game-error' signal""" logger.error("%s crashed", game) dialogs.ErrorDialog(error, parent=self) def on_game_installed(self, game): self.game_selection_changed(None, game) def on_game_started(self, game): self.game_panel.refresh() return True def on_game_updated(self, game): """Callback to refresh the view when a game is updated""" logger.debug("Updating game %s", game) if not game.is_installed: game = Game(game_id=game.id) self.game_selection_changed(None, None) game.load_config() try: self.game_store.update_game_by_id(game.id) except ValueError: self.game_store.add_game_by_id(game.id) self.game_panel.refresh() return True def on_search_games_fire(self, value): GLib.source_remove(self.search_timer_id) self.search_timer_id = None self.search_games(value) return False def search_games(self, query): """Search for games from the website API""" logger.debug("%s search for :%s", self.search_mode, query) self.search_terms = query self.view.destroy() self.game_store = self.get_store(api.search_games(query) if query else None) self.game_store.set_image_type(self.image_type) self.game_store.load(from_search=bool(query)) self.game_store.filters["text"] = self.search_entry.props.text self.search_spinner.props.active = False self.switch_view(self.get_view_type()) self.invalidate_game_filter() def game_selection_changed(self, _widget, game): """Callback to handle the selection of a game in the view""" child = self.game_scrolled.get_child() if child: self.game_scrolled.remove(child) child.destroy() if not game: self.game_panel = GenericPanel(application=self.application) else: self.game_actions.set_game(game=game) self.game_panel = GamePanel(self.game_actions) self.game_panel.connect("panel-closed", self.on_panel_closed) self.view.contextual_menu.connect("shortcut-edited", self.game_panel.on_shortcut_edited) self.game_scrolled.add(self.game_panel) return True def on_panel_closed(self, panel): self.game_selection_changed(panel, None) def update_game(self, slug): for pga_game in pga.get_games_where(slug=slug): self.game_store.update(pga_game) @GtkTemplate.Callback def on_add_game_button_clicked(self, *_args): """Add a new game manually with the AddGameDialog.""" self.add_popover.hide() AddGameDialog(self, runner=self.selected_runner) return True def remove_game_from_view(self, game_id, from_library=False): """Remove a game from the view""" self.game_store.update_game_by_id(game_id) self.sidebar_listbox.update() def on_toggle_viewtype(self, *args): self.switch_view("list" if self.current_view_type == "grid" else "grid") def on_viewtype_state_change(self, action, val): """Callback to handle view type switch""" action.set_state(val) view_type = val.get_string() if view_type != self.current_view_type: self.switch_view(view_type) def _set_image_type(self, image_type): self.image_type = image_type if self.image_type == self.game_store.image_type: return if self.current_view_type == "grid": settings.write_setting("icon_type_gridview", self.image_type._name_) elif self.current_view_type == "list": settings.write_setting("icon_type_listview", self.image_type._name_) self.game_store.set_image_type(self.image_type) self.switch_view(self.get_view_type()) def on_icontype_state_change(self, action, value): action.set_state(value) self._set_image_type(ImageType[value.get_string()]) def on_view_sorting_state_change(self, action, value): self.game_store.sort_view(value.get_string(), self.view_sorting_ascending) def on_view_sorting_direction_change(self, action, value): self.game_store.sort_view(self.view_sorting, value.get_boolean()) def on_game_store_sorting_changed(self, _game_store, key, ascending): self.actions["view-sorting"].set_state(GLib.Variant.new_string(key)) settings.write_setting("view_sorting", key) self.actions["view-sorting-ascending"].set_state(GLib.Variant.new_boolean(ascending)) settings.write_setting("view_sorting_ascending", bool(ascending)) def on_left_side_panel_state_change(self, action, value): """Callback to handle left side panel toggle""" action.set_state(value) left_side_panel_visible = value.get_boolean() settings.write_setting("left_side_panel_visible", bool(left_side_panel_visible)) self.sidebar_revealer.set_reveal_child(left_side_panel_visible) # Retrocompatibility with sidebar_visible : # if we change the new attribute, we must set the old one to false if self.sidebar_visible: settings.write_setting("sidebar_visible", "false") def on_right_side_panel_state_change(self, action, value): """Callback to handle right side panel toggle""" action.set_state(value) right_side_panel_visible = value.get_boolean() settings.write_setting("right_side_panel_visible", bool(right_side_panel_visible)) self.panel_revealer.set_reveal_child(right_side_panel_visible) self.game_scrolled.set_visible(right_side_panel_visible) # Retrocompatibility with sidebar_visible : # if we change the new attribute, we must set the old one to false if self.sidebar_visible: settings.write_setting("sidebar_visible", "false") def on_sidebar_changed(self, widget): row = widget.get_selected_row() if row is None: self.set_selected_filter(None, None, None) elif row.type == "runner": self.set_selected_filter(row.id, None, None) elif row.type == "category": self.set_selected_filter(None, None, row.id) else: self.set_selected_filter(None, row.id, None) def set_selected_filter(self, runner, platform, category): """Filter the view to a given runner and platform""" self.selected_runner = runner self.selected_platform = platform self.selected_category = category self.game_store.filters["runner"] = self.selected_runner self.game_store.filters["platform"] = self.selected_platform self.game_store.filters["category"] = self.selected_category self.invalidate_game_filter() def show_invalid_credential_warning(self): dialogs.ErrorDialog(_("Could not connect to your Lutris account. Please sign in again.")) def show_library_sync_error(self): dialogs.ErrorDialog(_("Failed to retrieve game library. There might be some problems contacting lutris.net"))