Add workaround for autocomplete not showing up if query doesn't finish fast enough. Add some padding around autocomplete cells. Show an icon for desktop contacts and favourites. Replace whole model rather than clearing and re-populating to avoid the entries flashing in the UI.
325 lines
11 KiB
Vala
325 lines
11 KiB
Vala
/*
|
|
* 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.
|
|
*/
|
|
|
|
public class ContactEntryCompletion : Gtk.EntryCompletion, Geary.BaseInterface {
|
|
|
|
|
|
// Minimum visibility for the contact to appear in autocompletion.
|
|
private const Geary.Contact.Importance VISIBILITY_THRESHOLD =
|
|
Geary.Contact.Importance.RECEIVED_FROM;
|
|
|
|
|
|
public enum Column {
|
|
CONTACT,
|
|
MAILBOX;
|
|
|
|
public static Type[] get_types() {
|
|
return {
|
|
typeof(Application.Contact), // CONTACT
|
|
typeof(Geary.RFC822.MailboxAddress) // MAILBOX
|
|
};
|
|
}
|
|
}
|
|
|
|
|
|
private Application.ContactStore contacts;
|
|
|
|
// 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 = "";
|
|
|
|
// List of (possibly incomplete) email addresses in the entry.
|
|
private string[] email_addresses = {};
|
|
|
|
// Index of the email address the cursor is currently at
|
|
private int cursor_at_address = -1;
|
|
|
|
private GLib.Cancellable? search_cancellable = null;
|
|
private Gtk.TreeIter? last_iter = null;
|
|
|
|
|
|
public ContactEntryCompletion(Application.ContactStore contacts) {
|
|
base_ref();
|
|
this.contacts = contacts;
|
|
this.model = new_model();
|
|
|
|
// Always match all rows, since the model will only contain
|
|
// matching addresses from the search query
|
|
set_match_func(() => true);
|
|
|
|
Gtk.CellRendererPixbuf icon_renderer = new Gtk.CellRendererPixbuf();
|
|
icon_renderer.xpad = 2;
|
|
icon_renderer.ypad = 2;
|
|
pack_start(icon_renderer, false);
|
|
set_cell_data_func(icon_renderer, cell_icon_data);
|
|
|
|
Gtk.CellRendererText text_renderer = new Gtk.CellRendererText();
|
|
icon_renderer.ypad = 2;
|
|
pack_start(text_renderer, true);
|
|
set_cell_data_func(text_renderer, cell_text_data);
|
|
|
|
this.match_selected.connect(on_match_selected);
|
|
this.cursor_on_match.connect(on_cursor_on_match);
|
|
}
|
|
|
|
~ContactEntryCompletion() {
|
|
base_unref();
|
|
}
|
|
|
|
public void update_model() {
|
|
this.last_iter = null;
|
|
|
|
update_addresses();
|
|
|
|
if (this.search_cancellable != null) {
|
|
this.search_cancellable.cancel();
|
|
this.search_cancellable = null;
|
|
}
|
|
|
|
Gtk.ListStore model = (Gtk.ListStore) this.model;
|
|
string completion_key = this.current_key;
|
|
if (!Geary.String.is_empty_or_whitespace(completion_key)) {
|
|
// Append a placeholder row here if the model is empty to
|
|
// work around the issue descried in
|
|
// https://gitlab.gnome.org/GNOME/gtk/merge_requests/939
|
|
Gtk.TreeIter iter;
|
|
if (!model.get_iter_first(out iter)) {
|
|
model.append(out iter);
|
|
}
|
|
|
|
this.search_cancellable = new GLib.Cancellable();
|
|
this.search_contacts.begin(completion_key, this.search_cancellable);
|
|
} else {
|
|
model.clear();
|
|
}
|
|
}
|
|
public void trigger_selection() {
|
|
if (last_iter != null) {
|
|
on_match_selected(model, last_iter);
|
|
last_iter = null;
|
|
}
|
|
}
|
|
|
|
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++;
|
|
}
|
|
|
|
// Add any remaining text after the last comma
|
|
string address = text.substring(start_idx);
|
|
this.email_addresses += address.strip();
|
|
}
|
|
}
|
|
|
|
public async void search_contacts(string query,
|
|
GLib.Cancellable? cancellable) {
|
|
Gee.Collection<Application.Contact>? results = null;
|
|
try {
|
|
results = yield this.contacts.search(
|
|
query,
|
|
VISIBILITY_THRESHOLD,
|
|
20,
|
|
cancellable
|
|
);
|
|
} catch (GLib.IOError.CANCELLED err) {
|
|
// All good
|
|
} catch (GLib.Error err) {
|
|
debug("Error searching contacts for completion: %s", err.message);
|
|
}
|
|
|
|
if (!cancellable.is_cancelled()) {
|
|
Gtk.ListStore model = new_model();
|
|
foreach (Application.Contact contact in results) {
|
|
foreach (Geary.RFC822.MailboxAddress addr
|
|
in contact.email_addresses) {
|
|
Gtk.TreeIter iter;
|
|
model.append(out iter);
|
|
model.set(iter, Column.CONTACT, contact);
|
|
model.set(iter, Column.MAILBOX, addr);
|
|
}
|
|
}
|
|
this.model = model;
|
|
complete();
|
|
}
|
|
}
|
|
|
|
private string match_prefix_contact(Geary.RFC822.MailboxAddress mailbox) {
|
|
string email = match_prefix_string(mailbox.address);
|
|
if (mailbox.name != null && !mailbox.is_spoofed()) {
|
|
string real_name = match_prefix_string(mailbox.name);
|
|
// email and real_name were already escaped, then <b></b> tags
|
|
// were added to highlight matches. We don't want to escape
|
|
// them again.
|
|
email = (
|
|
real_name +
|
|
Markup.escape_text(" <") + email + Markup.escape_text(">")
|
|
);
|
|
}
|
|
return email;
|
|
}
|
|
|
|
private string? match_prefix_string(string haystack) {
|
|
string value = haystack;
|
|
if (!Geary.String.is_empty(this.current_key)) {
|
|
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);
|
|
}
|
|
|
|
value = Markup.escape_text(value)
|
|
.replace("‘", "<b>")
|
|
.replace("’", "</b>");
|
|
}
|
|
|
|
return value;
|
|
}
|
|
|
|
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_icon_data(Gtk.CellLayout cell_layout,
|
|
Gtk.CellRenderer cell,
|
|
Gtk.TreeModel tree_model,
|
|
Gtk.TreeIter iter) {
|
|
GLib.Value value;
|
|
tree_model.get_value(iter, Column.CONTACT, out value);
|
|
Application.Contact? contact = value.get_object() as Application.Contact;
|
|
|
|
string icon = "";
|
|
if (contact != null) {
|
|
if (contact.is_favourite) {
|
|
icon = "starred-symbolic";
|
|
} else if (contact.is_desktop_contact) {
|
|
icon = "avatar-default-symbolic";
|
|
}
|
|
}
|
|
|
|
Gtk.CellRendererPixbuf renderer = (Gtk.CellRendererPixbuf) cell;
|
|
renderer.icon_name = icon;
|
|
}
|
|
|
|
private void cell_text_data(Gtk.CellLayout cell_layout,
|
|
Gtk.CellRenderer cell,
|
|
Gtk.TreeModel tree_model,
|
|
Gtk.TreeIter iter) {
|
|
GLib.Value value;
|
|
tree_model.get_value(iter, Column.MAILBOX, out value);
|
|
Geary.RFC822.MailboxAddress? mailbox =
|
|
value.get_object() as Geary.RFC822.MailboxAddress;
|
|
|
|
string markup = "";
|
|
if (mailbox != null) {
|
|
markup = this.match_prefix_contact(mailbox);
|
|
}
|
|
|
|
Gtk.CellRendererText renderer = (Gtk.CellRendererText) cell;
|
|
renderer.markup = markup;
|
|
}
|
|
|
|
private inline Gtk.ListStore new_model() {
|
|
return new Gtk.ListStore.newv(Column.get_types());
|
|
}
|
|
|
|
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
|
|
GLib.Value value;
|
|
model.get_value(iter, Column.MAILBOX, out value);
|
|
Geary.RFC822.MailboxAddress mailbox =
|
|
(Geary.RFC822.MailboxAddress) value.get_object();
|
|
this.email_addresses[this.cursor_at_address] =
|
|
mailbox.to_full_display();
|
|
|
|
// 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;
|
|
}
|
|
|
|
}
|