Files
MusicPlayer/popups/search_popup.gd
T
SansanandClaude Opus 5.5 0fc3be79e1
Build / test (push) Successful in 1m39s
Build / build (push) Successful in 1m14s
Add animations to popups, points, songs, the winner and the tabs (#48)
Popups fade in while growing and close the reverse way, with the backdrop
fading along. The winner gets confetti, bouncing pictures and a score graph
that draws itself from the left. Filtered statistics cards and search rows
fade in, the tabs slide in from their side, and every button bounces on hover
and click (ButtonBounce, now added to all buttons).

In the player list the score pops on every point, the first point flashes the
row, and a player on match point glows with a "Match point!" tag. A song
starting fades in the title, flashes its row (kept across list rebuilds) and
pops the play button.

Common/anim.gd holds the helpers. A saved "Reduce animations" switch in
Settings turns all movement off; the tests use it. The flash colours are
theme colours (Palette first_point, match_point, flash) in every theme.

Closes #48

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 21:49:01 +02:00

160 lines
5.2 KiB
GDScript

class_name SearchPopup
extends BasePopup
## Searches every soundtrack on the server as you type, with [SoundtrackSearch], and
## highlights the letters that matched.
## 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 = {}
@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 handle_soundtracks: Callable = func handle_soundtracks(array: Variant) -> void:
if typeof(array) == TYPE_ARRAY:
set_soundtracks(array)
else:
GameLog.warning("Unexpected data")
MusicServerClient.fetch_all_soundtracks_ordered(handle_soundtracks)
## Replaces the searchable soundtracks and shows them, keeping the current search.
func set_soundtracks(list: Array) -> void:
soundtracks = []
soundtracks.append_array(list)
_matcher.set_names(soundtracks)
_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
func _show_name(row: PanelContainer, soundtrack_name: String, search_text: String) -> void:
var text: String = highlighted_name(soundtrack_name, search_text,
get_theme_color(&"font_color", &"HighlightLarge"))
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()