class_name SearchPopup extends PopupWindow @onready var search_list: VBoxContainer = $ScrollContainer/VBoxContainer @onready var search_panel: Panel = %SearchPanel @onready var clear_button: Button = $ClearButton @onready var search_bar: TextEdit = $Searchbar var soundtracks: Array = [] # Precomputed soundtrack data: array of dicts with original, lowercase, and cleaned fields var precomputed_soundtracks: Array = [] # Regex cache: maps cleaned search terms to compiled RegEx objects var regex_cache: Dictionary = {} var label_pool: Array[Label] = [] # Called when the node enters the scene tree for the first time. func _ready() -> void: clear_button.pressed.connect(clear) search_bar.grab_focus() search_bar.text_changed.connect(search) visibility_changed.connect(focus) func focus() -> void: if self.visible == true: search_bar.grab_focus() clear() func close() -> void: clear() super() func search() -> void: GameLog.debug(search_bar.text) var search_text: String = search_bar.text if search_text == "": show_all_soundtracks() return perform_search(search_text) func show_all_soundtracks() -> void: # Hide all visible labels and add to pool for child: Label in search_list.get_children(): if child.visible: child.visible = false label_pool.append(child) # Show all soundtracks in order for soundtrack: String in soundtracks: add_soundtrack_reuse(soundtrack) func perform_search(search_text: String) -> void: # Hide all visible labels and add to pool for child: Label in search_list.get_children(): if child.visible: child.visible = false label_pool.append(child) for soundtrack: String in find_matches(search_text): add_soundtrack_reuse(soundtrack) ## Returns the soundtracks 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 matches: Array[String] = [] # Pass 1: Exact case-insensitive contains match on lowercase versions for i in range(precomputed_soundtracks.size()): var st_data = precomputed_soundtracks[i] if st_data["lowercase"].contains(search_text_lower): if not st_data["original"] in seen: seen[st_data["original"]] = true matches.append(st_data["original"]) # Pass 2: Contains match on cleaned terms for i in range(precomputed_soundtracks.size()): var st_data = precomputed_soundtracks[i] if st_data["cleaned"].contains(clean_search_text): if not st_data["original"] in seen: seen[st_data["original"]] = true matches.append(st_data["original"]) # Pass 3: Regex match on cleaned terms var regex = compile_regex_cached(clean_search_text) for i in range(precomputed_soundtracks.size()): var st_data = precomputed_soundtracks[i] if regex.search(st_data["cleaned"]): if not st_data["original"] in seen: seen[st_data["original"]] = true matches.append(st_data["original"]) return matches # Cleans a term by removing spaces and replacing special characters. # Matches MusicServer's clean_term() function. func clean_term(term: String) -> String: return term.replace(" ", "").replace("é", "e").replace("+", "plus").replace("&", "and").replace("'n", "and").to_lower() # Compiles a regex pattern with caching to avoid recompiling the same pattern multiple times. func compile_regex_cached(search_term: String) -> RegEx: if not search_term in regex_cache: var regex = RegEx.new() var regText: String = ".*" for letter: String in search_term: # Escape regex metacharacters so searches like "Q*bert" or "(Remix)" work if "\\^$.|?*+()[]{}".contains(letter): regText += "\\" regText += letter + ".*" regex.compile(regText) regex_cache[search_term] = regex return regex_cache[search_term] func add_soundtrack(soundtrack: String) -> void: # Backward compatibility - delegates to reuse version add_soundtrack_reuse(soundtrack) # Optimized version that reuses label nodes from pool instead of creating new ones func add_soundtrack_reuse(soundtrack: String) -> void: var label: Label if label_pool.size() > 0: label = label_pool.pop_front() label.text = soundtrack label.visible = true # Move to end to maintain display order search_list.move_child(label, search_list.get_child_count() - 1) else: label = Label.new() label.text = soundtrack label.autowrap_mode = TextServer.AUTOWRAP_WORD search_list.add_child(label) label.visible = true func check_if_soundtrack_exists(soundtrack: String) -> bool: var soundtrack_exists: bool = false for child: Label in search_list.get_children(): if child.text == soundtrack and child.visible: soundtrack_exists = true return soundtrack_exists func get_list_of_soundtracks() -> void: GameLog.debug("get_list_of_soundtracks") var handle_soundtracks: Callable = func handle_soundtracks(array) -> void: if typeof(array) == TYPE_ARRAY: # Reuse existing labels: hide all and add to pool for child: Label in search_list.get_children(): child.visible = false label_pool.append(child) set_soundtracks(array) for soundtrack: String in soundtracks: add_soundtrack_reuse(soundtrack) else: GameLog.warning("Unexpected data") MusicServerClient.fetch_all_soundtracks_ordered(handle_soundtracks) ## Replaces the searchable soundtracks and precomputes their search forms. func set_soundtracks(list: Array) -> void: soundtracks = [] precomputed_soundtracks = [] soundtracks.append_array(list) for soundtrack: String in soundtracks: precomputed_soundtracks.append({ "original": soundtrack, "lowercase": soundtrack.to_lower(), "cleaned": clean_term(soundtrack) }) func clear() -> void: search_bar.text = "" show_all_soundtracks() search_bar.grab_focus()