[geary/wip/composer-folks: 15/22] Convert composer email autocomplete to use search for matching



commit cc27bae7920b8b010f5e0e85a3a959e8bae90822
Author: Michael Gratton <mike vee net>
Date:   Thu Jun 13 16:44:44 2019 +1000

    Convert composer email autocomplete to use search for matching
    
    Populate ContactListStore using Geary.ContactStore.search. Simplify
    ContactEntryCompletion greatly.

 src/client/composer/composer-widget.vala          |   4 +
 src/client/composer/contact-entry-completion.vala | 354 +++++++++++-----------
 src/client/composer/contact-list-store-cache.vala |   2 +-
 src/client/composer/contact-list-store.vala       | 125 ++------
 src/client/composer/email-entry.vala              |   2 +-
 5 files changed, 206 insertions(+), 281 deletions(-)
---
diff --git a/src/client/composer/composer-widget.vala b/src/client/composer/composer-widget.vala
index 5f1823cd..e329a2d2 100644
--- a/src/client/composer/composer-widget.vala
+++ b/src/client/composer/composer-widget.vala
@@ -649,6 +649,10 @@ public class ComposerWidget : Gtk.EventBox, Geary.BaseInterface {
      */
     private void load_entry_completions() {
         Geary.ContactStore contacts = this.account.contact_store;
+        this.to_entry.completion = new ContactEntryCompletion(contacts);
+        this.cc_entry.completion = new ContactEntryCompletion(contacts);
+        this.bcc_entry.completion = new ContactEntryCompletion(contacts);
+        this.reply_to_entry.completion = new ContactEntryCompletion(contacts);
     }
 
     /**
diff --git a/src/client/composer/contact-entry-completion.vala 
b/src/client/composer/contact-entry-completion.vala
index ded1e5a5..96ab1333 100644
--- a/src/client/composer/contact-entry-completion.vala
+++ b/src/client/composer/contact-entry-completion.vala
@@ -1,39 +1,40 @@
-/* Copyright 2016 Software Freedom Conservancy Inc.
+/*
+ * Copyright 2016 Software Freedom Conservancy Inc.
+ * Copyright 2019 Michael Gratton <mike vee net>
  *
  * This software is licensed under the GNU Lesser General Public License
- * (version 2.1 or later).  See the COPYING file in this distribution.
+ * (version 2.1 or later). See the COPYING file in this distribution.
  */
 
-public class ContactEntryCompletion : Gtk.EntryCompletion {
+public class ContactEntryCompletion : Gtk.EntryCompletion, Geary.BaseInterface {
 
 
-    private static bool completion_match_func(Gtk.EntryCompletion completion, string key, Gtk.TreeIter iter) 
{
-        ContactEntryCompletion contacts = (ContactEntryCompletion) completion;
-
-        // We don't use the provided key, because the user can enter multiple addresses.
-        int current_address_index;
-        string current_address_key;
-        contacts.get_addresses(completion, out current_address_index, out current_address_key);
+    public new ContactListStore model {
+        get { return base.get_model() as ContactListStore; }
+        set { base.set_model(value); }
+    }
 
-        Geary.Contact? contact = contacts.list_store.get_contact(iter);
-        if (contact == null)
-            return false;
+    // Text between the start of the entry or of the previous email
+    // address and the current position of the cursor, if any.
+    private string current_key = "";
 
-        string highlighted_result;
-        if (!contacts.match_prefix_contact(current_address_key, contact, out highlighted_result))
-            return false;
+    // List of (possibly incomplete) email addresses in the entry.
+    private string[] email_addresses = {};
 
-        return true;
-    }
+    // Index of the email address the cursor is currently at
+    private int cursor_at_address = -1;
 
-    private ContactListStore list_store;
+    private GLib.Cancellable? search_cancellable = null;
     private Gtk.TreeIter? last_iter = null;
 
-    public ContactEntryCompletion(ContactListStore list_store) {
-        this.list_store = list_store;
 
-        model = list_store;
-        set_match_func(ContactEntryCompletion.completion_match_func);
+    public ContactEntryCompletion(Geary.ContactStore contacts) {
+        base_ref();
+        this.model = new ContactListStore(contacts);
+
+        // Always match all rows, since the model will only contain
+        // matching addresses from the search query
+        set_match_func(() => true);
 
         Gtk.CellRendererText text_renderer = new Gtk.CellRendererText();
         pack_start(text_renderer, true);
@@ -44,191 +45,192 @@ public class ContactEntryCompletion : Gtk.EntryCompletion {
         cursor_on_match.connect(on_cursor_on_match);
     }
 
-    private void cell_layout_data_func(Gtk.CellLayout cell_layout, Gtk.CellRenderer cell,
-        Gtk.TreeModel tree_model, Gtk.TreeIter iter) {
-        string highlighted_result = "";
-
-        GLib.Value contact_value;
-        tree_model.get_value(iter, ContactListStore.Column.CONTACT_OBJECT, out contact_value);
-
-        Geary.Contact? contact = (Geary.Contact) contact_value.get_object();
-
-        if (contact != null) {
-            string current_address_key;
-            this.get_addresses(this, null, out current_address_key);
-
-            this.match_prefix_contact(current_address_key, contact, out highlighted_result);
-        }
-
-        Gtk.CellRendererText text_renderer = (Gtk.CellRendererText) cell;
-        text_renderer.markup = highlighted_result;
+    ~ContactEntryCompletion() {
+        base_unref();
     }
 
-    private bool on_match_selected(Gtk.EntryCompletion sender, Gtk.TreeModel model, Gtk.TreeIter iter) {
-        string full_address = list_store.to_full_address(iter);
+    public void update_model() {
+        this.last_iter = null;
 
-        Gtk.Entry? entry = sender.get_entry() as Gtk.Entry;
-        if (entry == null)
-            return false;
+        update_addresses();
 
-        int current_address_index;
-        string current_address_remainder;
-        Gee.List<string> addresses = get_addresses(sender, out current_address_index, null,
-            out current_address_remainder);
-        bool current_address_is_last = (current_address_index == addresses.size - 1);
-        addresses[current_address_index] = full_address;
-        if (!Geary.String.is_empty_or_whitespace(current_address_remainder))
-            addresses.insert(current_address_index + 1, current_address_remainder);
-        string delimiter = ", ";
-        entry.text = concat_strings(addresses, delimiter, current_address_is_last);
-
-        int characters_seen_so_far = 0;
-        for (int i = 0; i <= current_address_index; i++)
-            characters_seen_so_far += addresses[i].char_count() + delimiter.char_count();
-
-        entry.set_position(characters_seen_so_far);
-
-        return true;
-    }
+        if (this.search_cancellable != null) {
+            this.search_cancellable.cancel();
+            this.search_cancellable = null;
+        }
 
-    private bool on_cursor_on_match(Gtk.EntryCompletion sender, Gtk.TreeModel model, Gtk.TreeIter iter) {
-        last_iter = iter;
-        return true;
+        string completion_key = this.current_key;
+        if (!Geary.String.is_empty_or_whitespace(completion_key)) {
+            this.search_cancellable = new GLib.Cancellable();
+            this.model.search.begin(completion_key, this.search_cancellable);
+        } else {
+            this.model.clear();
+        }
     }
 
     public void trigger_selection() {
         if (last_iter != null) {
-            on_match_selected(this, model, last_iter);
+            on_match_selected(model, last_iter);
             last_iter = null;
         }
     }
 
-    public void reset_selection() {
-        last_iter = null;
-    }
-
-    private Gee.List<string> get_addresses(Gtk.EntryCompletion completion,
-        out int current_address_index = null, out string current_address_key = null,
-        out string current_address_remainder = null) {
-        current_address_index = 0;
-        current_address_key = "";
-        current_address_remainder = "";
-        Gtk.Entry? entry = completion.get_entry() as Gtk.Entry;
-        Gee.List<string> empty_addresses = new Gee.ArrayList<string>();
-        empty_addresses.add("");
-        if (entry == null)
-            return empty_addresses;
-
-        string? original_text = entry.get_text();
-        if (original_text == null)
-            return empty_addresses;
-
-        int cursor_position = entry.cursor_position;
-        int cursor_offset = original_text.index_of_nth_char(cursor_position);
-        if (cursor_offset < 0)
-            return empty_addresses;
-
-        Gee.List<string> addresses = new Gee.ArrayList<string>();
-        string delimiter = ",";
-        string[] addresses_array = original_text.split(delimiter);
-        foreach (string address in addresses_array)
-            addresses.add(address);
-
-        if (addresses.size < 1)
-            return empty_addresses;
-
-        int bytes_seen_so_far = 0;
-        current_address_index = addresses.size - 1;
-        for (int i = 0; i < addresses.size; i++) {
-            int token_bytes = addresses[i].length + delimiter.length;
-            if ((bytes_seen_so_far + token_bytes) > cursor_offset) {
-                current_address_index = i;
-                current_address_key = addresses[i]
-                    .substring(0, cursor_offset - bytes_seen_so_far)
-                    .strip().normalize().casefold();
-
-                current_address_remainder = addresses[i]
-                    .substring(cursor_offset - bytes_seen_so_far).strip();
-                break;
+    private void update_addresses() {
+        Gtk.Entry? entry = get_entry() as Gtk.Entry;
+        if (entry != null) {
+            this.current_key = "";
+            this.cursor_at_address = -1;
+            this.email_addresses = {};
+
+            string text = entry.get_text();
+            int cursor_pos = entry.get_position();
+
+            int start_idx = 0;
+            int next_idx = 0;
+            unichar c = 0;
+            int current_char = 0;
+            bool in_quote = false;
+            while (text.get_next_char(ref next_idx, out c)) {
+                if (current_char == cursor_pos) {
+                    this.current_key = text.slice(start_idx, next_idx).strip();
+                    this.cursor_at_address = this.email_addresses.length;
+                }
+
+                switch (c) {
+                case ',':
+                    if (!in_quote) {
+                        // Don't include the comma in the address
+                        string address = text.slice(start_idx, next_idx -1);
+                        this.email_addresses += address.strip();
+                        // Don't include it in the next one, either
+                        start_idx = next_idx;
+                    }
+                    break;
+
+                case '"':
+                    in_quote = !in_quote;
+                    break;
+                }
+
+                current_char++;
             }
-            bytes_seen_so_far += token_bytes;
-        }
-        for (int i = 0; i < addresses.size; i++) {
-            addresses[i] = addresses[i].strip();
-        }
-
-        return addresses;
-    }
 
-    private string concat_strings(Gee.List<string> strings, string delimiter, bool add_trailing) {
-        StringBuilder builder = new StringBuilder(strings[0]);
-        for (int i = 1; i < strings.size; i++) {
-            builder.append(delimiter);
-            builder.append(strings[i]);
+            // Add any remaining text after the last comma
+            string address = text.substring(start_idx);
+            this.email_addresses += address.strip();
         }
-
-        // If the address was appended to the end of the list, add another
-        // delimiter so that the user doesn't have to manually type a comma
-        // after adding each address
-        if (add_trailing)
-            builder.append(delimiter);
-
-        return builder.str;
     }
 
-    private bool match_prefix_contact(string needle, Geary.Contact contact,
-        out string highlighted_result = null) {
-        string email_result;
-        bool email_match = match_prefix_string(needle, contact.email, out email_result);
-
-        string real_name_result;
-        bool real_name_match = match_prefix_string(needle, contact.real_name, out real_name_result);
-
-        // email_result and real_name_result were already escaped, then <b></b> tags were added to
-        // highlight matches. We don't want to escape them again.
-        highlighted_result = contact.real_name == null ? email_result :
-            real_name_result + Markup.escape_text(" <") + email_result + Markup.escape_text(">");
+    private string match_prefix_contact(Geary.Contact contact) {
+        string email = match_prefix_string(contact.email);
+        string real_name = match_prefix_string(contact.real_name);
 
-        return email_match || real_name_match;
+        // email and real_name were already escaped, then <b></b> tags
+        // were added to highlight matches. We don't want to escape
+        // them again.
+        return (contact.real_name == null)
+            ? email
+            : real_name + Markup.escape_text(" <") + email + Markup.escape_text(">");
     }
 
-    private bool match_prefix_string(string needle, string? haystack = null,
-        out string highlighted_result = null) {
-        highlighted_result = "";
-
-        if (Geary.String.is_empty(haystack) || Geary.String.is_empty(needle))
-            return false;
-
-        // Default result if there is no match or we encounter an error.
-        highlighted_result = haystack;
+    private string? match_prefix_string(string? haystack) {
+        string? value = haystack;
+        if (!Geary.String.is_empty(this.current_key) &&
+            !Geary.String.is_empty(haystack)) {
+
+            bool matched = false;
+            try {
+                string escaped_needle = Regex.escape_string(
+                    this.current_key.normalize()
+                );
+                Regex regex = new Regex(
+                    "\\b" + escaped_needle,
+                    RegexCompileFlags.CASELESS
+                );
+                string haystack_normalized = haystack.normalize();
+                if (regex.match(haystack_normalized)) {
+                    value = regex.replace_eval(
+                        haystack_normalized, -1, 0, 0, eval_callback
+                    );
+                    matched = true;
+                }
+            } catch (RegexError err) {
+                debug("Error matching regex: %s", err.message);
+            }
 
-        bool matched = false;
-        try {
-            string escaped_needle = Regex.escape_string(needle.normalize());
-            Regex regex = new Regex("\\b" + escaped_needle, RegexCompileFlags.CASELESS);
-            string haystack_normalized = haystack.normalize();
-            if (regex.match(haystack_normalized)) {
-                highlighted_result = regex.replace_eval(haystack_normalized, -1, 0, 0, eval_callback);
-                matched = true;
+            if (matched) {
+                value = Markup.escape_text(value)
+                    .replace("&#x91;", "<b>")
+                    .replace("&#x92;", "</b>");
             }
-        } catch (RegexError err) {
-            debug("Error matching regex: %s", err.message);
         }
 
-        highlighted_result = Markup.escape_text(highlighted_result)
-            .replace("&#x91;", "<b>").replace("&#x92;", "</b>");
-
-        return matched;
+        return value;
     }
 
-    private bool eval_callback(MatchInfo match_info, StringBuilder result) {
+    private bool eval_callback(GLib.MatchInfo match_info,
+                               GLib.StringBuilder result) {
         string? match = match_info.fetch(0);
         if (match != null) {
             result.append("\xc2\x91%s\xc2\x92".printf(match));
             // This is UTF-8 encoding of U+0091 and U+0092
         }
-
         return false;
     }
-}
 
+    private void cell_layout_data_func(Gtk.CellLayout cell_layout,
+                                       Gtk.CellRenderer cell,
+                                       Gtk.TreeModel tree_model,
+                                       Gtk.TreeIter iter) {
+        GLib.Value contact_value;
+        tree_model.get_value(
+            iter, ContactListStore.Column.CONTACT_OBJECT, out contact_value
+        );
+
+        string render = "";
+        Geary.Contact? contact = (Geary.Contact) contact_value.get_object();
+        if (contact != null) {
+            render = this.match_prefix_contact(contact);
+        }
+
+        Gtk.CellRendererText text_renderer = (Gtk.CellRendererText) cell;
+        text_renderer.markup = render;
+    }
+
+    private bool on_match_selected(Gtk.TreeModel model, Gtk.TreeIter iter) {
+        Gtk.Entry? entry = get_entry() as Gtk.Entry;
+        if (entry != null) {
+            // Update the address
+            this.email_addresses[this.cursor_at_address] =
+                this.model.to_full_address(iter);
+
+            // Update the entry text
+            bool current_is_last = (
+                this.cursor_at_address == this.email_addresses.length - 1
+            );
+            int new_cursor_pos = -1;
+            GLib.StringBuilder text = new GLib.StringBuilder();
+            int i = 0;
+            while (i < this.email_addresses.length) {
+                text.append(this.email_addresses[i]);
+                if (i == this.cursor_at_address) {
+                    new_cursor_pos = text.str.char_count();
+                }
+
+                i++;
+                if (i != this.email_addresses.length || current_is_last) {
+                    text.append(", ");
+                }
+            }
+            entry.text = text.str;
+            entry.set_position(current_is_last ? -1 : new_cursor_pos);
+        }
+        return true;
+    }
+
+    private bool on_cursor_on_match(Gtk.TreeModel model, Gtk.TreeIter iter) {
+        this.last_iter = iter;
+        return true;
+    }
+
+}
diff --git a/src/client/composer/contact-list-store-cache.vala 
b/src/client/composer/contact-list-store-cache.vala
index 4f8aa6c1..535a5b32 100644
--- a/src/client/composer/contact-list-store-cache.vala
+++ b/src/client/composer/contact-list-store-cache.vala
@@ -14,7 +14,7 @@ public class ContactListStoreCache {
 
         this.cache.set(contact_store, list_store);
 
-        list_store.load.begin();
+        //list_store.load.begin();
 
         return list_store;
     }
diff --git a/src/client/composer/contact-list-store.vala b/src/client/composer/contact-list-store.vala
index c30c08a1..f2418634 100644
--- a/src/client/composer/contact-list-store.vala
+++ b/src/client/composer/contact-list-store.vala
@@ -10,57 +10,12 @@ public class ContactListStore : Gtk.ListStore, Geary.BaseInterface {
     private const Geary.Contact.Importance VISIBILITY_THRESHOLD =
         Geary.Contact.Importance.RECEIVED_FROM;
 
-    // Batch size for loading contacts asynchronously
-    private uint LOAD_BATCH_SIZE = 4096;
-
-
-    private static int sort_func(Gtk.TreeModel model, Gtk.TreeIter aiter, Gtk.TreeIter biter) {
-        // Order by importance, then by real name, then by email.
-        GLib.Value avalue, bvalue;
-        model.get_value(aiter, Column.CONTACT_OBJECT, out avalue);
-        model.get_value(biter, Column.CONTACT_OBJECT, out bvalue);
-        Geary.Contact? acontact = avalue.get_object() as Geary.Contact;
-        Geary.Contact? bcontact = bvalue.get_object() as Geary.Contact;
-
-        // Contacts can be null if the sort func is called between TreeModel.append and
-        // TreeModel.set.
-        if (acontact == bcontact)
-            return 0;
-        if (acontact == null && bcontact != null)
-            return -1;
-        if (acontact != null && bcontact == null)
-            return 1;
-
-        // First order by importance.
-        if (acontact.highest_importance > bcontact.highest_importance)
-            return -1;
-        if (acontact.highest_importance < bcontact.highest_importance)
-            return 1;
-
-        // Then order by real name.
-        string? anormalized_real_name = acontact.real_name == null ? null :
-            acontact.real_name.normalize().casefold();
-        string? bnormalized_real_name = bcontact.real_name == null ? null :
-            bcontact.real_name.normalize().casefold();
-        // strcmp correctly marks 'null' as first in lexigraphic order, so we don't need to
-        // special-case it.
-        int result = strcmp(anormalized_real_name, bnormalized_real_name);
-        if (result != 0)
-            return result;
-
-        // Finally, order by email.
-        return strcmp(acontact.normalized_email, bcontact.normalized_email);
-    }
-
-
     public enum Column {
-        CONTACT_OBJECT,
-        PRIOR_KEYS;
+        CONTACT_OBJECT;
 
         public static Type[] get_types() {
             return {
-                typeof (Geary.Contact), // CONTACT_OBJECT
-                typeof (Gee.HashSet)    // PRIOR_KEYS
+                typeof (Geary.Contact) // CONTACT_OBJECT
             };
         }
     }
@@ -71,34 +26,30 @@ public class ContactListStore : Gtk.ListStore, Geary.BaseInterface {
         base_ref();
         set_column_types(Column.get_types());
         this.contact_store = contact_store;
-        //contact_store.contacts_added.connect(on_contacts_added);
-        //contact_store.contacts_updated.connect(on_contacts_updated);
     }
 
     ~ContactListStore() {
         base_unref();
-        //this.contact_store.contacts_added.disconnect(on_contacts_added);
-        //this.contact_store.contacts_updated.disconnect(on_contacts_updated);
     }
 
-    /**
-     * Loads contacts from the model's contact store.
-     */
-    public async void load() {
-        // uint count = 0;
-        // foreach (Geary.Contact contact in this.contact_store.contacts) {
-        //     add_contact(contact);
-        //     count++;
-        //     if (count % LOAD_BATCH_SIZE == 0) {
-        //         Idle.add(load.callback);
-        //         yield;
-        //     }
-        // }
-    }
-
-    public void set_sort_function() {
-        set_sort_func(Column.CONTACT_OBJECT, ContactListStore.sort_func);
-        set_sort_column_id(Column.CONTACT_OBJECT, Gtk.SortType.ASCENDING);
+    public async void search(string query, GLib.Cancellable? cancellable) {
+        try {
+            Gee.Collection<Geary.Contact> results = yield this.contact_store.search(
+                query,
+                VISIBILITY_THRESHOLD,
+                20,
+                cancellable
+            );
+
+            clear();
+            foreach (Geary.Contact contact in results) {
+                add_contact(contact);
+            }
+        } catch (GLib.IOError.CANCELLED err) {
+            // All good
+        } catch (GLib.Error err) {
+            debug("Error searching contacts for completion: %s", err.message);
+        }
     }
 
     public Geary.Contact get_contact(Gtk.TreeIter iter) {
@@ -113,41 +64,9 @@ public class ContactListStore : Gtk.ListStore, Geary.BaseInterface {
     }
 
     private inline void add_contact(Geary.Contact contact) {
-        if (contact.highest_importance >= VISIBILITY_THRESHOLD) {
-            Gtk.TreeIter iter;
-            append(out iter);
-            set(iter,
-                Column.CONTACT_OBJECT, contact,
-                Column.PRIOR_KEYS, new Gee.HashSet<string>());
-        }
-    }
-
-    private void update_contact(Geary.Contact updated_contact) {
         Gtk.TreeIter iter;
-        if (!get_iter_first(out iter))
-            return;
-
-        do {
-            if (get_contact(iter) != updated_contact)
-                continue;
-
-            Gtk.TreePath? path = get_path(iter);
-            if (path != null)
-                row_changed(path, iter);
-
-            return;
-        } while (iter_next(ref iter));
-    }
-
-    private void on_contacts_added(Gee.Collection<Geary.Contact> contacts) {
-        foreach (Geary.Contact contact in contacts)
-            add_contact(contact);
-    }
-
-    private void on_contacts_updated(Gee.Collection<Geary.Contact> contacts) {
-        foreach (Geary.Contact contact in contacts)
-            update_contact(contact);
+        append(out iter);
+        set(iter, Column.CONTACT_OBJECT, contact);
     }
 
 }
-
diff --git a/src/client/composer/email-entry.vala b/src/client/composer/email-entry.vala
index c0632ac6..eb0e35b1 100644
--- a/src/client/composer/email-entry.vala
+++ b/src/client/composer/email-entry.vala
@@ -46,7 +46,7 @@ public class EmailEntry : Gtk.Entry {
 
         ContactEntryCompletion? completion = get_completion() as ContactEntryCompletion;
         if (completion != null) {
-            completion.reset_selection();
+            completion.update_model();
         }
 
         if (Geary.String.is_empty(text.strip())) {


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