Smarter searching in the LutrisWindow.

This PR no longer uses SQL to search for games, but instead does it in Python.

However, the benefit is a more forgiving search. The game names and search text are normalized to form KD, and combining characters are extra whitespace are stripped.

This means that "The Signal From Tölva" can now be found by searching for "from   Tolva".

This is a bit sloppy, and a bit "Do what I mean"- but even languages which consider 'ö' a distinct letter should be okay, since they surely won't have games that differ in their names only in that one letter. At least, not often.
This commit is contained in:
Daniel Johnson 2024-03-18 19:01:37 -04:00
parent 22b0dac784
commit 71765daf4f
2 changed files with 47 additions and 14 deletions

View file

@ -39,7 +39,7 @@ from lutris.util.jobs import AsyncCall
from lutris.util.library_sync import LOCAL_LIBRARY_UPDATED, sync_local_library
from lutris.util.log import logger
from lutris.util.path_cache import MISSING_GAMES, add_to_path_cache
from lutris.util.strings import get_natural_sort_key
from lutris.util.strings import get_natural_sort_key, strip_accents
from lutris.util.system import update_desktop_icons
@ -407,17 +407,20 @@ class LutrisWindow(Gtk.ApplicationWindow, DialogLaunchUIDelegate, DialogInstallU
def get_recent_games(self):
"""Return a list of currently running games"""
searches, _filters, excludes = self.get_sql_filters()
games = games_db.get_games(searches=searches, filters={"installed": "1"}, excludes=excludes)
games = games_db.get_games(filters={"installed": "1"})
games = [game for game in games if self.game_matches(game)]
return sorted(games, key=lambda game: max(game["installed_at"] or 0, game["lastplayed"] or 0), reverse=True)
def game_matches(self, game):
if self.filters.get("installed"):
if game["appid"] not in games_db.get_service_games(self.service.id):
return False
if not self.filters.get("text"):
text = self.filters.get("text")
if not text:
return True
return self.filters["text"] in game["name"].lower()
text = strip_accents(text).casefold()
name = strip_accents(game["name"]).casefold()
return text in name
def set_service(self, service_name):
if self.service and self.service.id == service_name:
@ -469,26 +472,24 @@ class LutrisWindow(Gtk.ApplicationWindow, DialogLaunchUIDelegate, DialogInstallU
excluded = [".hidden"] if category != ".hidden" else []
category_game_ids = categories_db.get_game_ids_for_categories(included, excluded)
searches, filters, excludes = self.get_sql_filters()
games = games_db.get_games(searches=searches, filters=filters, excludes=excludes)
games = [game for game in games if game["id"] in category_game_ids]
filters = self.get_sql_filters()
games = games_db.get_games(filters=filters)
games = [game for game in games if game["id"] in category_game_ids and self.game_matches(game)]
return self.apply_view_sort(games)
def get_sql_filters(self):
"""Return the current filters for the view"""
sql_filters = {}
sql_excludes = {}
if self.filters.get("runner"):
sql_filters["runner"] = self.filters["runner"]
if self.filters.get("platform"):
sql_filters["platform"] = self.filters["platform"]
if self.filters.get("installed"):
sql_filters["installed"] = "1"
if self.filters.get("text"):
searches = {"name": self.filters["text"]}
else:
searches = None
return searches, sql_filters, sql_excludes
# We omit the "text" search here because SQLite does a fairly literal
# search, which is accent sensitive. We'll do better with self.game_matches()
return sql_filters
def get_service_media(self, icon_type):
"""Return the ServiceMedia class used for this view"""

View file

@ -42,6 +42,38 @@ def slugify(value: str) -> str:
return slug
def strip_accents(value: str) -> str:
"""Returns a string that is 'value', but with combining characters removed.
This normalizes the text to form KD, which also removes any compatibility characters,
so things like ellipsis are expanded to '...'.
This also strips leading a trailing whitespace, and normalizes all remaining whitespace
to single spaces.
This does allow non-ASCII characters (like Greek or Cyrillic), and does not interfere with
casing.
We use this for a more forgiving search.
"""
if value:
decomposed = unicodedata.normalize("NFKD", value)
result = ""
prev_whitespace = False
for ch in reversed(decomposed.strip()):
combining = unicodedata.combining(ch)
if not combining:
whitespace = ch.isspace()
if whitespace:
if not prev_whitespace:
result += " "
else:
result += ch
prev_whitespace = whitespace
return result[::-1] # We built the text backwards, so we must reverse it
return value
def get_natural_sort_key(value: str, number_width: int = 16) -> str:
"""Returns a string with the numerical parts (runs of digits)
0-padded out to 'number_width' digits."""