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. ## Emitted with every soundtrack on the server when the list has loaded. signal soundtracks_loaded(list: Array[Soundtrack]) ## 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) soundtracks_loaded.emit(list) 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()