2
* Copyright 2016 Software Freedom Conservancy Inc.
3
* Copyright 2019 Michael Gratton <mike@vee.net>
5
* This software is licensed under the GNU Lesser General Public License
6
* (version 2.1 or later). See the COPYING file in this distribution.
9
public class ContactEntryCompletion : Gtk.EntryCompletion, Geary.BaseInterface {
12
// Minimum visibility for the contact to appear in autocompletion.
13
private const Geary.Contact.Importance VISIBILITY_THRESHOLD =
14
Geary.Contact.Importance.RECEIVED_FROM;
21
public static Type[] get_types() {
23
typeof(Application.Contact), // CONTACT
24
typeof(Geary.RFC822.MailboxAddress) // MAILBOX
30
private Application.ContactStore contacts;
32
// Text between the start of the entry or of the previous email
33
// address and the current position of the cursor, if any.
34
private string current_key = "";
36
// List of (possibly incomplete) email addresses in the entry.
37
private string[] email_addresses = {};
39
// Index of the email address the cursor is currently at
40
private int cursor_at_address = -1;
42
private GLib.Cancellable? search_cancellable = null;
43
private Gtk.TreeIter? last_iter = null;
46
public ContactEntryCompletion(Application.ContactStore contacts) {
48
this.contacts = contacts;
49
this.model = new_model();
51
// Always match all rows, since the model will only contain
52
// matching addresses from the search query
53
set_match_func(() => true);
55
Gtk.CellRendererPixbuf icon_renderer = new Gtk.CellRendererPixbuf();
56
icon_renderer.xpad = 2;
57
icon_renderer.ypad = 2;
58
pack_start(icon_renderer, false);
59
set_cell_data_func(icon_renderer, cell_icon_data);
61
Gtk.CellRendererText text_renderer = new Gtk.CellRendererText();
62
icon_renderer.ypad = 2;
63
pack_start(text_renderer, true);
64
set_cell_data_func(text_renderer, cell_text_data);
66
this.match_selected.connect(on_match_selected);
67
this.cursor_on_match.connect(on_cursor_on_match);
70
~ContactEntryCompletion() {
74
public void update_model() {
75
this.last_iter = null;
79
if (this.search_cancellable != null) {
80
this.search_cancellable.cancel();
81
this.search_cancellable = null;
84
Gtk.ListStore model = (Gtk.ListStore) this.model;
85
string completion_key = this.current_key;
86
if (!Geary.String.is_empty_or_whitespace(completion_key)) {
87
// Append a placeholder row here if the model is empty to
88
// work around the issue descried in
89
// https://gitlab.gnome.org/GNOME/gtk/merge_requests/939
91
if (!model.get_iter_first(out iter)) {
92
model.append(out iter);
95
this.search_cancellable = new GLib.Cancellable();
96
this.search_contacts.begin(completion_key, this.search_cancellable);
101
public void trigger_selection() {
102
if (last_iter != null) {
103
on_match_selected(model, last_iter);
108
private void update_addresses() {
109
Gtk.Entry? entry = get_entry() as Gtk.Entry;
111
this.current_key = "";
112
this.cursor_at_address = -1;
113
this.email_addresses = {};
115
string text = entry.get_text();
116
int cursor_pos = entry.get_position();
121
int current_char = 0;
122
bool in_quote = false;
123
while (text.get_next_char(ref next_idx, out c)) {
124
if (current_char == cursor_pos) {
125
this.current_key = text.slice(start_idx, next_idx).strip();
126
this.cursor_at_address = this.email_addresses.length;
132
// Don't include the comma in the address
133
string address = text.slice(start_idx, next_idx -1);
134
this.email_addresses += address.strip();
135
// Don't include it in the next one, either
136
start_idx = next_idx;
141
in_quote = !in_quote;
148
// Add any remaining text after the last comma
149
string address = text.substring(start_idx);
150
this.email_addresses += address.strip();
154
public async void search_contacts(string query,
155
GLib.Cancellable? cancellable) {
156
Gee.Collection<Application.Contact>? results = null;
158
results = yield this.contacts.search(
160
VISIBILITY_THRESHOLD,
164
} catch (GLib.IOError.CANCELLED err) {
166
} catch (GLib.Error err) {
167
debug("Error searching contacts for completion: %s", err.message);
170
if (!cancellable.is_cancelled()) {
171
Gtk.ListStore model = new_model();
172
foreach (Application.Contact contact in results) {
173
foreach (Geary.RFC822.MailboxAddress addr
174
in contact.email_addresses) {
176
model.append(out iter);
177
model.set(iter, Column.CONTACT, contact);
178
model.set(iter, Column.MAILBOX, addr);
186
private string match_prefix_contact(Geary.RFC822.MailboxAddress mailbox) {
187
string email = match_prefix_string(mailbox.address);
188
if (mailbox.name != null && !mailbox.is_spoofed()) {
189
string real_name = match_prefix_string(mailbox.name);
190
// email and real_name were already escaped, then <b></b> tags
191
// were added to highlight matches. We don't want to escape
195
Markup.escape_text(" <") + email + Markup.escape_text(">")
201
private string? match_prefix_string(string haystack) {
202
string value = haystack;
203
if (!Geary.String.is_empty(this.current_key)) {
204
bool matched = false;
206
string escaped_needle = Regex.escape_string(
207
this.current_key.normalize()
209
Regex regex = new Regex(
210
"\\b" + escaped_needle,
211
RegexCompileFlags.CASELESS
213
string haystack_normalized = haystack.normalize();
214
if (regex.match(haystack_normalized)) {
215
value = regex.replace_eval(
216
haystack_normalized, -1, 0, 0, eval_callback
220
} catch (RegexError err) {
221
debug("Error matching regex: %s", err.message);
224
value = Markup.escape_text(value)
225
.replace("‘", "<b>")
226
.replace("’", "</b>");
232
private bool eval_callback(GLib.MatchInfo match_info,
233
GLib.StringBuilder result) {
234
string? match = match_info.fetch(0);
236
result.append("\xc2\x91%s\xc2\x92".printf(match));
237
// This is UTF-8 encoding of U+0091 and U+0092
242
private void cell_icon_data(Gtk.CellLayout cell_layout,
243
Gtk.CellRenderer cell,
244
Gtk.TreeModel tree_model,
247
tree_model.get_value(iter, Column.CONTACT, out value);
248
Application.Contact? contact = value.get_object() as Application.Contact;
251
if (contact != null) {
252
if (contact.is_favourite) {
253
icon = "starred-symbolic";
254
} else if (contact.is_desktop_contact) {
255
icon = "avatar-default-symbolic";
259
Gtk.CellRendererPixbuf renderer = (Gtk.CellRendererPixbuf) cell;
260
renderer.icon_name = icon;
263
private void cell_text_data(Gtk.CellLayout cell_layout,
264
Gtk.CellRenderer cell,
265
Gtk.TreeModel tree_model,
268
tree_model.get_value(iter, Column.MAILBOX, out value);
269
Geary.RFC822.MailboxAddress? mailbox =
270
value.get_object() as Geary.RFC822.MailboxAddress;
273
if (mailbox != null) {
274
markup = this.match_prefix_contact(mailbox);
277
Gtk.CellRendererText renderer = (Gtk.CellRendererText) cell;
278
renderer.markup = markup;
281
private inline Gtk.ListStore new_model() {
282
return new Gtk.ListStore.newv(Column.get_types());
285
private bool on_match_selected(Gtk.TreeModel model, Gtk.TreeIter iter) {
286
Gtk.Entry? entry = get_entry() as Gtk.Entry;
288
// Update the address
290
model.get_value(iter, Column.MAILBOX, out value);
291
Geary.RFC822.MailboxAddress mailbox =
292
(Geary.RFC822.MailboxAddress) value.get_object();
293
this.email_addresses[this.cursor_at_address] =
294
mailbox.to_full_display();
296
// Update the entry text
297
bool current_is_last = (
298
this.cursor_at_address == this.email_addresses.length - 1
300
int new_cursor_pos = -1;
301
GLib.StringBuilder text = new GLib.StringBuilder();
303
while (i < this.email_addresses.length) {
304
text.append(this.email_addresses[i]);
305
if (i == this.cursor_at_address) {
306
new_cursor_pos = text.str.char_count();
310
if (i != this.email_addresses.length || current_is_last) {
314
entry.text = text.str;
315
entry.set_position(current_is_last ? -1 : new_cursor_pos);
320
private bool on_cursor_on_match(Gtk.TreeModel model, Gtk.TreeIter iter) {
321
this.last_iter = iter;