[gnome-contacts/store-listmodel] Use selection and filter models to list contacts




commit 0897051bb8cbb32cd48c80ae6c8f57eff6ce805e
Author: Niels De Graef <nielsdegraef gmail com>
Date:   Fri Jan 14 11:09:10 2022 +0100

    Use selection and filter models to list contacts
    
    GTK4 added some interesting concepts on top of / in conjunction with
    list models, for example to map one list model on another by sorting
    and/or filtering. Another example is to use this with selections.
    
    This commit applies that concept to Contacts, which now uses the
    `Contacts.Store` to build a base list model on top of the
    `Folks.IndividualAggregator`, on top a sorted model (which can be
    adjusted to sort on First/Last name, and finally a filter model, to
    filter on the text in the search entry.
    
    Another reason to do this, is that it allows us to use a `Gtk.ListView`
    in the future. It's not possible to do so already due to the fact that
    we need to differentiate between "Favorites" and "Other Contacts", which
    needs extra API on Gtk.Listview side.

 data/contacts.gresource.xml         |   1 -
 data/ui/contacts-list-pane.ui       |  49 ------
 data/ui/contacts-main-window.ui     |  60 ++++++-
 data/ui/style.css                   |   2 +-
 po/POTFILES.in                      |   2 -
 po/POTFILES.skip                    |   1 -
 src/contacts-app.vala               |  67 ++++----
 src/contacts-avatar.vala            |  49 +++---
 src/contacts-contact-list.vala      | 325 +++++++++++-------------------------
 src/contacts-contact-pane.vala      |  37 ++--
 src/contacts-individual-sorter.vala |  71 ++++++++
 src/contacts-list-pane.vala         | 112 -------------
 src/contacts-main-window.vala       | 263 ++++++++++++++++++-----------
 src/contacts-query-filter.vala      |  64 +++++++
 src/contacts-setup-window.vala      |   2 +-
 src/contacts-store.vala             | 163 ++++++++++++++----
 src/meson.build                     |   3 +-
 17 files changed, 655 insertions(+), 616 deletions(-)
---
diff --git a/data/contacts.gresource.xml b/data/contacts.gresource.xml
index 0455ec70..533e3d0b 100644
--- a/data/contacts.gresource.xml
+++ b/data/contacts.gresource.xml
@@ -21,7 +21,6 @@
     <file compressed="true" preprocess="xml-stripblanks">ui/contacts-editor-menu.ui</file>
     <file compressed="true" preprocess="xml-stripblanks">ui/contacts-link-suggestion-grid.ui</file>
     <file compressed="true" preprocess="xml-stripblanks">ui/contacts-linked-personas-dialog.ui</file>
-    <file compressed="true" preprocess="xml-stripblanks">ui/contacts-list-pane.ui</file>
     <file compressed="true" preprocess="xml-stripblanks">ui/contacts-main-window.ui</file>
     <file compressed="true" preprocess="xml-stripblanks">ui/contacts-setup-window.ui</file>
   </gresource>
diff --git a/data/ui/contacts-main-window.ui b/data/ui/contacts-main-window.ui
index 41b99e89..deab856b 100644
--- a/data/ui/contacts-main-window.ui
+++ b/data/ui/contacts-main-window.ui
@@ -5,12 +5,12 @@
       <attribute name="label" translatable="yes">List Contacts By:</attribute>
       <item>
         <attribute name="label" translatable="yes">First Name</attribute>
-        <attribute name="action">window.sort-on</attribute>
+        <attribute name="action">win.sort-on</attribute>
         <attribute name="target">firstname</attribute>
       </item>
       <item>
         <attribute name="label" translatable="yes">Surname</attribute>
-        <attribute name="action">window.sort-on</attribute>
+        <attribute name="action">win.sort-on</attribute>
         <attribute name="target">surname</attribute>
       </item>
     </section>
@@ -52,7 +52,7 @@
         <child>
           <object class="GtkShortcut">
             <property name="trigger">&lt;Control&gt;n</property>
-            <property name="action">action(window.new-contact)</property>
+            <property name="action">action(win.new-contact)</property>
           </object>
         </child>
       </object>
@@ -81,7 +81,7 @@
                           <object class="GtkButton" id="add_button">
                             <property name="tooltip-text" translatable="yes">Create new contact</property>
                             <property name="icon-name">list-add-symbolic</property>
-                            <property name="action-name">window.new-contact</property>
+                            <property name="action-name">win.new-contact</property>
                           </object>
                         </child>
 
@@ -112,6 +112,7 @@
                     <child>
                       <object class="GtkStack" id="list_pane_stack">
                         <property name="hexpand">False</property>
+                        <!-- The loading spinner page -->
                         <child>
                           <object class="GtkBox">
                             <property name="orientation">vertical</property>
@@ -135,6 +136,49 @@
                             </child>
                           </object>
                         </child>
+                        <!-- The list pane with the actual contacts -->
+                        <child>
+                          <object class="GtkBox" id="list_pane">
+                            <property name="orientation">vertical</property>
+                            <child>
+                              <object class="GtkSearchEntry" id="filter_entry">
+                                <property name="placeholder-text" translatable="yes">Type to 
search</property>
+                                <signal name="search-changed" handler="filter_entry_changed"/>
+                                <style>
+                                  <class name="contacts-filter-entry"/>
+                                </style>
+                              </object>
+                            </child>
+                            <child>
+                              <object class="AdwBin" id="contacts_list_container">
+                                <property name="hexpand">True</property>
+                                <property name="vexpand">True</property>
+                              </object>
+                            </child>
+                            <child>
+                              <object class="GtkActionBar" id="actions_bar">
+                                <property name="revealed">False</property>
+                                <child>
+                                  <object class="GtkButton" id="link_button">
+                                    <property name="focus_on_click">False</property>
+                                    <property name="label" translatable="yes" comments="Link refers to the 
verb, from linking contacts together">Link</property>
+                                    <property name="action-name">win.link-marked-contacts</property>
+                                  </object>
+                                </child>
+                                <child type="end">
+                                  <object class="GtkButton" id="delete_button">
+                                    <property name="focus_on_click">False</property>
+                                    <property name="label" translatable="yes">Remove</property>
+                                    <property name="action-name">win.delete-marked-contacts</property>
+                                    <style>
+                                      <class name="destructive-action"/>
+                                    </style>
+                                  </object>
+                                </child>
+                              </object>
+                            </child>
+                          </object>
+                        </child>
                       </object>
                     </child>
                   </object>
@@ -185,6 +229,8 @@
                             <property name="label" translatable="yes">_Cancel</property>
                             <property name="use_underline">True</property>
                             <signal name="notify::visible" handler="on_cancel_visible" 
object="ContactsMainWindow" after="yes" swapped="no"/>
+                            <property name="action-name">win.stop-editing-contact</property>
+                            <property name="action-target">true</property>
                           </object>
                         </child>
                         <child type="end">
@@ -202,14 +248,14 @@
                             <child>
                               <object class="GtkButton" id="edit_contact_button">
                                 <property name="icon-name">document-edit-symbolic</property>
-                                <property name="action-name">window.edit-contact</property>
+                                <property name="action-name">win.edit-contact</property>
                                 <property name="tooltip-text" translatable="yes">Edit Contact</property>
                               </object>
                             </child>
                             <child>
                               <object class="GtkButton" id="delete_contact_button">
                                 <property name="icon-name">user-trash-symbolic</property>
-                                <property name="action-name">window.delete-contact</property>
+                                <property name="action-name">win.delete-contact</property>
                                 <property name="tooltip-text" translatable="yes">Delete Contact</property>
                               </object>
                             </child>
@@ -221,6 +267,8 @@
                             <property name="use_underline">True</property>
                             <property name="label" translatable="yes">Done</property>
                             <property name="valign">center</property>
+                            <property name="action-name">win.stop-editing-contact</property>
+                            <property name="action-target">false</property>
                             <style>
                               <class name="suggested-action"/>
                             </style>
diff --git a/data/ui/style.css b/data/ui/style.css
index c80cfb8d..6bb5f58e 100644
--- a/data/ui/style.css
+++ b/data/ui/style.css
@@ -13,7 +13,7 @@
 }
 
 /* Draw a little shadow about the contact list when scrolling */
