Commit 66c845c1 authored by Eric Gregory's avatar Eric Gregory

Closes #6771 Search result highlighting

parent 6fb41535
......@@ -103,6 +103,12 @@ public class GearyController {
public signal void conversations_selected(Gee.Set<Geary.Conversation>? 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) {
......
......@@ -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() {
......
......@@ -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<Geary.EmailIdentifier, WebKit.DOM.HTMLElement> email_to_element = new
Gee.HashMap<Geary.EmailIdentifier, WebKit.DOM.HTMLElement>();
// 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 <div> 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<Geary.Conversation>? 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<Geary.EmailIdentifier> ids = new Gee.ArrayList<Geary.EmailIdentifier>();
foreach (Geary.Email email in messages)
ids.add(email.id);
try {
// Request a list of search terms.
Gee.Collection<string>? 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;
}
}
......@@ -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);
}
}
}
......@@ -88,6 +88,9 @@ public abstract class Geary.AbstractAccount : BaseObject, Geary.Account {
Gee.Collection<Geary.FolderPath?>? folder_blacklist = null,
Gee.Collection<Geary.EmailIdentifier>? search_ids = null, Cancellable? cancellable = null) throws Error;
public abstract async Gee.Collection<string>? get_search_keywords_async(
Gee.Collection<Geary.EmailIdentifier> ids, Cancellable? cancellable = null) throws Error;
public virtual string to_string() {
return name;
}
......
......@@ -227,6 +227,13 @@ public interface Geary.Account : BaseObject {
Gee.Collection<Geary.FolderPath?>? folder_blacklist = null,
Gee.Collection<Geary.EmailIdentifier>? 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<string>? get_search_keywords_async(
Gee.Collection<Geary.EmailIdentifier> ids, Cancellable? cancellable = null) throws Error;
/**
* Used only for debugging. Should not be used for user-visible strings.
*/
......
......@@ -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<string>? get_search_keywords_async(
Gee.Collection<Geary.EmailIdentifier> 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());
}
......
......@@ -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<string>? get_search_keywords_async(string prepared_query,
Gee.Collection<Geary.EmailIdentifier> ids, Cancellable? cancellable = null) throws Error {
Gee.Set<string> search_keywords = new Gee.HashSet<string>();
// 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<SearchOffset> all_offsets = new Gee.ArrayList<SearchOffset>();
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))
......
......@@ -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<string>? get_search_keywords_async(
Gee.Collection<Geary.EmailIdentifier> 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);
}
......
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment