Rework the Search popup and highlight what matched

- The search field is a single-line field, so Enter no longer adds a
  line break that made every search come up empty.
- The field, button and list use real font sizes from the theme instead
  of being drawn at half size and scaled up, so the text is sharp, and
  everything fits inside the panel.
- Results have alternating row backgrounds like the other lists, a count
  ("3 of 1045 soundtracks") and a message when nothing matches.
- The letters that matched are highlighted.
- Searches shorter than Settings.LETTERS_FOR_LOOSE_SEARCH (3) only find
  names that contain the text, instead of names with the letters far
  apart, which matched most of the library. The Statistics tab uses the
  same search.
This commit is contained in:
Sansan committed 2026-10-07 14:53:51 +02:00
1 parent 13fdb3f68c
commit 845d08c260
12 files changed
+2063 -808

No files matched your search

+53 -27
View File
@@ -16,37 +16,63 @@ panel = NodePath("SearchPanel")
unique_name_in_owner = true
layout_mode = 0
offset_right = 1152.0
offset_bottom = 640.0
offset_bottom = 880.0
theme_type_variation = &"PopupBackground"
[node name="ClearButton" type="Button" parent="." unique_id=1549439398]
[node name="TopRow" type="HBoxContainer" parent="SearchPanel" unique_id=1262945995]
layout_mode = 0
offset_left = 936.0
offset_top = 16.0
offset_right = 1027.0
offset_bottom = 56.0
scale = Vector2(2, 2)
focus_mode = 0
action_mode = 0
text = "Clear"
offset_left = 24.0
offset_top = 24.0
offset_right = 1128.0
offset_bottom = 80.0
theme_override_constants/separation = 16
[node name="Searchbar" type="TextEdit" parent="." unique_id=2112669372]
layout_mode = 0
offset_left = 32.0
offset_top = 16.0
offset_right = 468.0
offset_bottom = 56.0
scale = Vector2(2, 2)
placeholder_text = "Search"
[node name="ScrollContainer" type="ScrollContainer" parent="." unique_id=213733486]
layout_mode = 0
offset_top = 128.0
offset_right = 576.0
offset_bottom = 384.0
scale = Vector2(2, 2)
[node name="SearchResults" type="VBoxContainer" parent="ScrollContainer" unique_id=1567403144]
[node name="SearchField" type="LineEdit" parent="SearchPanel/TopRow" unique_id=2112669372]
unique_name_in_owner = true
layout_mode = 2
size_flags_horizontal = 3
theme_type_variation = &"SearchField"
placeholder_text = "Search"
caret_blink = true
[node name="ClearButton" type="Button" parent="SearchPanel/TopRow" unique_id=1549439398]
unique_name_in_owner = true
custom_minimum_size = Vector2(140, 0)
layout_mode = 2
focus_mode = 0
theme_type_variation = &"LargeButton"
action_mode = 0
text = "Clear"
[node name="CountLabel" type="Label" parent="SearchPanel" unique_id=1262945996]
unique_name_in_owner = true
layout_mode = 0
offset_left = 24.0
offset_top = 90.0
offset_right = 1128.0
offset_bottom = 116.0
[node name="ScrollContainer" type="ScrollContainer" parent="SearchPanel" unique_id=213733486]
layout_mode = 0
offset_left = 24.0
offset_top = 124.0
offset_right = 1128.0
offset_bottom = 856.0
horizontal_scroll_mode = 0
[node name="SearchResults" type="VBoxContainer" parent="SearchPanel/ScrollContainer" unique_id=1567403144]
unique_name_in_owner = true
layout_mode = 2
size_flags_horizontal = 3
[node name="EmptyLabel" type="Label" parent="SearchPanel" unique_id=1262945997]
unique_name_in_owner = true
visible = false
layout_mode = 0
offset_left = 24.0
offset_top = 160.0
offset_right = 1128.0
offset_bottom = 200.0
theme_type_variation = &"Title"
text = "No soundtracks found"
horizontal_alignment = 1
+112 -83
View File
@@ -1,29 +1,28 @@
class_name SearchPopup
extends BasePopup
## Searches every soundtrack on the server as you type, with [SoundtrackSearch].
## 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 label_pool: Array[Label] = []
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_list: VBoxContainer = %SearchResults
@onready var search_panel: Panel = %SearchPanel
@onready var clear_button: Button = $ClearButton
@onready var search_bar: TextEdit = $Searchbar
@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_bar.grab_focus()
search_bar.text_changed.connect(search)
visibility_changed.connect(focus)
func focus() -> void:
if visible:
search_bar.grab_focus()
clear()
search_field.text_changed.connect(func(_text: String) -> void: search())
visibility_changed.connect(_on_visibility_changed)
func close() -> void:
@@ -31,94 +30,124 @@ func close() -> void:
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:
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 _matcher.find_matches(search_text):
add_soundtrack_reuse(soundtrack)
func add_soundtrack(soundtrack: String) -> void:
# Backward compatibility - delegates to reuse version
add_soundtrack_reuse(soundtrack)
## Shows [param soundtrack], reusing a hidden label from the pool when there is one.
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)
var search_text: String = search_field.text.strip_edges()
var shown: Array[int] = []
if search_text.is_empty():
shown.assign(range(soundtracks.size()))
else:
label = Label.new()
label.text = soundtrack
label.autowrap_mode = TextServer.AUTOWRAP_WORD
search_list.add_child(label)
label.visible = true
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)
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()]
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
## 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")
var handle_soundtracks: Callable = func handle_soundtracks(array: Variant) -> 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.
## 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()
func clear() -> void:
search_bar.text = ""
show_all_soundtracks()
search_bar.grab_focus()
## [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()