diff --git a/src/Core/FlatpakBackend.vala b/src/Core/FlatpakBackend.vala index 12c994265..a9d22fb84 100644 --- a/src/Core/FlatpakBackend.vala +++ b/src/Core/FlatpakBackend.vala @@ -448,6 +448,10 @@ public class AppCenterCore.FlatpakBackend : Object { return apps.values; } + public SearchEngine get_search_engine () { + return new SearchEngine (package_list.values.to_array (), user_appstream_pool); + } + public Gee.Collection search_applications (string query, AppStream.Category? category) { var results = new Gee.TreeSet (); var comps = user_appstream_pool.search (query); @@ -501,10 +505,6 @@ public class AppCenterCore.FlatpakBackend : Object { return apps.values; } - public Gee.Collection search_applications_mime (string query) { - return new Gee.ArrayList (); - } - public Package? get_package_for_component_id (string id) { var suffixed_id = id + ".desktop"; foreach (var package in package_list.values) { diff --git a/src/Core/Package.vala b/src/Core/Package.vala index 21d8654d4..1dc6fe034 100644 --- a/src/Core/Package.vala +++ b/src/Core/Package.vala @@ -569,6 +569,31 @@ public class AppCenterCore.Package : Object { } } + public uint cached_search_score = 0; + public uint matches_search (string[] queries) { + // TODO: We don't use AppStream.Component.search_matches_all because it has some broken vapi + // (or at least I think so: the c code takes gchar** but vapi says string) + + if (queries.length == 0) { + cached_search_score = 0; + return 0; + } + + uint score = 0; + foreach (var query in queries) { + var query_score = component.search_matches (query); + + if (query_score == 0) { + score = 0; + break; + } + + score += query_score; + } + cached_search_score = score / queries.length; + return cached_search_score; + } + private string? name = null; public string? get_name () { if (name != null) { diff --git a/src/Core/SearchEngine.vala b/src/Core/SearchEngine.vala new file mode 100644 index 000000000..0d22c3eda --- /dev/null +++ b/src/Core/SearchEngine.vala @@ -0,0 +1,74 @@ +/* + * SPDX-FileCopyrightText: 2024 elementary, Inc. (https://elementary.io) + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Authored by: Leonhard Kargl + */ + +public class AppCenterCore.SearchEngine : Object { + public ListModel results { get; private set; } + + private ListStore packages; + private AppStream.Pool pool; + + private string[] query; + private AppStream.Category? category; + + public SearchEngine (Package[] packages, AppStream.Pool pool) { + var unique_packages = new Gee.HashMap (); + foreach (var package in packages) { + var package_component_id = package.normalized_component_id; + if (unique_packages.has_key (package_component_id)) { + if (package.origin_score > unique_packages[package_component_id].origin_score) { + unique_packages[package_component_id] = package; + } + } else { + unique_packages[package_component_id] = package; + } + } + + this.packages.splice (0, 0, unique_packages.values.to_array ()); + this.pool = pool; + } + + construct { + packages = new ListStore (typeof (Package)); + + var filter_model = new Gtk.FilterListModel (packages, new Gtk.CustomFilter ((obj) => { + var package = (Package) obj; + + if (category != null && !package.component.is_member_of_category (category)) { + return false; + } + + return ((Package) obj).matches_search (query) > 0; + })) { + incremental = true + }; + + var sort_model = new Gtk.SortListModel (filter_model, new Gtk.CustomSorter ((obj1, obj2) => { + var package1 = (Package) obj1; + var package2 = (Package) obj2; + return (int) (package2.cached_search_score - package1.cached_search_score); + })); + + results = sort_model; + } + + public void search (string query, AppStream.Category? category) { + this.query = pool.build_search_tokens (query); + this.category = category; + packages.items_changed (0, packages.n_items, packages.n_items); + } + + /** + * This should be called if the engine is no longer needed. + * We need this because thanks to how vala sets delegates we get a reference cycle, + * where the filter and sorter keep a reference on us and we on them. + * Setting results to null will free them and they will in turn free us. + * https://gitlab.gnome.org/GNOME/vala/-/issues/957 + */ + public void cleanup () { + results = null; + } +} diff --git a/src/Views/AppInfoView.vala b/src/Views/AppInfoView.vala index f04ba61bb..f2bb0cd67 100644 --- a/src/Views/AppInfoView.vala +++ b/src/Views/AppInfoView.vala @@ -210,7 +210,8 @@ public class AppCenter.Views.AppInfoView : Adw.NavigationPage { overflow = VISIBLE }; - action_stack = new ActionStack (package); + action_stack = new ActionStack (); + action_stack.package = package; var button_box = new Gtk.Box (Gtk.Orientation.HORIZONTAL, 0) { halign = Gtk.Align.END, diff --git a/src/Views/SearchView.vala b/src/Views/SearchView.vala index 8a3274888..686e03965 100644 --- a/src/Views/SearchView.vala +++ b/src/Views/SearchView.vala @@ -27,8 +27,12 @@ public class AppCenter.SearchView : Adw.NavigationPage { public string search_term { get; construct; } public bool mimetype { get; set; default = false; } - private GLib.ListStore list_store; + private AppCenterCore.SearchEngine search_engine; private Gtk.SearchEntry search_entry; + private Gtk.NoSelection selection_model; + private Gtk.ListView list_view; + private Gtk.ScrolledWindow scrolled; + private Gtk.Stack stack; private Granite.Placeholder alert_view; public SearchView (string search_term) { @@ -61,23 +65,37 @@ public class AppCenter.SearchView : Adw.NavigationPage { }; headerbar.pack_start (new BackButton ()); - list_store = new GLib.ListStore (typeof (AppCenterCore.Package)); + search_engine = AppCenterCore.FlatpakBackend.get_default ().get_search_engine (); - var list_box = new Gtk.ListBox () { - activate_on_single_click = true, + selection_model = new Gtk.NoSelection (search_engine.results); + + var factory = new Gtk.SignalListItemFactory (); + factory.setup.connect ((obj) => { + var list_item = (Gtk.ListItem) obj; + list_item.child = new Widgets.ListPackageRowGrid (null); + }); + factory.bind.connect ((obj) => { + var list_item = (Gtk.ListItem) obj; + ((Widgets.ListPackageRowGrid) list_item.child).bind ((AppCenterCore.Package) list_item.item); + }); + + list_view = new Gtk.ListView (selection_model, factory) { + single_click_activate = true, hexpand = true, vexpand = true }; - list_box.bind_model (list_store, create_row_from_package); - list_box.set_placeholder (alert_view); - var scrolled = new Gtk.ScrolledWindow () { - child = list_box, + scrolled = new Gtk.ScrolledWindow () { + child = list_view, hscrollbar_policy = Gtk.PolicyType.NEVER }; + stack = new Gtk.Stack (); + stack.add_child (alert_view); + stack.add_child (scrolled); + var toolbarview = new Adw.ToolbarView () { - content = scrolled + content = stack }; toolbarview.add_top_bar (headerbar); @@ -91,10 +109,10 @@ public class AppCenter.SearchView : Adw.NavigationPage { search_entry.grab_focus (); }); - list_box.row_activated.connect ((row) => { - if (row is Widgets.PackageRow) { - show_app (((Widgets.PackageRow) row).get_package ()); - } + selection_model.items_changed.connect (on_items_changed); + + list_view.activate.connect ((index) => { + show_app ((AppCenterCore.Package) search_engine.results.get_item (index)); }); search_entry.search_changed.connect (search); @@ -113,29 +131,34 @@ public class AppCenter.SearchView : Adw.NavigationPage { }); } - private void search () { - list_store.remove_all (); + ~SearchView () { + search_engine.cleanup (); + } + + private void on_items_changed () { + list_view.scroll_to (0, NONE, null); + if (selection_model.n_items > 0) { + stack.visible_child = scrolled; + } else { + stack.visible_child = alert_view; + } + } + + private void search () { if (search_entry.text.length >= VALID_QUERY_LENGTH) { var dyn_flathub_link = "%s".printf (search_entry.text, _("Flathub")); alert_view.description = _("Try changing search terms. You can also sideload Flatpak apps e.g. from %s").printf (dyn_flathub_link); - unowned var flatpak_backend = AppCenterCore.FlatpakBackend.get_default (); - - Gee.Collection found_apps; - if (mimetype) { - found_apps = flatpak_backend.search_applications_mime (search_entry.text); - add_packages (found_apps); + // This didn't do anything so TODO } else { - var category = update_category (); - - found_apps = flatpak_backend.search_applications (search_entry.text, category); - add_packages (found_apps); + search_engine.search (search_entry.text, update_category ()); } } else { alert_view.description = _("The search term must be at least 3 characters long."); + stack.visible_child = alert_view; } if (mimetype) { @@ -156,56 +179,4 @@ public class AppCenter.SearchView : Adw.NavigationPage { search_entry.placeholder_text = _("Search Apps"); return null; } - - public void add_packages (Gee.Collection packages) { - foreach (var package in packages) { - // Don't show plugins or fonts in search and category views - if (package.kind != AppStream.ComponentKind.ADDON && package.kind != AppStream.ComponentKind.FONT) { - GLib.CompareDataFunc sort_fn = (a, b) => { - return compare_packages (a, b); - }; - - list_store.insert_sorted (package, sort_fn); - } - } - } - - private Gtk.Widget create_row_from_package (Object object) { - unowned var package = (AppCenterCore.Package) object; - return new Widgets.PackageRow.list (package); - } - - private int search_priority (string name) { - if (name != null && search_entry.text != "") { - var name_lower = name.down (); - var term_lower = search_entry.text.down (); - - var term_position = name_lower.index_of (term_lower); - - // App name starts with our search term, highest priority - if (term_position == 0) { - return 2; - // App name contains our search term, high priority - } else if (term_position != -1) { - return 1; - } - } - - // Otherwise, normal appstream search ranking order - return 0; - } - - private int compare_packages (AppCenterCore.Package p1, AppCenterCore.Package p2) { - if ((p1.kind == AppStream.ComponentKind.ADDON) != (p2.kind == AppStream.ComponentKind.ADDON)) { - return p1.kind == AppStream.ComponentKind.ADDON ? 1 : -1; - } - - int sp1 = search_priority (p1.get_name ()); - int sp2 = search_priority (p2.get_name ()); - if (sp1 != sp2) { - return sp2 - sp1; - } - - return p1.get_name ().collate (p2.get_name ()); - } } diff --git a/src/Widgets/ActionStack.vala b/src/Widgets/ActionStack.vala index d1e5cf76b..6d77364d9 100644 --- a/src/Widgets/ActionStack.vala +++ b/src/Widgets/ActionStack.vala @@ -4,7 +4,27 @@ */ public class AppCenter.ActionStack : Gtk.Box { - public AppCenterCore.Package package { get; construct set; } + private AppCenterCore.Package? _package; + public AppCenterCore.Package? package { + get { + return _package; + } + set { + if (package != null) { + package.notify["state"].disconnect (on_package_state_changed); + } + + _package = value; + + package.notify["state"].connect (on_package_state_changed); + + action_button.package = package; + cancel_button.package = package; + + update_action (); + } + } + public bool show_open { get; set; default = true; } public bool updates_view = false; @@ -23,12 +43,8 @@ public class AppCenter.ActionStack : Gtk.Box { private Gtk.Revealer action_button_revealer; private Gtk.Revealer open_button_revealer; - public ActionStack (AppCenterCore.Package package) { - Object (package: package); - } - construct { - action_button = new Widgets.HumbleButton (package); + action_button = new Widgets.HumbleButton (); action_button_revealer = new Gtk.Revealer () { child = action_button, @@ -53,7 +69,7 @@ public class AppCenter.ActionStack : Gtk.Box { button_box.append (action_button_revealer); button_box.append (open_button_revealer); - cancel_button = new ProgressButton (package); + cancel_button = new ProgressButton (); cancel_button.clicked.connect (() => action_cancelled ()); var action_button_group = new Gtk.SizeGroup (Gtk.SizeGroupMode.BOTH); @@ -72,9 +88,6 @@ public class AppCenter.ActionStack : Gtk.Box { append (stack); - package.notify["state"].connect (on_package_state_changed); - update_action (); - destroy.connect (() => { if (state_source > 0) { GLib.Source.remove (state_source); @@ -157,12 +170,12 @@ public class AppCenter.ActionStack : Gtk.Box { } } - private void action_cancelled () { + private void action_cancelled () requires (package != null) { update_action (); package.action_cancellable.cancel (); } - private void launch_package_app () { + private void launch_package_app () requires (package != null) { try { package.launch (); } catch (Error e) { @@ -170,7 +183,7 @@ public class AppCenter.ActionStack : Gtk.Box { } } - private async void action_clicked () { + private async void action_clicked () requires (package != null) { if (package.installed && !package.update_available) { action_button_revealer.reveal_child = false; } else if (package.update_available) { diff --git a/src/Widgets/AppContainers/AbstractPackageRowGrid.vala b/src/Widgets/AppContainers/AbstractPackageRowGrid.vala index dd1ed2cab..ff979fbbe 100644 --- a/src/Widgets/AppContainers/AbstractPackageRowGrid.vala +++ b/src/Widgets/AppContainers/AbstractPackageRowGrid.vala @@ -19,7 +19,34 @@ */ public abstract class AppCenter.Widgets.AbstractPackageRowGrid : Gtk.Box { - public AppCenterCore.Package package { get; construct set; } + private AppCenterCore.Package _package; + public AppCenterCore.Package package { + get { + return _package; + } + set { + _package = value; + + action_stack.package = package; + + var scale_factor = get_scale_factor (); + + var plugin_host_package = package.get_plugin_host_package (); + if (package.kind == AppStream.ComponentKind.ADDON && plugin_host_package != null) { + app_icon.gicon = plugin_host_package.get_icon (app_icon.pixel_size, scale_factor); + badge_image.gicon = package.get_icon (badge_image.pixel_size / 2, scale_factor); + + app_icon_overlay.add_overlay (badge_image); + } else { + app_icon.gicon = package.get_icon (app_icon.pixel_size, scale_factor); + + if (package.is_runtime_updates) { + badge_image.icon_name = "system-software-update"; + app_icon_overlay.add_overlay (badge_image); + } + } + } + } public bool action_sensitive { set { @@ -31,16 +58,15 @@ public abstract class AppCenter.Widgets.AbstractPackageRowGrid : Gtk.Box { protected Gtk.Label package_name; protected Gtk.Overlay app_icon_overlay; - protected AbstractPackageRowGrid (AppCenterCore.Package package) { - Object (package: package); - } + private Gtk.Image app_icon; + private Gtk.Image badge_image; construct { - var app_icon = new Gtk.Image () { + app_icon = new Gtk.Image () { pixel_size = 48 }; - var badge_image = new Gtk.Image () { + badge_image = new Gtk.Image () { halign = Gtk.Align.END, valign = Gtk.Align.END, pixel_size = 24 @@ -50,27 +76,10 @@ public abstract class AppCenter.Widgets.AbstractPackageRowGrid : Gtk.Box { child = app_icon }; - action_stack = new ActionStack (package) { + action_stack = new ActionStack () { show_open = false }; - var scale_factor = get_scale_factor (); - - var plugin_host_package = package.get_plugin_host_package (); - if (package.kind == AppStream.ComponentKind.ADDON && plugin_host_package != null) { - app_icon.gicon = plugin_host_package.get_icon (app_icon.pixel_size, scale_factor); - badge_image.gicon = package.get_icon (badge_image.pixel_size / 2, scale_factor); - - app_icon_overlay.add_overlay (badge_image); - } else { - app_icon.gicon = package.get_icon (app_icon.pixel_size, scale_factor); - - if (package.is_runtime_updates) { - badge_image.icon_name = "system-software-update"; - app_icon_overlay.add_overlay (badge_image); - } - } - margin_top = 6; margin_start = 12; margin_bottom = 6; diff --git a/src/Widgets/AppContainers/InstalledPackageRowGrid.vala b/src/Widgets/AppContainers/InstalledPackageRowGrid.vala index 7b46442cc..66fc685be 100644 --- a/src/Widgets/AppContainers/InstalledPackageRowGrid.vala +++ b/src/Widgets/AppContainers/InstalledPackageRowGrid.vala @@ -25,17 +25,8 @@ public class AppCenter.Widgets.InstalledPackageRowGrid : AbstractPackageRowGrid private Gtk.Revealer release_button_revealer; public InstalledPackageRowGrid (AppCenterCore.Package package, Gtk.SizeGroup? action_size_group) { - Object (package: package); + this.package = package; - if (action_size_group != null) { - action_size_group.add_widget (action_stack.action_button); - action_size_group.add_widget (action_stack.cancel_button); - } - - set_up_package (); - } - - construct { app_icon_overlay.margin_end = 12; action_stack.updates_view = true; @@ -89,6 +80,13 @@ public class AppCenter.Widgets.InstalledPackageRowGrid : AbstractPackageRowGrid }; releases_dialog.present (); }); + + if (action_size_group != null) { + action_size_group.add_widget (action_stack.action_button); + action_size_group.add_widget (action_stack.cancel_button); + } + + set_up_package (); } private void set_up_package () { diff --git a/src/Widgets/AppContainers/ListPackageRowGrid.vala b/src/Widgets/AppContainers/ListPackageRowGrid.vala index c7bbfa5e3..0001e21ee 100644 --- a/src/Widgets/AppContainers/ListPackageRowGrid.vala +++ b/src/Widgets/AppContainers/ListPackageRowGrid.vala @@ -20,12 +20,14 @@ public class AppCenter.Widgets.ListPackageRowGrid : AbstractPackageRowGrid { private Gtk.Label package_summary; - public ListPackageRowGrid (AppCenterCore.Package package) { - Object (package: package); + public ListPackageRowGrid (AppCenterCore.Package? package) { + if (package != null) { + bind (package); + } } construct { - var package_name = new Gtk.Label (package.get_name ()) { + package_name = new Gtk.Label (null) { ellipsize = Pango.EllipsizeMode.END, lines = 2, max_width_chars = 1, @@ -35,7 +37,7 @@ public class AppCenter.Widgets.ListPackageRowGrid : AbstractPackageRowGrid { }; package_name.add_css_class (Granite.STYLE_CLASS_H3_LABEL); - package_summary = new Gtk.Label (package.get_summary ()) { + package_summary = new Gtk.Label (null) { ellipsize = Pango.EllipsizeMode.END, hexpand = true, lines = 2, @@ -47,10 +49,6 @@ public class AppCenter.Widgets.ListPackageRowGrid : AbstractPackageRowGrid { }; package_summary.add_css_class (Granite.STYLE_CLASS_DIM_LABEL); - if (package.is_local) { - action_stack.visible = false; - } - var grid = new Gtk.Grid () { column_spacing = 12, row_spacing = 3 @@ -62,4 +60,13 @@ public class AppCenter.Widgets.ListPackageRowGrid : AbstractPackageRowGrid { append (grid); } + + public void bind (AppCenterCore.Package package) { + package_name.label = package.get_name (); + package_summary.label = package.get_summary (); + + action_stack.visible = !package.is_local; + + this.package = package; + } } diff --git a/src/Widgets/HumbleButton.vala b/src/Widgets/HumbleButton.vala index 81603db9b..1bb290239 100644 --- a/src/Widgets/HumbleButton.vala +++ b/src/Widgets/HumbleButton.vala @@ -20,7 +20,7 @@ public class AppCenter.Widgets.HumbleButton : Gtk.Button { public signal void download_requested (); - public AppCenterCore.Package package { get; construct; } + public AppCenterCore.Package? package { get; set; } private int _amount = 1; public int amount { @@ -69,10 +69,6 @@ public class AppCenter.Widgets.HumbleButton : Gtk.Button { } } - public HumbleButton (AppCenterCore.Package package) { - Object (package: package); - } - construct { hexpand = true; @@ -83,6 +79,11 @@ public class AppCenter.Widgets.HumbleButton : Gtk.Button { #endif clicked.connect (() => { + if (package == null) { + warning ("Humble button with no associated package clicked."); + return; + } + if (amount != 0) { show_stripe_dialog (); } else { diff --git a/src/Widgets/ProgressButton.vala b/src/Widgets/ProgressButton.vala index a3036f0eb..cea58fd5e 100644 --- a/src/Widgets/ProgressButton.vala +++ b/src/Widgets/ProgressButton.vala @@ -4,24 +4,32 @@ */ public class AppCenter.ProgressButton : Gtk.Button { - public AppCenterCore.Package package { get; construct; } + private AppCenterCore.Package? _package; + public AppCenterCore.Package package { + get { + return _package; + } set { + if (package != null) { + package.change_information.progress_changed.disconnect (update_progress); + package.change_information.status_changed.disconnect (update_progress_status); + } - private Gtk.ProgressBar progressbar; + _package = value; - public ProgressButton (AppCenterCore.Package package) { - Object (package: package); + package.change_information.progress_changed.connect (update_progress); + package.change_information.status_changed.connect (update_progress_status); + + update_progress_status (); + update_progress (); + } } + private Gtk.ProgressBar progressbar; + construct { add_css_class ("progress"); add_css_class ("text-button"); - package.change_information.progress_changed.connect (update_progress); - package.change_information.status_changed.connect (update_progress_status); - - update_progress_status (); - update_progress (); - var cancel_label = new Gtk.Label (_("Cancel")) { mnemonic_widget = this }; diff --git a/src/meson.build b/src/meson.build index ba1a92086..8d108f937 100644 --- a/src/meson.build +++ b/src/meson.build @@ -12,6 +12,7 @@ appcenter_files = files( 'Core/Houston.vala', 'Core/Package.vala', 'Core/ScreenshotCache.vala', + 'Core/SearchEngine.vala', 'Core/SoupClient.vala', 'Core/Stripe.vala', 'Core/UpdateManager.vala',