"""Misc widgets used in the GUI.""" # Standard Library import os import shlex import urllib.parse from gettext import gettext as _ # Third Party Libraries from gi.repository import GLib, GObject, Gtk, Pango from lutris.gui.widgets.utils import open_uri # Lutris Modules from lutris.util import system from lutris.util.linux import LINUX_SYSTEM # MyPy does not like GTK's notion of multiple inheritancs, but # we don't control this, so we'll suppress type checking. class SlugEntry(Gtk.Entry, Gtk.Editable): # type:ignore[misc] def do_insert_text(self, new_text, length, position): """Filter inserted characters to only accept alphanumeric and dashes""" new_text = "".join([c for c in new_text if c.isalnum() or c == "-"]).lower() length = len(new_text) self.get_buffer().insert_text(position, new_text, length) return position + length class NumberEntry(Gtk.Entry, Gtk.Editable): # type:ignore[misc] def do_insert_text(self, new_text, length, position): """Filter inserted characters to only accept numbers""" new_text = "".join([c for c in new_text if c.isnumeric()]) if new_text: self.get_buffer().insert_text(position, new_text, length) return position + length return position class FileChooserEntry(Gtk.Box): # type:ignore[misc] """Editable entry with a file picker button""" max_completion_items = 15 # Maximum number of items to display in the autocompletion dropdown. __gsignals__ = { "changed": (GObject.SIGNAL_RUN_FIRST, None, ()), } def __init__( self, title=_("Select file"), action=Gtk.FileChooserAction.OPEN, text=None, default_path=None, warn_if_non_empty=False, warn_if_non_writable_parent=False, warn_if_ntfs=False, activates_default=False, shell_quoting=False, ): # pylint: disable=too-many-arguments super().__init__(orientation=Gtk.Orientation.VERTICAL, spacing=0, visible=True) self.title = title self.action = action self.warn_if_non_empty = warn_if_non_empty self.warn_if_non_writable_parent = warn_if_non_writable_parent self.warn_if_ntfs = warn_if_ntfs self.shell_quoting = shell_quoting self.path_completion = Gtk.ListStore(str) self.entry = Gtk.Entry(visible=True) self.set_text(text) # do before set up signal handlers self.original_text = self.get_text() self.default_path = os.path.expanduser(default_path) if default_path else self.get_path() self.entry.set_activates_default(activates_default) self.entry.set_completion(self.get_completion()) self.entry.connect("changed", self.on_entry_changed) self.entry.connect("activate", self.on_activate) self.entry.connect("focus-out-event", self.on_focus_out) self.entry.connect("backspace", self.on_backspace) browse_button = Gtk.Button.new_from_icon_name("view-more-horizontal-symbolic", Gtk.IconSize.BUTTON) browse_button.show() if action == Gtk.FileChooserAction.SELECT_FOLDER: browse_button.set_tooltip_text(_("Select a folder")) else: browse_button.set_tooltip_text(_("Select a file")) browse_button.get_style_context().add_class("circular") browse_button.connect("clicked", self.on_browse_clicked) self.open_button = Gtk.Button.new_from_icon_name("folder-symbolic", Gtk.IconSize.BUTTON) self.open_button.show() self.open_button.set_tooltip_text(_("Open in file browser")) self.open_button.get_style_context().add_class("circular") self.open_button.connect("clicked", self.on_open_clicked) self.open_button.set_sensitive(bool(self.get_open_directory())) box = Gtk.Box(spacing=6, visible=True) box.pack_start(self.entry, True, True, 0) box.pack_end(self.open_button, False, False, 0) box.pack_end(browse_button, False, False, 0) self.pack_start(box, False, False, 0) def set_text(self, text): if self.shell_quoting and text: try: command_array = shlex.split(self.get_text()) except ValueError: command_array = None # split can fail due to imbalanced quoted if command_array: expanded = os.path.expanduser(command_array[0]) command_array[0] = expanded rejoined = shlex.join(command_array) self.original_text = rejoined self.entry.set_text(rejoined) return expanded = os.path.expanduser(text) if text else "" self.original_text = expanded self.entry.set_text(expanded) def set_path(self, path): if self.shell_quoting: try: command_array = shlex.split(self.get_text()) except ValueError: command_array = None # split can fail due to imbalanced quoted if command_array: command_array[0] = os.path.expanduser(path) if path else "" rejoined = shlex.join(command_array) self.original_text = rejoined self.entry.set_text(rejoined) return expanded = os.path.expanduser(path) if path else "" self.original_text = expanded self.entry.set_text(expanded) def get_text(self): """Return the entry's text. If shell_quoting is one, this is actually a command line (with argument quoting) and not a simple path.""" return self.entry.get_text() def get_path(self): """Returns the path in the entry; if shell_quoting is set, this extracts the command from the text and returns only that.""" text = self.get_text() if self.shell_quoting: try: command_array = shlex.split(text) return command_array[0] if command_array else "" except ValueError: pass return text def get_completion(self): """Return an EntryCompletion widget""" completion = Gtk.EntryCompletion() completion.set_model(self.path_completion) completion.set_text_column(0) return completion def get_filechooser_dialog(self): """Return an instance of a FileChooserNative configured for this widget""" dialog = Gtk.FileChooserNative.new(self.title, self.get_toplevel(), self.action, _("_OK"), _("_Cancel")) dialog.set_create_folders(True) dialog.set_current_folder(self.get_default_folder()) return dialog def get_default_folder(self): """Return the default folder for the file picker""" default_path = self.get_path() or self.default_path or "" if not default_path or not system.path_exists(default_path): current_entry = self.get_text() if system.path_exists(current_entry): default_path = current_entry if not os.path.isdir(default_path): default_path = os.path.dirname(default_path) return os.path.expanduser(default_path or "~") def on_browse_clicked(self, _widget): """Browse button click callback""" file_chooser_dialog = self.get_filechooser_dialog() response = file_chooser_dialog.run() if response == Gtk.ResponseType.ACCEPT: target_path = file_chooser_dialog.get_filename() if target_path and self.shell_quoting: try: command_array = shlex.split(self.entry.get_text()) text = shlex.join([target_path] + command_array[1:]) except ValueError: text = shlex.join([target_path]) else: text = target_path self.original_text = text self.entry.set_text(text) file_chooser_dialog.destroy() def on_open_clicked(self, _widget): path = self.get_open_directory() if path: open_uri(path) def get_open_directory(self): path = self.get_path() while path and not os.path.isdir(path): path = os.path.dirname(path) return path def on_entry_changed(self, widget): """Entry changed callback""" self.clear_warnings() # If the user isn't editing this entry, we'll apply updates # immediately upon any change if not self.entry.has_focus(): if self.normalize_path(): # We changed the text on commit, so we return here to avoid a double changed signal return text = self.get_text() path = self.get_path() self.original_text = text if self.warn_if_ntfs and LINUX_SYSTEM.get_fs_type_for_path(path) == "ntfs": ntfs_box = Gtk.Box(spacing=6, visible=True) warning_image = Gtk.Image(visible=True) warning_image.set_from_icon_name("dialog-warning", Gtk.IconSize.DND) ntfs_box.add(warning_image) ntfs_label = Gtk.Label(visible=True) ntfs_label.set_markup( _( "Warning! The selected path is located on a drive formatted by Windows.\n" "Games and programs installed on Windows drives don't work." ) ) ntfs_box.add(ntfs_label) self.pack_end(ntfs_box, False, False, 10) if self.warn_if_non_empty and os.path.exists(path) and os.listdir(path): non_empty_label = Gtk.Label(visible=True) non_empty_label.set_markup( _("Warning! The selected path contains files. Installation will not work properly.") ) self.pack_end(non_empty_label, False, False, 10) if self.warn_if_non_writable_parent: parent = system.get_existing_parent(path) if parent is not None and not os.access(parent, os.W_OK): non_writable_destination_label = Gtk.Label(visible=True) non_writable_destination_label.set_markup( _("Warning The destination folder is not writable by the current user.") ) self.pack_end(non_writable_destination_label, False, False, 10) self.open_button.set_sensitive(bool(self.get_open_directory())) self.emit("changed") def on_activate(self, _widget): self.normalize_path() self.detect_changes() def on_focus_out(self, _widget, _event): self.normalize_path() self.detect_changes() def on_backspace(self, _widget): GLib.idle_add(self.detect_changes) def detect_changes(self): """Detects if the text has changed and updates self.original_text and fires the changed signal. Lame, but Gtk.Entry does not always fire its changed event when edited!""" new_text = self.get_text() if self.original_text != new_text: self.original_text = new_text self.emit("changed") return False # used as idle function def normalize_path(self): original_path = self.get_path() path = original_path.strip("\r\n") if path.startswith("file:///"): path = urllib.parse.unquote(path[len("file://") :]) path = os.path.expanduser(path) self.update_completion(path) if path != original_path: self.set_path(path) return True return False def update_completion(self, current_path): """Update the auto-completion widget with the current path""" self.path_completion.clear() if not os.path.exists(current_path): current_path, filefilter = os.path.split(current_path) else: filefilter = None if os.path.isdir(current_path): index = 0 for filename in sorted(os.listdir(current_path)): if filename.startswith("."): continue if filefilter is not None and not filename.startswith(filefilter): continue self.path_completion.append([os.path.join(current_path, filename)]) index += 1 if index > self.max_completion_items: break def clear_warnings(self): """Delete all the warning labels from the container""" for index, child in enumerate(self.get_children()): if index > 0: child.destroy() class Label(Gtk.Label): """Standardised label for config vboxes.""" def __init__(self, message=None, width_request=230, max_width_chars=22, visible=True): """Custom init of label.""" super().__init__(label=message, visible=visible) self.set_line_wrap(True) self.set_max_width_chars(max_width_chars) self.set_line_wrap_mode(Pango.WrapMode.WORD_CHAR) self.set_size_request(width_request, -1) self.set_alignment(0, 0.5) self.set_justify(Gtk.Justification.LEFT) class VBox(Gtk.Box): def __init__(self, **kwargs): super().__init__(orientation=Gtk.Orientation.VERTICAL, margin_top=18, **kwargs) class EditableGrid(Gtk.Box): __gsignals__ = {"changed": (GObject.SIGNAL_RUN_FIRST, None, ())} def __init__(self, data, columns): self.columns = columns super().__init__(orientation=Gtk.Orientation.VERTICAL, spacing=6) self.liststore = Gtk.ListStore(str, str) for item in data: self.liststore.append([str(value) for value in item]) self.treeview = Gtk.TreeView.new_with_model(self.liststore) self.treeview.set_grid_lines(Gtk.TreeViewGridLines.BOTH) for i, column_title in enumerate(self.columns): renderer = Gtk.CellRendererText() renderer.set_property("editable", True) renderer.connect("edited", self.on_text_edited, i) column = Gtk.TreeViewColumn(column_title, renderer, text=i) column.set_resizable(True) column.set_min_width(100) column.set_sort_column_id(0) self.treeview.append_column(column) self.buttons = [] self.add_button = Gtk.Button(_("Add")) self.buttons.append(self.add_button) self.add_button.connect("clicked", self.on_add) self.delete_button = Gtk.Button(_("Delete")) self.buttons.append(self.delete_button) self.delete_button.connect("clicked", self.on_delete) self.scrollable_treelist = Gtk.ScrolledWindow() self.scrollable_treelist.set_size_request(-1, 209) self.scrollable_treelist.add(self.treeview) self.scrollable_treelist.set_shadow_type(Gtk.ShadowType.IN) self.pack_start(self.scrollable_treelist, True, True, 0) button_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6) for button in reversed(self.buttons): button.set_size_request(80, -1) button_box.pack_end(button, False, False, 0) self.pack_end(button_box, False, False, 0) self.show_all() def on_add(self, widget): # pylint: disable=unused-argument self.liststore.append(["", ""]) row_position = len(self.liststore) - 1 self.treeview.set_cursor(row_position, None, False) self.treeview.scroll_to_cell(row_position, None, False, 0.0, 0.0) self.emit("changed") def on_delete(self, widget): # pylint: disable=unused-argument selection = self.treeview.get_selection() _, iteration = selection.get_selected() if iteration: self.liststore.remove(iteration) self.emit("changed") def on_text_edited(self, widget, path, text, field): # pylint: disable=unused-argument self.liststore[path][field] = text.strip() # pylint: disable=unsubscriptable-object self.emit("changed") def get_data(self): # pylint: disable=arguments-differ model_data = [] for row in self.liststore: # pylint: disable=not-an-iterable model_data.append(row) return model_data