"""Commonly used dialogs""" import builtins import inspect import os import traceback from gettext import gettext as _ from typing import Any, Callable, Dict, Optional, Type, TypeVar, Union, cast import gi from lutris.exceptions import LutrisError gi.require_version("Gdk", "3.0") gi.require_version("Gtk", "3.0") from gi.repository import Gdk, GObject, Gtk from lutris import api, settings from lutris.gui.widgets.log_text_view import LogTextView from lutris.gui.widgets.utils import get_widget_children, get_widget_window from lutris.util import datapath from lutris.util.jobs import schedule_at_idle from lutris.util.log import get_log_contents, logger from lutris.util.strings import gtk_safe class Dialog(Gtk.Dialog): """A base class for dialogs that provides handling for the response signal; you can override its on_response() methods, but that method will record the response for you via 'response_type' or 'confirmed' and destory this dialog if it isn't NONE.""" def __init__( self, title: str = None, parent: Gtk.Widget = None, flags: Gtk.DialogFlags = 0, buttons: Gtk.ButtonsType = None, **kwargs, ): # MyPy can't see it, but __init__ can handle the new_with_buttons arguments for us super().__init__(title, parent, flags, buttons, **kwargs) # type:ignore self._response_type = Gtk.ResponseType.NONE self.connect("response", self.on_response) @property def response_type(self) -> Gtk.ResponseType: """The response type of the response that occurred; initially this is NONE. Use the GTK response() method to artificially generate a response, rather than setting this.""" return self._response_type @property def confirmed(self) -> bool: """True if 'response_type' is OK or YES.""" return self.response_type in (Gtk.ResponseType.OK, Gtk.ResponseType.YES) def on_response(self, _dialog, response: Gtk.ResponseType) -> None: """Handles the dialog response; you can override this but by default this records the response for 'response_type'.""" self._response_type = response def destroy_at_idle(self, condition: Optional[Callable] = None): """Adds as idle task to destroy this window at idle time; it can do so conditionally if you provide a callable to check, but it checks only once. You can still explicitly destroy the dialog after calling this. This is used to ensure destruction of ModalDialog after run().""" def idle_destroy(): if not condition or condition(): self.destroy() def on_destroy(*_args): self.disconnect(on_destroy_id) idle_destroy_task.unschedule() self.hide() idle_destroy_task = schedule_at_idle(idle_destroy) on_destroy_id = self.connect("destroy", on_destroy) def add_styled_button(self, button_text: str, response_id: Gtk.ResponseType, css_class: str): button = self.add_button(button_text, response_id) if css_class: style_context = button.get_style_context() style_context.add_class(css_class) return button def add_default_button(self, button_text: str, response_id: Gtk.ResponseType, css_class: str = "suggested-action"): """Adds a button to the dialog with a particular response id, but also makes it the default and styles it as the suggested action.""" button = self.add_styled_button(button_text, response_id, css_class) self.set_default_response(response_id) return button class ModalDialog(Dialog): """A base class of modal dialogs, which sets the flag for you. Unlike plain Gtk.Dialog, these destroy themselves (at idle-time) after you call run(), even if you forget to. They aren't meant to be reused.""" def __init__( self, title: str = None, parent: Gtk.Widget = None, flags: Gtk.DialogFlags = 0, buttons: Gtk.ButtonsType = None, **kwargs, ): super().__init__(title, parent, flags | Gtk.DialogFlags.MODAL, buttons, **kwargs) self.set_destroy_with_parent(True) def on_response(self, dialog, response: Gtk.ResponseType) -> None: super().on_response(dialog, response) # Model dialogs do return from run() in response from respose() but the # dialog is visible and locks out its parent. So we hide it. Watch out- # self.destroy() changes the run() result to NONE. if response != Gtk.ResponseType.NONE: self.hide() self.destroy_at_idle(condition=lambda: not self.get_visible()) class ModelessDialog(Dialog): """A base class for modeless dialogs. They have a parent only temporarily, so they can be centered over it during creation. But each modeless dialog gets its own window group, so it treats its own modal dialogs separately, and it resets its transient-for after being created.""" def __init__( self, title: str = None, parent: Gtk.Widget = None, flags: Gtk.DialogFlags = 0, buttons: Gtk.ButtonsType = None, **kwargs, ): super().__init__(title, parent, flags, buttons, **kwargs) # These are not stuck above the 'main' window, but can be # re-ordered freely. self.set_type_hint(Gdk.WindowTypeHint.NORMAL) # These are independent windows, but start centered over # a parent like a dialog. Not modal, not really transient, # and does not share modality with other windows - so it # needs its own window group. Gtk.WindowGroup().add_window(self) schedule_at_idle(self._clear_transient_for) def _clear_transient_for(self) -> None: # we need the parent set to be centered over the parent, but # we don't want to be transient really - we want other windows # able to come to the front. self.set_transient_for(None) def on_response(self, dialog, response: Gtk.ResponseType) -> None: super().on_response(dialog, response) # Modal dialogs self-destruct, but modeless ones must commit # suicide more explicitly. if response != Gtk.ResponseType.NONE: self.destroy() class SavableModelessDialog(ModelessDialog): """This is a modeless dialog that has a Cancel and a Save button in the header-bar, with a ctrl-S keyboard shortcut to save.""" def __init__(self, title: str, parent: Gtk.Widget = None, **kwargs): super().__init__(title, parent=parent, use_header_bar=True, **kwargs) self.cancel_button = self.add_button(_("Cancel"), Gtk.ResponseType.CANCEL) self.cancel_button.set_valign(Gtk.Align.CENTER) self.save_button = self.add_styled_button(_("Save"), Gtk.ResponseType.NONE, css_class="suggested-action") self.save_button.set_valign(Gtk.Align.CENTER) self.save_button.connect("clicked", self.on_save) self.accelerators = Gtk.AccelGroup() self.add_accel_group(self.accelerators) key, mod = Gtk.accelerator_parse("s") self.save_button.add_accelerator("clicked", self.accelerators, key, mod, Gtk.AccelFlags.VISIBLE) def on_save(self, _button): pass class GtkBuilderDialog(GObject.Object): dialog_object = NotImplemented __gsignals__ = { "destroy": (GObject.SignalFlags.RUN_LAST, None, ()), } def __init__(self, parent=None, **kwargs): # pylint: disable=no-member super().__init__() ui_filename = os.path.join(datapath.get(), "ui", self.glade_file) if not os.path.exists(ui_filename): raise ValueError("ui file does not exists: %s" % ui_filename) self.builder = Gtk.Builder() self.builder.add_from_file(ui_filename) self.dialog = self.builder.get_object(self.dialog_object) self.builder.connect_signals(self) if parent: self.dialog.set_transient_for(parent) self.dialog.show_all() self.dialog.connect("delete-event", self.on_close) self.initialize(**kwargs) def initialize(self, **kwargs): """Implement further customizations in subclasses""" def present(self): self.dialog.present() def on_close(self, *args): # pylint: disable=unused-argument """Propagate the destroy event after closing the dialog""" self.dialog.destroy() self.emit("destroy") def on_response(self, widget, response): # pylint: disable=unused-argument if response == Gtk.ResponseType.DELETE_EVENT: try: self.dialog.hide() except AttributeError: pass class AboutDialog(GtkBuilderDialog): glade_file = "about-dialog.ui" dialog_object = "about_dialog" def initialize(self): # pylint: disable=arguments-differ self.dialog.set_version(settings.VERSION) class NoticeDialog(Gtk.MessageDialog): """Display a message to the user.""" def __init__(self, message_markup: str, secondary: Optional[str] = None, parent: Optional[Gtk.Widget] = None): parent: Gtk.Window = get_widget_window(parent) super().__init__(message_type=Gtk.MessageType.INFO, buttons=Gtk.ButtonsType.OK, parent=parent) self.set_markup(message_markup) if secondary: self.format_secondary_text(secondary[:256]) # So you can copy warning text for child in get_widget_children(self.get_message_area(), child_type=Gtk.Label): child.set_selectable(True) self.run() self.destroy() class WarningDialog(Gtk.MessageDialog): """Display a warning to the user, who responds with whether to proceed, like a QuestionDialog.""" def __init__(self, message_markup: str, secondary: Optional[str] = None, parent: Optional[Gtk.Widget] = None): parent: Gtk.Window = get_widget_window(parent) super().__init__(message_type=Gtk.MessageType.WARNING, buttons=Gtk.ButtonsType.OK_CANCEL, parent=parent) self.set_markup(message_markup) if secondary: self.format_secondary_text(secondary[:256]) # So you can copy warning text for child in get_widget_children(self.get_message_area(), child_type=Gtk.Label): child.set_selectable(True) self.result = self.run() self.destroy() class ErrorDialog(Gtk.MessageDialog): """Display an error message.""" def __init__( self, error: Union[str, builtins.BaseException], message_markup: Optional[str] = None, secondary_markup: Optional[str] = None, parent: Optional[Gtk.Widget] = None, ): parent: Gtk.Window = get_widget_window(parent) super().__init__(message_type=Gtk.MessageType.ERROR, buttons=Gtk.ButtonsType.OK, parent=parent) def get_message_markup(err: Union[BaseException, str]) -> str: if isinstance(err, LutrisError): return err.message_markup or gtk_safe(str(err)) else: return gtk_safe(str(err)) if isinstance(error, builtins.BaseException): if secondary_markup: # Some errors contain < and > and look like markup, but aren't- # we'll need to protect the message dialog against this. To use markup, # you must pass the message itself directly. message_markup = message_markup or get_message_markup(error) elif not message_markup: message_markup = "%s" % _("Lutris has encountered an error") secondary_markup = get_message_markup(error) elif not message_markup: message_markup = get_message_markup(error) # Gtk doesn't wrap long labels containing no space correctly # the length of the message is limited to avoid display issues if message_markup: self.set_markup(message_markup[:256]) if secondary_markup: self.format_secondary_markup(secondary_markup[:256]) # So you can copy error text for child in get_widget_children(self.get_message_area(), child_type=Gtk.Label): child.set_selectable(True) if isinstance(error, BaseException): content_area = self.get_content_area() spacing = content_area.get_spacing() content_area.set_spacing(0) details_expander = self.get_details_expander(error) details_expander.set_margin_top(spacing) content_area.pack_end(details_expander, False, False, 0) action_area = cast(Gtk.ButtonBox, self.get_action_area()) copy_button = Gtk.Button(label=_("Copy Details to Clipboard"), visible=True) action_area.pack_start(copy_button, False, True, 0) action_area.set_child_secondary(copy_button, True) copy_button.connect("clicked", self.on_copy_clicked, error) self.run() self.destroy() def on_copy_clicked(self, _button, error: BaseException): details = self.format_error(error) clipboard = Gtk.Clipboard.get(Gdk.SELECTION_CLIPBOARD) clipboard.set_text(details, -1) def get_details_expander(self, error: BaseException) -> Gtk.Widget: details = self.format_error(error, include_message=False) box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) label = Gtk.Label(xalign=0.0, wrap=True, margin_left=6, margin_right=6, margin_bottom=6) label.set_markup( _( "You can get support from " "GitHub or " "Discord. " "Make sure to provide the error details;\n" "use the 'Copy Details to Clipboard' button to get them." ) ) box.pack_start(label, False, False, 0) expander = Gtk.Expander.new(_("Error details")) details_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) details_box.pack_start(Gtk.Separator(), False, False, 0) details_textview = Gtk.TextView(editable=False) details_textview.get_buffer().set_text(details) details_scrolledwindow = Gtk.ScrolledWindow(width_request=800, height_request=400) details_scrolledwindow.add(details_textview) details_box.pack_start(details_scrolledwindow, False, False, 0) expander.add(details_box) box.pack_start(expander, True, True, 0) box.show_all() return box @staticmethod def format_error(error: BaseException, include_message: bool = True): formatted = traceback.format_exception(type(error), error, error.__traceback__) if include_message: formatted = [str(error), ""] + formatted text = "\n".join(formatted).strip() log = get_log_contents() if log: text = f"{text}\n\nLutris log:\n{log}".strip() return text class QuestionDialog(Gtk.MessageDialog): """Ask the user a yes or no question.""" YES = Gtk.ResponseType.YES NO = Gtk.ResponseType.NO def __init__(self, dialog_settings): super().__init__(message_type=Gtk.MessageType.QUESTION, buttons=Gtk.ButtonsType.YES_NO) self.set_markup(dialog_settings["question"]) self.set_title(dialog_settings["title"]) if "parent" in dialog_settings: self.set_transient_for(dialog_settings["parent"]) if "widgets" in dialog_settings: for widget in dialog_settings["widgets"]: self.get_message_area().add(widget) self.result = self.run() self.destroy() class InputDialog(ModalDialog): """Ask the user for a text input""" def __init__(self, dialog_settings): super().__init__(parent=dialog_settings["parent"]) self.set_border_width(12) self.user_value = "" self.add_button(Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL) self.ok_button = self.add_default_button(Gtk.STOCK_OK, Gtk.ResponseType.OK) self.set_default_response(Gtk.ResponseType.OK) self.ok_button.set_sensitive(False) self.set_title(dialog_settings["title"]) label = Gtk.Label(visible=True) label.set_markup(dialog_settings["question"]) self.get_content_area().pack_start(label, True, True, 12) self.entry = Gtk.Entry(visible=True, activates_default=True) self.entry.connect("changed", self.on_entry_changed) self.get_content_area().pack_start(self.entry, True, True, 12) self.entry.set_text(dialog_settings.get("initial_value") or "") def on_entry_changed(self, widget): self.user_value = widget.get_text() self.ok_button.set_sensitive(bool(self.user_value)) class DirectoryDialog: """Ask the user to select a directory.""" def __init__(self, message, default_path=None, parent=None): self.folder = None dialog = Gtk.FileChooserNative.new( message, parent, Gtk.FileChooserAction.SELECT_FOLDER, _("_OK"), _("_Cancel"), ) if default_path: dialog.set_current_folder(default_path) self.result = dialog.run() if self.result == Gtk.ResponseType.ACCEPT: self.folder = dialog.get_filename() dialog.destroy() class FileDialog: """Ask the user to select a file.""" def __init__(self, message=None, default_path=None, mode="open", parent=None): self.filename = None if not message: message = _("Please choose a file") if mode == "save": action = Gtk.FileChooserAction.SAVE else: action = Gtk.FileChooserAction.OPEN dialog = Gtk.FileChooserNative.new( message, parent, action, _("_OK"), _("_Cancel"), ) if default_path and os.path.exists(default_path): dialog.set_current_folder(default_path) dialog.set_local_only(False) response = dialog.run() if response == Gtk.ResponseType.ACCEPT: self.filename = dialog.get_filename() dialog.destroy() class InstallOrPlayDialog(ModalDialog): def __init__(self, game_name, parent=None): super().__init__(title=_("%s is already installed") % game_name, parent=parent, border_width=10) self.action = "play" self.action_confirmed = False self.add_button(Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL) self.add_default_button(Gtk.STOCK_OK, Gtk.ResponseType.OK) self.set_size_request(320, 120) vbox = Gtk.Box.new(Gtk.Orientation.VERTICAL, 6) self.get_content_area().add(vbox) play_button = Gtk.RadioButton.new_with_label_from_widget(None, _("Launch game")) play_button.connect("toggled", self.on_button_toggled, "play") vbox.pack_start(play_button, False, False, 0) install_button = Gtk.RadioButton.new_from_widget(play_button) install_button.set_label(_("Install the game again")) install_button.connect("toggled", self.on_button_toggled, "install") vbox.pack_start(install_button, False, False, 0) self.show_all() self.run() def on_button_toggled(self, _button, action): logger.debug("Action set to %s", action) self.action = action def on_response(self, _widget, response): if response == Gtk.ResponseType.CANCEL: self.action = None super().on_response(_widget, response) class LaunchConfigSelectDialog(ModalDialog): def __init__(self, game, configs, title, parent=None): super().__init__(title=title, parent=parent, border_width=10) self.config_index = 0 self.dont_show_again = False self.add_button(Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL) self.add_default_button(Gtk.STOCK_OK, Gtk.ResponseType.OK) self.set_size_request(320, 120) vbox = Gtk.Box.new(Gtk.Orientation.VERTICAL, 6) self.get_content_area().add(vbox) primary_game_radio = Gtk.RadioButton.new_with_label_from_widget(None, game.name) primary_game_radio.connect("toggled", self.on_button_toggled, 0) vbox.pack_start(primary_game_radio, False, False, 0) for i, config in enumerate(configs): _button = Gtk.RadioButton.new_from_widget(primary_game_radio) _button.set_label(config["name"]) _button.connect("toggled", self.on_button_toggled, i + 1) vbox.pack_start(_button, False, False, 0) dont_show_checkbutton = Gtk.CheckButton(_("Do not ask again for this game.")) dont_show_checkbutton.connect("toggled", self.on_dont_show_checkbutton_toggled) vbox.pack_end(dont_show_checkbutton, False, False, 6) self.show_all() self.run() def on_button_toggled(self, _button, index): self.config_index = index def on_dont_show_checkbutton_toggled(self, _button): self.dont_show_again = _button.get_active() class ClientLoginDialog(GtkBuilderDialog): glade_file = "dialog-lutris-login.ui" dialog_object = "lutris-login" __gsignals__ = { "connected": (GObject.SignalFlags.RUN_LAST, None, (GObject.TYPE_PYOBJECT,)), "cancel": (GObject.SignalFlags.RUN_LAST, None, (GObject.TYPE_PYOBJECT,)), } def __init__(self, parent): super().__init__(parent=parent) self.parent = parent self.username_entry = self.builder.get_object("username_entry") self.password_entry = self.builder.get_object("password_entry") cancel_button = self.builder.get_object("cancel_button") cancel_button.connect("clicked", self.on_close) connect_button = self.builder.get_object("connect_button") connect_button.connect("clicked", self.on_connect) def get_credentials(self): username = self.username_entry.get_text() password = self.password_entry.get_text() return username, password def on_username_entry_activate(self, widget): # pylint: disable=unused-argument if all(self.get_credentials()): self.on_connect(None) else: self.password_entry.grab_focus() def on_password_entry_activate(self, widget): # pylint: disable=unused-argument if all(self.get_credentials()): self.on_connect(None) else: self.username_entry.grab_focus() def on_connect(self, widget): # pylint: disable=unused-argument username, password = self.get_credentials() token = api.connect(username, password) if not token: NoticeDialog(_("Login failed"), parent=self.parent) else: self.dialog.destroy() self.emit("connected", username) class InstallerSourceDialog(ModelessDialog): """Show install script source""" def __init__(self, code, name, parent): super().__init__(title=_("Install script for {}").format(name), parent=parent, border_width=0) self.set_default_size(800, 750) ok_button = self.add_default_button(Gtk.STOCK_OK, Gtk.ResponseType.OK) ok_button.set_border_width(10) self.scrolled_window = Gtk.ScrolledWindow() self.scrolled_window.set_hexpand(True) self.scrolled_window.set_vexpand(True) source_buffer = Gtk.TextBuffer() source_buffer.set_text(code) source_box = LogTextView(source_buffer, autoscroll=False) self.get_content_area().set_border_width(0) self.get_content_area().add(self.scrolled_window) self.scrolled_window.add(source_box) self.show_all() class HumbleBundleCookiesDialog(ModalDialog): def __init__(self, parent=None): super().__init__(_("Humble Bundle Cookie Authentication"), parent) self.cookies_content = None self.add_button(Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL) self.add_default_button(Gtk.STOCK_OK, Gtk.ResponseType.OK) self.set_size_request(640, 512) vbox = Gtk.Box.new(Gtk.Orientation.VERTICAL, 6) self.get_content_area().add(vbox) label = Gtk.Label() label.set_markup( _( "Humble Bundle Authentication via cookie import\n" "\n" "In Firefox\n" "- Install the following extension: " "" "https://addons.mozilla.org/en-US/firefox/addon/export-cookies-txt/" "\n" "- Open a tab to humblebundle.com and make sure you are logged in.\n" "- Click the cookie icon in the top right corner, next to the settings menu\n" "- Check 'Prefix HttpOnly cookies' and click 'humblebundle.com'\n" "- Open the generated file and paste the contents below. Click OK to finish.\n" "- You can delete the cookies file generated by Firefox\n" "- Optionally, " "open a support ticket to ask Humble Bundle to fix their configuration." ) ) vbox.pack_start(label, False, False, 24) self.textview = Gtk.TextView() self.textview.set_left_margin(12) self.textview.set_right_margin(12) scrolledwindow = Gtk.ScrolledWindow() scrolledwindow.set_hexpand(True) scrolledwindow.set_vexpand(True) scrolledwindow.add(self.textview) vbox.pack_start(scrolledwindow, True, True, 24) self.show_all() self.run() def on_response(self, dialog, response): if response == Gtk.ResponseType.CANCEL: self.cookies_content = None else: buffer = self.textview.get_buffer() self.cookies_content = buffer.get_text(buffer.get_start_iter(), buffer.get_end_iter(), True) super().on_response(dialog, response) def _call_when_destroyed(self: Gtk.Widget, callback: Callable[[], None]) -> Callable[[], None]: handler_id = self.connect("destroy", lambda *x: callback()) return lambda: self.disconnect(handler_id) # call_when_destroyed is a utility that hooks up the 'destroy' signal to call your callback, # and returns a callable that unhooks it. This is used by AsyncJob to avoid sending a callback # to a destroyed widget. Gtk.Widget.call_when_destroyed = _call_when_destroyed # type: ignore[attr-defined] _error_handlers: Dict[Type[BaseException], Callable[[BaseException, Gtk.Window], Any]] = {} TError = TypeVar("TError", bound=BaseException) def display_error(error: BaseException, parent: Gtk.Widget) -> None: """Displays an error in a modal dialog. This can be customized via register_error_handler(), but displays an ErrorDialog by default. This allows custom error handling to be invoked anywhere that can show an ErrorDialog, instead of having to bounce exceptions off the backstop.""" handler = get_error_handler(type(error)) if isinstance(parent, Gtk.Window): handler(error, parent) else: handler(error, cast(Gtk.Window, parent.get_toplevel())) def register_error_handler(error_class: Type[TError], handler: Callable[[TError, Gtk.Window], Any]) -> None: """Records a function to call to handle errors of a particular class or its subclasses. The function is given the error and a parent window, and can display a modal dialog.""" _error_handlers[error_class] = handler def get_error_handler(error_class: Type[TError]) -> Callable[[TError, Gtk.Window], Any]: """Returns the register error handler for an exception class. If none is registered, this returns a default handler that shows an ErrorDialog.""" if not isinstance(error_class, type): if isinstance(error_class, BaseException): logger.debug("An error was passed where an error class should be passed.") error_class = type(error_class) else: raise ValueError(f"'{error_class}' was passed to get_error_handler, but an error class is required here.") if error_class in _error_handlers: return _error_handlers[error_class] for base_class in inspect.getmro(error_class): if base_class in _error_handlers: return _error_handlers[base_class] return lambda e, p: ErrorDialog(e, parent=p) def _handle_keyerror(error: KeyError, parent: Gtk.Window) -> None: message = _("The key '%s' could not be found.") % error.args[0] ErrorDialog(error, message_markup=gtk_safe(message), parent=parent) register_error_handler(KeyError, _handle_keyerror)