From 38bb9711523ba00a7422a8884fed1fb303bf4592 Mon Sep 17 00:00:00 2001 From: Daniel Johnson Date: Sun, 14 Jun 2026 14:44:51 -0400 Subject: [PATCH] Port multiple-file widget off Gtk.TreeView to match EditableGrid _generate_multiple_file was the last Gtk.TreeView in a config dialog. Replace it with the same pattern EditableGrid uses: a Gtk.ListBox of rows where each row is a Gtk.Entry + a small reset-button-styled delete icon, the whole thing wrapped in a Gtk.Frame. Behavioural changes: - Paths are now editable inline (consistent with EditableGrid). Useful for paths the file dialog can't reach easily (network shares, symlinked locations). - The Delete-key handler is gone; per-row delete buttons replace it. - The "Add Files" button is right-aligned in a button_box below the frame so it sits where EditableGrid's "Add" does. Shared .editable-grid CSS now styles both widgets identically. Update GTK4-MIGRATION.md to drop the widget_generator entry from the TreeView holdouts list and describe both ports together. Co-Authored-By: Claude Opus 4.7 --- GTK4-MIGRATION.md | 28 ++--- lutris/gui/config/widget_generator.py | 153 ++++++++++++++++---------- 2 files changed, 111 insertions(+), 70 deletions(-) diff --git a/GTK4-MIGRATION.md b/GTK4-MIGRATION.md index 8fed4f3ea..82b0bd1d1 100644 --- a/GTK4-MIGRATION.md +++ b/GTK4-MIGRATION.md @@ -362,23 +362,23 @@ isn't worth carrying for the duration of the GTK 4 cycle. When Deprecated in GTK 4.10. The replacement story is the `Gtk.ColumnView` / `Gtk.ListView` + `Gio.ListStore` + `Gtk.SignalListItemFactory` stack — which the main game list and grid -already moved to. Remaining holdouts: +already moved to. The only remaining `Gtk.ListStore` users live +underneath `Gtk.EntryCompletion`: -- `lutris/gui/config/widget_generator.py` — the file list in the - installer-script file-picker widget (`_generate_files()`). -- `lutris/gui/widgets/searchable_entrybox.py` and - `lutris/gui/widgets/common.py` — `Gtk.ListStore` backing the - `Gtk.EntryCompletion` autocomplete; both go away when the - `EntryCompletion` swap lands. +- `lutris/gui/widgets/searchable_entrybox.py` (service-search dropdown) +- `lutris/gui/widgets/common.py` (`FileChooserEntry` path completion) -Each is small and self-contained. Swap when convenient or when GTK 5 -removes the API; no urgency until then. +Both `ListStore`s go away when the `EntryCompletion` swap lands; until +then they're harmless deprecation noise. -`EditableGrid` (in `widgets/common.py`) used to be a 2-column editable -`TreeView`; it now uses a `Gtk.ListBox` of rows, each row holding one -`Gtk.Entry` per column and a small per-row delete button. CSS in -`share/lutris/ui/lutris.css` strips the entry borders and adds a -column separator so the rows still read as a tabular grid. +`EditableGrid` (in `widgets/common.py`) and the multiple-file picker +in `widget_generator.py` (`_generate_multiple_file()`) both used to be +`Gtk.TreeView`s. They now use a shared pattern: a `Gtk.ListBox` of +rows where each row is a `Gtk.Entry` plus a small per-row delete +button, wrapped in a `Gtk.Frame`. CSS in +`share/lutris/ui/lutris.css` (scoped to the `.editable-grid` class) +strips entry borders/padding/radius and tightens row padding so the +rows still read as a clean tabular list. ### `Gtk.StyleContext.add_provider_for_display` / `remove_provider_for_display` diff --git a/lutris/gui/config/widget_generator.py b/lutris/gui/config/widget_generator.py index 1c9fd72a9..e8d5ea893 100644 --- a/lutris/gui/config/widget_generator.py +++ b/lutris/gui/config/widget_generator.py @@ -7,7 +7,7 @@ from gettext import gettext as _ from inspect import Parameter, signature from typing import TYPE_CHECKING, Any -from gi.repository import Gdk, Gio, GLib, Gtk # type: ignore +from gi.repository import Gio, GLib, Gtk # type: ignore from lutris.config import LutrisConfig from lutris.gui.widgets import NotificationSource @@ -605,17 +605,63 @@ class WidgetGenerator(ABC): def _generate_command_line(self, option, value, default): return self._generate_file(option, value, default, shell_quoting=True) - # TreeView def _generate_multiple_file(self, option, value, default): """Generate a multiple file selector.""" + option_key = option["option"] + label_text = option["label"] + # entries holds the canonical state: one Gtk.Entry per row, in + # display order. fire_changed() and the add-files dedupe both + # read it; add_row() appends to it; on_remove() removes from it. + entries = [] + + def current_files(): + return [e.get_text().strip() for e in entries] + + def fire_changed(): + self.changed.fire(option_key, current_files()) + + def add_row(filename: str) -> None: + row = Gtk.ListBoxRow() + row.set_activatable(False) + row_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6) + row_box.set_margin_top(2) + row_box.set_margin_bottom(2) + row_box.set_margin_start(6) + row_box.set_margin_end(6) + + entry = Gtk.Entry() + entry.set_text(filename) + entry.set_hexpand(True) + entry.connect("changed", lambda _e: fire_changed()) + row_box.append(entry) + + delete_button = Gtk.Button.new_from_icon_name("list-remove-symbolic") + delete_button.add_css_class("reset-button") + delete_button.set_valign(Gtk.Align.CENTER) + delete_button.set_halign(Gtk.Align.CENTER) + delete_button.set_has_frame(False) + delete_button.set_tooltip_text(_("Remove this file")) + + def on_remove(_button: Gtk.Button) -> None: + files_listbox.remove(row) + entries.remove(entry) + fire_changed() + + delete_button.connect("clicked", on_remove) + row_box.append(delete_button) + + row.set_child(row_box) + files_listbox.append(row) + entries.append(entry) + def on_add_files_clicked(_widget): """Create and run multi-file chooser dialog.""" dialog = Gtk.FileDialog() dialog.set_title(_("Select files")) - files = [row[0] for row in files_list_store] - first_file_dir = os.path.dirname(files[0]) if files else None + existing = current_files() + first_file_dir = os.path.dirname(existing[0]) if existing else None initial = first_file_dir or self.default_directory if initial: dialog.set_initial_folder(Gio.File.new_for_path(initial)) @@ -625,71 +671,66 @@ class WidgetGenerator(ABC): gfiles = _dialog.open_multiple_finish(async_result) except GLib.Error: return + added_any = False for i in range(gfiles.get_n_items()): gfile = gfiles.get_item(i) filename = gfile.get_path() - if filename and filename not in files: - files_list_store.append([filename]) - files.append(filename) - self.changed.fire(option_key, files) + if filename and filename not in current_files(): + add_row(filename) + added_any = True + if added_any: + fire_changed() dialog.open_multiple(None, None, on_finish) - def on_files_treeview_keypress(_controller, keyval, _keycode, _state): - """Action triggered when a row is deleted from the filechooser.""" - if keyval == Gdk.KEY_Delete: - treeview = _controller.get_widget() - selection = treeview.get_selection() - (model, treepaths) = selection.get_selected_rows() - for treepath in treepaths: - treeiter = model.get_iter(treepath) - model.remove(treeiter) - - files = [row[0] for row in files_list_store] - self.changed.fire(option_key, files) - - option_key = option["option"] - label = option["label"] - - files_list_store = Gtk.ListStore(str) - vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) - label = Label(label + ":") - label.set_halign(Gtk.Align.START) - label.set_margin_bottom(5) - button = Gtk.Button(label=_("Add Files")) - button.connect("clicked", on_add_files_clicked) - button.set_margin_start(10) - if not value: value = default - if value: if isinstance(value, str): - files = [value] + initial_files = [value] else: - files = value + initial_files = list(value) else: - files = [] - for filename in files: - files_list_store.append([filename]) - cell_renderer = Gtk.CellRendererText() - files_treeview = Gtk.TreeView(model=files_list_store) - files_column = Gtk.TreeViewColumn(_("Files"), cell_renderer, text=0) - files_treeview.append_column(files_column) - key_controller = Gtk.EventControllerKey() - key_controller.connect("key-pressed", on_files_treeview_keypress) - files_treeview.add_controller(key_controller) - treeview_scroll = Gtk.ScrolledWindow() - treeview_scroll.set_min_content_height(130) - treeview_scroll.set_margin_start(10) - treeview_scroll.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC) - treeview_scroll.set_child(files_treeview) + initial_files = [] - vbox.append(label) - treeview_scroll.set_hexpand(True) - treeview_scroll.set_vexpand(True) - vbox.append(treeview_scroll) - vbox.append(button) + vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) + vbox.add_css_class("editable-grid") + header_label = Label(label_text + ":") + header_label.set_halign(Gtk.Align.START) + header_label.set_margin_bottom(5) + + files_listbox = Gtk.ListBox() + files_listbox.set_selection_mode(Gtk.SelectionMode.NONE) + files_listbox.set_show_separators(True) + + scrolled = Gtk.ScrolledWindow() + scrolled.set_min_content_height(130) + scrolled.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC) + scrolled.set_child(files_listbox) + + # Frame gives the listbox a visible border (especially when empty), + # matching the EditableGrid styling. + frame = Gtk.Frame() + frame.set_margin_start(10) + frame.set_hexpand(True) + frame.set_vexpand(True) + frame.set_child(scrolled) + + for filename in initial_files: + add_row(filename) + + add_button = Gtk.Button(label=_("Add Files")) + add_button.set_size_request(80, -1) + add_button.connect("clicked", on_add_files_clicked) + + button_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6) + button_box.set_halign(Gtk.Align.END) + button_box.set_margin_top(6) + button_box.append(add_button) + + vbox.append(header_label) + vbox.append(frame) + vbox.append(button_box) return self.build_option_widget(option, vbox, no_label=True) # FileChooserEntry