Files
MusicPlayer/common/soundtrack_search.gd
T
Sansan 845d08c260 Rework the Search popup and highlight what matched
- The search field is a single-line field, so Enter no longer adds a
  line break that made every search come up empty.
- The field, button and list use real font sizes from the theme instead
  of being drawn at half size and scaled up, so the text is sharp, and
  everything fits inside the panel.
- Results have alternating row backgrounds like the other lists, a count
  ("3 of 1045 soundtracks") and a message when nothing matches.
- The letters that matched are highlighted.
- Searches shorter than Settings.LETTERS_FOR_LOOSE_SEARCH (3) only find
  names that contain the text, instead of names with the letters far
  apart, which matched most of the library. The Statistics tab uses the
  same search.
2026-10-07 14:53:51 +02:00

123 lines
4.4 KiB
GDScript

class_name SoundtrackSearch
extends RefCounted
## Finds soundtrack names matching a search text. Matches ignore case, spaces and
## special characters. Searches of at least [code]Settings.LETTERS_FOR_LOOSE_SEARCH[/code]
## letters also find the letters in order with others in between.
##
## Used by the Search popup and the statistics view, so both find the same soundtracks.
## Each name as a Dictionary with its original, lowercase and cleaned form.
var _names: Array[Dictionary] = []
## Compiled patterns for the letters-in-order search, by cleaned search term.
var _regex_cache: Dictionary = {}
## Replaces the names to search in.
func set_names(names: Array) -> void:
_names.clear()
for soundtrack: String in names:
_names.append({
"original": soundtrack,
"lowercase": soundtrack.to_lower(),
"cleaned": clean_term(soundtrack),
})
## Returns the names matching [param search_text], best matches first: plain
## case-insensitive matches, then matches ignoring spaces and special characters,
## then matches with the letters in order but not next to each other.
func find_matches(search_text: String) -> Array[String]:
var seen: Dictionary = {}
var search_text_lower: String = search_text.to_lower()
var clean_search_text: String = clean_term(search_text)
var passes: Array[Callable] = [
func(entry: Dictionary) -> bool: return entry.lowercase.contains(search_text_lower),
func(entry: Dictionary) -> bool: return entry.cleaned.contains(clean_search_text),
]
if clean_search_text.length() >= Settings.LETTERS_FOR_LOOSE_SEARCH:
var regex: RegEx = _compile_regex_cached(clean_search_text)
passes.append(
func(entry: Dictionary) -> bool: return regex.search(entry.cleaned) != null)
var matches: Array[String] = []
for matches_pass: Callable in passes:
for entry: Dictionary in _names:
if !seen.has(entry.original) && matches_pass.call(entry):
seen[entry.original] = true
matches.append(entry.original)
return matches
## The positions of the letters in [param soundtrack_name] that [param search_text]
## matched, for highlighting them: the text itself if the name contains it, otherwise the
## letters found in order the way [method find_matches] finds them. Empty if it does not
## match.
static func matched_letters(soundtrack_name: String, search_text: String) -> Array[int]:
var positions: Array[int] = []
if search_text.is_empty():
return positions
var start: int = soundtrack_name.to_lower().find(search_text.to_lower())
if start >= 0:
for i: int in search_text.length():
positions.append(start + i)
return positions
# Clean the name piece by piece, remembering which letter each cleaned letter came from
var cleaned: String = ""
var source: Array[int] = []
var i: int = 0
while i < soundtrack_name.length():
var piece: String
var length: int = 1
if soundtrack_name.substr(i, 2).to_lower() == "'n":
piece = "and"
length = 2
else:
piece = clean_term(soundtrack_name[i])
for letter: String in piece:
cleaned += letter
source.append(i)
i += length
var clean_search_text: String = clean_term(search_text)
var found: int = cleaned.find(clean_search_text)
var cleaned_positions: Array[int] = []
if found >= 0:
for j: int in clean_search_text.length():
cleaned_positions.append(found + j)
elif clean_search_text.length() >= Settings.LETTERS_FOR_LOOSE_SEARCH:
var at: int = 0
for letter: String in clean_search_text:
at = cleaned.find(letter, at)
if at < 0:
return positions
cleaned_positions.append(at)
at += 1
for cleaned_position: int in cleaned_positions:
if !positions.has(source[cleaned_position]):
positions.append(source[cleaned_position])
return positions
## Cleans a term by removing spaces and replacing special characters.
## Matches MusicServer's clean_term() function.
static func clean_term(term: String) -> String:
return term.replace(" ", "").replace("é", "e").replace("+", "plus").replace("&", "and") \
.replace("'n", "and").to_lower()
# Compiles the letters-in-order pattern for [param search_term], once per term
func _compile_regex_cached(search_term: String) -> RegEx:
if not search_term in _regex_cache:
var regex: RegEx = RegEx.new()
var regex_text: String = ".*"
for letter: String in search_term:
# Escape regex metacharacters so searches like "Q*bert" or "(Remix)" work
if "\\^$.|?*+()[]{}".contains(letter):
regex_text += "\\"
regex_text += letter + ".*"
regex.compile(regex_text)
_regex_cache[search_term] = regex
return _regex_cache[search_term]