diff --git a/helper.py b/helper.py index f6b5abb0..014871df 100755 --- a/helper.py +++ b/helper.py @@ -243,7 +243,7 @@ def create_module(slug): try: tree = xml.etree.ElementTree.parse(panel_filename) except FileNotFoundError: - raise UserException(f"Panel not found at {panel_filename}") + raise UserException(f"Panel not found at {panel_filename}. Run this command with no arguments for instructions to create panels.") components = panel_to_components(tree) print(f"Components extracted from {panel_filename}") diff --git a/include/string.hpp b/include/string.hpp index 76c070e6..ad093e05 100644 --- a/include/string.hpp +++ b/include/string.hpp @@ -18,6 +18,7 @@ std::string f(const char *format, ...); std::string lowercase(const std::string &s); /** Replaces all characters to uppercase letters */ std::string uppercase(const std::string &s); +std::string trim(const std::string &s); /** Truncates and adds "..." to a string, not exceeding `len` characters */ std::string ellipsize(const std::string &s, size_t len); bool startsWith(const std::string &str, const std::string &prefix); @@ -29,6 +30,11 @@ std::string filename(const std::string &path); std::string basename(const std::string &path); /** Extracts the extension of a path */ std::string extension(const std::string &path); +/** Scores how well a query matches a string. +A score of 0 means no match. +The score is arbitrary and is only meaningful for sorting. +*/ +float fuzzyScore(const std::string &s, const std::string &query); struct CaseInsensitiveCompare { bool operator()(const std::string &a, const std::string &b) const { diff --git a/src/app/ModuleBrowser.cpp b/src/app/ModuleBrowser.cpp index 6f63d11e..bf71abdf 100644 --- a/src/app/ModuleBrowser.cpp +++ b/src/app/ModuleBrowser.cpp @@ -31,13 +31,7 @@ namespace app { static std::set sFavoriteModels; -bool isMatch(const std::string &s, const std::string &search) { - std::string s2 = string::lowercase(s); - std::string search2 = string::lowercase(search); - return (s2.find(search2) != std::string::npos); -} - -static bool isModelMatch(plugin::Model *model, const std::string &search) { +static float modelScore(plugin::Model *model, const std::string &search) { if (search.empty()) return true; std::string s; @@ -50,12 +44,13 @@ static bool isModelMatch(plugin::Model *model, const std::string &search) { s += model->slug; s += " "; s += model->name; - for (const std::string &tag : model->tags) { - // TODO Normalize tag - s += tag; - s += " "; - } - return isMatch(s, search); + // for (const std::string &tag : model->tags) { + // s += " "; + // s += tag; + // } + float score = string::fuzzyScore(s, search); + DEBUG("%s %f", s.c_str(), score); + return score; } @@ -374,12 +369,21 @@ struct ModuleBrowser : widget::OpaqueWidget { } void setSearch(const std::string &search) { + std::string searchTrimmed = string::trim(search); + std::map scores; + // Compute scores and set visibility for (Widget *w : modelContainer->children) { ModelBox *modelBox = dynamic_cast(w); assert(modelBox); - bool match = isModelMatch(modelBox->model, search); - modelBox->visible = match; + float score = modelScore(modelBox->model, searchTrimmed); + scores[modelBox] = score; + modelBox->visible = (score > 0); } + DEBUG(""); + // Sort by score + modelContainer->children.sort([&](const Widget *w1, const Widget *w2) { + return scores[w1] > scores[w2]; + }); // Reset scroll position modelScroll->offset = math::Vec(); } diff --git a/src/main.cpp b/src/main.cpp index 09bc61f0..da12615f 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -14,6 +14,7 @@ #include "patch.hpp" #include "ui.hpp" #include "system.hpp" +#include "string.hpp" #include #include diff --git a/src/plugin/Plugin.cpp b/src/plugin/Plugin.cpp index 3b529586..861168bf 100644 --- a/src/plugin/Plugin.cpp +++ b/src/plugin/Plugin.cpp @@ -77,14 +77,14 @@ void Plugin::fromJson(json_t *rootJ) { size_t moduleId; json_t *moduleJ; json_array_foreach(modulesJ, moduleId, moduleJ) { - json_t *slugJ = json_object_get(rootJ, "slug"); - if (!slugJ) + json_t *modelSlugJ = json_object_get(moduleJ, "slug"); + if (!modelSlugJ) continue; - std::string slug = json_string_value(slugJ); + std::string modelSlug = json_string_value(modelSlugJ); - Model *model = getModel(slug); + Model *model = getModel(modelSlug); if (!model) { - WARN("plugin.json contains module \"%s\" but it is not defined in the plugin", slug); + WARN("plugin.json of \"%s\" contains module \"%s\" but it is not defined in the plugin", slug.c_str(), modelSlug.c_str()); continue; } diff --git a/src/string.cpp b/src/string.cpp index eb1342a0..aa9a404a 100644 --- a/src/string.cpp +++ b/src/string.cpp @@ -1,4 +1,5 @@ #include "string.hpp" +#include // for tolower and toupper #include // for transform #include // for dirname and basename @@ -26,16 +27,27 @@ std::string f(const char *format, ...) { std::string lowercase(const std::string &s) { std::string r = s; - std::transform(r.begin(), r.end(), r.begin(), [](unsigned char c){ return std::tolower(c); }); + std::transform(r.begin(), r.end(), r.begin(), [](unsigned char c) { return std::tolower(c); }); return r; } std::string uppercase(const std::string &s) { std::string r = s; - std::transform(r.begin(), r.end(), r.begin(), [](unsigned char c){ return std::toupper(c); }); + std::transform(r.begin(), r.end(), r.begin(), [](unsigned char c) { return std::toupper(c); }); return r; } +std::string trim(const std::string &s) { + const std::string whitespace = " \n\r\t"; + size_t first = s.find_first_not_of(whitespace); + if (first == std::string::npos) + return ""; + size_t last = s.find_last_not_of(whitespace); + if (last == std::string::npos) + return ""; + return s.substr(first, last - first + 1); +} + std::string ellipsize(const std::string &s, size_t len) { if (s.size() <= len) return s; @@ -82,5 +94,158 @@ std::string extension(const std::string &path) { } +/* +From https://github.com/forrestthewoods/lib_fts by Forrest Smith +License: + +This software is dual-licensed to the public domain and under the following +license: you are granted a perpetual, irrevocable license to copy, modify, +publish, and distribute this file as you see fit. +*/ +static bool fuzzy_match_recursive(const char *pattern, const char *str, int &outScore, const char *strBegin, uint8_t const *srcMatches, uint8_t *matches, int maxMatches, int nextMatch, int &recursionCount, int recursionLimit) { + // Count recursions + ++recursionCount; + if (recursionCount >= recursionLimit) + return false; + + // Detect end of strings + if (*pattern == '\0' || *str == '\0') + return false; + + // Recursion params + bool recursiveMatch = false; + uint8_t bestRecursiveMatches[256]; + int bestRecursiveScore = 0; + + // Loop through pattern and str looking for a match + bool first_match = true; + while (*pattern != '\0' && *str != '\0') { + + // Found match + if (std::tolower(*pattern) == std::tolower(*str)) { + + // Supplied matches buffer was too short + if (nextMatch >= maxMatches) + return false; + + // "Copy-on-Write" srcMatches into matches + if (first_match && srcMatches) { + std::memcpy(matches, srcMatches, nextMatch); + first_match = false; + } + + // Recursive call that "skips" this match + uint8_t recursiveMatches[256]; + int recursiveScore; + if (fuzzy_match_recursive(pattern, str + 1, recursiveScore, strBegin, matches, recursiveMatches, sizeof(recursiveMatches), nextMatch, recursionCount, recursionLimit)) { + + // Pick best recursive score + if (!recursiveMatch || recursiveScore > bestRecursiveScore) { + std::memcpy(bestRecursiveMatches, recursiveMatches, 256); + bestRecursiveScore = recursiveScore; + } + recursiveMatch = true; + } + + // Advance + matches[nextMatch++] = (uint8_t)(str - strBegin); + ++pattern; + } + ++str; + } + + // Determine if full pattern was matched + bool matched = *pattern == '\0' ? true : false; + + // Calculate score + if (matched) { + const int sequential_bonus = 5; // bonus for adjacent matches + const int separator_bonus = 30; // bonus if match occurs after a separator + const int camel_bonus = 0; // bonus if match is uppercase and prev is lower + const int first_letter_bonus = 15; // bonus if the first letter is matched + + const int leading_letter_penalty = 0; // penalty applied for every letter in str before the first match + const int max_leading_letter_penalty = 0; // maximum penalty for leading letters + const int unmatched_letter_penalty = -1; // penalty for every letter that doesn't matter + + // Iterate str to end + while (*str != '\0') + ++str; + + // Initialize score + outScore = 100; + + // Apply leading letter penalty + int penalty = leading_letter_penalty * matches[0]; + if (penalty < max_leading_letter_penalty) + penalty = max_leading_letter_penalty; + outScore += penalty; + + // Apply unmatched penalty + int unmatched = (int)(str - strBegin) - nextMatch; + outScore += unmatched_letter_penalty * unmatched; + + // Apply ordering bonuses + for (int i = 0; i < nextMatch; ++i) { + uint8_t currIdx = matches[i]; + + if (i > 0) { + uint8_t prevIdx = matches[i - 1]; + + // Sequential + if (currIdx == (prevIdx + 1)) + outScore += sequential_bonus; + } + + // Check for bonuses based on neighbor character value + if (currIdx > 0) { + // Camel case + char neighbor = strBegin[currIdx - 1]; + char curr = strBegin[currIdx]; + if (std::islower(neighbor) && std::isupper(curr)) + outScore += camel_bonus; + + // Separator + bool neighborSeparator = neighbor == '_' || neighbor == ' '; + if (neighborSeparator) + outScore += separator_bonus; + } + else { + // First letter + outScore += first_letter_bonus; + } + } + } + + // Return best result + if (recursiveMatch && (!matched || bestRecursiveScore > outScore)) { + // Recursive score is better than "this" + std::memcpy(matches, bestRecursiveMatches, maxMatches); + outScore = bestRecursiveScore; + return true; + } + else if (matched) { + // "this" score is better than recursive + return true; + } + else { + // no match + return false; + } +} + + +float fuzzyScore(const std::string &s, const std::string &query) { + uint8_t matches[256]; + int recursionCount = 0; + int recursionLimit = 10; + int score = 0; + bool match = fuzzy_match_recursive(query.c_str(), s.c_str(), score, s.c_str(), + NULL, matches, sizeof(matches), + 0, recursionCount, recursionLimit); + return match ? score : 0.f; +} + + } // namespace string } // namespace rack