From 65e703f40281369fcb1c5530488456d02009e9ec Mon Sep 17 00:00:00 2001 From: Aurumbi <109061320+Aurumbi@users.noreply.github.com.> Date: Mon, 27 Apr 2026 10:59:46 +0300 Subject: [PATCH 01/14] Implemented searching algorithm --- mods/core/modules/modmenu_search.py | 110 ++++++++++++++++++++++++++++ 1 file changed, 110 insertions(+) create mode 100644 mods/core/modules/modmenu_search.py diff --git a/mods/core/modules/modmenu_search.py b/mods/core/modules/modmenu_search.py new file mode 100644 index 0000000..3023948 --- /dev/null +++ b/mods/core/modules/modmenu_search.py @@ -0,0 +1,110 @@ +import collections + + +# Copied from nltk (https://www.nltk.org/_modules/nltk/metrics/distance.html#jaro_similarity) +def jaro_similarity(s1, s2): + """ + Computes the Jaro similarity between 2 sequences from: + + Matthew A. Jaro (1989). Advances in record linkage methodology + as applied to the 1985 census of Tampa Florida. Journal of the + American Statistical Association. 84 (406): 414-20. + + The Jaro distance between is the min no. of single-character transpositions + required to change one word into another. The Jaro similarity formula from + https://en.wikipedia.org/wiki/Jaro%E2%80%93Winkler_distance : + + ``jaro_sim = 0 if m = 0 else 1/3 * (m/|s_1| + m/s_2 + (m-t)/m)`` + + where + - `|s_i|` is the length of string `s_i` + - `m` is the no. of matching characters + - `t` is the half no. of possible transpositions. + """ + # First, store the length of the strings + # because they will be re-used several times. + len_s1, len_s2 = len(s1), len(s2) + + # The upper bound of the distance for being a matched character. + match_bound = int(max(len_s1, len_s2) / 2) - 1 + + # Initialize the counts for matches and transpositions. + matches = 0 # no.of matched characters in s1 and s2 + transpositions = 0 # no. of transpositions between s1 and s2 + flagged_1 = [] # positions in s1 which are matches to some character in s2 + flagged_2 = [] # positions in s2 which are matches to some character in s1 + + # Iterate through sequences, check for matches and compute transpositions. + for i in range(len_s1): # Iterate through each character. + upperbound = min(i + match_bound, len_s2 - 1) + lowerbound = max(0, i - match_bound) + for j in range(lowerbound, upperbound + 1): + if s1[i] == s2[j] and j not in flagged_2: + matches += 1 + flagged_1.append(i) + flagged_2.append(j) + break + flagged_2.sort() + for i, j in zip(flagged_1, flagged_2): + if s1[i] != s2[j]: + transpositions += 1 + + if matches == 0: + return 0 + else: + matches = float(matches) + return ( + 1 / 3.0 + * ( + matches / len_s1 + + matches / len_s2 + + (matches - int(transpositions / 2)) / matches + ) + ) + +def jaro_counter_similarity(query_counter, target_counter): + """Finds approximate similarity between word counter query_counter and word counter target_counter. + each word in query_counter is compared against all of target_counter's words to find the best match. + the similarity is then the weighted average of each of those best similarity numbers, weighted by word count in query_counter""" + + best_similarity = {} + for query_word in query_counter.iterkeys(): + curr_best = 0 + for target_word in target_counter.iterkeys(): + curr_best = max(curr_best, jaro_similarity(query_word, target_word)) + best_similarity[query_word] = curr_best + + return sum(best_similarity[query_word] * count for query_word, count in query_counter.iteritems()) / sum(query_counter.itervalues()) + +def jaro_split_compare(query, modlist): + """Compare the modlist to the query using jaro similarity on each word. + :returns dict from modname to similarity tuple, which contains the similarity of query to modname, then the similarity of query to mod description. + """ + comps = {} + query_words = collections.Counter(query.lower().split()) + + for _, name, _, desc, _ in modlist: + name_words = collections.Counter(name.lower().split()) + name_similarity = jaro_counter_similarity(query_words, name_words) + + desc_words = collections.Counter(desc.lower().split()) + desc_similarity = jaro_counter_similarity(query_words, desc_words) + + comps[name] = (name_similarity, desc_similarity) + + return comps + + +def sort_best(query, modlist, return_score = False): + """Sort mods by best match to query""" + similarities = jaro_split_compare(query, modlist) + + mod_order = [entry for entry in sorted(similarities.items(), key=lambda e: max(e[1]), reverse=True)] + + mods_by_name = {mod[1]: mod for mod in modlist} + + if return_score: + return [(mods_by_name[name], score) for name, score in mod_order] + else: + return [mods_by_name[name] for name, _ in mod_order] + From 70ff6176d5a2e91457441e75125b725480c93f6a Mon Sep 17 00:00:00 2001 From: Aurumbi <109061320+Aurumbi@users.noreply.github.com.> Date: Mon, 27 Apr 2026 12:37:06 +0300 Subject: [PATCH 02/14] Implemented mod searchbar --- mods/core/download_mods.rpy | 46 +++++++++++++++++++++++++++++++++---- 1 file changed, 42 insertions(+), 4 deletions(-) diff --git a/mods/core/download_mods.rpy b/mods/core/download_mods.rpy index 8cc6714..0f96ce1 100644 --- a/mods/core/download_mods.rpy +++ b/mods/core/download_mods.rpy @@ -153,6 +153,32 @@ init python: modconfig.steam_modlist_preloader.load() + from modmenu_search import sort_best + import time + + def search_modlist(query): + # As renpy input doesn't allow for additional variables, I've had to resort to this cursed thing + curr_screen_scope = renpy.current_screen().scope + + modlist = curr_screen_scope["contents"] + page = curr_screen_scope["current_page"] + page_size = curr_screen_scope["PAGE_SIZE"] + use_steam = curr_screen_scope["use_steam"] + + s_time = time.time() + if query.strip(): + sorted_ml = sort_best(query, modlist) + else: + sorted_ml = curr_screen_scope["contents"] + print "Search took: {:.5}".format(time.time() - s_time) + + curr_screen_scope["ordered_contents"] = sorted_ml + _refresh_modlist_page(page, page_size, sorted_ml, use_steam) + renpy.restart_interaction() + + return + + init -1 python: import sys import math @@ -393,6 +419,10 @@ screen modmenu_paged(contents, use_steam): $ MIN_PAGE = 1 # Do note, modpage numbers are 1-indexed $ MAX_PAGE = int(math.ceil(len(contents) / float(PAGE_SIZE))) + # ordered_contents used for searchbar sorting. as it needs to be set mainly from that function, we use this if node so that it only gets set here if missing + if not "ordered_contents" in renpy.current_screen().scope: + $ ordered_contents = contents + frame id "modmenu_paged" at alpha_dissolve: add "image/ui/ingame_menu_bg3.png" @@ -440,7 +470,7 @@ screen modmenu_paged(contents, use_steam): ycenter 0.5 # Tried to bind this to shift+scroll, but it didn't work... action [SetScreenVariable("current_page", max(current_page-5, MIN_PAGE)), - Function(_refresh_modlist_page, max(current_page-5, MIN_PAGE), PAGE_SIZE, contents, use_steam=use_steam) + Function(_refresh_modlist_page, max(current_page-5, MIN_PAGE), PAGE_SIZE, ordered_contents, use_steam=use_steam) ] sensitive (current_page > 1) @@ -449,7 +479,7 @@ screen modmenu_paged(contents, use_steam): ycenter 0.5 keysym "mousedown_4" action [SetScreenVariable("current_page", current_page-1), - Function(_refresh_modlist_page, current_page-1, PAGE_SIZE, contents, use_steam=use_steam) + Function(_refresh_modlist_page, current_page-1, PAGE_SIZE, ordered_contents, use_steam=use_steam) ] sensitive (current_page > 1) @@ -464,7 +494,7 @@ screen modmenu_paged(contents, use_steam): ycenter 0.5 keysym "mousedown_5" action [SetScreenVariable("current_page", current_page+1), - Function(_refresh_modlist_page, current_page+1, PAGE_SIZE, contents, use_steam=use_steam) + Function(_refresh_modlist_page, current_page+1, PAGE_SIZE, ordered_contents, use_steam=use_steam) ] sensitive (current_page < MAX_PAGE) @@ -473,10 +503,18 @@ screen modmenu_paged(contents, use_steam): ycenter 0.5 # Also tried to bind this to shift+scroll, but it didn't work... action [SetScreenVariable("current_page", min(current_page+5, MAX_PAGE)), - Function(_refresh_modlist_page, min(current_page+5, MAX_PAGE), PAGE_SIZE, contents, use_steam=use_steam) + Function(_refresh_modlist_page, min(current_page+5, MAX_PAGE), PAGE_SIZE, ordered_contents, use_steam=use_steam) ] sensitive (current_page < MAX_PAGE) + input default "" changed search_modlist: + size 30 +# xpos 0.15 + ypos 0.05 + xcenter 0.15 + yanchor 0.5 + + on "show" action [Function(_refresh_modlist_page, current_page, PAGE_SIZE, contents, use_steam=use_steam), Function(_preload_mod_images, contents, None)] From 7db102eca716106e3a237689a5480ed6c308f5b7 Mon Sep 17 00:00:00 2001 From: Aurumbi <109061320+Aurumbi@users.noreply.github.com.> Date: Mon, 27 Apr 2026 13:03:01 +0300 Subject: [PATCH 03/14] Cached jaro similarity results to significantly improve performance on long queries --- mods/core/modules/modmenu_search.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/mods/core/modules/modmenu_search.py b/mods/core/modules/modmenu_search.py index 3023948..d9f28c0 100644 --- a/mods/core/modules/modmenu_search.py +++ b/mods/core/modules/modmenu_search.py @@ -1,7 +1,17 @@ import collections +def cache(function): + def inner(*args): + if not hasattr(function, "results"): + function.results = {args: function(*args)} + elif args not in function.results: + function.results[args] = function(*args) + return function.results[args] + return inner + # Copied from nltk (https://www.nltk.org/_modules/nltk/metrics/distance.html#jaro_similarity) +@cache def jaro_similarity(s1, s2): """ Computes the Jaro similarity between 2 sequences from: From e3d99e6c16ee67f4fd70fd426113127efc81c6e6 Mon Sep 17 00:00:00 2001 From: Aurumbi <109061320+Aurumbi@users.noreply.github.com.> Date: Tue, 28 Apr 2026 08:46:33 +0300 Subject: [PATCH 04/14] Added word-to-sentence caching to make runtime depend only on longest word in query --- mods/core/modules/modmenu_search.py | 61 +++++++++++++++++++---------- 1 file changed, 41 insertions(+), 20 deletions(-) diff --git a/mods/core/modules/modmenu_search.py b/mods/core/modules/modmenu_search.py index d9f28c0..4e66595 100644 --- a/mods/core/modules/modmenu_search.py +++ b/mods/core/modules/modmenu_search.py @@ -36,7 +36,7 @@ def jaro_similarity(s1, s2): len_s1, len_s2 = len(s1), len(s2) # The upper bound of the distance for being a matched character. - match_bound = int(max(len_s1, len_s2) / 2) - 1 + match_bound = max(int(max(len_s1, len_s2) / 2) - 1, 0) # My one change from the original algorithm: allows two length=1 words to match if they are the same word. # Initialize the counts for matches and transpositions. matches = 0 # no.of matched characters in s1 and s2 @@ -72,20 +72,47 @@ def jaro_similarity(s1, s2): ) ) -def jaro_counter_similarity(query_counter, target_counter): - """Finds approximate similarity between word counter query_counter and word counter target_counter. - each word in query_counter is compared against all of target_counter's words to find the best match. - the similarity is then the weighted average of each of those best similarity numbers, weighted by word count in query_counter""" + + +_jaro_best_match_cache = {} + + +def jaro_counter_similarity(query_counter, target1, target2): + """Finds approximate similarity between word counter query_counter and target word strings target1 and target2. + each word in query_counter is compared against all of target1's and target2's words to find the best match. + the similarity is then the weighted average of each of those best similarity numbers, weighted by word count in query_counter. + these results are cached by target1 for each word of query_counter, and as such, for each value of target1 there should only be a single value of target2.""" - best_similarity = {} - for query_word in query_counter.iterkeys(): - curr_best = 0 - for target_word in target_counter.iterkeys(): - curr_best = max(curr_best, jaro_similarity(query_word, target_word)) - best_similarity[query_word] = curr_best - return sum(best_similarity[query_word] * count for query_word, count in query_counter.iteritems()) / sum(query_counter.itervalues()) + best_similarity_1 = {} + best_similarity_2 = {} + for query_word in query_counter.iterkeys(): + if (query_word, target1) not in _jaro_best_match_cache: + t1_counter = collections.Counter(target1.lower().split()) + curr_best_1 = 0 + for target_word in t1_counter.iterkeys(): + curr_best_1 = max(curr_best_1, jaro_similarity(query_word, target_word)) + best_similarity_1[query_word] = curr_best_1 + + t2_counter = collections.Counter(target2.lower().split()) + curr_best_2 = 0 + for target_word in t2_counter.iterkeys(): + curr_best_2 = max(curr_best_2, jaro_similarity(query_word, target_word)) + best_similarity_2[query_word] = curr_best_2 + + + _jaro_best_match_cache[(query_word, target1)] = (curr_best_1, curr_best_2) + else: + sim1, sim2 = _jaro_best_match_cache[(query_word, target1)] + best_similarity_1[query_word] = sim1 + best_similarity_2[query_word] = sim2 + n_values = sum(query_counter.itervalues()) + return (sum(best_similarity_1[query_word] * count for query_word, count in query_counter.iteritems()) / n_values, + sum(best_similarity_2[query_word] * count for query_word, count in query_counter.iteritems()) / n_values) + + + def jaro_split_compare(query, modlist): """Compare the modlist to the query using jaro similarity on each word. :returns dict from modname to similarity tuple, which contains the similarity of query to modname, then the similarity of query to mod description. @@ -94,18 +121,12 @@ def jaro_split_compare(query, modlist): query_words = collections.Counter(query.lower().split()) for _, name, _, desc, _ in modlist: - name_words = collections.Counter(name.lower().split()) - name_similarity = jaro_counter_similarity(query_words, name_words) - - desc_words = collections.Counter(desc.lower().split()) - desc_similarity = jaro_counter_similarity(query_words, desc_words) - - comps[name] = (name_similarity, desc_similarity) + comps[name] = jaro_counter_similarity(query_words, name, desc) return comps -def sort_best(query, modlist, return_score = False): +def sort_best(query, modlist, return_score=False): """Sort mods by best match to query""" similarities = jaro_split_compare(query, modlist) From 6fa789912d6d1ab520ee95ff7802ed3c73b3b29a Mon Sep 17 00:00:00 2001 From: Aurumbi <109061320+Aurumbi@users.noreply.github.com.> Date: Tue, 28 Apr 2026 09:33:43 +0300 Subject: [PATCH 05/14] Improved look of search bar --- mods/core/download_mods.rpy | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/mods/core/download_mods.rpy b/mods/core/download_mods.rpy index 0f96ce1..05dea49 100644 --- a/mods/core/download_mods.rpy +++ b/mods/core/download_mods.rpy @@ -508,10 +508,11 @@ screen modmenu_paged(contents, use_steam): sensitive (current_page < MAX_PAGE) input default "" changed search_modlist: - size 30 -# xpos 0.15 + size 34 + color "#FFF000" + xpos 0.034 ypos 0.05 - xcenter 0.15 + xanchor 0.0 yanchor 0.5 From 6e678c8377d54c5697b064b906832e52acbc9d42 Mon Sep 17 00:00:00 2001 From: Aurumbi <109061320+Aurumbi@users.noreply.github.com.> Date: Tue, 28 Apr 2026 09:35:01 +0300 Subject: [PATCH 06/14] Added modname bias to search function to produce better results --- mods/core/modules/modmenu_search.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/mods/core/modules/modmenu_search.py b/mods/core/modules/modmenu_search.py index 4e66595..31922d1 100644 --- a/mods/core/modules/modmenu_search.py +++ b/mods/core/modules/modmenu_search.py @@ -130,7 +130,10 @@ def sort_best(query, modlist, return_score=False): """Sort mods by best match to query""" similarities = jaro_split_compare(query, modlist) - mod_order = [entry for entry in sorted(similarities.items(), key=lambda e: max(e[1]), reverse=True)] + # Sort by best match, with bias to strong modname matches + # This bias is useful as the description normally takes the stronger value, unless the mod name is searched specifically. + # Max gave me better results than sum, so I used it. + mod_order = [entry for entry in sorted(similarities.items(), key=lambda e: (max(e[1]) + int(e[1][0] > 0.9) * e[1][0]), reverse=True)] mods_by_name = {mod[1]: mod for mod in modlist} From 7c48d817a7f2c7f8f408b785488ae6de9ce13c69 Mon Sep 17 00:00:00 2001 From: Aurumbi <109061320+Aurumbi@users.noreply.github.com.> Date: Tue, 12 May 2026 10:08:50 +0300 Subject: [PATCH 07/14] Cleared image cache on enter and exit of the modmenu to avoid out of memory issues --- mods/core/download_mods.rpy | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/mods/core/download_mods.rpy b/mods/core/download_mods.rpy index 05dea49..eab8c94 100644 --- a/mods/core/download_mods.rpy +++ b/mods/core/download_mods.rpy @@ -517,9 +517,12 @@ screen modmenu_paged(contents, use_steam): on "show" action [Function(_refresh_modlist_page, current_page, PAGE_SIZE, contents, use_steam=use_steam), - Function(_preload_mod_images, contents, None)] + Function(_preload_mod_images, contents, None), + Function(im.cache.clear) # I tended to get 'out of memory' errors on this menu, so we use this precaution + ] - on "hide" action [Function(mod_image_preloader.clear)] + on "hide" action [Function(mod_image_preloader.clear), # Cleanup after ourselves + Function(im.cache.clear)] From 5044022cb42c3ac4978e4688cc1ddc8d20a5b1e6 Mon Sep 17 00:00:00 2001 From: Aurumbi <109061320+Aurumbi@users.noreply.github.com.> Date: Sun, 24 May 2026 12:20:31 +0300 Subject: [PATCH 08/14] Actually used a 'default' statement instead of that weird construct. Improved naming and clarity. --- mods/core/download_mods.rpy | 29 +++++++++++++---------------- 1 file changed, 13 insertions(+), 16 deletions(-) diff --git a/mods/core/download_mods.rpy b/mods/core/download_mods.rpy index eab8c94..de01858 100644 --- a/mods/core/download_mods.rpy +++ b/mods/core/download_mods.rpy @@ -153,27 +153,26 @@ init python: modconfig.steam_modlist_preloader.load() - from modmenu_search import sort_best + import modmenu_search import time def search_modlist(query): - # As renpy input doesn't allow for additional variables, I've had to resort to this cursed thing + # As renpy input doesn't allow for additional variables to this method, I've had to resort to this cursed thing curr_screen_scope = renpy.current_screen().scope - modlist = curr_screen_scope["contents"] page = curr_screen_scope["current_page"] page_size = curr_screen_scope["PAGE_SIZE"] use_steam = curr_screen_scope["use_steam"] s_time = time.time() - if query.strip(): - sorted_ml = sort_best(query, modlist) + if query.strip(): # There's no reason to reorder the modlist if no search has been done. + reordered_modlist = modmenu_search.sort_best(query, modlist) else: - sorted_ml = curr_screen_scope["contents"] - print "Search took: {:.5}".format(time.time() - s_time) + reordered_modlist = curr_screen_scope["contents"] + print "Search took: {:.5}".format(time.time() - s_time) # Hopefully this never goes above 0.3 - curr_screen_scope["ordered_contents"] = sorted_ml - _refresh_modlist_page(page, page_size, sorted_ml, use_steam) + curr_screen_scope["search_order_contents"] = reordered_modlist + _refresh_modlist_page(page, page_size, reordered_modlist, use_steam) renpy.restart_interaction() return @@ -419,9 +418,7 @@ screen modmenu_paged(contents, use_steam): $ MIN_PAGE = 1 # Do note, modpage numbers are 1-indexed $ MAX_PAGE = int(math.ceil(len(contents) / float(PAGE_SIZE))) - # ordered_contents used for searchbar sorting. as it needs to be set mainly from that function, we use this if node so that it only gets set here if missing - if not "ordered_contents" in renpy.current_screen().scope: - $ ordered_contents = contents + default search_order_contents = contents frame id "modmenu_paged" at alpha_dissolve: add "image/ui/ingame_menu_bg3.png" @@ -470,7 +467,7 @@ screen modmenu_paged(contents, use_steam): ycenter 0.5 # Tried to bind this to shift+scroll, but it didn't work... action [SetScreenVariable("current_page", max(current_page-5, MIN_PAGE)), - Function(_refresh_modlist_page, max(current_page-5, MIN_PAGE), PAGE_SIZE, ordered_contents, use_steam=use_steam) + Function(_refresh_modlist_page, max(current_page-5, MIN_PAGE), PAGE_SIZE, search_order_contents, use_steam=use_steam) ] sensitive (current_page > 1) @@ -479,7 +476,7 @@ screen modmenu_paged(contents, use_steam): ycenter 0.5 keysym "mousedown_4" action [SetScreenVariable("current_page", current_page-1), - Function(_refresh_modlist_page, current_page-1, PAGE_SIZE, ordered_contents, use_steam=use_steam) + Function(_refresh_modlist_page, current_page-1, PAGE_SIZE, search_order_contents, use_steam=use_steam) ] sensitive (current_page > 1) @@ -494,7 +491,7 @@ screen modmenu_paged(contents, use_steam): ycenter 0.5 keysym "mousedown_5" action [SetScreenVariable("current_page", current_page+1), - Function(_refresh_modlist_page, current_page+1, PAGE_SIZE, ordered_contents, use_steam=use_steam) + Function(_refresh_modlist_page, current_page+1, PAGE_SIZE, search_order_contents, use_steam=use_steam) ] sensitive (current_page < MAX_PAGE) @@ -503,7 +500,7 @@ screen modmenu_paged(contents, use_steam): ycenter 0.5 # Also tried to bind this to shift+scroll, but it didn't work... action [SetScreenVariable("current_page", min(current_page+5, MAX_PAGE)), - Function(_refresh_modlist_page, min(current_page+5, MAX_PAGE), PAGE_SIZE, ordered_contents, use_steam=use_steam) + Function(_refresh_modlist_page, min(current_page+5, MAX_PAGE), PAGE_SIZE, search_order_contents, use_steam=use_steam) ] sensitive (current_page < MAX_PAGE) From bbbf7746fe18023babdc54d30e42169f7e40d431 Mon Sep 17 00:00:00 2001 From: Aurumbi <109061320+Aurumbi@users.noreply.github.com.> Date: Sat, 30 May 2026 15:39:48 +0300 Subject: [PATCH 09/14] Cleared the search cache on mod menu close --- mods/core/download_mods.rpy | 4 +++- mods/core/modules/modmenu_search.py | 17 ++++++++++++----- 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/mods/core/download_mods.rpy b/mods/core/download_mods.rpy index de01858..d127f86 100644 --- a/mods/core/download_mods.rpy +++ b/mods/core/download_mods.rpy @@ -519,7 +519,9 @@ screen modmenu_paged(contents, use_steam): ] on "hide" action [Function(mod_image_preloader.clear), # Cleanup after ourselves - Function(im.cache.clear)] + Function(im.cache.clear), + Function(modmenu_search.clear_cache), + ] diff --git a/mods/core/modules/modmenu_search.py b/mods/core/modules/modmenu_search.py index 31922d1..62df791 100644 --- a/mods/core/modules/modmenu_search.py +++ b/mods/core/modules/modmenu_search.py @@ -3,11 +3,14 @@ def cache(function): def inner(*args): - if not hasattr(function, "results"): - function.results = {args: function(*args)} - elif args not in function.results: - function.results[args] = function(*args) - return function.results[args] + if not hasattr(inner, "results"): + inner.results = {args: function(*args)} + elif args not in inner.results: + inner.results[args] = function(*args) + return inner.results[args] + def clear_cache(): + inner.results.clear() + inner.clear_cache = clear_cache return inner # Copied from nltk (https://www.nltk.org/_modules/nltk/metrics/distance.html#jaro_similarity) @@ -142,3 +145,7 @@ def sort_best(query, modlist, return_score=False): else: return [mods_by_name[name] for name, _ in mod_order] + +def clear_cache(): + _jaro_best_match_cache.clear() + jaro_similarity.clear_cache() \ No newline at end of file From 260fc5a928f7afdb51409a562d4eedb0edaa263a Mon Sep 17 00:00:00 2001 From: Aurumbi <109061320+Aurumbi@users.noreply.github.com.> Date: Mon, 1 Jun 2026 18:58:30 +0300 Subject: [PATCH 10/14] Fixed modmenu_search.clear_cache crash when no searches have been done --- mods/core/modules/modmenu_search.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/mods/core/modules/modmenu_search.py b/mods/core/modules/modmenu_search.py index 62df791..dae935d 100644 --- a/mods/core/modules/modmenu_search.py +++ b/mods/core/modules/modmenu_search.py @@ -9,7 +9,8 @@ def inner(*args): inner.results[args] = function(*args) return inner.results[args] def clear_cache(): - inner.results.clear() + if hasattr(inner, "results"): + inner.results.clear() inner.clear_cache = clear_cache return inner From 468787fa1e1c833d65bc03d7ebe046badb1894e7 Mon Sep 17 00:00:00 2001 From: Aurumbi <109061320+Aurumbi@users.noreply.github.com.> Date: Mon, 1 Jun 2026 18:58:35 +0300 Subject: [PATCH 11/14] Added a second input box for mod author name. Mod author search not implemented --- mods/core/download_mods.rpy | 99 ++++++++++++++++++++++++++++++++++--- 1 file changed, 92 insertions(+), 7 deletions(-) diff --git a/mods/core/download_mods.rpy b/mods/core/download_mods.rpy index d127f86..ac1fa9f 100644 --- a/mods/core/download_mods.rpy +++ b/mods/core/download_mods.rpy @@ -156,7 +156,22 @@ init python: import modmenu_search import time - def search_modlist(query): + def set_query(value): + curr_screen_scope = renpy.current_screen().scope + curr_screen_scope["query"] = value + search_modlist(value, curr_screen_scope["author_query"]) + return + + def set_author_query(value): + curr_screen_scope = renpy.current_screen().scope + curr_screen_scope["author_query"] = value + search_modlist(curr_screen_scope["query"], value) + return + + + def search_modlist(query, author_query=""): + print "searching with {}, {}".format(query, author_query) + # As renpy input doesn't allow for additional variables to this method, I've had to resort to this cursed thing curr_screen_scope = renpy.current_screen().scope modlist = curr_screen_scope["contents"] @@ -419,6 +434,8 @@ screen modmenu_paged(contents, use_steam): $ MAX_PAGE = int(math.ceil(len(contents) / float(PAGE_SIZE))) default search_order_contents = contents + default query = "" + default author_query = "" frame id "modmenu_paged" at alpha_dissolve: add "image/ui/ingame_menu_bg3.png" @@ -504,13 +521,81 @@ screen modmenu_paged(contents, use_steam): ] sensitive (current_page < MAX_PAGE) - input default "" changed search_modlist: - size 34 - color "#FFF000" - xpos 0.034 - ypos 0.05 + + hbox: + xpos 65 + ypos 10 xanchor 0.0 - yanchor 0.5 + yanchor 0.0 + + xsize 425 + ysize 74 + + spacing 10 + + vbox: + xalign 0.0 + ycenter 0.5 + xsize 75 + yfill True + spacing 6 + + label "author:": + text_size 24 + ysize 32 + xalign 0.0 + + label "mod:": + text_size 24 + ysize 32 + xalign 0.0 + + vbox: + xalign 0.0 + ycenter 0.5 + xfill True + yfill True + spacing 6 + + button: + background "#000000CD" + hover_background "#000040CD" + activate_sound None + action NullAction() + xfill True + ysize 32 + top_padding 5 + bottom_padding -5 + xpadding 0 + + input: + color "#FF7F00" + xalign 0.0 + ycenter 0.5 + size 24 + pixel_width 320 + changed set_author_query + + + + button: + background "#000000CD" + hover_background "#000040CD" + activate_sound None + action NullAction() + xfill True + ysize 32 + top_padding 5 + bottom_padding -5 + xpadding 0 + + input: + color "#FFFF00" + xalign 0.0 + ycenter 0.5 + size 24 + pixel_width 320 + changed set_query on "show" action [Function(_refresh_modlist_page, current_page, PAGE_SIZE, contents, use_steam=use_steam), From 064740d26f04f5e11255d3cedb7de2bec4c1f6f0 Mon Sep 17 00:00:00 2001 From: Aurumbi <109061320+Aurumbi@users.noreply.github.com.> Date: Tue, 2 Jun 2026 09:31:20 +0300 Subject: [PATCH 12/14] Visual and usability improvements to the search textboxes. textboxes receive focus by selection instead of by hovering. --- mods/core/download_mods.rpy | 44 +++++++++++++++++++------------------ 1 file changed, 23 insertions(+), 21 deletions(-) diff --git a/mods/core/download_mods.rpy b/mods/core/download_mods.rpy index ac1fa9f..2d1201c 100644 --- a/mods/core/download_mods.rpy +++ b/mods/core/download_mods.rpy @@ -527,9 +527,8 @@ screen modmenu_paged(contents, use_steam): ypos 10 xanchor 0.0 yanchor 0.0 - xsize 425 - ysize 74 + ysize 70 spacing 10 @@ -537,15 +536,17 @@ screen modmenu_paged(contents, use_steam): xalign 0.0 ycenter 0.5 xsize 75 - yfill True spacing 6 - label "author:": + # For some reason 'label' and 'text' text components insisted on being ever so slightly larger than necessary, which made everything look misaligned + textbutton "author:": + background "#00000000" text_size 24 ysize 32 xalign 0.0 - label "mod:": + textbutton "mod:": + background "#00000000" text_size 24 ysize 32 xalign 0.0 @@ -553,49 +554,50 @@ screen modmenu_paged(contents, use_steam): vbox: xalign 0.0 ycenter 0.5 - xfill True - yfill True spacing 6 + default query_input_capture_focus = False + default author_query_input_capture_focus = False + + # input components aggressively capture focus, to the point where you can't use more than one of them in a single screen. + # a button is used to circumvent this, as it is a container that can itself hold focus, so it is able to intercept the aggressive behaviour. button: - background "#000000CD" - hover_background "#000040CD" + background If(author_query_input_capture_focus, "#FFFFFFCD", "#000000CD") + hover_background If(author_query_input_capture_focus, "#BFBFFFCD", "#000040CD") activate_sound None - action NullAction() + key_events author_query_input_capture_focus + action [ToggleScreenVariable("author_query_input_capture_focus"), SetScreenVariable("query_input_capture_focus", False)] xfill True ysize 32 - top_padding 5 - bottom_padding -5 xpadding 0 input: - color "#FF7F00" + color If(author_query_input_capture_focus, "#000", "#FF7F00") xalign 0.0 ycenter 0.5 size 24 - pixel_width 320 + pixel_width 320 # While the horizontal space is supposed to be 340, The inputs have a tendency to drop down a row... changed set_author_query - button: - background "#000000CD" - hover_background "#000040CD" + background If(query_input_capture_focus, "#FFFFFFCD", "#000000CD") + hover_background If(query_input_capture_focus, "#BFBFFFCD", "#000040CD") activate_sound None - action NullAction() + key_events query_input_capture_focus + action [ToggleScreenVariable("query_input_capture_focus"), SetScreenVariable("author_query_input_capture_focus", False)] xfill True ysize 32 - top_padding 5 - bottom_padding -5 xpadding 0 input: - color "#FFFF00" + color If(query_input_capture_focus, "#000", "#FFFF00") xalign 0.0 ycenter 0.5 size 24 pixel_width 320 changed set_query + key "K_ESCAPE" action [SetScreenVariable("query_input_capture_focus", False), SetScreenVariable("author_query_input_capture_focus", False)] on "show" action [Function(_refresh_modlist_page, current_page, PAGE_SIZE, contents, use_steam=use_steam), From 9ef1b208047451e5f577100d2608957c8c98ef91 Mon Sep 17 00:00:00 2001 From: Aurumbi <109061320+Aurumbi@users.noreply.github.com.> Date: Thu, 4 Jun 2026 14:36:43 +0300 Subject: [PATCH 13/14] Author name search implemented. Tab can be used to switch between the input fields. --- mods/core/download_mods.rpy | 34 ++++++++++++++++------------- mods/core/modules/modmenu_search.py | 30 +++++++++++++++++++++---- 2 files changed, 45 insertions(+), 19 deletions(-) diff --git a/mods/core/download_mods.rpy b/mods/core/download_mods.rpy index 2d1201c..5d75626 100644 --- a/mods/core/download_mods.rpy +++ b/mods/core/download_mods.rpy @@ -180,8 +180,8 @@ init python: use_steam = curr_screen_scope["use_steam"] s_time = time.time() - if query.strip(): # There's no reason to reorder the modlist if no search has been done. - reordered_modlist = modmenu_search.sort_best(query, modlist) + if query.strip() or author_query.strip(): # There's no reason to reorder the modlist if no search has been done. + reordered_modlist = modmenu_search.sort_best(query, modlist, author_query=author_query) else: reordered_modlist = curr_screen_scope["contents"] print "Search took: {:.5}".format(time.time() - s_time) # Hopefully this never goes above 0.3 @@ -556,23 +556,23 @@ screen modmenu_paged(contents, use_steam): ycenter 0.5 spacing 6 - default query_input_capture_focus = False - default author_query_input_capture_focus = False + default focus_query_input = False + default focus_author_query_input = False # input components aggressively capture focus, to the point where you can't use more than one of them in a single screen. # a button is used to circumvent this, as it is a container that can itself hold focus, so it is able to intercept the aggressive behaviour. button: - background If(author_query_input_capture_focus, "#FFFFFFCD", "#000000CD") - hover_background If(author_query_input_capture_focus, "#BFBFFFCD", "#000040CD") + background If(focus_author_query_input, "#FFFFFFCD", "#000000CD") + hover_background If(focus_author_query_input, "#BFBFFFCD", "#000040CD") activate_sound None - key_events author_query_input_capture_focus - action [ToggleScreenVariable("author_query_input_capture_focus"), SetScreenVariable("query_input_capture_focus", False)] + key_events focus_author_query_input + action [ToggleScreenVariable("focus_author_query_input"), SetScreenVariable("focus_query_input", False)] xfill True ysize 32 xpadding 0 input: - color If(author_query_input_capture_focus, "#000", "#FF7F00") + color If(focus_author_query_input, "#000", "#FF7F00") xalign 0.0 ycenter 0.5 size 24 @@ -581,23 +581,27 @@ screen modmenu_paged(contents, use_steam): button: - background If(query_input_capture_focus, "#FFFFFFCD", "#000000CD") - hover_background If(query_input_capture_focus, "#BFBFFFCD", "#000040CD") + background If(focus_query_input, "#FFFFFFCD", "#000000CD") + hover_background If(focus_query_input, "#BFBFFFCD", "#000040CD") activate_sound None - key_events query_input_capture_focus - action [ToggleScreenVariable("query_input_capture_focus"), SetScreenVariable("author_query_input_capture_focus", False)] + key_events focus_query_input + action [ToggleScreenVariable("focus_query_input"), SetScreenVariable("focus_author_query_input", False)] xfill True ysize 32 xpadding 0 input: - color If(query_input_capture_focus, "#000", "#FFFF00") + color If(focus_query_input, "#000", "#FFFF00") xalign 0.0 ycenter 0.5 size 24 pixel_width 320 changed set_query - key "K_ESCAPE" action [SetScreenVariable("query_input_capture_focus", False), SetScreenVariable("author_query_input_capture_focus", False)] + key "K_ESCAPE" action [SetScreenVariable("focus_query_input", False), SetScreenVariable("focus_author_query_input", False)] + key "K_TAB" action [ToggleScreenVariable("focus_author_query_input"), + If(focus_author_query_input, + ToggleScreenVariable("focus_query_input"), + SetScreenVariable("focus_query_input", False))] on "show" action [Function(_refresh_modlist_page, current_page, PAGE_SIZE, contents, use_steam=use_steam), diff --git a/mods/core/modules/modmenu_search.py b/mods/core/modules/modmenu_search.py index dae935d..2e4ee93 100644 --- a/mods/core/modules/modmenu_search.py +++ b/mods/core/modules/modmenu_search.py @@ -129,15 +129,37 @@ def jaro_split_compare(query, modlist): return comps +def jaro_author_compare(author_query, modlist): + comps = {} + author_query = author_query.lower() + + for _, name, author, _, _ in modlist: + comps[name] = jaro_similarity(author_query, author.lower()) + + return comps -def sort_best(query, modlist, return_score=False): + +def sort_best(query, modlist, author_query="", return_score=False): """Sort mods by best match to query""" - similarities = jaro_split_compare(query, modlist) + if query.strip(): + similarities = jaro_split_compare(query, modlist) + else: + similarities = {name: (0.0, 0.0) for _, name, _, _, _ in modlist} + if author_query.strip(): + author_similarities = jaro_author_compare(author_query, modlist) + else: + author_similarities = {name: 0.0 for name in similarities.iterkeys()} + similarities = {name: scores + (author_similarities[name],) for name, scores in similarities.iteritems()} # Much easier to deal with if it's a single iterable - # Sort by best match, with bias to strong modname matches + # Sort by best match, with bias to strong modname matches and strong authorname matches # This bias is useful as the description normally takes the stronger value, unless the mod name is searched specifically. # Max gave me better results than sum, so I used it. - mod_order = [entry for entry in sorted(similarities.items(), key=lambda e: (max(e[1]) + int(e[1][0] > 0.9) * e[1][0]), reverse=True)] + comp_func = lambda e: (max(e[1][:2]) + (int(e[1][0] > 0.9) * e[1][0]) + (int(e[1][2] > 0.7) * e[1][2])) + mod_order = list(sorted(similarities.items(), key=comp_func, reverse=True)) + + if comp_func(mod_order[0]) <= 0.3: # All bad matches, don't reorder + print "No good matches. reordering suppressed" + mod_order = list((name, 0.0) for _, name, _, _, _ in modlist) mods_by_name = {mod[1]: mod for mod in modlist} From b56155c1e91c5ec752367c608d97411a20871b1e Mon Sep 17 00:00:00 2001 From: Aurumbi <109061320+Aurumbi@users.noreply.github.com.> Date: Thu, 4 Jun 2026 14:43:37 +0300 Subject: [PATCH 14/14] search function transitioned from collections.Counters to sets, as word counts haven't actually been used --- mods/core/modules/modmenu_search.py | 33 +++++++++++++---------------- 1 file changed, 15 insertions(+), 18 deletions(-) diff --git a/mods/core/modules/modmenu_search.py b/mods/core/modules/modmenu_search.py index 2e4ee93..52030bb 100644 --- a/mods/core/modules/modmenu_search.py +++ b/mods/core/modules/modmenu_search.py @@ -1,5 +1,3 @@ -import collections - def cache(function): def inner(*args): @@ -81,39 +79,38 @@ def jaro_similarity(s1, s2): _jaro_best_match_cache = {} -def jaro_counter_similarity(query_counter, target1, target2): - """Finds approximate similarity between word counter query_counter and target word strings target1 and target2. - each word in query_counter is compared against all of target1's and target2's words to find the best match. - the similarity is then the weighted average of each of those best similarity numbers, weighted by word count in query_counter. - these results are cached by target1 for each word of query_counter, and as such, for each value of target1 there should only be a single value of target2.""" +def jaro_set_similarity(query_set, target1, target2): + """Finds approximate similarity between word set query_set and target word strings target1 and target2. + each word in query_set is compared against all of target1's and target2's words to find the best match. + the similarity is then the average of each of those best similarity numbers. + these results are cached by target1 for each word of query_set, and as such, for each value of target1 there should only be a single value of target2.""" best_similarity_1 = {} best_similarity_2 = {} - for query_word in query_counter.iterkeys(): + for query_word in query_set: if (query_word, target1) not in _jaro_best_match_cache: - t1_counter = collections.Counter(target1.lower().split()) + t1_word_set = set(target1.lower().split()) curr_best_1 = 0 - for target_word in t1_counter.iterkeys(): + for target_word in t1_word_set: curr_best_1 = max(curr_best_1, jaro_similarity(query_word, target_word)) best_similarity_1[query_word] = curr_best_1 - t2_counter = collections.Counter(target2.lower().split()) + t2_word_set = set(target2.lower().split()) curr_best_2 = 0 - for target_word in t2_counter.iterkeys(): + for target_word in t2_word_set: curr_best_2 = max(curr_best_2, jaro_similarity(query_word, target_word)) best_similarity_2[query_word] = curr_best_2 - _jaro_best_match_cache[(query_word, target1)] = (curr_best_1, curr_best_2) else: sim1, sim2 = _jaro_best_match_cache[(query_word, target1)] best_similarity_1[query_word] = sim1 best_similarity_2[query_word] = sim2 - n_values = sum(query_counter.itervalues()) - return (sum(best_similarity_1[query_word] * count for query_word, count in query_counter.iteritems()) / n_values, - sum(best_similarity_2[query_word] * count for query_word, count in query_counter.iteritems()) / n_values) + n_values = len(query_set) + return (sum(best_similarity_1[query_word] for query_word in query_set) / n_values, + sum(best_similarity_2[query_word] for query_word in query_set) / n_values) @@ -122,10 +119,10 @@ def jaro_split_compare(query, modlist): :returns dict from modname to similarity tuple, which contains the similarity of query to modname, then the similarity of query to mod description. """ comps = {} - query_words = collections.Counter(query.lower().split()) + query_words = set(query.lower().split()) for _, name, _, desc, _ in modlist: - comps[name] = jaro_counter_similarity(query_words, name, desc) + comps[name] = jaro_set_similarity(query_words, name, desc) return comps