diff options
Diffstat (limited to 'lib/apps')
-rw-r--r-- | lib/apps/application.vala | 135 | ||||
-rw-r--r-- | lib/apps/apps.vala | 128 | ||||
-rw-r--r-- | lib/apps/fuzzy.vala | 73 | ||||
l--------- | lib/apps/gir.py | 1 | ||||
-rw-r--r-- | lib/apps/meson.build | 47 |
5 files changed, 299 insertions, 85 deletions
diff --git a/lib/apps/application.vala b/lib/apps/application.vala index 5748fc6..0a2f73c 100644 --- a/lib/apps/application.vala +++ b/lib/apps/application.vala @@ -1,23 +1,68 @@ -namespace AstalApps { -public class Application : Object { +public class AstalApps.Application : Object { + /** + * The underlying DesktopAppInfo. + */ public DesktopAppInfo app { get; construct set; } + + /** + * The number of times [[email protected]] was called on this Application. + */ public int frequency { get; set; default = 0; } + + /** + * The name of this Application. + */ public string name { get { return app.get_name(); } } + + /** + * Name of the .desktop of this Application. + */ public string entry { get { return app.get_id(); } } + + /** + * Description of this Application. + */ public string description { get { return app.get_description(); } } + + /** + * `StartupWMClass` field from the desktop file. + * This represents the `WM_CLASS` property of the main window of the application. + */ public string wm_class { get { return app.get_startup_wm_class(); } } + + /** + * `Exec` field from the desktop file. + * Note that if you want to launch this Application you should use the [[email protected]] method. + */ public string executable { owned get { return app.get_string("Exec"); } } + + /** + * `Icon` field from the desktop file. + * This is usually a named icon or a path to a file. + */ public string icon_name { owned get { return app.get_string("Icon"); } } + /** + * `Keywords` field from the desktop file. + */ + public string[] keywords { owned get { return app.get_keywords(); } } + internal Application(string id, int? frequency = 0) { Object(app: new DesktopAppInfo(id)); this.frequency = frequency; } + /** + * Get a value from the .desktop file by its key. + */ public string get_key(string key) { return app.get_string(key); } + /** + * Launches this application. + * The launched application inherits the environment of the launching process + */ public bool launch() { try { var s = app.launch(null, null); @@ -29,22 +74,36 @@ public class Application : Object { } } + /** + * Calculate a score for an application using fuzzy matching algorithm. + */ public Score fuzzy_match(string term) { var score = Score(); + if (name != null) - score.name = levenshtein(term, name); + score.name = fuzzy_match_string(term, name); if (entry != null) - score.entry = levenshtein(term, entry); + score.entry = fuzzy_match_string(term, entry); if (executable != null) - score.executable = levenshtein(term, executable); + score.executable = fuzzy_match_string(term, executable); if (description != null) - score.description = levenshtein(term, description); + score.description = fuzzy_match_string(term, description); + foreach (var keyword in keywords) { + var s = fuzzy_match_string(term, keyword); + if (s > score.keywords) { + score.keywords = s; + } + } return score; } + /** + * Calculate a score using exact string algorithm. + */ public Score exact_match(string term) { var score = Score(); + if (name != null) score.name = name.down().contains(term.down()) ? 1 : 0; if (entry != null) @@ -53,12 +112,17 @@ public class Application : Object { score.executable = executable.down().contains(term.down()) ? 1 : 0; if (description != null) score.description = description.down().contains(term.down()) ? 1 : 0; + foreach (var keyword in keywords) { + if (score.keywords == 0) { + score.keywords = keyword.down().contains(term.down()) ? 1 : 0; + } + } return score; } internal Json.Node to_json() { - return new Json.Builder() + var builder = new Json.Builder() .begin_object() .set_member_name("name").add_string_value(name) .set_member_name("entry").add_string_value(entry) @@ -66,53 +130,24 @@ public class Application : Object { .set_member_name("description").add_string_value(description) .set_member_name("icon_name").add_string_value(icon_name) .set_member_name("frequency").add_int_value(frequency) - .end_object() - .get_root(); - } -} - -int min3(int a, int b, int c) { - return (a < b) ? ((a < c) ? a : c) : ((b < c) ? b : c); -} - -double levenshtein(string s1, string s2) { - int len1 = s1.length; - int len2 = s2.length; - - int[, ] d = new int[len1 + 1, len2 + 1]; - - for (int i = 0; i <= len1; i++) { - d[i, 0] = i; - } - for (int j = 0; j <= len2; j++) { - d[0, j] = j; - } + .set_member_name("keywords") + .begin_array(); - for (int i = 1; i <= len1; i++) { - for (int j = 1; j <= len2; j++) { - int cost = (s1[i - 1] == s2[j - 1]) ? 0 : 1; - d[i, j] = min3( - d[i - 1, j] + 1, // deletion - d[i, j - 1] + 1, // insertion - d[i - 1, j - 1] + cost // substitution - ); + foreach (string keyword in keywords) { + builder.add_string_value(keyword); } - } - var distance = d[len1, len2]; - int max_len = len1 > len2 ? len1 : len2; - - if (max_len == 0) { - return 1.0; + return builder + .end_array() + .end_object() + .get_root(); } - - return 1.0 - ((double)distance / max_len); } -public struct Score { - double name; - double entry; - double executable; - double description; -} +public struct AstalApps.Score { + int name; + int entry; + int executable; + int description; + int keywords; } diff --git a/lib/apps/apps.vala b/lib/apps/apps.vala index 2a0d507..dde7d44 100644 --- a/lib/apps/apps.vala +++ b/lib/apps/apps.vala @@ -1,25 +1,84 @@ -namespace AstalApps { -public class Apps : Object { +public class AstalApps.Apps : Object { private string cache_directory; private string cache_file; private List<Application> _list; private HashTable<string, int> frequents { get; private set; } + /** + * Indicates wether hidden applications should included in queries. + */ public bool show_hidden { get; set; } + + /** + * Full list of available applications. + */ public List<weak Application> list { owned get { return _list.copy(); } } - public double min_score { get; set; default = 0.5; } + /** + * The minimum score the application has to meet in order to be included in queries. + */ + public double min_score { get; set; default = 0; } + /** + * Extra multiplier to apply when matching the `name` of an application. + * Defaults to `2` + */ public double name_multiplier { get; set; default = 2; } + + /** + * Extra multiplier to apply when matching the entry of an application. + * Defaults to `1` + */ public double entry_multiplier { get; set; default = 1; } + + /** + * Extra multiplier to apply when matching the executable of an application. + * Defaults to `1` + */ public double executable_multiplier { get; set; default = 1; } + + /** + * Extra multiplier to apply when matching the description of an application. + * Defaults to `0.5` + */ public double description_multiplier { get; set; default = 0.5; } + /** + * Extra multiplier to apply when matching the keywords of an application. + * Defaults to `0.5` + */ + public double keywords_multiplier { get; set; default = 0.5; } + + /** + * Consider the name of an application during queries. + * Defaults to `true` + */ public bool include_name { get; set; default = true; } + + /** + * Consider the entry of an application during queries. + * Defaults to `false` + */ public bool include_entry { get; set; default = false; } + + /** + * Consider the executable of an application during queries. + * Defaults to `false` + */ public bool include_executable { get; set; default = false; } + + /** + * Consider the description of an application during queries. + * Defaults to `false` + */ public bool include_description { get; set; default = false; } + /** + * Consider the keywords of an application during queries. + * Defaults to `false` + */ + public bool include_keywords { get; set; default = false; } + construct { cache_directory = Environment.get_user_cache_dir() + "/astal"; cache_file = cache_directory + "/apps-frequents.json"; @@ -49,23 +108,39 @@ public class Apps : Object { reload(); } - private double score (string search, Application a, bool exact) { - var am = exact ? a.exact_match(search) : a.fuzzy_match(search); + private double score(string search, Application a, SearchAlgorithm alg) { + var s = Score(); double r = 0; - if (include_name) - r += am.name * name_multiplier; - if (include_entry) - r += am.entry * entry_multiplier; - if (include_executable) - r += am.executable * executable_multiplier; - if (include_description) - r += am.description * description_multiplier; + if (alg == FUZZY) s = a.fuzzy_match(search); + if (alg == EXACT) s = a.exact_match(search); + + if (include_name) r += s.name * name_multiplier; + if (include_entry) r += s.entry * entry_multiplier; + if (include_executable) r += s.executable * executable_multiplier; + if (include_description) r += s.description * description_multiplier; + if (include_keywords) r += s.keywords * keywords_multiplier; return r; } - public List<weak Application> query(string? search = "", bool exact = false) { + /** + * Calculate a score for an application using fuzzy matching algorithm. + * Taking this Apps' include settings into consideration . + */ + public double fuzzy_score(string search, Application a) { + return score(search, a, FUZZY); + } + + /** + * Calculate a score for an application using exact string algorithm. + * Taking this Apps' include settings into consideration . + */ + public double exact_score(string search, Application a) { + return score(search, a, EXACT); + } + + internal List<weak Application> query(string? search = "", SearchAlgorithm alg = FUZZY) { if (search == null) search = ""; @@ -83,7 +158,7 @@ public class Apps : Object { // single character, sort by frequency and exact match if (search.length == 1) { foreach (var app in list) { - if (score(search, app, true) == 0) + if (score(search, app, alg) == 0) arr.remove(app); } @@ -96,14 +171,14 @@ public class Apps : Object { // filter foreach (var app in list) { - if (score(search, app, exact) < min_score) + if (score(search, app, alg) < min_score) arr.remove(app); } // sort by score, frequency arr.sort_with_data((a, b) => { - var s1 = score(search, a, exact); - var s2 = score(search, b, exact); + var s1 = score(search, a, alg); + var s2 = score(search, b, alg); if (s1 == s2) return (int)b.frequency - (int)a.frequency; @@ -114,14 +189,23 @@ public class Apps : Object { return arr; } + /** + * Query the `list` of applications with a fuzzy matching algorithm. + */ public List<weak Application> fuzzy_query(string? search = "") { - return query(search, false); + return query(search, FUZZY); } + /** + * Query the `list` of applications with a simple string matching algorithm. + */ public List<weak Application> exact_query(string? search = "") { - return query(search, true); + return query(search, EXACT); } + /** + * Reload the `list` of Applications. + */ public void reload() { var arr = AppInfo.get_all(); @@ -169,4 +253,8 @@ public class Apps : Object { } } } + +private enum AstalApps.SearchAlgorithm { + EXACT, + FUZZY, } diff --git a/lib/apps/fuzzy.vala b/lib/apps/fuzzy.vala new file mode 100644 index 0000000..7745320 --- /dev/null +++ b/lib/apps/fuzzy.vala @@ -0,0 +1,73 @@ +namespace AstalApps { +private int fuzzy_match_string(string pattern, string str) { + const int unmatched_letter_penalty = -1; + int score = 100; + + if (pattern.length == 0) return score; + if (str.length < pattern.length) return int.MIN; + + bool found = fuzzy_match_recurse(pattern, str, score, true, out score); + score += unmatched_letter_penalty * (str.length - pattern.length); + + if(!found) score = -10; + + return score; +} + +private bool fuzzy_match_recurse(string pattern, string str, int score, bool first_char, out int result) { + result = score; + if (pattern.length == 0) return true; + + int match_idx = 0; + int offset = 0; + unichar search = pattern.casefold().get_char(0); + int best_score = int.MIN; + + while ((match_idx = str.casefold().substring(offset).index_of_char(search)) >= 0) { + offset += match_idx; + int subscore; + bool found = fuzzy_match_recurse( + pattern.substring(1), + str.substring(offset + 1), + compute_score(offset, first_char, str, offset), false, out subscore); + if(!found) break; + best_score = int.max(best_score, subscore); + offset++; + } + + if (best_score == int.MIN) return false; + result += best_score; + return true; +} + +private int compute_score(int jump, bool first_char, string match, int idx) { + const int adjacency_bonus = 15; + const int separator_bonus = 30; + const int camel_bonus = 30; + const int first_letter_bonus = 15; + const int leading_letter_penalty = -5; + const int max_leading_letter_penalty = -15; + + int score = 0; + + if (!first_char && jump == 0) { + score += adjacency_bonus; + } + if (!first_char || jump > 0) { + if (match[idx].isupper() && match[idx-1].islower()) { + score += camel_bonus; + } + if (match[idx].isalnum() && !match[idx-1].isalnum()) { + score += separator_bonus; + } + } + if (first_char && jump == 0) { + score += first_letter_bonus; + } + if (first_char) { + score += int.max(leading_letter_penalty * jump, max_leading_letter_penalty); + } + + return score; +} +} diff --git a/lib/apps/gir.py b/lib/apps/gir.py new file mode 120000 index 0000000..b5b4f1d --- /dev/null +++ b/lib/apps/gir.py @@ -0,0 +1 @@ +../gir.py
\ No newline at end of file diff --git a/lib/apps/meson.build b/lib/apps/meson.build index fb87e22..eb7a90b 100644 --- a/lib/apps/meson.build +++ b/lib/apps/meson.build @@ -39,34 +39,41 @@ deps = [ dependency('json-glib-1.0'), ] -sources = [ - config, - 'apps.vala', +sources = [config] + files( 'application.vala', + 'apps.vala', 'cli.vala', -] + 'fuzzy.vala', +) if get_option('lib') lib = library( meson.project_name(), sources, dependencies: deps, + vala_args: ['--vapi-comments'], vala_header: meson.project_name() + '.h', vala_vapi: meson.project_name() + '-' + api_version + '.vapi', - vala_gir: gir, version: meson.project_version(), install: true, - install_dir: [true, true, true, true], + install_dir: [true, true, true], ) - import('pkgconfig').generate( - lib, - name: meson.project_name(), - filebase: meson.project_name() + '-' + api_version, - version: meson.project_version(), - subdirs: meson.project_name(), - requires: deps, - install_dir: get_option('libdir') / 'pkgconfig', + pkgs = [] + foreach dep : deps + pkgs += ['--pkg=' + dep.name()] + endforeach + + gir_tgt = custom_target( + gir, + command: [find_program('python3'), files('gir.py'), meson.project_name(), gir] + + pkgs + + sources, + input: sources, + depends: lib, + output: gir, + install: true, + install_dir: get_option('datadir') / 'gir-1.0', ) custom_target( @@ -79,10 +86,20 @@ if get_option('lib') ], input: lib, output: typelib, - depends: lib, + depends: [lib, gir_tgt], install: true, install_dir: get_option('libdir') / 'girepository-1.0', ) + + import('pkgconfig').generate( + lib, + name: meson.project_name(), + filebase: meson.project_name() + '-' + api_version, + version: meson.project_version(), + subdirs: meson.project_name(), + requires: deps, + install_dir: get_option('libdir') / 'pkgconfig', + ) endif if get_option('cli') |