Closes #6771 Search result highlighting
This commit is contained in:
parent
6fb415356b
commit
66c845c11b
9 changed files with 254 additions and 21 deletions
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue