Popups: - All nine popups now extend a shared PopupWindow class with open(), close() and toggle(), instead of each copying its own close-on-click code. Popups that need cleanup override close(). - A click outside a popup closed it and also pressed whatever was underneath, e.g. Reset playlist. The closing click is now consumed. - Popups used Godot's see-through default panel, so the song list and titles showed through. They now use an opaque popup theme, and a backdrop dims the window behind them. - The main window root now fills the screen, so the backdrop covers it. Text and images: - Many labels and buttons forced nearest filtering, which made text and icons jagged when the window is not 1920x1080. They now use the default smooth filtering. Only the pixel-art "no character" picture keeps nearest filtering when the winner window enlarges it. Player list: - A long player name pushed that row's character and buttons to the right. Names now take the free space and shorten with an ellipsis, so all rows line up and use the full width of the panel.
186 lines
5.8 KiB
GDScript
186 lines
5.8 KiB
GDScript
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:
|
|
print(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:
|
|
print("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:
|
|
print("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()
|