diff --git a/src/client/geary-controller.vala b/src/client/geary-controller.vala index 18f645b2..70c047d0 100644 --- a/src/client/geary-controller.vala +++ b/src/client/geary-controller.vala @@ -103,6 +103,12 @@ public class GearyController { public signal void conversations_selected(Gee.Set? conversations, Geary.Folder? current_folder); + /** + * Fired when the search text is changed according to the controller. This accounts + * for a brief typmatic delay. + */ + public signal void search_text_changed(string keywords); + public GearyController() { } @@ -1639,6 +1645,7 @@ public class GearyController { main_window.folder_list.select_folder(previous_non_search_folder); main_window.folder_list.remove_search(); + search_text_changed(""); return; } @@ -1660,6 +1667,7 @@ public class GearyController { } main_window.folder_list.set_search(folder); + search_text_changed(main_window.main_toolbar.search_text); } private void on_search_text_changed(string search_text) { diff --git a/src/client/views/conversation-find-bar.vala b/src/client/views/conversation-find-bar.vala index 929cc78a..9dc92d86 100644 --- a/src/client/views/conversation-find-bar.vala +++ b/src/client/views/conversation-find-bar.vala @@ -22,7 +22,7 @@ public class ConversationFindBar : Gtk.Layout { private Gtk.Builder builder; private Gtk.Box contents_box; private Gtk.Entry entry; - private WebKit.WebView web_view; + private ConversationWebView web_view; private Gtk.Label result_label; private Gtk.CheckButton case_sensitive_check; private Gtk.Button next_button; @@ -31,7 +31,9 @@ public class ConversationFindBar : Gtk.Layout { private uint matches; private bool searching = false; - public ConversationFindBar(WebKit.WebView web_view) { + public signal void close(); + + public ConversationFindBar(ConversationWebView web_view) { this.web_view = web_view; builder = GearyApplication.instance.create_builder("find_bar.glade"); @@ -83,24 +85,14 @@ public class ConversationFindBar : Gtk.Layout { base.show(); - try { - web_view.get_dom_document().get_body().get_class_list().add("nohide"); - } catch (Error error) { - debug("Error setting body class: %s", error.message); - } - fill_entry_with_web_view_selection(); commence_search(); } public override void hide() { base.hide(); + end_search(); - try { - web_view.get_dom_document().get_body().get_class_list().remove("nohide"); - } catch (Error error) { - debug("Error setting body class: %s", error.message); - } } public void focus_entry() { @@ -306,6 +298,7 @@ public class ConversationFindBar : Gtk.Layout { private void on_close_button_clicked() { hide(); + close(); } private void on_case_sensitive_check_toggled() { diff --git a/src/client/views/conversation-viewer.vala b/src/client/views/conversation-viewer.vala index 60be9f66..fead02e8 100644 --- a/src/client/views/conversation-viewer.vala +++ b/src/client/views/conversation-viewer.vala @@ -19,6 +19,25 @@ public class ConversationViewer : Gtk.Box { private const string MESSAGE_CONTAINER_ID = "message_container"; private const string SELECTION_COUNTER_ID = "multiple_messages"; + private enum SearchState { + // Search/find states. + NONE, // Not in search + FIND, // Find toolbar + SEARCH_FOLDER, // Search folder + + COUNT; + } + + private enum SearchEvent { + // User-initated events. + RESET, + OPEN_FIND_BAR, + CLOSE_FIND_BAR, + ENTER_SEARCH_FOLDER, + + COUNT; + } + // Fired when the user clicks a link. public signal void link_selected(string link); @@ -57,6 +76,10 @@ public class ConversationViewer : Gtk.Box { private Gee.HashMap email_to_element = new Gee.HashMap(); + // State machine setup for search/find modes. + private Geary.State.MachineDescriptor search_machine_desc = new Geary.State.MachineDescriptor( + "ConversationViewer search", SearchState.NONE, SearchState.COUNT, SearchEvent.COUNT, null, null); + private string? hover_url = null; private Gtk.Menu? context_menu = null; private Gtk.Menu? message_menu = null; @@ -65,14 +88,37 @@ public class ConversationViewer : Gtk.Box { private Geary.AccountInformation? current_account_information = null; private ConversationFindBar conversation_find_bar; private Cancellable cancellable_fetch = new Cancellable(); + private Geary.State.Machine fsm; public ConversationViewer() { Object(orientation: Gtk.Orientation.VERTICAL, spacing: 0); web_view = new ConversationWebView(); + // Setup state machine for search/find states. + Geary.State.Mapping[] mappings = { + new Geary.State.Mapping(SearchState.NONE, SearchEvent.RESET, on_reset), + new Geary.State.Mapping(SearchState.NONE, SearchEvent.OPEN_FIND_BAR, on_open_find_bar), + new Geary.State.Mapping(SearchState.NONE, SearchEvent.CLOSE_FIND_BAR, Geary.State.nop), + new Geary.State.Mapping(SearchState.NONE, SearchEvent.ENTER_SEARCH_FOLDER, on_enter_search_folder), + + new Geary.State.Mapping(SearchState.FIND, SearchEvent.RESET, on_reset), + new Geary.State.Mapping(SearchState.FIND, SearchEvent.OPEN_FIND_BAR, Geary.State.nop), + new Geary.State.Mapping(SearchState.FIND, SearchEvent.CLOSE_FIND_BAR, on_close_find_bar), + new Geary.State.Mapping(SearchState.FIND, SearchEvent.ENTER_SEARCH_FOLDER, Geary.State.nop), + + new Geary.State.Mapping(SearchState.SEARCH_FOLDER, SearchEvent.RESET, on_reset), + new Geary.State.Mapping(SearchState.SEARCH_FOLDER, SearchEvent.OPEN_FIND_BAR, on_open_find_bar), + new Geary.State.Mapping(SearchState.SEARCH_FOLDER, SearchEvent.CLOSE_FIND_BAR, Geary.State.nop), + new Geary.State.Mapping(SearchState.SEARCH_FOLDER, SearchEvent.ENTER_SEARCH_FOLDER, Geary.State.nop), + }; + + fsm = new Geary.State.Machine(search_machine_desc, mappings, null); + fsm.set_logging(false); + GearyApplication.instance.controller.conversations_selected.connect(on_conversations_selected); GearyApplication.instance.controller.folder_selected.connect(on_folder_selected); + GearyApplication.instance.controller.search_text_changed.connect(on_search_text_changed); web_view.hovering_over_link.connect(on_hovering_over_link); web_view.context_menu.connect(() => { return true; }); // Suppress default context menu. @@ -98,6 +144,7 @@ public class ConversationViewer : Gtk.Box { conversation_find_bar = new ConversationFindBar(web_view); conversation_find_bar.no_show_all = true; + conversation_find_bar.close.connect(() => { fsm.issue(SearchEvent.CLOSE_FIND_BAR); }); pack_start(conversation_find_bar, false); } @@ -120,11 +167,7 @@ public class ConversationViewer : Gtk.Box { email_to_element.clear(); messages.clear(); - current_folder = new_folder; current_account_information = account_information; - - if (conversation_find_bar.visible) - conversation_find_bar.hide(); } // Converts an email ID into HTML ID used by the
for the email. @@ -153,10 +196,20 @@ public class ConversationViewer : Gtk.Box { } private void on_folder_selected(Geary.Folder? folder) { + current_folder = folder; + fsm.issue(SearchEvent.RESET); + if (folder == null) { clear(null, null); current_conversation = null; } + + if (current_folder is Geary.SearchFolder) { + fsm.issue(SearchEvent.ENTER_SEARCH_FOLDER); + web_view.allow_collapsing(false); + } else { + web_view.allow_collapsing(true); + } } private void on_conversations_selected(Gee.Set? conversations, @@ -212,8 +265,12 @@ public class ConversationViewer : Gtk.Box { foreach (Geary.Email email in messages_to_add) add_message(email); - unhide_last_email(); - compress_emails(); + if (current_folder is Geary.SearchFolder) { + yield highlight_search_terms(); + } else { + unhide_last_email(); + compress_emails(); + } } private void on_select_conversation_completed(Object? source, AsyncResult result) { @@ -227,6 +284,37 @@ public class ConversationViewer : Gtk.Box { } } + private void on_search_text_changed() { + highlight_search_terms.begin(); + } + + private async void highlight_search_terms() { + Geary.SearchFolder? search_folder = current_folder as Geary.SearchFolder; + if (search_folder == null) + return; + + // List all IDs of emails we're viewing. + Gee.Collection ids = new Gee.ArrayList(); + foreach (Geary.Email email in messages) + ids.add(email.id); + + try { + // Request a list of search terms. + Gee.Collection? search_keywords = yield search_folder.get_search_keywords_async( + ids, cancellable_fetch); + + // Highlight the search terms. + if (search_keywords != null) { + foreach(string keyword in search_keywords) + web_view.mark_text_matches(keyword, false, 0); + } + } catch (Error e) { + debug("Error highlighting search results: %s", e.message); + } + + web_view.set_highlight_text_matches(true); + } + // Given an email, fetch the full version with all required fields. private async Geary.Email fetch_full_message_async(Geary.Email email) throws Error { Geary.Email.Field required_fields = ConversationViewer.REQUIRED_FIELDS | @@ -1577,7 +1665,7 @@ public class ConversationViewer : Gtk.Box { } public void show_find_bar() { - conversation_find_bar.show(); + fsm.issue(SearchEvent.OPEN_FIND_BAR); conversation_find_bar.focus_entry(); } @@ -1618,5 +1706,46 @@ public class ConversationViewer : Gtk.Box { supports_mark.mark_email_async.begin(ids, null, flags, null); } } + + // State reset. + private uint on_reset(uint state, uint event, void *user, Object? object) { + web_view.set_highlight_text_matches(false); + web_view.allow_collapsing(true); + web_view.unmark_text_matches(); + + if (conversation_find_bar.visible) + conversation_find_bar.hide(); // Close the find bar. + + return SearchState.NONE; + } + + // Find bar opened. + private uint on_open_find_bar(uint state, uint event, void *user, Object? object) { + if (!conversation_find_bar.visible) + conversation_find_bar.show(); + + conversation_find_bar.focus_entry(); + web_view.allow_collapsing(false); + + return SearchState.FIND; + } + + // Find bar closed. + private uint on_close_find_bar(uint state, uint event, void *user, Object? object) { + if (current_folder is Geary.SearchFolder) { + highlight_search_terms.begin(); + + return SearchState.SEARCH_FOLDER; + } else { + web_view.allow_collapsing(true); + + return SearchState.NONE; + } + } + + // Search folder entered. + private uint on_enter_search_folder(uint state, uint event, void *user, Object? object) { + return SearchState.SEARCH_FOLDER; + } } diff --git a/src/client/views/conversation-web-view.vala b/src/client/views/conversation-web-view.vala index 87d6008c..295f8dc6 100644 --- a/src/client/views/conversation-web-view.vala +++ b/src/client/views/conversation-web-view.vala @@ -12,7 +12,8 @@ public class ConversationWebView : WebKit.WebView { private const string USER_CSS = "user-message.css"; private const string STYLE_NAME = "STYLE"; - + private const string PREVENT_HIDE_STYLE = "nohide"; + // HTML element that contains message DIVs. public WebKit.DOM.HTMLDivElement? container { get; private set; default = null; } @@ -300,5 +301,16 @@ public class ConversationWebView : WebKit.WebView { unowned WebKit.WebView r = inspector_view; return r; } + + public void allow_collapsing(bool allow) { + try { + if (allow) + get_dom_document().get_body().get_class_list().remove("nohide"); + else + get_dom_document().get_body().get_class_list().add("nohide"); + } catch (Error error) { + debug("Error setting body class: %s", error.message); + } + } } diff --git a/src/engine/abstract/geary-abstract-account.vala b/src/engine/abstract/geary-abstract-account.vala index 516788e5..e58396e5 100644 --- a/src/engine/abstract/geary-abstract-account.vala +++ b/src/engine/abstract/geary-abstract-account.vala @@ -88,6 +88,9 @@ public abstract class Geary.AbstractAccount : BaseObject, Geary.Account { Gee.Collection? folder_blacklist = null, Gee.Collection? search_ids = null, Cancellable? cancellable = null) throws Error; + public abstract async Gee.Collection? get_search_keywords_async( + Gee.Collection ids, Cancellable? cancellable = null) throws Error; + public virtual string to_string() { return name; } diff --git a/src/engine/api/geary-account.vala b/src/engine/api/geary-account.vala index f33fbbf7..8df584ba 100644 --- a/src/engine/api/geary-account.vala +++ b/src/engine/api/geary-account.vala @@ -227,6 +227,13 @@ public interface Geary.Account : BaseObject { Gee.Collection? folder_blacklist = null, Gee.Collection? search_ids = null, Cancellable? cancellable = null) throws Error; + /** + * Given a list of mail IDs, returns a list of keywords that match for the current + * search keywords. + */ + public abstract async Gee.Collection? get_search_keywords_async( + Gee.Collection ids, Cancellable? cancellable = null) throws Error; + /** * Used only for debugging. Should not be used for user-visible strings. */ diff --git a/src/engine/api/geary-search-folder.vala b/src/engine/api/geary-search-folder.vala index 7c64fa38..eb041199 100644 --- a/src/engine/api/geary-search-folder.vala +++ b/src/engine/api/geary-search-folder.vala @@ -236,6 +236,15 @@ public class Geary.SearchFolder : Geary.AbstractLocalFolder { return yield account.local_fetch_email_async(id, required_fields, cancellable); } + /** + * Given a list of mail IDs, returns a list of keywords that match for the current + * search keywords. + */ + public async Gee.Collection? get_search_keywords_async( + Gee.Collection ids, Cancellable? cancellable = null) throws Error { + return yield account.get_search_keywords_async(ids, cancellable); + } + private void exclude_folder(Geary.Folder folder) { exclude_folders.add(folder.get_path()); } diff --git a/src/engine/imap-db/imap-db-account.vala b/src/engine/imap-db/imap-db-account.vala index 15798757..de339e33 100644 --- a/src/engine/imap-db/imap-db-account.vala +++ b/src/engine/imap-db/imap-db-account.vala @@ -15,6 +15,18 @@ private class Geary.ImapDB.Account : BaseObject { } } + private class SearchOffset { + public int column; // Column in search table + public int byte_offset; // Offset (in bytes) of search term in string + public int size; // Size (in bytes) of the search term in string + + public SearchOffset(string[] offset_string) { + column = int.parse(offset_string[0]); + byte_offset = int.parse(offset_string[2]); + size = int.parse(offset_string[3]); + } + } + // Only available when the Account is opened public SmtpOutboxFolder? outbox { get; private set; default = null; } public SearchFolder? search_folder { get; private set; default = null; } @@ -657,6 +669,58 @@ private class Geary.ImapDB.Account : BaseObject { return (search_results.size == 0 ? null : search_results); } + public async Gee.Collection? get_search_keywords_async(string prepared_query, + Gee.Collection ids, Cancellable? cancellable = null) throws Error { + Gee.Set search_keywords = new Gee.HashSet(); + + // Create a question mark for each ID. + string id_string = ""; + for(int i = 0; i < ids.size; i++) { + id_string += "?"; + if (i != ids.size - 1) + id_string += ", "; + } + + yield db.exec_transaction_async(Db.TransactionType.RO, (cx) => { + Db.Statement stmt = cx.prepare("SELECT offsets(MessageSearchTable), * FROM MessageSearchTable " + + "WHERE MessageSearchTable MATCH ? AND id IN (%s)".printf(id_string)); + + // Bind query and IDs. + int i = 0; + stmt.bind_string(i++, prepared_query); + foreach(Geary.EmailIdentifier id in ids) + stmt.bind_rowid(i++, id.ordering); + + Db.Result result = stmt.exec(cancellable); + while (!result.finished) { + // Build a list of search offsets. + string[] offset_array = result.string_at(0).split(" "); + Gee.ArrayList all_offsets = new Gee.ArrayList(); + int j = 0; + while (true) { + all_offsets.add(new SearchOffset(offset_array[j:j+4])); + + j += 4; + if (j >= offset_array.length) + break; + } + + // Iterate over the offset list, scrape strings from the database, and push + // the results into our return set. + foreach(SearchOffset offset in all_offsets) { + string text = result.string_at(offset.column + 1); + search_keywords.add(text[offset.byte_offset : offset.byte_offset + offset.size].down()); + } + + result.next(cancellable); + } + + return Db.TransactionOutcome.DONE; + }, cancellable); + + return (search_keywords.size == 0 ? null : search_keywords); + } + public async Geary.Email fetch_email_async(Geary.EmailIdentifier email_id, Geary.Email.Field required_fields, Cancellable? cancellable = null) throws Error { if (!(email_id is Geary.ImapDB.EmailIdentifier)) diff --git a/src/engine/imap-engine/imap-engine-generic-account.vala b/src/engine/imap-engine/imap-engine-generic-account.vala index 751d7392..df709168 100644 --- a/src/engine/imap-engine/imap-engine-generic-account.vala +++ b/src/engine/imap-engine/imap-engine-generic-account.vala @@ -19,6 +19,7 @@ private abstract class Geary.ImapEngine.GenericAccount : Geary.AbstractAccount { private uint refresh_folder_timeout_id = 0; private bool in_refresh_enumerate = false; private Cancellable refresh_cancellable = new Cancellable(); + private string previous_prepared_search_query = ""; public GenericAccount(string name, Geary.AccountInformation information, Imap.Account remote, ImapDB.Account local) { @@ -483,10 +484,17 @@ private abstract class Geary.ImapEngine.GenericAccount : Geary.AbstractAccount { if (offset < 0) throw new EngineError.BAD_PARAMETERS("Offset must not be negative"); + previous_prepared_search_query = local.prepare_search_query(keywords); + return yield local.search_async(local.prepare_search_query(keywords), requested_fields, partial_ok, limit, offset, folder_blacklist, search_ids, cancellable); } + public override async Gee.Collection? get_search_keywords_async( + Gee.Collection ids, Cancellable? cancellable = null) throws Error { + return yield local.get_search_keywords_async(previous_prepared_search_query, ids, cancellable); + } + private void on_login_failed(Geary.Credentials? credentials) { do_login_failed_async.begin(credentials); }