Short songs (#30): a quiz settings switch and a Seconds per song row (30 by default, 10-120 in steps of 5), saved with the quiz settings. Only part of each quiz song plays, from a random point 10-60% into it, picked once per song so restart and repeat play the same part. The progress bar, time and seeking stay within the part, a hidden beginning counts inside it, and its end counts as the song ending (repeat, stop or go to the next song as set). Random songs (#51): the low-played switch in Settings is now a Normal / Low played / Classic choice, stored as Settings.random_mode and not saved. fetch_random_song(mode) picks /music/rand, /music/rand/low or /music/rand/classic. Closes #30 Closes #51 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
518 lines
19 KiB
GDScript
518 lines
19 KiB
GDScript
class_name UiTour
|
||
extends RefCounted
|
||
## Walks the main window through a fixed set of states against a fake MusicServer:
|
||
## startup, players added, a song playing, every popup open, a sync and the statistics
|
||
## tab.
|
||
##
|
||
## Used for screenshots ([code]tools/take_screenshots.gd[/code]) and for the layout
|
||
## test, so both look at exactly the same states. Nodes are looked up by name in the
|
||
## main window and in its Quiz and TopBar scenes, so the tour works no matter which of
|
||
## them a node lives in.
|
||
|
||
const SOUNDTRACKS: Array[String] = [
|
||
"Chrono Trigger", "Final Fantasy VI", "Secret of Mana", "Terranigma",
|
||
"Earthbound", "Super Metroid", "The Legend of Zelda: A Link to the Past",
|
||
"Donkey Kong Country 2", "Lufia II", "Illusion of Gaia",
|
||
]
|
||
const SONG_INFO: Dictionary = {
|
||
"SongNo": 0, "Soundtrack": "Chrono Trigger", "Song": "Corridors of Time",
|
||
}
|
||
const SYNC_PROGRESS: Dictionary = {"progress": 40, "time_spent": "00:00:02"}
|
||
const SYNC_RESULT: Dictionary = {
|
||
"soundtracks_added": ["Terranigma", "Lufia II"],
|
||
"soundtracks_re_added": [],
|
||
"soundtracks_changed_title": {"FF6": "Final Fantasy VI"},
|
||
"soundtracks_changed_content": ["Chrono Trigger"],
|
||
"soundtracks_removed": ["Old Soundtrack"],
|
||
"catched_errors": [],
|
||
"total_time": "00:00:03",
|
||
}
|
||
|
||
## The server's version history, newest first.
|
||
const SERVER_HISTORY: Array[Dictionary] = [
|
||
{"version": "5.0.0", "changelog": [
|
||
"#9 - Major routes refactor and expansion", "#15 - Added API for statistics",
|
||
"Token authentication for /api/v1",
|
||
]},
|
||
{"version": "4.5.0", "changelog": [
|
||
"#1 - Created request to check newest version of the app",
|
||
"#2 - Added request to download the newest version of the app",
|
||
]},
|
||
{"version": "4.0.0", "changelog": ["Changed framework from gin to Echo", "Reorganized the code"]},
|
||
]
|
||
|
||
## Song names of the first soundtrack in the statistics, to show Japanese text.
|
||
const JAPANESE_SONGS: Array[String] = [
|
||
"時の回廊", "カエルのテーマ", "風の憧憬", "魔王決戦", "遥かなる時の彼方へ",
|
||
]
|
||
|
||
## The character pictures the fake server hands out.
|
||
const CHARACTERS_DIR: String = "res://characters"
|
||
|
||
## Popups in the order the tour opens them: the state name, then the popup's node. The
|
||
## state names are what the popups were first called, so screenshots taken before they
|
||
## were renamed can still be compared with new ones.
|
||
const POPUPS: Array[Array] = [
|
||
["Search", "SearchPopup"], ["SyncWindow", "SyncPopup"],
|
||
["SettingsWindow", "SettingsPopup"], ["QRWindow", "QRPopup"],
|
||
["NewCharacterSelect", "CharacterSelectPopup"], ["ShortcutsWindow", "ShortcutsPopup"],
|
||
["AboutWindow", "AboutPopup"], ["WinnerWindow", "WinnerPopup"], ["Log", "LogPopup"],
|
||
["QuizSettings", "QuizSettingsCard"],
|
||
]
|
||
|
||
var _main: Control
|
||
var _scores_before_win: Array[int] = []
|
||
var _winning_score_before_win: int = 0
|
||
|
||
|
||
func _init(main: Control) -> void:
|
||
_main = main
|
||
|
||
|
||
## Returns a fake server that answers every request the main window makes on the tour.
|
||
## Call [method FakeHttpTransport.install] before the main window is created.
|
||
static func make_server() -> FakeHttpTransport:
|
||
var server: FakeHttpTransport = FakeHttpTransport.new()
|
||
var mp3: PackedByteArray = FileAccess.get_file_as_bytes("res://tests/data/short.mp3")
|
||
server.respond_json("/music/all", SOUNDTRACKS)
|
||
server.respond_json("/music/all/order", SOUNDTRACKS)
|
||
server.respond_json("/music/list", [])
|
||
server.respond_json("/download", "1.0.0")
|
||
var characters: Array[String] = character_names()
|
||
server.respond_json("/character/list", characters)
|
||
for character_name: String in characters:
|
||
server.respond_bytes("/character?name=" + character_name.uri_encode(),
|
||
FileAccess.get_file_as_bytes(CHARACTERS_DIR.path_join(character_name)))
|
||
server.respond_bytes("/music/rand", mp3)
|
||
server.respond_bytes("/music/addQue", PackedByteArray())
|
||
server.respond_json("/music/info", SONG_INFO)
|
||
server.respond_bytes("/music/reset", PackedByteArray())
|
||
server.respond_bytes("/music/played?song=0", PackedByteArray())
|
||
server.respond_bytes("/sync", PackedByteArray())
|
||
server.respond_json("/sync/progress", SYNC_PROGRESS)
|
||
server.respond_json("/statistics/soundtracks", soundtrack_statistics())
|
||
server.respond_json("/version/history", SERVER_HISTORY)
|
||
return server
|
||
|
||
|
||
## Play statistics for every soundtrack in [constant SOUNDTRACKS], the same on every
|
||
## run. Some have never been played.
|
||
static func soundtrack_statistics() -> Array[Dictionary]:
|
||
var statistics: Array[Dictionary] = []
|
||
for i: int in SOUNDTRACKS.size():
|
||
var played: int = (i * 7) % 11
|
||
var songs: Array[Dictionary] = []
|
||
for song: int in 5:
|
||
songs.append({
|
||
"song_uuid": "song-%d-%d" % [i, song],
|
||
"soundtrack_uuid": "soundtrack-%d" % i,
|
||
"soundtrack_name": SOUNDTRACKS[i],
|
||
"song_name": JAPANESE_SONGS[song] if i == 0 else "Track %d" % (song + 1),
|
||
"times_played": maxi(0, played - song * 2),
|
||
})
|
||
var soundtrack: Dictionary = {
|
||
"soundtrack_uuid": "soundtrack-%d" % i,
|
||
"soundtrack_name": SOUNDTRACKS[i],
|
||
"soundtrack_played": played,
|
||
"songs": songs,
|
||
}
|
||
if played > 0:
|
||
soundtrack.soundtrack_last_played = "2026-09-%02dT20:00:00Z" % (i + 1)
|
||
statistics.append(soundtrack)
|
||
return statistics
|
||
|
||
|
||
## Who scored on each song of a 60-song game to 20 points, the same on every run: the
|
||
## third of [param players] wins on the last song, nobody scores on songs 21–27 and the
|
||
## others stay below 20. Needs at least three players.
|
||
static func long_game(players: Array[PlayerObject]) -> Array:
|
||
var random: RandomNumberGenerator = RandomNumberGenerator.new()
|
||
random.seed = 7
|
||
var winner: int = 2
|
||
var scores: Array[int] = []
|
||
scores.resize(players.size())
|
||
var song_scorers: Array = []
|
||
for song: int in 60:
|
||
var scorers: Array[String] = []
|
||
for index: int in players.size():
|
||
var limit: int = 19 if index == winner else 18
|
||
var chance: float = 0.36 if index == winner else 0.2 + 0.02 * index
|
||
if song < 59 && (song < 20 || song >= 27) && scores[index] < limit \
|
||
&& random.randf() < chance:
|
||
scorers.append(players[index].id)
|
||
scores[index] += 1
|
||
song_scorers.append(scorers)
|
||
song_scorers[59].append(players[winner].id)
|
||
return song_scorers
|
||
|
||
|
||
## The pictures in [constant CHARACTERS_DIR], sorted by name.
|
||
static func character_names() -> Array[String]:
|
||
var names: Array[String] = []
|
||
for file_name: String in DirAccess.get_files_at(CHARACTERS_DIR):
|
||
if file_name.ends_with(".png"):
|
||
names.append(file_name)
|
||
names.sort()
|
||
return names
|
||
|
||
|
||
## Puts Settings and Playlist in the state the tour expects. Call it before the main
|
||
## window is created.
|
||
static func prepare_autoloads() -> void:
|
||
Playlist.reset_playlist()
|
||
Playlist.first_song_played = false
|
||
Playlist.is_song_downloading = false
|
||
Settings.player_array.clear()
|
||
Settings.music_folder = null
|
||
Settings.character_folder = ""
|
||
# A quiz saved by an earlier run would be continued
|
||
QuizSave.delete()
|
||
Settings.edit_players = false
|
||
Settings.currently_syncing = false
|
||
Settings.character_select_open = false
|
||
Settings.add_to_stats = true
|
||
Settings.hide_next_track = true
|
||
Settings.random_mode = Settings.RandomMode.NORMAL
|
||
Settings.winning_score = 20
|
||
Settings.last_seen_version = Settings.version
|
||
# The same picture every time, without half-finished animations
|
||
Settings.reduce_animations = true
|
||
# The inspiration list scrolls by itself, which would make every picture different
|
||
Settings.inspiration_list_speed = 0
|
||
|
||
|
||
## Runs the tour. [param on_state] is called (and awaited) with the name of each state
|
||
## and the popup that is open in it, or [code]null[/code] when no popup is open.
|
||
func run(on_state: Callable) -> void:
|
||
_stop_caret_blink(_main)
|
||
await _settle()
|
||
await on_state.call("startup", null)
|
||
|
||
_call("add_players")
|
||
await _settle()
|
||
await on_state.call("add_player_row", null)
|
||
|
||
_add_player("Crono")
|
||
_add_player("Marle")
|
||
_call("add_players")
|
||
await _settle()
|
||
await on_state.call("players_added", null)
|
||
|
||
_call("play_next_song")
|
||
await _settle()
|
||
_freeze_music_player()
|
||
var picture: Image = Image.new()
|
||
picture.load_png_from_buffer(
|
||
FileAccess.get_file_as_bytes(CHARACTERS_DIR.path_join(character_names()[0])))
|
||
_player_row(0).new_character_selected(ImageTexture.create_from_image(picture),
|
||
character_names()[0])
|
||
_player_row(0).add_point()
|
||
_player_row(0).add_point()
|
||
_player_row(1).add_point()
|
||
_call("show_answer_pressed")
|
||
await _settle()
|
||
_freeze_music_player()
|
||
_fix_log_times()
|
||
await _settle()
|
||
await on_state.call("playing", null)
|
||
|
||
for state_and_node: Array in POPUPS:
|
||
var popup: Node = await _open_popup(state_and_node[1])
|
||
await _settle()
|
||
await on_state.call("popup_" + state_and_node[0], popup)
|
||
_close_popup(popup)
|
||
await _settle()
|
||
|
||
var search_popup: SearchPopup = _node("SearchPopup")
|
||
search_popup.open()
|
||
search_popup.search_field.text = "lttp"
|
||
search_popup.search()
|
||
await _settle()
|
||
await on_state.call("popup_Search_typed", search_popup)
|
||
search_popup.close()
|
||
await _settle()
|
||
|
||
var sync_popup: BasePopup = _node("SyncPopup")
|
||
Settings.currently_syncing = false
|
||
sync_popup.has_synced = false
|
||
sync_popup.start_sync()
|
||
sync_popup.sync_timer.stop()
|
||
sync_popup._on_sync_timeout()
|
||
await _settle()
|
||
await on_state.call("sync_in_progress", sync_popup)
|
||
sync_popup.close()
|
||
await _settle()
|
||
await on_state.call("sync_buttons_locked", null)
|
||
|
||
_server().respond_json("/sync/progress", SYNC_RESULT)
|
||
sync_popup._on_sync_timeout()
|
||
sync_popup.open()
|
||
await _settle()
|
||
await on_state.call("sync_result", sync_popup)
|
||
sync_popup.close()
|
||
await _settle()
|
||
|
||
var top_bar: TopBar = _node("TopBar")
|
||
top_bar.show_latest_version("99.10.10")
|
||
await _settle()
|
||
await on_state.call("update_available", null)
|
||
top_bar.show_latest_version("1.0.0")
|
||
await _settle()
|
||
|
||
var statistics_view: StatisticsView = _node("StatisticsView")
|
||
_main.show_tab(&"statistics")
|
||
await _settle()
|
||
await on_state.call("statistics", statistics_view)
|
||
statistics_view.show_option.select(StatisticsView.ShowFilter.NEVER_PLAYED)
|
||
statistics_view.apply_filter()
|
||
await _settle()
|
||
await on_state.call("statistics_never_played", statistics_view)
|
||
statistics_view.show_option.select(StatisticsView.ShowFilter.ALL)
|
||
statistics_view.sort_option.select(StatisticsView.SortOrder.MOST_PLAYED)
|
||
statistics_view.apply_sort()
|
||
statistics_view.search_field.text = "chrono"
|
||
statistics_view.apply_filter()
|
||
await _settle()
|
||
await on_state.call("statistics_search", statistics_view)
|
||
# Chrono Trigger has never been played, so the song list searches for another
|
||
statistics_view.search_field.text = "zelda"
|
||
statistics_view.set_mode(StatisticsView.Mode.SONGS)
|
||
await _settle()
|
||
await on_state.call("statistics_songs_search", statistics_view)
|
||
statistics_view.clear_search()
|
||
await _settle()
|
||
await on_state.call("statistics_songs", statistics_view)
|
||
statistics_view.search_field.text = "chrono"
|
||
statistics_view.set_mode(StatisticsView.Mode.SOUNDTRACKS)
|
||
await _settle()
|
||
var stats_popup: SoundtrackStatsPopup = _node("SoundtrackStatsPopup")
|
||
for card: SoundtrackCard in statistics_view.cards:
|
||
if card.visible:
|
||
stats_popup.show_soundtrack(card.soundtrack)
|
||
break
|
||
await _settle()
|
||
await on_state.call("popup_SoundtrackStats", stats_popup)
|
||
stats_popup.close()
|
||
_main.show_tab(&"quiz")
|
||
await _settle()
|
||
|
||
# Last, as it adds players: a long game with five players on the Winner popup
|
||
for player_name: String in ["Lucca", "Frog", "Robo"]:
|
||
_add_player(player_name)
|
||
await _settle()
|
||
var players: Array[PlayerObject] = Settings.player_array
|
||
var song_scorers: Array = long_game(players)
|
||
var history: ScoreHistory = ScoreHistory.from_songs(players, song_scorers)
|
||
for player: PlayerObject in players:
|
||
player.player_score = history.final_score(player.id)
|
||
Settings.winning_score = 20
|
||
var winner_popup: WinnerPopup = _node("WinnerPopup")
|
||
winner_popup.show_winner(players[2])
|
||
winner_popup.show_history(history)
|
||
await _settle()
|
||
await on_state.call("popup_WinnerWindow_long_game", winner_popup)
|
||
winner_popup.close()
|
||
await _settle()
|
||
|
||
# Playing from a music folder: a soundtrack folder, then a prepared quiz in Settings
|
||
var music: String = _make_music_folders()
|
||
var settings_popup: SettingsPopup = _node("SettingsPopup")
|
||
# Songs are picked at random; the same picks every time keep the pictures the same
|
||
seed(1)
|
||
settings_popup.use_music_folder(music.path_join("Soundtracks"))
|
||
await _settle()
|
||
_call("play_next_song")
|
||
# A playing song is stopped first, which takes half a second
|
||
await _main.get_tree().create_timer(0.7).timeout
|
||
await _settle()
|
||
_call("show_answer_pressed")
|
||
await _settle()
|
||
await on_state.call("offline", null)
|
||
settings_popup.use_music_folder(music.path_join("Musiktävling"))
|
||
settings_popup.open()
|
||
await _settle()
|
||
await on_state.call("popup_SettingsWindow_offline", settings_popup)
|
||
settings_popup.close()
|
||
# The end of a prepared quiz, where two players share the most points
|
||
var scores: Array[int] = [7, 12, 12, 3, 9]
|
||
for index: int in Settings.player_array.size():
|
||
Settings.player_array[index].player_score = scores[index % scores.size()]
|
||
_call("show_final_winners")
|
||
var final_popup: WinnerPopup = _node("WinnerPopup")
|
||
await _settle()
|
||
await on_state.call("popup_WinnerWindow_prepared_quiz", final_popup)
|
||
final_popup.close()
|
||
settings_popup.use_server()
|
||
await _settle()
|
||
|
||
|
||
# Writes a soundtrack folder and a prepared quiz with copies of the test song, and
|
||
# returns the folder they are in
|
||
static func _make_music_folders() -> String:
|
||
var root: String = OS.get_user_data_dir().path_join("ui_tour_music")
|
||
var song: PackedByteArray = FileAccess.get_file_as_bytes("res://tests/data/short.mp3")
|
||
var files: Array[String] = [
|
||
"Soundtracks/Chrono Trigger/Corridors of Time.mp3",
|
||
"Soundtracks/Final Fantasy VI/Dancing Mad.mp3",
|
||
"Soundtracks/Secret of Mana/Into the Thick of It.mp3",
|
||
"Musiktävling/01. Opening.mp3",
|
||
"Musiktävling/02. Ace Combat 7 - Daredevil.mp3",
|
||
]
|
||
for file: String in files:
|
||
var path: String = root.path_join(file)
|
||
DirAccess.make_dir_recursive_absolute(path.get_base_dir())
|
||
FileAccess.open(path, FileAccess.WRITE).store_buffer(song)
|
||
return root
|
||
|
||
|
||
## Returns where every Control and Window in [param subtree] is on screen, keyed by its
|
||
## path from [param main]: [code]{"rect": [x, y, width, height], "visible": bool}[/code].
|
||
## Nodes added at runtime without a name are keyed by their index, e.g. [code]#2[/code],
|
||
## so the keys are the same on every run.
|
||
static func dump_layout(main: Node, subtree: Node) -> Dictionary:
|
||
var layout: Dictionary = {}
|
||
var path: String = String(main.get_path_to(subtree))
|
||
_dump_node(subtree, path, layout)
|
||
return layout
|
||
|
||
|
||
static func _dump_node(node: Node, path: String, layout: Dictionary) -> void:
|
||
if node is Control:
|
||
var rect: Rect2 = (node as Control).get_global_rect()
|
||
layout[path] = {"rect": _round_rect(rect), "visible": node.is_visible_in_tree()}
|
||
elif node is Window:
|
||
var window: Window = node
|
||
layout[path] = {
|
||
"rect": _round_rect(Rect2(window.position, window.size)),
|
||
"visible": window.visible,
|
||
}
|
||
for child: Node in node.get_children():
|
||
var key: String = "#%d" % child.get_index() if "@" in child.name \
|
||
else String(child.name)
|
||
_dump_node(child, key if path == "." else path + "/" + key, layout)
|
||
|
||
|
||
static func _round_rect(rect: Rect2) -> Array[float]:
|
||
return [snappedf(rect.position.x, 0.01), snappedf(rect.position.y, 0.01),
|
||
snappedf(rect.size.x, 0.01), snappedf(rect.size.y, 0.01)]
|
||
|
||
|
||
## Finds a node of the main window by [param node_name], whether it is a direct child
|
||
## of the main window or of its Quiz or TopBar scene.
|
||
func _node(node_name: String) -> Node:
|
||
for parent: Node in [_main, _main.get_node_or_null("Quiz"),
|
||
_main.get_node_or_null("TopBar")]:
|
||
if parent != null && parent.has_node(node_name):
|
||
return parent.get_node(node_name)
|
||
push_error("UiTour: no node named " + node_name)
|
||
return null
|
||
|
||
|
||
## Calls [param method] on the players panel, the quiz or the main window, whichever
|
||
## has it first.
|
||
func _call(method: String, args: Array = []) -> Variant:
|
||
for path: String in ["Quiz/PlayersPanel", "Quiz"]:
|
||
var target: Node = _main.get_node_or_null(path)
|
||
if target != null && target.has_method(method):
|
||
return target.callv(method, args)
|
||
return _main.callv(method, args)
|
||
|
||
|
||
# A blinking text cursor would be shown in some pictures and not in others
|
||
func _stop_caret_blink(node: Node) -> void:
|
||
if node is TextEdit || node is LineEdit:
|
||
node.caret_blink = false
|
||
for child: Node in node.get_children():
|
||
_stop_caret_blink(child)
|
||
|
||
|
||
func _server() -> FakeHttpTransport:
|
||
return MusicServerClient.transport
|
||
|
||
|
||
func _settle() -> void:
|
||
for i: int in 4:
|
||
await _main.get_tree().process_frame
|
||
|
||
|
||
func _add_player(player_name: String) -> void:
|
||
(_node("PlayersPanel").get_node("VBoxContainer/AddPlayerContainer/PlayerNameField") \
|
||
as LineEdit).text = player_name
|
||
_call("add_player")
|
||
|
||
|
||
func _player_row(index: int) -> PlayerRow:
|
||
return _node("PlayersPanel").get_node(
|
||
"VBoxContainer/ScrollContainer/PlayersListVBoxContainer").get_child(index)
|
||
|
||
|
||
## Stops the song so the time and progress bar look the same on every run.
|
||
func _freeze_music_player() -> void:
|
||
var music_player: MusicPlayer = _node("MusicPlayer")
|
||
music_player.audio_player.stop()
|
||
music_player.sound_player.stop()
|
||
music_player.progress_slider.value = 0
|
||
music_player.music_time_label.text = "00:00 / 00:01"
|
||
# The buttons' animations would otherwise be caught halfway, see ButtonBounce
|
||
for button: Button in [music_player.play_button, music_player.restart_button]:
|
||
if button.has_meta(&"button_bounce_tween"):
|
||
(button.get_meta(&"button_bounce_tween") as Tween).kill()
|
||
button.scale = Vector2.ONE
|
||
|
||
|
||
## The log starts each row with the time of day; use a fixed time instead.
|
||
func _fix_log_times() -> void:
|
||
var log_popup: BasePopup = _node("LogPopup")
|
||
for row: ListRow in log_popup.log_box.get_children():
|
||
row.label.text = "12:00:00 " + row.label.text.substr(9)
|
||
|
||
|
||
func _open_popup(popup_name: String) -> Node:
|
||
var popup: Node = _node(popup_name)
|
||
match popup_name:
|
||
"SyncPopup":
|
||
# Opening the popup starts a sync; show the empty popup instead
|
||
popup.open()
|
||
"CharacterSelectPopup":
|
||
popup.show_grid("Crono")
|
||
"WinnerPopup":
|
||
_show_winner_after_a_game(popup)
|
||
_:
|
||
popup.open()
|
||
return popup
|
||
|
||
|
||
## Shows the Winner popup after a whole game, so the graph has something to show. The
|
||
## scores are put back afterwards by [method _close_popup].
|
||
func _show_winner_after_a_game(popup: WinnerPopup) -> void:
|
||
_scores_before_win = []
|
||
for player: PlayerObject in Settings.player_array:
|
||
_scores_before_win.append(player.player_score)
|
||
var crono: PlayerObject = Settings.player_array[0]
|
||
var marle: PlayerObject = Settings.player_array[1]
|
||
var song_scorers: Array = []
|
||
for song: int in range(1, 17):
|
||
var scorers: Array[String] = []
|
||
if song in [2, 4, 5, 10, 11, 12, 13, 16]:
|
||
scorers.append(crono.id)
|
||
if song in [1, 3, 10, 14, 15]:
|
||
scorers.append(marle.id)
|
||
song_scorers.append(scorers)
|
||
crono.player_score = 8
|
||
marle.player_score = 5
|
||
_winning_score_before_win = Settings.winning_score
|
||
Settings.winning_score = 8
|
||
popup.show_winner(crono)
|
||
popup.show_history(ScoreHistory.from_songs(Settings.player_array, song_scorers))
|
||
|
||
|
||
func _close_popup(popup: Node) -> void:
|
||
if popup is WinnerPopup:
|
||
Settings.winning_score = _winning_score_before_win
|
||
for i: int in _scores_before_win.size():
|
||
Settings.player_array[i].player_score = _scores_before_win[i]
|
||
if popup is BasePopup:
|
||
popup.close()
|
||
else:
|
||
popup.visible = false
|