-.contacts-list-scrolled-window undershoot.top {
+.contact-list-scrolled-window undershoot.top {
   box-shadow: inset 0 1px @borders;
 }
 
diff --git a/po/POTFILES.in b/po/POTFILES.in
index b012851b..da710551 100644
--- a/po/POTFILES.in
+++ b/po/POTFILES.in
@@ -10,7 +10,6 @@ data/ui/contacts-crop-dialog.ui
 data/ui/contacts-editor-menu.ui
 data/ui/contacts-link-suggestion-grid.ui
 data/ui/contacts-linked-personas-dialog.ui
-data/ui/contacts-list-pane.ui
 data/ui/contacts-main-window.ui
 data/ui/contacts-setup-window.ui
 src/contacts-accounts-list.vala
@@ -32,7 +31,6 @@ src/contacts-im-service.vala
 src/contacts-link-operation.vala
 src/contacts-link-suggestion-grid.vala
 src/contacts-linked-personas-dialog.vala
-src/contacts-list-pane.vala
 src/contacts-main-window.vala
 src/contacts-operation.vala
 src/contacts-settings.vala
diff --git a/po/POTFILES.skip b/po/POTFILES.skip
index 4f620d06..6347f419 100644
--- a/po/POTFILES.skip
+++ b/po/POTFILES.skip
@@ -21,7 +21,6 @@ src/contacts-im-service.c
 src/contacts-link-operation.c
 src/contacts-link-suggestion-grid.c
 src/contacts-linked-personas-dialog.c
-src/contacts-list-pane.c
 src/contacts-main-window.c
 src/contacts-operation.c
 src/contacts-settings.c
diff --git a/src/contacts-app.vala b/src/contacts-app.vala
index d241830f..767a6b64 100644
--- a/src/contacts-app.vala
+++ b/src/contacts-app.vala
@@ -18,6 +18,7 @@
 using Folks;
 
 public class Contacts.App : Adw.Application {
+
   private Settings settings;
 
   private Store contacts_store;
@@ -41,15 +42,25 @@ public class Contacts.App : Adw.Application {
     {}
   };
 
+  construct {
+    this.settings = new Settings (this);
+
+    string[] filtered_fields = Query.MATCH_FIELDS_NAMES;
+    foreach (unowned var field in Query.MATCH_FIELDS_ADDRESSES)
+      filtered_fields += field;
+    var query = new SimpleQuery ("", filtered_fields);
+
+    this.contacts_store = new Store (this.settings, query);
+
+    add_main_option_entries (options);
+  }
+
   public App () {
     Object (
       application_id: Config.APP_ID,
       resource_base_path: "/org/gnome/Contacts",
       flags: ApplicationFlags.HANDLES_COMMAND_LINE
     );
-
-    this.settings = new Settings (this);
-    add_main_option_entries (options);
   }
 
   public override int command_line (ApplicationCommandLine command_line) {
@@ -60,7 +71,7 @@ public class Contacts.App : Adw.Application {
     if ("individual" in options) {
       var individual = options.lookup_value ("individual", VariantType.STRING);
       if (individual != null)
-        show_individual.begin (individual.get_string ());
+        show_individual_for_id.begin (individual.get_string ());
     } else if ("email" in options) {
       var email = options.lookup_value ("email", VariantType.STRING);
       if (email != null)
@@ -83,29 +94,10 @@ public class Contacts.App : Adw.Application {
     return -1;
   }
 
-  public void show_contact (Individual? individual) {
-    this.window.set_shown_contact (individual);
-  }
-
-  public async void show_individual (string id) {
-    if (contacts_store.is_quiescent) {
-      show_individual_ready.begin (id);
-    } else {
-      contacts_store.quiescent.connect (() => {
-        show_individual_ready.begin (id);
-      });
-    }
-  }
-
-  private async void show_individual_ready (string id) {
-    Individual? contact = null;
-    try {
-      contact = yield contacts_store.aggregator.look_up_individual (id);
-    } catch (Error e) {
-      debug ("Couldn't look up individual");
-    }
-    if (contact != null) {
-      show_contact (contact);
+  public async void show_individual_for_id (string id) {
+    uint pos = yield this.contacts_store.find_individual_for_id (id);
+    if (pos != Gtk.INVALID_LIST_POSITION) {
+      this.contacts_store.selection.selected = pos;
     } else {
       var dialog = new Gtk.MessageDialog (this.window, Gtk.DialogFlags.DESTROY_WITH_PARENT,
                                           Gtk.MessageType.ERROR, Gtk.ButtonsType.CLOSE,
@@ -179,9 +171,9 @@ public class Contacts.App : Adw.Application {
 
   public async void show_by_email (string email_address) {
     var query = new SimpleQuery (email_address, { "email-addresses" });
-    Individual individual = yield contacts_store.find_contact (query);
-    if (individual != null) {
-      show_contact (individual);
+    uint pos = yield this.contacts_store.find_individual_for_query (query);
+    if (pos != Gtk.INVALID_LIST_POSITION) {
+      this.contacts_store.selection.selected = pos;
     } else {
       var dialog = new Gtk.MessageDialog (this.window, Gtk.DialogFlags.DESTROY_WITH_PARENT,
                                           Gtk.MessageType.ERROR, Gtk.ButtonsType.CLOSE,
@@ -193,10 +185,10 @@ public class Contacts.App : Adw.Application {
   }
 
   public void show_search (string query) {
-    if (contacts_store.is_quiescent) {
+    if (this.contacts_store.aggregator.is_quiescent) {
       this.window.show_search (query);
     } else {
-      contacts_store.quiescent.connect_after (() => {
+      this.contacts_store.quiescent.connect_after (() => {
         this.window.show_search (query);
       });
     }
@@ -239,7 +231,6 @@ public class Contacts.App : Adw.Application {
     if (!ensure_eds_accounts (true))
       quit ();
 
-    this.contacts_store = new Store ();
     base.startup ();
 
     load_styling ();
@@ -290,12 +281,12 @@ public class Contacts.App : Adw.Application {
     setup_window.show ();
   }
 
-  private void on_show_contact(SimpleAction action, Variant? param) {
-    activate();
+  private void on_show_contact (SimpleAction action, Variant? param) {
+    activate ();
 
-    var individual = param as string;
-    if (individual != null)
-      show_individual.begin (individual);
+    var individual_id = param as string;
+    if (individual_id != null)
+      show_individual_for_id.begin (individual_id);
   }
 
 }
diff --git a/src/contacts-avatar.vala b/src/contacts-avatar.vala
index 330fa8ed..bbd9dc77 100644
--- a/src/contacts-avatar.vala
+++ b/src/contacts-avatar.vala
@@ -23,15 +23,28 @@ using Folks;
  */
 public class Contacts.Avatar : Adw.Bin {
 
-  private unowned Individual? individual = null;
+  private unowned Individual? _individual = null;
+  public Individual? individual {
+    get { return this._individual; }
+    set {
+      if (this._individual == value)
+        return;
+
+      this._individual = value;
+      update_individual ();
+    }
+  }
 
   private int avatar_size;
-  private bool load_avatar_started = false;
 
   public Avatar (int size, Individual? individual = null) {
-    this.individual = individual;
+    this.child = new Adw.Avatar (size, "", false);
     this.avatar_size = size;
 
+    this.individual = individual;
+  }
+
+  private void update_individual () {
     string name = "";
     bool show_initials = false;
     if (this.individual != null) {
@@ -46,21 +59,17 @@ public class Contacts.Avatar : Adw.Bin {
       }
     }
 
-    this.child = new Adw.Avatar (size, name, show_initials);
+    ((Adw.Avatar) this.child).show_initials = show_initials;
+    ((Adw.Avatar) this.child).text = name;
 
-    // FIXME: ideally we lazy-load this only when we become visible for the
-    // first time
     this.load_avatar.begin ();
   }
 
-  public async void load_avatar() {
-    if (this.load_avatar_started)
-      return;
-
-    if (individual == null || individual.avatar == null)
+  public async void load_avatar () {
+    if (this.individual == null || this.individual.avatar == null) {
+      set_pixbuf (null);
       return;
-
-    this.load_avatar_started = true;
+    }
 
     try {
       var stream = yield this.individual.avatar.load_async (this.avatar_size,
@@ -75,22 +84,12 @@ public class Contacts.Avatar : Adw.Bin {
     }
   }
 
-  /**
-   * Forces a reload of the avatar (e.g. after a property change).
-   */
-  public async void reload () {
-    this.load_avatar_started = false;
-    yield this.load_avatar ();
-  }
-
   /**
    * Manually set the avatar to the given pixbuf, even if the contact has an avatar.
    */
   public void set_pixbuf (Gdk.Pixbuf? a_pixbuf) {
-    if (a_pixbuf != null)
-      ((Adw.Avatar) this.child).set_custom_image (Gdk.Texture.for_pixbuf (a_pixbuf));
-    else
-      ((Adw.Avatar) this.child).set_icon_name ("avatar-default-symbolic");
+    var img = (a_pixbuf != null)? Gdk.Texture.for_pixbuf (a_pixbuf) : null;
+    ((Adw.Avatar) this.child).set_custom_image (img);
   }
 
   /* Find a nice name to generate the label and color for the fallback avatar
diff --git a/src/contacts-contact-list.vala b/src/contacts-contact-list.vala
index 6517a0ae..730fd549 100644
--- a/src/contacts-contact-list.vala
+++ b/src/contacts-contact-list.vala
@@ -18,61 +18,39 @@
 using Folks;
 
 /**
- * The ContactList is the actual list of {@link Individual}s that the user sees on
- * the left. It is contained by the {@link ListPane}, which also provides other
- * functionality, such as an action bar.
+ * The ContactList is the widget that diplays the list of contacts
+ * ({@link Individual}s) that the user sees on the left. It is contained by the
+ * {@link ListPane}, which also provides other functionality, such as an action
+ * bar.
+ *
+ * On top of the list models, we have a {@link Gtk.SelectionModel} which keeps
+ * track of the contacts that were selected.
  */
 public class Contacts.ContactList : Adw.Bin {
 
-  int nr_contacts_marked = 0;
-
-  private Query filter_query;
-
-  private Store store;
-
-  private bool sort_on_surname = false; // keep in sync with the setting
+  public Store store { get; construct; }
 
-  private bool got_long_press = false;
+  public Gtk.MultiSelection marked_contacts { get; construct; }
 
   public UiState state { get; set; }
 
   private unowned Gtk.ListBox listbox;
 
-  // The vertical adjustment of the scrolled window
-  private unowned Gtk.Adjustment vadjustment;
-
-  public signal void selection_changed (Individual? individual);
-  public signal void contacts_marked (int contacts_marked);
-
   construct {
-    // First construct a ScrolledWindow with a Viewport
-    var sw = new Gtk.ScrolledWindow ();
-    sw.hscrollbar_policy = Gtk.PolicyType.NEVER;
-    sw.add_css_class ("contact-list-scrolled-window");
-    this.vadjustment = sw.vadjustment;
-    sw.vadjustment.value_changed.connect ((vadj) => { this.load_visible_avatars (); });
-    this.child = sw;
+    this.add_css_class ("contacts-contact-list");
 
-    var viewport = new Gtk.Viewport (sw.hadjustment, sw.vadjustment);
-    viewport.scroll_to_focus = true;
-    sw.set_child (viewport);
+    // Our selection model for marked contacts (used in selection mode)
+    this.marked_contacts.selection_changed.connect (on_marked_contacts_changed);
 
-    // Then create the listbox
     var list_box = new Gtk.ListBox ();
     this.listbox = list_box;
-    viewport.set_child (list_box);
-
+    this.listbox.bind_model (this.store.filter_model, create_row_for_item);
     this.listbox.selection_mode = Gtk.SelectionMode.BROWSE;
-    this.listbox.set_sort_func (compare_rows);
-    this.listbox.set_filter_func (filter_row);
     this.listbox.set_header_func (update_header);
     this.listbox.add_css_class ("navigation-sidebar");
 
-    this.add_css_class ("contacts-contact-list");
-
-    // Row selection/activation
-    this.listbox.row_activated.connect (on_row_activated);
     this.listbox.row_selected.connect (on_row_selected);
+    this.listbox.row_activated.connect (on_row_activated);
 
     // Connect events right-click and long-press
     var secondary_click_gesture = new Gtk.GestureClick ();
@@ -83,72 +61,41 @@ public class Contacts.ContactList : Adw.Bin {
     var long_press_gesture = new Gtk.GestureLongPress ();
     long_press_gesture.pressed.connect (on_long_press);
     this.listbox.add_controller (long_press_gesture);
-  }
-
-  public ContactList (Settings settings,
-                      Store    store,
-                      Query    query) {
-    this.store = store;
-    this.filter_query = query;
-    this.filter_query.notify.connect (() => { this.listbox.invalidate_filter ();
-                                      });
-
-    this.notify["state"].connect (on_ui_state_changed);
 
-    this.sort_on_surname = settings.sort_on_surname;
-    settings.changed["sort-on-surname"].connect (() => {
-      this.sort_on_surname = settings.sort_on_surname;
-      this.listbox.invalidate_sort ();
-    });
+    // Construct our widget tree (just a scrolledwindow + vp with a listbox)
+    var sw = new Gtk.ScrolledWindow ();
+    sw.hscrollbar_policy = Gtk.PolicyType.NEVER;
+    sw.add_css_class ("contact-list-scrolled-window");
+    this.child = sw;
 
-    this.store.added.connect (contact_added_cb);
-    this.store.removed.connect (contact_removed_cb);
-    foreach (var i in this.store.get_contacts ())
-      contact_added_cb (this.store, i);
+    var viewport = new Gtk.Viewport (sw.hadjustment, sw.vadjustment);
+    viewport.scroll_to_focus = true;
+    viewport.set_child (this.listbox);
+    sw.set_child (viewport);
   }
 
-  private void on_ui_state_changed (Object obj, ParamSpec pspec) {
-    for (int i = 0; true; i++) {
-      unowned var row = (ContactDataRow) this.listbox.get_row_at_index (i);
-      if (row == null)
-        break;
-
-      row.selector_button.visible = (this.state == UiState.SELECTING);
-
-      if (this.state != UiState.SELECTING)
-        row.selector_button.active = false;
-    }
-
-    // Disalbe highlighted (blue) selection since we use the checkbox to show selection
-    if (this.state == UiState.SELECTING) {
-      this.listbox.selection_mode = Gtk.SelectionMode.NONE;
-    } else {
-      this.listbox.selection_mode = Gtk.SelectionMode.BROWSE;
-      this.nr_contacts_marked = 0;
-    }
+  public ContactList (Store store, Gtk.MultiSelection marked_contacts) {
+    Object (store: store, marked_contacts: marked_contacts);
   }
 
-  private int compare_rows (Gtk.ListBoxRow row_a, Gtk.ListBoxRow row_b) {
-    unowned var a = ((ContactDataRow) row_a).individual;
-    unowned var b = ((ContactDataRow) row_b).individual;
+  private Gtk.Widget create_row_for_item (GLib.Object item) {
+    unowned var individual = (Individual) item;
 
-    // Always prefer favourites over non-favourites.
-    if (a.is_favourite != b.is_favourite)
-      return a.is_favourite? -1 : 1;
-
-    // Both are (non-)favourites: sort by either first name or surname (user preference)
-    unowned var a_name = this.sort_on_surname? try_get_surname (a) : a.display_name;
-    unowned var b_name = this.sort_on_surname? try_get_surname (b) : b.display_name;
+    var row = new ContactDataRow (individual, this.marked_contacts);
+    this.notify["state"].connect ((obj, pspec) => {
+      row.selection_mode = (this.state == UiState.SELECTING);
+    });
 
-    return a_name.collate (b_name);
+    return row;
   }
 
-  private unowned string try_get_surname (Individual indiv) {
-    if (indiv.structured_name != null && indiv.structured_name.family_name != "")
-      return indiv.structured_name.family_name;
-
-    // Fall back to the display_name
-    return indiv.display_name;
+  private void on_marked_contacts_changed (Gtk.SelectionModel marked_contacts,
+                                           uint position,
+                                           uint n_changed) {
+    for (uint i = position; i < position + n_changed; i++) {
+      unowned var row = (ContactDataRow) this.listbox.get_row_at_index ((int) i);
+      row.marked = marked_contacts.is_selected (i);
+    }
   }
 
   private void update_header (Gtk.ListBoxRow row, Gtk.ListBoxRow? before) {
@@ -182,76 +129,21 @@ public class Contacts.ContactList : Adw.Bin {
     return label;
   }
 
-  private void contact_added_cb (Store store, Individual i) {
-    // Don't create a row for ignorable contacts are the individual already has a row
-    if (!Contacts.Utils.is_ignorable (i) && find_row_for_contact(i) == null) {
-      var row =  new ContactDataRow (i);
-      row.selector_button.toggled.connect (() => { on_row_checkbox_toggled (row); });
-      row.selector_button.visible = (this.state == UiState.SELECTING);
-
-      this.listbox.append (row);
-    } else {
-      debug ("Contact %s was ignored", i.id);
-    }
-  }
-
-  private void on_row_checkbox_toggled (ContactDataRow row) {
-    this.nr_contacts_marked += (row.selector_button.active)? 1 : -1;
-
-    // User selected a first checkbox: enter selection mode
-    if (row.selector_button.active && this.nr_contacts_marked == 1)
-      this.state = UiState.SELECTING;
-
-    contacts_marked (this.nr_contacts_marked);
-  }
-
-  private void contact_removed_cb (Store store, Individual i) {
-    var row = find_row_for_contact (i);
-    if (row != null)
-      row.destroy ();
-  }
-
   private void on_row_activated (Gtk.ListBox listbox, Gtk.ListBoxRow row) {
-    if (!this.got_long_press) {
-      unowned var data = row as ContactDataRow;
-      if (data != null && this.state == UiState.SELECTING)
-        data.selector_button.active = !data.selector_button.active;
-    } else {
-      this.got_long_press = false;
+    if (this.state == UiState.SELECTING) {
+      unowned var c_row = (ContactDataRow) row;
+      c_row.marked = !c_row.marked;
     }
   }
 
   private void on_row_selected (Gtk.ListBox listbox, Gtk.ListBoxRow? row) {
     if (this.state != UiState.SELECTING) {
-      unowned var data = (ContactDataRow?) row;
-      unowned var individual = data != null? data.individual : null;
-      selection_changed (individual);
-#if HAVE_TELEPATHY
-      if (individual != null)
-        Contacts.Utils.fetch_contact_info (individual);
-#endif
-    }
-  }
-
-  private bool filter_row (Gtk.ListBoxRow row) {
-    unowned var individual = ((ContactDataRow) row).individual;
-    return this.filter_query.is_match (individual) > 0;
-  }
-
-  public void select_contact (Individual? individual) {
-    if (individual == null) {
-      /* deselect */
-      this.listbox.select_row (null);
-      return;
+      if (row == null) {
+        this.store.selection.unselect_all ();
+      } else {
+        this.store.selection.select_item (row.get_index (), true);
+      }
     }
-
-    unowned var row = find_row_for_contact (individual);
-    this.listbox.select_row (row);
-    scroll_to_contact (row);
-  }
-
-  private void load_visible_avatars () {
-    // FIXME: use the vadjustment to load only the avatars of the visible rows
   }
 
   public void scroll_to_contact (Gtk.ListBoxRow? row = null) {
@@ -268,109 +160,90 @@ public class Contacts.ContactList : Adw.Bin {
     });
   }
 
-  public void set_contact_visible (Individual? individual, bool visible) {
-    if (individual != null) {
-      find_row_for_contact (individual).visible = visible;
-    }
-  }
-
-  private unowned ContactDataRow? find_row_for_contact (Individual individual) {
-    for (int i = 0; true; i++) {
-      unowned var row = (ContactDataRow) this.listbox.get_row_at_index (i);
-      if (row == null)
-        break;
-
-      if (row.individual == individual)
-        return row;
-    }
-
-    return null;
-  }
-
-  public Gee.LinkedList<Individual> get_marked_contacts () {
-    var cs = new Gee.LinkedList<Individual> ();
-
-    for (int i = 0; true; i++) {
-      unowned var row = (ContactDataRow) this.listbox.get_row_at_index (i);
-      if (row == null)
-        break;
-
-      if (row.selector_button.active)
-        cs.add (row.individual);
-    }
-
-    return cs;
-  }
-
-  public Gee.LinkedList<Individual> get_marked_contacts_and_hide () {
-    var cs = new Gee.LinkedList<Individual> ();
-
-    for (int i = 0; true; i++) {
-      unowned var row = (ContactDataRow) this.listbox.get_row_at_index (i);
-      if (row == null)
-        break;
+  public void set_contacts_visible (Gtk.Bitset selection, bool visible) {
+    var iter = Gtk.BitsetIter ();
+    uint index;
+    if (!iter.init_first (selection, out index))
+      return;
 
-      if (row.selector_button.active) {
-        row.visible = false;
-        cs.add (row.individual);
-      }
-    }
-    return cs;
+    do {
+      this.listbox.get_row_at_index ((int) index).visible = visible;
+    } while (iter.next (out index));
   }
 
   private void on_right_click (Gtk.GestureClick gesture, int n_press, double x, double y) {
-    unowned var row = (ContactDataRow) this.listbox.get_row_at_y ((int) Math.round (y));
-    if (row != null) {
-      row.selector_button.active = this.state != UiState.SELECTING || !row.selector_button.active;
-    }
+    unowned var row = this.listbox.get_row_at_y ((int) Math.round (y));
+    if (row == null)
+      return;
+
+    this.state = UiState.SELECTING;
+    row.activate ();
   }
 
   private void on_long_press (Gtk.GestureLongPress gesture, double x, double y) {
-    this.got_long_press = true;
-    unowned var row = (ContactDataRow) this.listbox.get_row_at_y ((int) Math.round (y));
-    if (row != null) {
-      row.selector_button.active = this.state != UiState.SELECTING || !row.selector_button.active;
-    }
+    unowned var row = this.listbox.get_row_at_y ((int) Math.round (y));
+    if (row == null)
+      return;
+
+    this.state = UiState.SELECTING;
+    row.activate ();
   }
 
-  // A class for the ListBoxRows
+  /**
+   * A widget that shows a small summary for a contact.
+   */
   private class ContactDataRow : Gtk.ListBoxRow {
     private const int LIST_AVATAR_SIZE = 48;
 
     public Individual individual { get; construct; }
 
-    private unowned Gtk.Label label;
+    private unowned Gtk.Label name_label;
     private unowned Avatar avatar;
     public unowned Gtk.CheckButton selector_button;
 
-    public ContactDataRow (Individual i) {
-      Object (individual: i);
-      this.individual.notify.connect (on_contact_changed);
+    public bool selection_mode {
+      get { return this.selector_button.visible; }
+      set { this.selector_button.visible = value; }
+    }
+
+    private unowned Gtk.SelectionModel marked_contacts;
+    public bool marked {
+      get { return this.marked_contacts.is_selected (this.get_index ()); }
+      set {
+        if (value)
+          this.marked_contacts.select_item (this.get_index (), false);
+        else
+          this.marked_contacts.unselect_item (this.get_index ());
+        notify_property ("marked");
+      }
+    }
 
+    construct {
       add_css_class ("contact-data-row");
 
       var box = new Gtk.Box (HORIZONTAL, 12);
       box.margin_top = 6;
       box.margin_bottom = 6;
 
-      var avatar = new Avatar (LIST_AVATAR_SIZE, this.individual);
+      var avatar = new Avatar (LIST_AVATAR_SIZE);
       box.append (avatar);
       this.avatar = avatar;
 
-      var label = new Gtk.Label (individual.display_name);
+      var label = new Gtk.Label ("");
       label.ellipsize = Pango.EllipsizeMode.END;
       label.valign = Gtk.Align.CENTER;
       label.halign = Gtk.Align.START;
       // Make sure it doesn't "twitch" when the checkbox becomes visible
       label.xalign = 0;
       box.append (label);
-      this.label = label;
+      this.name_label = label;
 
       var selector_button = new Gtk.CheckButton ();
       selector_button.visible = false;
       selector_button.valign = Gtk.Align.CENTER;
       selector_button.halign = Gtk.Align.END;
       selector_button.hexpand = true;
+      bind_property ("marked", selector_button, "active", BindingFlags.BIDIRECTIONAL);
       selector_button.add_css_class ("selection-mode");
       // Make sure it doesn't overlap with the scrollbar
       selector_button.margin_end = 12;
@@ -380,12 +253,18 @@ public class Contacts.ContactList : Adw.Bin {
       this.set_child (box);
     }
 
+    public ContactDataRow (Individual individual, Gtk.SelectionModel marked_contacts) {
+      Object (individual: individual);
+
+      this.marked_contacts = marked_contacts;
+      this.name_label.set_text (individual.display_name);
+      this.avatar.individual = individual;
+      individual.notify.connect (on_contact_changed);
+    }
+
     private void on_contact_changed (Object obj, ParamSpec pspec) {
-      if (pspec.get_name () == "avatar") {
-        this.avatar.reload.begin ();
-      }
       // Always update the label, since it can depend on a lot of properties
-      this.label.set_text (this.individual.display_name);
+      this.name_label.set_text (this.individual.display_name);
       changed ();
     }
   }
diff --git a/src/contacts-contact-pane.vala b/src/contacts-contact-pane.vala
index 97262161..258104e2 100644
--- a/src/contacts-contact-pane.vala
+++ b/src/contacts-contact-pane.vala
@@ -28,11 +28,9 @@ const int PROFILE_SIZE = 128;
 [GtkTemplate (ui = "/org/gnome/Contacts/ui/contacts-contact-pane.ui")]
 public class Contacts.ContactPane : Adw.Bin {
 
-  private MainWindow main_window;
+  private unowned Store store;
 
-  private Store store;
-
-  public Individual? individual { get; set; default = null; }
+  private Individual? individual = null;
 
   [GtkChild]
   private unowned Gtk.Stack stack;
@@ -48,16 +46,9 @@ public class Contacts.ContactPane : Adw.Bin {
   public bool on_edit_mode = false;
   private LinkSuggestionGrid? suggestion_grid = null;
 
-  /* Signals */
   public signal void contacts_linked (string? main_contact, string linked_contact, LinkOperation operation);
-  /**
-   * Passes the changed display name to all listeners after edit mode has been completed.
-   */
-  public signal void display_name_changed (string new_display_name);
-
 
   public ContactPane (MainWindow main_window, Store contacts_store) {
-    this.main_window = main_window;
     this.store = contacts_store;
   }
 
@@ -239,25 +230,27 @@ public class Contacts.ContactPane : Adw.Bin {
       persona = yield primary_store.add_persona_from_details (details);
     } catch (Error e) {
       show_message_dialog (_("Unable to create new contacts: %s").printf (e.message));
-      this.main_window.set_shown_contact (null);
+      this.store.selection.unselect_all ();
       return;
     }
 
-    // Now show the real persona to the user
-    var individual = persona.individual;
-
-    if (individual != null) {
-      //FIXME: This causes a flicker, especially visible when an avatar is set
-      this.main_window.set_shown_contact (individual);
-    } else {
-      show_message_dialog (_("Unable to find newly created contact"));
-      this.main_window.set_shown_contact (null);
+    // Now show the real persona to the user (if we can find it)
+    for (uint i = 0; i < this.store.filter_model.get_n_items (); i++) {
+      if (persona.individual == this.store.filter_model.get_item (i)) {
+        // FIXME: This causes a flicker, especially visible when an avatar is set
+        this.store.selection.selected = i;
+        return;
+      }
     }
+
+    // If we got here, we couldn't find the individual
+    show_message_dialog (_("Unable to find newly created contact"));
+    this.store.selection.unselect_all ();
   }
 
   private void show_message_dialog (string message) {
     var dialog =
-      new Gtk.MessageDialog (this.main_window,
+      new Gtk.MessageDialog (this.get_root () as Gtk.Window,
                              Gtk.DialogFlags.DESTROY_WITH_PARENT | Gtk.DialogFlags.MODAL,
                              Gtk.MessageType.ERROR,
                              Gtk.ButtonsType.OK,
diff --git a/src/contacts-individual-sorter.vala b/src/contacts-individual-sorter.vala
new file mode 100644
index 00000000..ea503f26
--- /dev/null
+++ b/src/contacts-individual-sorter.vala
@@ -0,0 +1,71 @@
+/*
+ * Copyright (C) 2021 Niels De Graef <nielsdegraef gmail com>
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+using Folks;
+
+/**
+ * A subclass of {@link Gtk.Sorter} which sorts {@link Folks.Individual}s.
+ */
+public class Contacts.IndividualSorter : Gtk.Sorter {
+
+  private bool sort_on_surname = false;
+
+  public IndividualSorter (GLib.Settings settings) {
+    this.sort_on_surname = settings.get_boolean ("sort-on-surname");
+    settings.changed["sort-on-surname"].connect (() => {
+      this.sort_on_surname = settings.get_boolean ("sort-on-surname");
+      this.changed (Gtk.SorterChange.DIFFERENT);
+    });
+  }
+
+  public override Gtk.Ordering compare (Object? item1, Object? item2) {
+    unowned var a = item1 as Individual;
+    if (a == null)
+      return Gtk.Ordering.SMALLER;
+
+    unowned var b = item2 as Individual;
+    if (b == null)
+      return Gtk.Ordering.LARGER;
+
+    // Always prefer favourites over non-favourites.
+    if (a.is_favourite != b.is_favourite)
+      return a.is_favourite? Gtk.Ordering.SMALLER : Gtk.Ordering.LARGER;
+
+    // Both are (non-)favourites: sort by either first name or surname (user preference)
+    unowned var a_name = this.sort_on_surname? try_get_surname (a) : a.display_name;
+    unowned var b_name = this.sort_on_surname? try_get_surname (b) : b.display_name;
+
+    int names_cmp = a_name.collate (b_name);
+    if (names_cmp != 0)
+      return Gtk.Ordering.from_cmpfunc (names_cmp);
+
+    // Since we want total ordering, compare uuids as a last resort
+    return Gtk.Ordering.from_cmpfunc (strcmp (a.id, b.id));
+  }
+
+  private unowned string try_get_surname (Individual indiv) {
+    if (indiv.structured_name != null && indiv.structured_name.family_name != "")
+      return indiv.structured_name.family_name;
+
+    // Fall back to the display_name
+    return indiv.display_name;
+  }
+
+  public override Gtk.SorterOrder get_order () {
+    return Gtk.SorterOrder.TOTAL;
+  }
+}
diff --git a/src/contacts-main-window.vala b/src/contacts-main-window.vala
index 4750f538..89cbe3f3 100644
--- a/src/contacts-main-window.vala
+++ b/src/contacts-main-window.vala
@@ -24,6 +24,9 @@ public class Contacts.MainWindow : Adw.ApplicationWindow {
   private const GLib.ActionEntry[] ACTION_ENTRIES = {
     { "new-contact", new_contact },
     { "edit-contact", edit_contact },
+    { "stop-editing-contact", stop_editing_contact, "b" },
+    { "link-marked-contacts", link_marked_contacts },
+    { "delete-marked-contacts", delete_marked_contacts },
     // { "share-contact", share_contact },
     { "unlink-contact", unlink_contact },
     { "delete-contact", delete_contact },
@@ -42,8 +45,17 @@ public class Contacts.MainWindow : Adw.ApplicationWindow {
   private unowned Gtk.Overlay contact_pane_container;
   [GtkChild]
   private unowned Gtk.Box list_pane_page;
+  [GtkChild]
+  private unowned Gtk.Widget list_pane;
+  [GtkChild]
+  public unowned Gtk.SearchEntry filter_entry;
+  [GtkChild]
+  private unowned Adw.Bin contacts_list_container;
+  private unowned ContactList contacts_list;
+
   [GtkChild]
   private unowned Gtk.Box contact_pane_page;
+  private ContactPane contact_pane;
   [GtkChild]
   private unowned Adw.HeaderBar left_header;
   [GtkChild]
@@ -68,12 +80,9 @@ public class Contacts.MainWindow : Adw.ApplicationWindow {
   [GtkChild]
   private unowned Gtk.Button selection_button;
 
-  // The 2 panes the window consists of
-  private ListPane list_pane;
-  private ContactPane contact_pane;
+  [GtkChild]
+  private unowned Gtk.ActionBar actions_bar;
 
-  // Actions
-  private SimpleActionGroup actions = new SimpleActionGroup ();
   private bool delete_cancelled;
 
   public UiState state { get; set; default = UiState.NORMAL; }
@@ -84,19 +93,28 @@ public class Contacts.MainWindow : Adw.ApplicationWindow {
 
   public Settings settings { get; construct set; }
 
-  public Store store {
-    get; construct set;
-  }
+  public Store store { get; construct set; }
+
+  // A separate SelectionModel for all marked contacts
+  private Gtk.MultiSelection marked_contacts;
 
   // If an unduable operation was recently performed, this will be set
   public Operation? last_operation = null;
 
   construct {
-    this.actions.add_action_entries (ACTION_ENTRIES, this);
-    this.insert_action_group ("window", this.actions);
+    add_action_entries (ACTION_ENTRIES, this);
+
+    this.store.selection.notify["selected-item"].connect (on_selection_changed);
+
+    this.marked_contacts = new Gtk.MultiSelection (this.store.filter_model);
+    this.marked_contacts.selection_changed.connect (on_marked_contacts_changed);
+    this.marked_contacts.unselect_all (); // Call here to sync actions
+
+    this.filter_entry.set_key_capture_widget (this);
 
     this.notify["state"].connect (on_ui_state_changed);
 
+    this.create_list_pane ();
     this.create_contact_pane ();
     this.connect_button_signals ();
     this.restore_window_state ();
@@ -113,7 +131,7 @@ public class Contacts.MainWindow : Adw.ApplicationWindow {
     );
 
     unowned var sort_key = this.settings.sort_on_surname? "surname" : "firstname";
-    var sort_action = (SimpleAction) this.actions.lookup_action ("sort-on");
+    var sort_action = (SimpleAction) this.lookup_action ("sort-on");
     sort_action.set_state (new Variant.string (sort_key));
   }
 
@@ -125,6 +143,13 @@ public class Contacts.MainWindow : Adw.ApplicationWindow {
     this.fullscreened = this.settings.window_fullscreen;
   }
 
+  private void create_list_pane () {
+    var contactslist = new ContactList (this.store, this.marked_contacts);
+    bind_property ("state", contactslist, "state", BindingFlags.BIDIRECTIONAL | BindingFlags.SYNC_CREATE);
+    this.contacts_list = contactslist;
+    this.contacts_list_container.set_child (contactslist);
+  }
+
   private void create_contact_pane () {
     this.contact_pane = new ContactPane (this, this.store);
     this.contact_pane.visible = true;
@@ -141,28 +166,27 @@ public class Contacts.MainWindow : Adw.ApplicationWindow {
   public void show_contact_list () {
     // FIXME: if no contact is loaded per backend, I must place a sign
     // saying "import your contacts/add online account"
-    if (this.list_pane != null)
-      return;
+    this.list_pane_stack.visible_child = this.list_pane;
+  }
 
-    this.list_pane = new ListPane (this, this.settings, store);
-    bind_property ("state", this.list_pane, "state", BindingFlags.BIDIRECTIONAL | BindingFlags.SYNC_CREATE);
-    this.list_pane.selection_changed.connect (list_pane_selection_changed_cb);
-    this.list_pane.link_contacts.connect (list_pane_link_contacts_cb);
-    this.list_pane.delete_contacts.connect (delete_contacts);
-
-    this.list_pane.contacts_marked.connect ((nr_contacts) => {
-      string left_title = _("Contacts");
-      if (this.state == UiState.SELECTING)
-        left_title = ngettext ("%d Selected", "%d Selected", nr_contacts)
-                                     .printf (nr_contacts);
-      this.left_header.title_widget = new Adw.WindowTitle (left_title, "");
-    });
+  private void on_marked_contacts_changed (Gtk.SelectionModel marked,
+                                           uint position,
+                                           uint n_changed) {
+    var n_selected = marked.get_selection ().get_size ();
 
-    this.list_pane_stack.add_child (this.list_pane);
-    this.list_pane_stack.visible_child = this.list_pane;
+    // Update related actions
+    unowned var action = lookup_action ("delete-marked-contacts");
+    ((SimpleAction) action).set_enabled (n_selected > 0);
 
-    if (this.contact_pane.individual != null)
-      this.list_pane.select_contact (this.contact_pane.individual);
+    action = lookup_action ("link-marked-contacts");
+    ((SimpleAction) action).set_enabled (n_selected > 1);
+
+    string left_title = _("Contacts");
+    if (this.state == UiState.SELECTING) {
+      left_title = ngettext ("%llu Selected", "%llu Selected", (ulong) n_selected)
+                                   .printf (n_selected);
+    }
+    this.left_header.title_widget = new Adw.WindowTitle (left_title, "");
   }
 
   private void on_ui_state_changed (Object obj, ParamSpec pspec) {
@@ -199,6 +223,13 @@ public class Contacts.MainWindow : Adw.ApplicationWindow {
     this.content_box.can_navigate_back = this.state == UiState.NORMAL ||
                                          this.state == UiState.SHOWING ||
                                          this.state == UiState.SELECTING;
+
+    // Disable when editing a contact
+    this.filter_entry.sensitive
+        = this.contacts_list.sensitive
+        = !this.state.editing ();
+
+    this.actions_bar.revealed = (this.state == UiState.SELECTING);
   }
 
   [GtkCallback]
@@ -207,13 +238,12 @@ public class Contacts.MainWindow : Adw.ApplicationWindow {
   }
 
   private void edit_contact (GLib.SimpleAction action, GLib.Variant? parameter) {
-    if (this.contact_pane.individual == null)
-      return;
+    unowned var selected = this.store.get_selected_contact ();
+    return_if_fail (selected != null);
 
     this.state = UiState.UPDATING;
 
-    unowned var name = this.contact_pane.individual.display_name;
-    var title = _("Editing %s").printf (name);
+    var title = _("Editing %s").printf (selected.display_name);
     this.right_header.title_widget = new Adw.WindowTitle (title, "");
     this.contact_pane.edit_contact ();
   }
@@ -223,11 +253,11 @@ public class Contacts.MainWindow : Adw.ApplicationWindow {
     // Don't change the contact being favorite while switching between the two of them
     if (this.ignore_favorite_button_toggled)
       return;
-    if (this.contact_pane.individual == null)
-      return;
 
-    var is_fav = this.contact_pane.individual.is_favourite;
-    this.contact_pane.individual.is_favourite = !is_fav;
+    unowned var selected = this.store.get_selected_contact ();
+    return_if_fail (selected != null);
+
+    selected.is_favourite = !selected.is_favourite;
   }
 
   [GtkCallback]
@@ -238,14 +268,13 @@ public class Contacts.MainWindow : Adw.ApplicationWindow {
   }
 
   private void unlink_contact (GLib.SimpleAction action, GLib.Variant? parameter) {
-    unowned var individual = this.contact_pane.individual;
-    if (individual == null)
-      return;
+    unowned Individual? selected = this.store.get_selected_contact ();
+    return_if_fail (selected != null);
 
-    set_shown_contact (null);
+    this.store.selection.unselect_all ();
     this.state = UiState.NORMAL;
 
-    this.last_operation = new UnlinkOperation (this.store, individual);
+    this.last_operation = new UnlinkOperation (this.store, selected);
     this.last_operation.execute.begin ((obj, res) => {
       try {
         this.last_operation.execute.end (res);
@@ -262,12 +291,12 @@ public class Contacts.MainWindow : Adw.ApplicationWindow {
   }
 
   private void delete_contact (GLib.SimpleAction action, GLib.Variant? parameter) {
-    var individual = this.contact_pane.individual;
-    if (individual == null)
+    var selection = this.store.selection.get_selection ();
+    if (selection.is_empty ())
       return;
 
-    this.list_pane.set_contact_visible (individual, false);
-    delete_contacts (new Gee.ArrayList<Individual>.wrap ({ individual }));
+    this.contacts_list.set_contacts_visible (selection, false);
+    delete_contacts (selection);
   }
 
   private void sort_on_changed (SimpleAction action, GLib.Variant? new_state) {
@@ -297,7 +326,9 @@ public class Contacts.MainWindow : Adw.ApplicationWindow {
     this.delete_cancelled = true;
   }
 
-  private void stop_editing (bool cancel = false) {
+  private void stop_editing_contact (SimpleAction action, GLib.Variant? parameter) {
+    bool cancel = parameter.get_boolean ();
+
     if (this.state == UiState.CREATING) {
       if (cancel) {
         show_list_pane ();
@@ -308,36 +339,16 @@ public class Contacts.MainWindow : Adw.ApplicationWindow {
       this.state = UiState.SHOWING;
     }
     this.contact_pane.stop_editing (cancel);
-    this.list_pane.scroll_to_contact ();
+    this.contacts_list.scroll_to_contact ();
 
     this.right_header.title_widget = new Adw.WindowTitle ("", "");
   }
 
-  public void set_shown_contact (Individual? i) {
-    /* FIXME: ask the user to leave edit-mode and act accordingly */
-    if (this.contact_pane.on_edit_mode)
-      stop_editing ();
-
-    this.contact_pane.show_contact (i);
-    if (list_pane != null)
-      list_pane.select_contact (i);
-
-    // clearing right_header
-    this.right_header.title_widget = new Adw.WindowTitle ("", "");
-    if (i != null) {
-      this.ignore_favorite_button_toggled = true;
-      this.favorite_button.active = i.is_favourite;
-      this.ignore_favorite_button_toggled = false;
-      this.favorite_button.tooltip_text = (i.is_favourite)? _("Unmark as favorite")
-                                                          : _("Mark as favorite");
-    }
-  }
-
   public void new_contact (GLib.SimpleAction action, GLib.Variant? parameter) {
     if (this.state == UiState.UPDATING || this.state == UiState.CREATING)
       return;
 
-    this.list_pane.select_contact (null);
+    this.store.selection.unselect_all ();
 
     this.state = UiState.CREATING;
 
@@ -361,7 +372,7 @@ public class Contacts.MainWindow : Adw.ApplicationWindow {
   private void on_child_transition_running () {
     if (!this.content_box.child_transition_running &&
          this.content_box.visible_child == this.list_pane_page)
-      this.list_pane.select_contact (null);
+      this.store.selection.unselect_all ();
   }
 
   private void update_header () {
@@ -383,28 +394,18 @@ public class Contacts.MainWindow : Adw.ApplicationWindow {
   }
 
   public void show_search (string query) {
-    this.list_pane.filter_entry.set_text (query);
+    this.filter_entry.set_text (query);
   }
 
   private void connect_button_signals () {
     this.select_cancel_button.clicked.connect (() => {
-        if (this.contact_pane.individual != null) {
+        this.marked_contacts.unselect_all ();
+        if (this.store.selection.get_selected () != Gtk.INVALID_LIST_POSITION) {
             this.state = UiState.SHOWING;
         } else {
             this.state = UiState.NORMAL;
         }
     });
-    this.done_button.clicked.connect (() => stop_editing ());
-    this.cancel_button.clicked.connect (() => stop_editing (true));
-
-    this.contact_pane.notify["individual"].connect (() => {
-      unowned var individual = this.contact_pane.individual;
-      if (individual == null)
-        return;
-
-      var unlink_action = this.actions.lookup_action ("unlink-contact");
-      ((SimpleAction) unlink_action).set_enabled (individual.personas.size > 1);
-    });
   }
 
   public override bool close_request () {
@@ -419,20 +420,55 @@ public class Contacts.MainWindow : Adw.ApplicationWindow {
     return base.close_request ();
   }
 
-  private void list_pane_selection_changed_cb (Individual? new_selection) {
-    set_shown_contact (new_selection);
-    if (this.state != UiState.SELECTING)
-      this.state = UiState.SHOWING;
+  private void on_selection_changed (Object object, ParamSpec pspec) {
+    unowned var selected = this.store.get_selected_contact ();
 
-    if (new_selection != null)
-      show_contact_pane ();
+    // Update related actions
+    unowned var unlink_action = lookup_action ("unlink-contact");
+    ((SimpleAction) unlink_action).set_enabled (selected.personas.size > 1);
+
+    // We really want to treat selection mode specially
+    if (this.state != UiState.SELECTING) {
+      // FIXME: ask the user to leave edit-mode and act accordingly
+      if (this.contact_pane.on_edit_mode)
+        activate_action ("stop-editing-contact", new Variant.boolean (false));
+
+      this.contact_pane.show_contact (selected);
+
+      // clearing right_header
+      this.right_header.title_widget = new Adw.WindowTitle ("", "");
+      if (selected == null) {
+        this.ignore_favorite_button_toggled = true;
+        this.favorite_button.active = selected.is_favourite;
+        this.ignore_favorite_button_toggled = false;
+        if (selected.is_favourite)
+          this.favorite_button.tooltip_text = _("Unmark as favorite");
+        else
+          this.favorite_button.tooltip_text = _("Mark as favorite");
+      }
+      this.state = UiState.SHOWING;
+      if (selected == null)
+        show_contact_pane ();
+    }
   }
 
-  private void list_pane_link_contacts_cb (Gee.LinkedList<Individual> contact_list) {
-    set_shown_contact (null);
+  private void link_marked_contacts (GLib.SimpleAction action, GLib.Variant? parameter) {
+    // Take a copy, since we'll unselect everything later
+    var selection = this.marked_contacts.get_selection ().copy ();
+
+    // Go back to normal state as much as possible, and hide the contacts that
+    // will be linked together
+    this.store.selection.unselect_all ();
+    this.marked_contacts.unselect_all ();
+    this.contacts_list.set_contacts_visible (selection, false);
     this.state = UiState.NORMAL;
 
-    this.last_operation = new LinkOperation (this.store, contact_list);
+    // Build the list of contacts
+    var list = bitset_to_individuals (this.marked_contacts,
+                                      selection);
+
+    // Perform the operation
+    this.last_operation = new LinkOperation (this.store, list);
     this.last_operation.execute.begin ((obj, res) => {
       try {
         this.last_operation.execute.end (res);
@@ -447,10 +483,21 @@ public class Contacts.MainWindow : Adw.ApplicationWindow {
     this.toast_overlay.add_toast (toast);
   }
 
-  private void delete_contacts (Gee.List<Individual> individuals) {
-    set_shown_contact (null);
+  private void delete_marked_contacts (GLib.SimpleAction action, GLib.Variant? parameter) {
+    var selection = this.marked_contacts.get_selection ().copy ();
+    delete_contacts (selection);
+  }
+
+  private void delete_contacts (Gtk.Bitset selection) {
+    // Go back to normal state as much as possible, and hide the contacts that
+    // will be deleted
+    this.store.selection.unselect_all ();
+    this.marked_contacts.unselect_all ();
+    this.contacts_list.set_contacts_visible (selection, false);
     this.state = UiState.NORMAL;
 
+    var individuals = bitset_to_individuals (this.store.filter_model,
+                                             selection);
     this.last_operation = new DeleteOperation (individuals);
     var toast = new Adw.Toast (this.last_operation.description);
     toast.set_button_label (_("_Undo"));
@@ -459,8 +506,7 @@ public class Contacts.MainWindow : Adw.ApplicationWindow {
     this.delete_cancelled = false;
     toast.dismissed.connect (() => {
         if (this.delete_cancelled) {
-          this.list_pane.set_contact_visible (individuals[0], true);
-          set_shown_contact (individuals[0]);
+          this.contacts_list.set_contacts_visible (selection, true);
           this.state = UiState.SHOWING;
         } else {
           this.last_operation.execute.begin ((obj, res) => {
@@ -483,4 +529,27 @@ public class Contacts.MainWindow : Adw.ApplicationWindow {
     toast.action_name = "window.undo-operation";
     this.toast_overlay.add_toast (toast);
   }
+
+  // Little helper
+  private Gee.LinkedList<Individual> bitset_to_individuals (GLib.ListModel model,
+                                                            Gtk.Bitset bitset) {
+    var list = new Gee.LinkedList<Individual> ();
+
+    var iter = Gtk.BitsetIter ();
+    uint index;
+    if (!iter.init_first (bitset, out index))
+      return list;
+
+    do {
+      list.add ((Individual) model.get_item (index));
+    } while (iter.next (out index));
+
+    return list;
+  }
+
+  [GtkCallback]
+  private void filter_entry_changed (Gtk.Editable editable) {
+    unowned var query = this.store.filter.query as SimpleQuery;
+    query.query_string = this.filter_entry.text;
+  }
 }
diff --git a/src/contacts-query-filter.vala b/src/contacts-query-filter.vala
new file mode 100644
index 00000000..c563f733
--- /dev/null
+++ b/src/contacts-query-filter.vala
@@ -0,0 +1,64 @@
+/*
+ * Copyright (C) 2021 Niels De Graef <nielsdegraef gmail com>
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+using Folks;
+
+/**
+ * A subclass of {@link Gtk.Filter} which applies a {@link Folks.Query} as a
+ * filter on a list of individuals.
+ *
+ * Since {@link Folks.Query.is_match} returns a "match strength" number, you
+ * can specify also an (exclusive) lower bound before the filter returns true.
+ */
+public class Contacts.QueryFilter : Gtk.Filter {
+
+  public Query query { get; construct; }
+
+  private uint _min_strength = 0;
+  public uint min_strength {
+    get { return this._min_strength; }
+    set {
+      if (value == this._min_strength)
+        return;
+
+      this._min_strength = value;
+      this.changed (Gtk.FilterChange.DIFFERENT);
+    }
+  }
+
+  public QueryFilter (Query query) {
+    Object (query: query);
+
+    query.notify.connect (on_query_notify);
+  }
+
+  private void on_query_notify (Object object, ParamSpec pspec) {
+    this.changed (Gtk.FilterChange.DIFFERENT);
+  }
+
+  public override bool match (GLib.Object? item) {
+    unowned var individual = item as Individual;
+    if (individual == null)
+      return false;
+
+    return this.query.is_match (individual) > this.min_strength;
+  }
+
+  public override Gtk.FilterMatch get_strictness () {
+    return Gtk.FilterMatch.SOME;
+  }
+}
diff --git a/src/contacts-setup-window.vala b/src/contacts-setup-window.vala
index 157d1d6e..8b680b0e 100644
--- a/src/contacts-setup-window.vala
+++ b/src/contacts-setup-window.vala
@@ -63,7 +63,7 @@ public class Contacts.SetupWindow : Adw.ApplicationWindow {
   }
 
   private void fill_accounts_list (Store store) {
-    if (store.is_prepared) {
+    if (store.aggregator.is_prepared) {
       this.setup_accounts_list.update_contents (false);
       return;
     }
diff --git a/src/contacts-store.vala b/src/contacts-store.vala
index 91e99e5a..47144fbb 100644
--- a/src/contacts-store.vala
+++ b/src/contacts-store.vala
@@ -17,29 +17,50 @@
 
 using Folks;
 
+/**
+ * The Contacts.Store is the base abstraction that holds all contacts (i.e.
+ * {@link Folks.Indidivual}s). Note that it also has a "quiescent" and
+ * "prepared" signal, with similar effects to those of a
+ * {@link Folks.IndividualAggregator}.
+ *
+ * Internally, the Store works with 3 list models layered on top of each other:
+ *
+ * - A base list model which contains all contacts in the
+ *   {@link Folks.IndividualAggregator}
+ * - A {@link Gtk.SortListModel}, which sorts the base model according to
+ *   first name or last name, or whatever user preference
+ * - A {@link Gtk.FilterListModel} to filter out contacts using a
+ *   {@link Folks.Query}, so a user can filter contacts with the search entry
+ */
 public class Contacts.Store : GLib.Object {
-  public signal void added (Individual c);
-  public signal void removed (Individual c);
+
   public signal void quiescent ();
   public signal void prepared ();
 
   public IndividualAggregator aggregator { get; private set; }
   public BackendStore backend_store { get { return this.aggregator.backend_store; } }
 
+  // Base list model
+  private GLib.ListStore _base_model = new ListStore (typeof (Individual));
+  public GLib.ListModel base_model { get { return this._base_model; } }
+
+  // Sorting list model
+  public Gtk.SortListModel sort_model { get; private set; }
+  public IndividualSorter sorter { get; private set; }
+
+  // Filtering list model
+  public Gtk.FilterListModel filter_model { get; private set; }
+  public QueryFilter filter { get; private set; }
+
+  // Selection list model
+  public Gtk.SingleSelection selection { get; private set; }
+
   public Gee.HashMultiMap<string, string> dont_suggest_link;
 
 #if HAVE_TELEPATHY
   public TelepathyGLib.Account? caller_account { get; private set; default = null; }
 #endif
 
-  public bool is_quiescent {
-    get { return this.aggregator.is_quiescent; }
-  }
-
-  public bool is_prepared {
-    get { return this.aggregator.is_prepared; }
-  }
-
   private void read_dont_suggest_db () {
     dont_suggest_link.clear ();
 
@@ -108,7 +129,7 @@ public class Contacts.Store : GLib.Object {
   }
 
   construct {
-    dont_suggest_link = new Gee.HashMultiMap<string, string> ();
+    this.dont_suggest_link = new Gee.HashMultiMap<string, string> ();
     read_dont_suggest_db ();
 
     var backend_store = BackendStore.dup ();
@@ -137,60 +158,128 @@ public class Contacts.Store : GLib.Object {
 #endif
   }
 
+  public Store (GLib.Settings settings, Folks.Query query) {
+    // Create the sorting, filtering and selection models
+    this.sorter = new IndividualSorter (settings);
+    this.sort_model = new Gtk.SortListModel (this.base_model, this.sorter);
+
+    this.filter = new QueryFilter (query);
+    this.filter_model = new Gtk.FilterListModel (this.sort_model, this.filter);
+
+    this.selection = new Gtk.SingleSelection (this.filter_model);
+    this.selection.autoselect = false;
+  }
+
   private void on_individuals_changed_detailed (Gee.MultiMap<Individual?,Individual?> changes) {
-    var to_add = new Gee.HashSet<Individual> ();
-    var to_remove = new Gee.HashSet<Individual> ();
-    foreach (var i in changes.get_keys()) {
-      if (i != null)
-        to_remove.add (i);
-      foreach (var new_i in changes[i]) {
+    var to_add = new GenericArray<unowned Individual> ();
+    var to_remove = new GenericArray<unowned Individual> ();
+
+    foreach (var individual in changes.get_keys ()) {
+      if (individual != null)
+        to_remove.add (individual);
+      foreach (var new_i in changes[individual]) {
         if (new_i != null)
           to_add.add (new_i);
       }
     }
 
-    debug ("Individuals changed: %d old, %d new", to_add.size, to_remove.size);
+    debug ("Individuals changed: %d added, %d removed", to_add.length, to_remove.length);
 
-    // Add new individuals
-    foreach (var i in to_add) {
-      if (i.personas.size > 0)
-        added (i);
+    // Remove old individuals. It's not the most performance way of doing it,
+    // but optimizing for it (and making it more comples) makes little sense.
+    foreach (unowned var indiv in to_remove) {
+      uint pos = 0;
+      if (this._base_model.find (indiv, out pos)) {
+        this._base_model.remove (pos);
+      } else {
+        debug ("Tried to remove individual '%s', but could't find it", indiv.display_name);
+      }
     }
 
-    // Remove old individuals
-    foreach (var i in to_remove) {
-      removed (i);
+    // Add new individuals
+    foreach (unowned var indiv in to_add) {
+      if (indiv.personas.size == 0 || Utils.is_ignorable (indiv)) {
+        to_add.remove_fast (indiv);
+      } else {
+        // We want to make sure that changes in the Individual triggers changes
+        // in the list model if it affects sorting and/or filtering. Atm, the
+        // only thing that can lead to this is a change in display name or
+        // whether they are marked as favourite.
+        indiv.notify.connect ((obj, pspec) => {
+          unowned var prop_name = pspec.get_name ();
+          if (prop_name != "display-name" && prop_name != "is-favourite")
+            return;
+
+          uint pos;
+          if (this._base_model.find (obj, out pos)) {
+            this._base_model.items_changed (pos, 1, 1);
+          }
+        });
+      }
     }
+    this._base_model.splice (this.base_model.get_n_items (), 0, (Object[]) to_add.data);
   }
 
-  public Gee.Collection<Individual> get_contacts () {
-    return this.aggregator.individuals.values.read_only_view;
+  public unowned Individual? get_selected_contact () {
+    return (Individual) this.selection.get_selected_item ();
   }
 
-  public async Individual? find_contact (Query query) {
+  /**
+   * A helper method to find a contact based on the given search query, while
+   * making sure to take care of (wait for) the "quiescent" property of the
+   * IndividualAggregator.
+   */
+  public async uint find_individual_for_query (Query query) {
     // Wait that the store gets quiescent if it isn't already
-    if (!is_quiescent) {
+    if (!this.aggregator.is_quiescent) {
       ulong signal_id;
-      SourceFunc callback = find_contact.callback;
-      signal_id = this.quiescent.connect ( () => {
-        callback();
+      SourceFunc callback = find_individual_for_query.callback;
+      signal_id = this.quiescent.connect (() => {
+        callback ();
       });
       yield;
       disconnect (signal_id);
     }
 
-    Individual? matched = null;
     // We search for the closest matching Individual
+    uint matched_pos = Gtk.INVALID_LIST_POSITION;
     uint strength = 0;
-    foreach (var i in this.aggregator.individuals.values) {
-      var this_strength = query.is_match(i);
+    for (uint i = 0; i < this.filter_model.get_n_items (); i++) {
+      var individual = (Individual) this.filter_model.get_item (i);
+      uint this_strength = query.is_match (individual);
       if (this_strength > strength) {
-        matched = i;
+        matched_pos = i;
         strength = this_strength;
       }
     }
 
-    return matched;
+    return matched_pos;
+  }
+
+  /**
+   * A helper method to find a contact based on the given individual id, while
+   * making sure to take care of (wait for) the "quiescent" property of the
+   * IndividualAggregator.
+   */
+  public async uint find_individual_for_id (string id) {
+    // Wait that the store gets quiescent if it isn't already
+    if (!this.aggregator.is_quiescent) {
+      ulong signal_id;
+      SourceFunc callback = find_individual_for_id.callback;
+      signal_id = this.quiescent.connect (() => {
+        callback ();
+      });
+      yield;
+      disconnect (signal_id);
+    }
+
+    for (uint i = 0; i < this.filter_model.get_n_items (); i++) {
+      var individual = (Individual) this.filter_model.get_item (i);
+      if (individual.id == id)
+        return i;
+    }
+
+    return Gtk.INVALID_LIST_POSITION;
   }
 
 #if HAVE_TELEPATHY
diff --git a/src/meson.build b/src/meson.build
index fc11d335..7a3ddf62 100644
--- a/src/meson.build
+++ b/src/meson.build
@@ -10,8 +10,10 @@ libcontacts_sources = files(
   'contacts-esd-setup.vala',
   'contacts-fake-persona-store.vala',
   'contacts-im-service.vala',
+  'contacts-individual-sorter.vala',
   'contacts-link-operation.vala',
   'contacts-operation.vala',
+  'contacts-query-filter.vala',
   'contacts-store.vala',
   'contacts-typeset.vala',
   'contacts-type-descriptor.vala',
@@ -84,7 +86,6 @@ contacts_vala_sources = files(
   'contacts-editor-property.vala',
   'contacts-link-suggestion-grid.vala',
   'contacts-linked-personas-dialog.vala',
-  'contacts-list-pane.vala',
   'contacts-main-window.vala',
   'contacts-settings.vala',
   'contacts-setup-window.vala',


[Date Prev][Date Next]   [Thread Prev][Thread Next]   [Thread Index] [Date Index] [Author Index]