Covers: statistics cards show the soundtrack's cover on the left, and the
statistics popup shows it large. Until it arrives, and without a cover, the
soundtrack's initials stand in. Covers load only for cards on or near the
screen, three at a time, and are cached in covers/ next to the settings
(CoverCache), downloaded again only when cover_modified changes. The index and
file handling are shared with CharacterCache in a new FileCache.
Alternative names: the popup shows them and has an editor (add, remove, save)
that sends PUT /soundtracks/{uuid}/alternative-names. The statistics search and
the Search popup find soundtracks by them; Search shows the matching name after
the real one. Search now loads /soundtracks, falling back to the plain list.
The API types are regenerated from the server's spec, with a SoundtrackCover
theme style in every theme.
Closes #49
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
185 lines
6.5 KiB
GDScript
185 lines
6.5 KiB
GDScript
class_name SearchPopup
|
|
extends BasePopup
|
|
## Searches every soundtrack on the server as you type, with [SoundtrackSearch], and
|
|
## highlights the letters that matched. Soundtracks are also found by their alternative
|
|
## names; the alternative name that matched is shown after the real name.
|
|
|
|
## Every soundtrack on the server, sorted by name.
|
|
var soundtracks: Array = []
|
|
|
|
var _matcher: SoundtrackSearch = SoundtrackSearch.new()
|
|
# One row per soundtrack, in the order of soundtracks. Rows are reused between searches.
|
|
var _rows: Array[PanelContainer] = []
|
|
var _index_of: Dictionary = {}
|
|
# The other names of each soundtrack, by its real name
|
|
var _alternatives: Dictionary = {}
|
|
|
|
@onready var search_panel: Panel = %SearchPanel
|
|
@onready var search_field: LineEdit = %SearchField
|
|
@onready var clear_button: Button = %ClearButton
|
|
@onready var count_label: Label = %CountLabel
|
|
@onready var empty_label: Label = %EmptyLabel
|
|
@onready var search_results: VBoxContainer = %SearchResults
|
|
|
|
|
|
func _ready() -> void:
|
|
clear_button.pressed.connect(clear)
|
|
search_field.text_changed.connect(func(_text: String) -> void: search())
|
|
visibility_changed.connect(_on_visibility_changed)
|
|
|
|
|
|
func close() -> void:
|
|
clear()
|
|
super()
|
|
|
|
|
|
## Empties the search field and shows every soundtrack.
|
|
func clear() -> void:
|
|
search_field.text = ""
|
|
search()
|
|
search_field.grab_focus()
|
|
|
|
|
|
## Shows the soundtracks matching the search field, best matches first, or all of them
|
|
## when it is empty.
|
|
func search() -> void:
|
|
var search_text: String = search_field.text.strip_edges()
|
|
var shown: Array[int] = []
|
|
if search_text.is_empty():
|
|
shown.assign(range(soundtracks.size()))
|
|
else:
|
|
for soundtrack: String in _matcher.find_matches(search_text):
|
|
shown.append(_index_of[soundtrack])
|
|
|
|
var is_shown: Array[bool] = []
|
|
is_shown.resize(_rows.size())
|
|
for position: int in shown.size():
|
|
var row: PanelContainer = _rows[shown[position]]
|
|
if row.get_index() != position:
|
|
search_results.move_child(row, position)
|
|
# Rows that come back with the search fade in
|
|
if !row.visible:
|
|
Anim.fade_in(row, Anim.FAST)
|
|
row.visible = true
|
|
row.theme_type_variation = &"ListRowOdd" if position % 2 != 0 else &"ListRowEven"
|
|
_show_name(row, soundtracks[shown[position]], search_text)
|
|
is_shown[shown[position]] = true
|
|
for index: int in _rows.size():
|
|
if !is_shown[index]:
|
|
_rows[index].visible = false
|
|
|
|
empty_label.visible = shown.is_empty() && !soundtracks.is_empty()
|
|
if search_text.is_empty():
|
|
count_label.text = "%d soundtracks" % soundtracks.size()
|
|
else:
|
|
count_label.text = "%d of %d soundtracks" % [shown.size(), soundtracks.size()]
|
|
|
|
|
|
## The names of the soundtracks shown, in the order they are shown.
|
|
func shown_names() -> Array[String]:
|
|
var names: Array[String] = []
|
|
for row: PanelContainer in search_results.get_children():
|
|
if row.visible:
|
|
names.append(row.get_meta(&"soundtrack"))
|
|
return names
|
|
|
|
|
|
func get_list_of_soundtracks() -> void:
|
|
GameLog.debug("get_list_of_soundtracks")
|
|
if Settings.is_offline():
|
|
set_soundtracks(Settings.music_folder.soundtrack_names())
|
|
return
|
|
var with_names: Callable = func with_names(list: Array[Soundtrack]) -> void:
|
|
var names: Array[String] = []
|
|
var alternatives: Dictionary = {}
|
|
for soundtrack: Soundtrack in list:
|
|
names.append(soundtrack.name)
|
|
alternatives[soundtrack.name] = soundtrack.alternative_names
|
|
names.sort_custom(func(a: String, b: String) -> bool:
|
|
return a.naturalnocasecmp_to(b) < 0)
|
|
set_soundtracks(names, alternatives)
|
|
MusicServerClient.fetch_soundtracks(with_names, _load_names_only)
|
|
|
|
|
|
## Replaces the searchable soundtracks and shows them, keeping the current search.
|
|
## [param alternatives] has the other names of a soundtrack, by its real name.
|
|
func set_soundtracks(list: Array, alternatives: Dictionary = {}) -> void:
|
|
soundtracks = []
|
|
soundtracks.append_array(list)
|
|
_alternatives = alternatives
|
|
_matcher.set_names(soundtracks, alternatives)
|
|
_index_of.clear()
|
|
for index: int in range(soundtracks.size() - 1, -1, -1):
|
|
_index_of[soundtracks[index]] = index
|
|
while _rows.size() < soundtracks.size():
|
|
var row: PanelContainer = _make_row()
|
|
search_results.add_child(row)
|
|
_rows.append(row)
|
|
while _rows.size() > soundtracks.size():
|
|
_rows.pop_back().free()
|
|
for index: int in soundtracks.size():
|
|
_rows[index].set_meta(&"soundtrack", soundtracks[index])
|
|
search()
|
|
|
|
|
|
## [param soundtrack_name] as BBCode, with the letters [param search_text] matched in
|
|
## [param highlight_color].
|
|
static func highlighted_name(
|
|
soundtrack_name: String, search_text: String, highlight_color: Color) -> String:
|
|
var matched: Array[int] = SoundtrackSearch.matched_letters(soundtrack_name, search_text)
|
|
var text: String = ""
|
|
var in_highlight: bool = false
|
|
for i: int in soundtrack_name.length():
|
|
var highlight: bool = matched.has(i)
|
|
if highlight != in_highlight:
|
|
text += "[color=#%s]" % highlight_color.to_html(false) if highlight else "[/color]"
|
|
in_highlight = highlight
|
|
var letter: String = soundtrack_name[i]
|
|
text += "[lb]" if letter == "[" else letter
|
|
if in_highlight:
|
|
text += "[/color]"
|
|
return text
|
|
|
|
|
|
func _make_row() -> PanelContainer:
|
|
var row: PanelContainer = PanelContainer.new()
|
|
var label: RichTextLabel = RichTextLabel.new()
|
|
label.theme_type_variation = &"SearchResult"
|
|
label.bbcode_enabled = true
|
|
label.fit_content = true
|
|
label.scroll_active = false
|
|
label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
|
label.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
|
row.add_child(label)
|
|
return row
|
|
|
|
|
|
# Servers without /soundtracks only list the names
|
|
func _load_names_only() -> void:
|
|
MusicServerClient.fetch_all_soundtracks_ordered(func(array: Variant) -> void:
|
|
if typeof(array) == TYPE_ARRAY:
|
|
set_soundtracks(array)
|
|
else:
|
|
GameLog.warning("Unexpected data"))
|
|
|
|
|
|
func _show_name(row: PanelContainer, soundtrack_name: String, search_text: String) -> void:
|
|
var highlight: Color = get_theme_color(&"font_color", &"HighlightLarge")
|
|
var text: String = highlighted_name(soundtrack_name, search_text, highlight)
|
|
# Found by another name: show which, dimmer, after the real name
|
|
var alternative: String = _matcher.matched_alternative(soundtrack_name) \
|
|
if !search_text.is_empty() else ""
|
|
if !alternative.is_empty():
|
|
text += " [color=#%s]%s[/color]" % [
|
|
get_theme_color(&"default_color", &"SongStatsSecondary").to_html(false),
|
|
highlighted_name(alternative, search_text, highlight)]
|
|
var label: RichTextLabel = row.get_child(0)
|
|
# Setting the same text again would make the label lay it out again for nothing
|
|
if label.text != text:
|
|
label.text = text
|
|
|
|
|
|
func _on_visibility_changed() -> void:
|
|
if visible:
|
|
clear()
|