From 557eba60bb98fa2fbef847bef6928745188c9c8f Mon Sep 17 00:00:00 2001 From: Alain Date: Tue, 22 Oct 2024 12:08:26 -0500 Subject: [PATCH] Highlight search --- src/SearchView.vala | 78 +++++++++++- src/Synapse.vala | 302 ++++++++++++++++++++++++++++++++++++++++++++ src/meson.build | 3 +- 3 files changed, 377 insertions(+), 6 deletions(-) create mode 100644 src/Synapse.vala diff --git a/src/SearchView.vala b/src/SearchView.vala index a2d2b9f3..0e1dfcf5 100644 --- a/src/SearchView.vala +++ b/src/SearchView.vala @@ -72,7 +72,12 @@ public class Switchboard.SearchView : Gtk.Box { return true; } - return search_text.down () in ((SearchRow) listbox_row).last_item.down (); + bool valid = search_text.down () in ((SearchRow) listbox_row).last_item.down (); + if (valid) { + ((SearchRow) listbox_row).pattern = search_entry.text; + } + + return valid; } private int sort_func (Gtk.ListBoxRow row1, Gtk.ListBoxRow row2) { @@ -142,6 +147,16 @@ public class Switchboard.SearchView : Gtk.Box { public string last_item { get; construct; } public string uri { get; construct; } + private Gtk.Label title; + private Gtk.Label description_label; + + public string pattern { + set { + title.label = markup_string_with_search (last_item, value); + description_label.label = markup_string_with_search (description, value); + } + } + public SearchRow (string icon_name, string description, string uri) { var path = description.split (" → "); var last_item = path[path.length - 1]; @@ -159,14 +174,18 @@ public class Switchboard.SearchView : Gtk.Box { icon_size = LARGE }; - var title = new Gtk.Label (last_item) { - halign = START + title = new Gtk.Label (null) { + halign = START, + use_markup = true }; + title.set_markup (GLib.Markup.escape_text (last_item, -1)); - var description_label = new Gtk.Label (description) { + description_label = new Gtk.Label (null) { ellipsize = MIDDLE, - halign = START + halign = START, + use_markup = true }; + description_label.set_markup (GLib.Markup.escape_text (description, -1)); description_label.add_css_class (Granite.STYLE_CLASS_DIM_LABEL); description_label.add_css_class (Granite.STYLE_CLASS_SMALL_LABEL); @@ -179,5 +198,54 @@ public class Switchboard.SearchView : Gtk.Box { child = grid; } + + private static string markup_string_with_search (string text, string pattern) { + const string MARKUP = "%s"; + + if (pattern == "") { + return MARKUP.printf (Markup.escape_text (text)); + } + + // if no text found, use pattern + if (text == "") { + return MARKUP.printf (Markup.escape_text (pattern)); + } + + var matchers = Synapse.Query.get_matchers_for_query ( + pattern, + 0, + RegexCompileFlags.OPTIMIZE | RegexCompileFlags.CASELESS + ); + + string? highlighted = null; + foreach (var matcher in matchers) { + MatchInfo mi; + if (matcher.key.match (text, 0, out mi)) { + int start_pos; + int end_pos; + int last_pos = 0; + int cnt = mi.get_match_count (); + StringBuilder res = new StringBuilder (); + for (int i = 1; i < cnt; i++) { + mi.fetch_pos (i, out start_pos, out end_pos); + warn_if_fail (start_pos >= 0 && end_pos >= 0); + res.append (Markup.escape_text (text.substring (last_pos, start_pos - last_pos))); + last_pos = end_pos; + res.append (Markup.printf_escaped ("%s", mi.fetch (i))); + if (i == cnt - 1) { + res.append (Markup.escape_text (text.substring (last_pos))); + } + } + highlighted = res.str; + break; + } + } + + if (highlighted != null) { + return MARKUP.printf (highlighted); + } else { + return MARKUP.printf (Markup.escape_text (text)); + } + } } } diff --git a/src/Synapse.vala b/src/Synapse.vala new file mode 100644 index 00000000..2972e23e --- /dev/null +++ b/src/Synapse.vala @@ -0,0 +1,302 @@ +/* +* Copyright (c) 2010 Michal Hruby +* 2017 elementary LLC. +* +* This program is free software; you can redistribute it and/or +* modify it under the terms of the GNU General Public +* License as published by the Free Software Foundation; either +* version 2 of the License, or (at your option) any later version. +* +* This program is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +* General Public License for more details. +* +* You should have received a copy of the GNU General Public +* License along with this program; if not, write to the +* Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, +* Boston, MA 02110-1301 USA +* +* Authored by: Michal Hruby +*/ + +namespace Synapse { + [Flags] + public enum QueryFlags { + /* HowTo create categories (32bit). + * Authored by Alberto Aldegheri + * Categories are "stored" in 3 Levels: + * Super-Category + * -> Category + * ----> Sub-Category + * ------------------------------------ + * if (Super-Category does NOT have childs): + * SUPER = 1 << FreeBitPosition + * else: + * if (Category does NOT have childs) + * CATEGORY = 1 << FreeBitPosition + * else + * SUB = 1 << FreeBitPosition + * CATEGORY = OR ([subcategories, ...]); + * + * SUPER = OR ([categories, ...]); + * + * + * Remember: + * if you add or remove a category, + * change labels in UIInterface.CategoryConfig.init_labels + * + */ + INCLUDE_REMOTE = 1 << 0, + UNCATEGORIZED = 1 << 1, + + APPLICATIONS = 1 << 2, + + ACTIONS = 1 << 3, + + AUDIO = 1 << 4, + VIDEO = 1 << 5, + DOCUMENTS = 1 << 6, + IMAGES = 1 << 7, + FILES = AUDIO | VIDEO | DOCUMENTS | IMAGES, + + PLACES = 1 << 8, + + // FIXME: shouldn't this be FILES | INCLUDE_REMOTE? + INTERNET = 1 << 9, + + // FIXME: Text Query flag? kinda weird, why do we have this here? + TEXT = 1 << 10, + + CONTACTS = 1 << 11, + + ALL = 0xFFFFFFFF, + LOCAL_CONTENT = ALL ^ QueryFlags.INCLUDE_REMOTE + } + + [Flags] + public enum MatcherFlags { + NO_REVERSED = 1 << 0, + NO_SUBSTRING = 1 << 1, + NO_PARTIAL = 1 << 2, + NO_FUZZY = 1 << 3 + } + + public struct Query { + string query_string; + string query_string_folded; + Cancellable cancellable; + QueryFlags query_type; + uint max_results; + uint query_id; + + public Query ( + uint query_id, + string query, + QueryFlags flags = QueryFlags.LOCAL_CONTENT, + uint num_results = 96 + ) { + this.query_id = query_id; + this.query_string = query; + this.query_string_folded = query.casefold (); + this.query_type = flags; + this.max_results = num_results; + } + + public bool is_cancelled () { + return cancellable.is_cancelled (); + } + + public static Gee.List> get_matchers_for_query ( + string query, + MatcherFlags match_flags = 0, + RegexCompileFlags flags = GLib.RegexCompileFlags.OPTIMIZE + ) { + /* create a couple of regexes and try to help with matching + * match with these regular expressions (with descending score): + * 1) ^query$ + * 2) ^query + * 3) \bquery + * 4) split to words and seach \bword1.+\bword2 (if there are 2+ words) + * 5) query + * 6) split to characters and search \bq.+\bu.+\be.+\br.+\by + * 7) split to characters and search \bq.*u.*e.*r.*y + * + * The set of returned regular expressions depends on MatcherFlags. + */ + + var results = new Gee.HashMap (); + Regex re; + + try { + re = new Regex ("^(%s)$".printf (Regex.escape_string (query)), flags); + results[re] = Match.Score.HIGHEST; + } catch (RegexError err) { } + + try { + re = new Regex ("^(%s)".printf (Regex.escape_string (query)), flags); + results[re] = Match.Score.EXCELLENT; + } catch (RegexError err) { } + + try { + re = new Regex ("\\b(%s)".printf (Regex.escape_string (query)), flags); + results[re] = Match.Score.VERY_GOOD; + } catch (RegexError err) { } + + // split to individual chars + string[] individual_words = Regex.split_simple ("\\s+", query.strip ()); + if (individual_words.length >= 2) { + string[] escaped_words = {}; + foreach (unowned string word in individual_words) { + escaped_words += Regex.escape_string (word); + } + string pattern = "\\b(%s)".printf (string.joinv (").+\\b(", escaped_words)); + + try { + re = new Regex (pattern, flags); + results[re] = Match.Score.GOOD; + } catch (RegexError err) { } + + // FIXME: do something generic here + if (!(MatcherFlags.NO_REVERSED in match_flags)) { + if (escaped_words.length == 2) { + var reversed = "\\b(%s)".printf ( + string.join (").+\\b(", escaped_words[1], escaped_words[0], null) + ); + try { + re = new Regex (reversed, flags); + results[re] = Match.Score.GOOD - Match.Score.INCREMENT_MINOR; + } catch (RegexError err) { } + } else { + // not too nice, but is quite fast to compute + var orred = "\\b((?:%s))".printf (string.joinv (")|(?:", escaped_words)); + var any_order = ""; + for (int i = 0; i < escaped_words.length; i++) { + bool is_last = i == escaped_words.length - 1; + any_order += orred; + if (!is_last) { + any_order += ".+"; + } + } + try { + re = new Regex (any_order, flags); + results[re] = Match.Score.AVERAGE + Match.Score.INCREMENT_MINOR; + } catch (RegexError err) { } + } + } + } + + if (!(MatcherFlags.NO_SUBSTRING in match_flags)) { + try { + re = new Regex ("(%s)".printf (Regex.escape_string (query)), flags); + results[re] = Match.Score.BELOW_AVERAGE; + } catch (RegexError err) { } + } + + // split to individual characters + string[] individual_chars = Regex.split_simple ("\\s*", query); + string[] escaped_chars = {}; + foreach (unowned string word in individual_chars) { + escaped_chars += Regex.escape_string (word); + } + + // make "aj" match "Activity Journal" + if ( + !(MatcherFlags.NO_PARTIAL in match_flags) + && individual_words.length == 1 + && individual_chars.length <= 5 + ) { + string pattern = "\\b(%s)".printf (string.joinv (").+\\b(", escaped_chars)); + + try { + re = new Regex (pattern, flags); + results[re] = Match.Score.ABOVE_AVERAGE; + } catch (RegexError err) { } + } + + if (!(MatcherFlags.NO_FUZZY in match_flags) && escaped_chars.length > 0) { + string pattern = "\\b(%s)".printf (string.joinv (").*(", escaped_chars)); + + try { + re = new Regex (pattern, flags); + results[re] = Match.Score.POOR; + } catch (RegexError err) { } + } + + var sorted_results = new Gee.ArrayList> (); + var entries = results.entries; + // FIXME: why it doesn't work without this? + sorted_results.set_data ("entries-ref", entries); + sorted_results.add_all (entries); + sorted_results.sort ((a, b) => { + unowned Gee.Map.Entry e1 = (Gee.Map.Entry) a; + unowned Gee.Map.Entry e2 = (Gee.Map.Entry) b; + return e2.value - e1.value; + }); + + return sorted_results; + } + } +} + +public enum Synapse.MatchType { + UNKNOWN = 0, + TEXT, + CALCULATION, + APPLICATION, + BOOKMARK, + GENERIC_URI, + ACTION, + SEARCH, + CONTACT +} + +public abstract class Synapse.Match: GLib.Object { + public enum Score { + INCREMENT_MINOR = 2000, + INCREMENT_SMALL = 5000, + INCREMENT_MEDIUM = 10000, + INCREMENT_LARGE = 20000, + URI_PENALTY = 15000, + + POOR = 50000, + BELOW_AVERAGE = 60000, + AVERAGE = 70000, + ABOVE_AVERAGE = 75000, + GOOD = 80000, + VERY_GOOD = 85000, + EXCELLENT = 90000, + + HIGHEST = 100000 + } + + // properties + public string title { get; construct set; default = ""; } + public string description { get; set; default = ""; } + public string? icon_name { get; construct set; default = null; } + public bool has_thumbnail { get; construct set; default = false; } + public string? thumbnail_path { get; construct set; default = null; } + public Synapse.MatchType match_type { get; construct set; default = Synapse.MatchType.UNKNOWN; } + + public virtual void execute (Synapse.Match? match) { + critical ("execute () is not implemented"); + } + + public virtual void execute_with_target (Synapse.Match? source, Synapse.Match? target = null) { + if (target == null) { + execute (source); + } else { + critical ("execute () is not implemented"); + } + } + + public virtual bool needs_target () { + return false; + } + + public virtual Synapse.QueryFlags target_flags () { + return Synapse.QueryFlags.ALL; + } + + public signal void executed (); +} \ No newline at end of file diff --git a/src/meson.build b/src/meson.build index bdc4873b..9bbf7931 100644 --- a/src/meson.build +++ b/src/meson.build @@ -3,8 +3,9 @@ switchboard_files = files( 'PlugsSearch.vala', 'CategoryView.vala', 'SearchView.vala', + 'Synapse.vala', 'Widgets/CategoryIcon.vala', - 'Widgets/CategoryFlowBox.vala', + 'Widgets/CategoryFlowBox.vala' ) switchboard_deps = [