diff --git a/assets/romfs/github/ftpsrv.json b/assets/romfs/github/ftpsrv.json new file mode 100644 index 0000000..2ca701c --- /dev/null +++ b/assets/romfs/github/ftpsrv.json @@ -0,0 +1,12 @@ +{ + "name": "ftpsrv", + "url": "https://github.com/ITotalJustice/ftpsrv", + "assets": [ + { + "name": "switch_application.zip" + }, + { + "name": "switch_sysmod.zip" + } + ] +} diff --git a/assets/romfs/github/sphaira.json b/assets/romfs/github/sphaira.json new file mode 100644 index 0000000..eca8a60 --- /dev/null +++ b/assets/romfs/github/sphaira.json @@ -0,0 +1,9 @@ +{ + "name": "sphaira", + "url": "https://github.com/ITotalJustice/sphaira", + "assets":[ + { + "name": "sphaira.zip" + } + ] +} diff --git a/assets/romfs/github/untitled.json b/assets/romfs/github/untitled.json new file mode 100644 index 0000000..5ce575f --- /dev/null +++ b/assets/romfs/github/untitled.json @@ -0,0 +1,9 @@ +{ + "name": "untitled", + "url": "https://github.com/ITotalJustice/untitled", + "assets":[ + { + "name": "untitled.zip" + } + ] +} diff --git a/sphaira/CMakeLists.txt b/sphaira/CMakeLists.txt index c4ac1a1..3bb168e 100644 --- a/sphaira/CMakeLists.txt +++ b/sphaira/CMakeLists.txt @@ -45,6 +45,7 @@ add_executable(sphaira source/ui/menus/main_menu.cpp source/ui/menus/menu_base.cpp source/ui/menus/themezer.cpp + source/ui/menus/ghdl.cpp source/ui/error_box.cpp source/ui/notification.cpp diff --git a/sphaira/include/ui/menus/ghdl.hpp b/sphaira/include/ui/menus/ghdl.hpp new file mode 100644 index 0000000..a21bbeb --- /dev/null +++ b/sphaira/include/ui/menus/ghdl.hpp @@ -0,0 +1,67 @@ +#pragma once + +#include "ui/menus/menu_base.hpp" +#include "fs.hpp" +#include "option.hpp" +#include +#include + +namespace sphaira::ui::menu::gh { + +struct AssetEntry { + std::string name; + std::string path; +}; + +struct Entry { + fs::FsPath json_path; + std::string name; + std::string url; + std::vector assets; +}; + +struct GhApiAsset { + std::string name; + std::string content_type; + u64 size; + u64 download_count; + std::string browser_download_url; +}; + +struct GhApiEntry { + std::string tag_name; + std::string name; + std::vector assets; +}; + +struct Menu final : MenuBase { + Menu(); + ~Menu(); + + void Update(Controller* controller, TouchInfo* touch) override; + void Draw(NVGcontext* vg, Theme* theme) override; + void OnFocusGained() override; + +private: + void SetIndex(std::size_t index); + void Scan(); + void LoadEntriesFromPath(const fs::FsPath& path); + + auto GetEntry() -> Entry& { + return m_entries[m_index]; + } + + auto GetEntry() const -> const Entry& { + return m_entries[m_index]; + } + + void Sort(); + void UpdateSubheading(); + +private: + std::vector m_entries; + std::size_t m_index{}; + std::size_t m_index_offset{}; +}; + +} // namespace sphaira::ui::menu::gh diff --git a/sphaira/include/ui/popup_list.hpp b/sphaira/include/ui/popup_list.hpp index 201668e..7d908ad 100644 --- a/sphaira/include/ui/popup_list.hpp +++ b/sphaira/include/ui/popup_list.hpp @@ -21,6 +21,8 @@ public: auto Update(Controller* controller, TouchInfo* touch) -> void override; auto OnLayoutChange() -> void override; auto Draw(NVGcontext* vg, Theme* theme) -> void override; + auto OnFocusGained() noexcept -> void override; + auto OnFocusLost() noexcept -> void override; private: static constexpr Vec2 m_title_pos{70.f, 28.f}; diff --git a/sphaira/include/yyjson_helper.hpp b/sphaira/include/yyjson_helper.hpp index 82893fe..5a543f5 100644 --- a/sphaira/include/yyjson_helper.hpp +++ b/sphaira/include/yyjson_helper.hpp @@ -39,13 +39,14 @@ constexpr auto cexprHash(const char *str, std::size_t v = 0) noexcept -> std::si JSON_SKIP_IF_NULL_PTR(str); \ e.name = str; \ } \ -} +} break #define JSON_SET_OBJ(name) case cexprHash(#name): { \ if (yyjson_is_obj(val)) { \ from_json(val, e.name); \ } \ -} +} break + #define JSON_SET_UINT(name) JSON_SET_TYPE(name, uint) #define JSON_SET_STR(name) JSON_SET_TYPE(name, str) #define JSON_SET_BOOL(name) JSON_SET_TYPE(name, bool) @@ -72,7 +73,7 @@ constexpr auto cexprHash(const char *str, std::size_t v = 0) noexcept -> std::si JSON_SET_ARR_TYPE(name, type); \ } \ } \ -} +} break #define JSON_SET_ARR_OBJ2(name, member) case cexprHash(#name): { \ if (yyjson_is_arr(val)) { \ @@ -87,7 +88,7 @@ constexpr auto cexprHash(const char *str, std::size_t v = 0) noexcept -> std::si from_json(hit, member[idx]); \ } \ } \ -} +} break #define JSON_SET_ARR_OBJ(name) JSON_SET_ARR_OBJ2(name, e.name) diff --git a/sphaira/source/app.cpp b/sphaira/source/app.cpp index e3ead37..d7192ac 100644 --- a/sphaira/source/app.cpp +++ b/sphaira/source/app.cpp @@ -845,6 +845,10 @@ void App::ScanThemes(const std::string& path) { continue; } + if (d->d_type != DT_REG) { + continue; + } + const std::string name = d->d_name; if (!name.ends_with(".ini")) { continue; @@ -917,6 +921,8 @@ App::App(const char* argv0) { fs::FsNativeSd fs; fs.CreateDirectoryRecursively("/config/sphaira/assoc"); fs.CreateDirectoryRecursively("/config/sphaira/themes"); + fs.CreateDirectoryRecursively("/config/sphaira/github"); + fs.CreateDirectoryRecursively("/config/sphaira/i18n"); if (App::GetLogEnable()) { log_file_init(); diff --git a/sphaira/source/ui/menus/filebrowser.cpp b/sphaira/source/ui/menus/filebrowser.cpp index b7e93c8..d30f3e5 100644 --- a/sphaira/source/ui/menus/filebrowser.cpp +++ b/sphaira/source/ui/menus/filebrowser.cpp @@ -1036,6 +1036,10 @@ void Menu::LoadAssocEntriesPath(const fs::FsPath& path) { continue; } + if (d->d_type != DT_REG) { + continue; + } + const auto ext = std::strrchr(d->d_name, '.'); if (!ext || strcasecmp(ext, ".ini")) { continue; diff --git a/sphaira/source/ui/menus/ghdl.cpp b/sphaira/source/ui/menus/ghdl.cpp new file mode 100644 index 0000000..cd848ea --- /dev/null +++ b/sphaira/source/ui/menus/ghdl.cpp @@ -0,0 +1,519 @@ +#include "ui/menus/ghdl.hpp" +#include "ui/sidebar.hpp" +#include "ui/option_box.hpp" +#include "ui/popup_list.hpp" +#include "ui/progress_box.hpp" +#include "ui/error_box.hpp" + +#include "log.hpp" +#include "app.hpp" +#include "ui/nvg_util.hpp" +#include "fs.hpp" +#include "defines.hpp" +#include "image.hpp" +#include "download.hpp" +#include "i18n.hpp" +#include "yyjson_helper.hpp" + +#include +#include +#include +#include +#include + +namespace sphaira::ui::menu::gh { +namespace { + +auto GenerateApiUrl(const std::string& url) { + if (url.starts_with("https://api.github.com/repos/")) { + return url; + } + return "https://api.github.com/repos/" + url.substr(std::strlen("https://github.com/")) + "/releases/latest"; +} + +void from_json(yyjson_val* json, AssetEntry& e) { + JSON_OBJ_ITR( + JSON_SET_STR(name); + JSON_SET_STR(path); + ); +} + +void from_json(const fs::FsPath& path, Entry& e) { + yyjson_read_err err; + JSON_INIT_VEC_FILE(path, nullptr, &err); + JSON_OBJ_ITR( + JSON_SET_STR(name); + JSON_SET_STR(url); + JSON_SET_ARR_OBJ(assets); + ); +} + +void from_json(yyjson_val* json, GhApiAsset& e) { + JSON_OBJ_ITR( + JSON_SET_STR(name); + JSON_SET_STR(content_type); + JSON_SET_UINT(size); + JSON_SET_UINT(download_count); + JSON_SET_STR(browser_download_url); + ); +} + +void from_json(const std::vector& data, GhApiEntry& e) { + JSON_INIT_VEC(data, nullptr); + JSON_OBJ_ITR( + JSON_SET_STR(tag_name); + JSON_SET_STR(name); + JSON_SET_ARR_OBJ(assets); + ); +} + +auto DownloadApp(ProgressBox* pbox, const GhApiAsset* gh_asset, const AssetEntry& entry = {}) -> bool { + constexpr auto chunk_size = 1024 * 512; // 512KiB + + fs::FsNativeSd fs; + R_TRY_RESULT(fs.GetFsOpenResult(), false); + + if (!gh_asset || gh_asset->browser_download_url.empty()) { + log_write("failed to find asset\n"); + return false; + } + + static fs::FsPath temp_file{"/switch/sphaira/cache/ghdl.temp"}; + + // 2. download the asset + if (!pbox->ShouldExit()) { + pbox->NewTransfer("Downloading "_i18n + gh_asset->name); + log_write("starting download: %s\n", gh_asset->browser_download_url.c_str()); + + DownloadClearCache(gh_asset->browser_download_url); + if (!DownloadFile(gh_asset->browser_download_url, temp_file, "", [pbox](u32 dltotal, u32 dlnow, u32 ultotal, u32 ulnow){ + if (pbox->ShouldExit()) { + return false; + } + pbox->UpdateTransfer(dlnow, dltotal); + return true; + })) { + log_write("error with download\n"); + // push popup error box + return false; + } + } + + ON_SCOPE_EXIT(fs.DeleteFile(temp_file)); + + fs::FsPath root_path{"/"}; + if (!entry.path.empty()) { + root_path = entry.path; + } + + // 3. extract the zip / file + if (gh_asset->content_type == "application/zip") { + log_write("found zip\n"); + auto zfile = unzOpen64(temp_file); + if (!zfile) { + log_write("failed to open zip: %s\n", temp_file); + return false; + } + ON_SCOPE_EXIT(unzClose(zfile)); + + unz_global_info64 pglobal_info; + if (UNZ_OK != unzGetGlobalInfo64(zfile, &pglobal_info)) { + return false; + } + + for (int i = 0; i < pglobal_info.number_entry; i++) { + if (i > 0) { + if (UNZ_OK != unzGoToNextFile(zfile)) { + log_write("failed to unzGoToNextFile\n"); + return false; + } + } + + if (UNZ_OK != unzOpenCurrentFile(zfile)) { + log_write("failed to open current file\n"); + return false; + } + ON_SCOPE_EXIT(unzCloseCurrentFile(zfile)); + + unz_file_info64 info; + fs::FsPath file_path; + if (UNZ_OK != unzGetCurrentFileInfo64(zfile, &info, file_path, sizeof(file_path), 0, 0, 0, 0)) { + log_write("failed to get current info\n"); + return false; + } + + file_path = fs::AppendPath(root_path, file_path); + + Result rc; + if (file_path[strlen(file_path) -1] == '/') { + if (R_FAILED(rc = fs.CreateDirectoryRecursively(file_path, true)) && rc != FsError_PathAlreadyExists) { + log_write("failed to create folder: %s 0x%04X\n", file_path, rc); + return false; + } + } else { + Result rc; + if (R_FAILED(rc = fs.CreateFile(file_path, info.uncompressed_size, 0, true)) && rc != FsError_PathAlreadyExists) { + log_write("failed to create file: %s 0x%04X\n", file_path, rc); + return false; + } + + FsFile f; + if (R_FAILED(rc = fs.OpenFile(file_path, FsOpenMode_Write, &f))) { + log_write("failed to open file: %s 0x%04X\n", file_path, rc); + return false; + } + ON_SCOPE_EXIT(fsFileClose(&f)); + + if (R_FAILED(rc = fsFileSetSize(&f, info.uncompressed_size))) { + log_write("failed to set file size: %s 0x%04X\n", file_path, rc); + return false; + } + + std::vector buf(chunk_size); + u64 offset{}; + while (offset < info.uncompressed_size) { + const auto bytes_read = unzReadCurrentFile(zfile, buf.data(), buf.size()); + if (bytes_read <= 0) { + log_write("failed to read zip file: %s\n", file_path.s); + return false; + } + + if (R_FAILED(rc = fsFileWrite(&f, offset, buf.data(), bytes_read, FsWriteOption_None))) { + log_write("failed to write file: %s 0x%04X\n", file_path.s, rc); + return false; + } + + pbox->UpdateTransfer(offset, info.uncompressed_size); + offset += bytes_read; + } + } + } + } else { + fs::FsNativeSd fs; + fs.CreateDirectoryRecursivelyWithPath(root_path, true); + fs.DeleteFile(root_path); + if (R_FAILED(fs.RenameFile(temp_file, root_path, true))) { + log_write("failed to rename file: %s -> %s\n", temp_file.s, root_path.s); + } + } + + log_write("success\n"); + return true; +} + +auto DownloadAssets(ProgressBox* pbox, const std::string& url, GhApiEntry& out) -> bool { + // 1. download the json + if (!pbox->ShouldExit()) { + pbox->NewTransfer("Downloading json"_i18n); + log_write("starting download\n"); + + DownloadClearCache(url); + const auto json = DownloadMemory(url, "", [pbox](u32 dltotal, u32 dlnow, u32 ultotal, u32 ulnow){ + if (pbox->ShouldExit()) { + return false; + } + pbox->UpdateTransfer(dlnow, dltotal); + return true; + }); + + if (json.empty()) { + log_write("error with download\n"); + return false; + } + + from_json(json, out); + } + + log_write("got: %s tag: %s\n", out.name.c_str(), out.tag_name.c_str()); + + return !out.assets.empty(); +} + +auto DownloadApp(ProgressBox* pbox, const std::string& url, const AssetEntry& entry) -> bool { + // 1. download the json + GhApiEntry gh_entry{}; + if (!DownloadAssets(pbox, url, gh_entry)) { + return false; + } + + if (gh_entry.assets.empty()) { + log_write("no assets\n"); + return false; + } + + GhApiAsset* gh_asset{}; + for (auto& p : gh_entry.assets) { + if (p.name == entry.name) { + gh_asset = &p; + } + } + + if (!gh_asset || gh_asset->browser_download_url.empty()) { + log_write("failed to find asset\n"); + return false; + } + + return DownloadApp(pbox, gh_asset, entry); +} + +} // namespace + +Menu::Menu() : MenuBase{"GitHub"_i18n} { + this->SetActions( + std::make_pair(Button::R2, Action{[this](){ + }}), + + std::make_pair(Button::DOWN, Action{[this](){ + if (m_index < (m_entries.size() - 1)) { + SetIndex(m_index + 1); + App::PlaySoundEffect(SoundEffect_Scroll); + if (m_index - m_index_offset >= 8) { + log_write("moved down\n"); + m_index_offset++; + } + } + }}), + + std::make_pair(Button::UP, Action{[this](){ + if (m_index != 0) { + SetIndex(m_index - 1); + App::PlaySoundEffect(SoundEffect_Scroll); + if (m_index < m_index_offset ) { + log_write("moved up\n"); + m_index_offset--; + } + } + }}), + + std::make_pair(Button::A, Action{"Download"_i18n, [this](){ + if (m_entries.empty()) { + return; + } + + // fetch all assets from github and present them to the user. + if (GetEntry().assets.empty()) { + // hack + static GhApiEntry gh_entry; + gh_entry = {}; + + App::Push(std::make_shared("Downloading "_i18n + GetEntry().name, [this](auto pbox){ + return DownloadAssets(pbox, GetEntry().url, gh_entry); + }, [this](bool success){ + if (success) { + PopupList::Items asset_items; + for (auto&p : gh_entry.assets) { + asset_items.emplace_back(p.name); + } + + App::Push(std::make_shared("Select asset to download"_i18n, asset_items, [this](auto op_index){ + if (!op_index) { + return; + } + + const auto index = *op_index; + const auto& asset_entry = gh_entry.assets[index]; + App::Push(std::make_shared("Downloading "_i18n + GetEntry().name, [this, &asset_entry](auto pbox){ + return DownloadApp(pbox, &asset_entry); + }, [this](bool success){ + if (success) { + App::Notify("Downloaded " + GetEntry().name); + } + }, 2)); + })); + } + }, 2)); + } else { + PopupList::Items asset_items; + for (auto&p : GetEntry().assets) { + asset_items.emplace_back(p.name); + } + + App::Push(std::make_shared("Select asset to download"_i18n, asset_items, [this](auto op_index){ + if (!op_index) { + return; + } + + const auto index = *op_index; + const auto& asset_entry = GetEntry().assets[index]; + App::Push(std::make_shared("Downloading "_i18n + GetEntry().name, [this, &asset_entry](auto pbox){ + return DownloadApp(pbox, GetEntry().url, asset_entry); + }, [this](bool success){ + if (success) { + App::Notify("Downloaded " + GetEntry().name); + } + }, 2)); + })); + } + }}), + + std::make_pair(Button::B, Action{"Back"_i18n, [this](){ + SetPop(); + }}) + ); +} + +Menu::~Menu() { +} + +void Menu::Update(Controller* controller, TouchInfo* touch) { + MenuBase::Update(controller, touch); +} + +void Menu::Draw(NVGcontext* vg, Theme* theme) { + MenuBase::Draw(vg, theme); + + const auto& text_col = theme->elements[ThemeEntryID_TEXT].colour; + + if (m_entries.empty()) { + gfx::drawTextArgs(vg, SCREEN_WIDTH / 2.f, SCREEN_HEIGHT / 2.f, 36.f, NVG_ALIGN_CENTER | NVG_ALIGN_MIDDLE, text_col, "Empty..."_i18n.c_str()); + return; + } + + const u64 SCROLL = m_index_offset; + constexpr u64 max_entry_display = 8; + const u64 entry_total = m_entries.size(); + + // only draw scrollbar if needed + if (entry_total > max_entry_display) { + const auto scrollbar_size = 500.f; + const auto sb_h = 1.f / (float)entry_total * scrollbar_size; + const auto sb_y = SCROLL; + gfx::drawRect(vg, SCREEN_WIDTH - 50, 100, 10, scrollbar_size, gfx::getColour(gfx::Colour::BLACK)); + gfx::drawRect(vg, SCREEN_WIDTH - 50+2, 102 + sb_h * sb_y, 10-4, sb_h + (sb_h * (max_entry_display - 1)) - 4, gfx::getColour(gfx::Colour::SILVER)); + } + + // constexpr Vec4 line_top{30.f, 86.f, 1220.f, 1.f}; + // constexpr Vec4 line_bottom{30.f, 646.f, 1220.f, 1.f}; + // constexpr Vec4 block{280.f, 110.f, 720.f, 60.f}; + constexpr Vec4 block{75.f, 110.f, 1220.f-45.f*2, 60.f}; + constexpr float text_xoffset{15.f}; + + // todo: cleanup + const float x = block.x; + float y = GetY() + 1.f + 42.f; + const float h = block.h; + const float w = block.w; + + nvgSave(vg); + nvgScissor(vg, GetX(), GetY(), GetW(), GetH()); + + for (std::size_t i = m_index_offset; i < m_entries.size(); i++) { + auto& e = m_entries[i]; + + auto text_id = ThemeEntryID_TEXT; + if (m_index == i) { + text_id = ThemeEntryID_TEXT_SELECTED; + gfx::drawRectOutline(vg, 4.f, theme->elements[ThemeEntryID_SELECTED_OVERLAY].colour, x, y, w, h, theme->elements[ThemeEntryID_SELECTED].colour); + } else { + if (i == m_index_offset) { + gfx::drawRect(vg, x, y, w, 1.f, text_col); + } + gfx::drawRect(vg, x, y + h, w, 1.f, text_col); + } + + nvgSave(vg); + const auto txt_clip = std::min(GetY() + GetH(), y + h) - y; + nvgScissor(vg, x + text_xoffset, y, w-(x+text_xoffset+50), txt_clip); + gfx::drawText(vg, x + text_xoffset, y + (h / 2.f), 20.f, e.name.c_str(), NULL, NVG_ALIGN_LEFT | NVG_ALIGN_MIDDLE, theme->elements[text_id].colour); + nvgRestore(vg); + + gfx::drawTextArgs(vg, x + w - text_xoffset, y + (h / 2.f), 16.f, NVG_ALIGN_RIGHT | NVG_ALIGN_MIDDLE, theme->elements[text_id].colour, e.url.c_str()); + + y += h; + if (!InYBounds(y)) { + break; + } + } + + nvgRestore(vg); +} + +void Menu::OnFocusGained() { + MenuBase::OnFocusGained(); + if (m_entries.empty()) { + Scan(); + } +} + +void Menu::SetIndex(std::size_t index) { + m_index = index; + if (!m_index) { + m_index_offset = 0; + } + + SetTitleSubHeading(m_entries[m_index].json_path); + UpdateSubheading(); +} + +void Menu::Scan() { + m_entries.clear(); + m_index = 0; + m_index_offset = 0; + + // load from romfs first + if (R_SUCCEEDED(romfsInit())) { + LoadEntriesFromPath("romfs:/github/"); + romfsExit(); + } + + // then load custom entries + LoadEntriesFromPath("/config/sphaira/github/"); + Sort(); + SetIndex(0); +} + +void Menu::LoadEntriesFromPath(const fs::FsPath& path) { + auto dir = opendir(path); + if (!dir) { + return; + } + ON_SCOPE_EXIT(closedir(dir)); + + while (auto d = readdir(dir)) { + if (d->d_name[0] == '.') { + continue; + } + + if (d->d_type != DT_REG) { + continue; + } + + const auto ext = std::strrchr(d->d_name, '.'); + if (!ext || strcasecmp(ext, ".json")) { + continue; + } + + Entry entry{}; + const auto full_path = fs::AppendPath(path, d->d_name); + from_json(full_path, entry); + + // check that we have a name and url + if (entry.name.empty() || entry.url.empty()) { + continue; + } + + // ensure this url is for github + if (!entry.url.starts_with("https://github.com/") && !entry.url.starts_with("https://api.github.com/repos/")) { + continue; + } + + entry.url = GenerateApiUrl(entry.url); + entry.json_path = full_path; + m_entries.emplace_back(entry); + } +} + +void Menu::Sort() { + const auto sorter = [this](Entry& lhs, Entry& rhs) -> bool { + return strcmp(lhs.name.c_str(), rhs.name.c_str()) < 0; + }; + + std::sort(m_entries.begin(), m_entries.end(), sorter); +} + +void Menu::UpdateSubheading() { + const auto index = m_entries.empty() ? 0 : m_index + 1; + this->SetSubHeading(std::to_string(index) + " / " + std::to_string(m_entries.size())); +} + +} // namespace sphaira::ui::menu::gh diff --git a/sphaira/source/ui/menus/main_menu.cpp b/sphaira/source/ui/menus/main_menu.cpp index 2239571..bc3c8aa 100644 --- a/sphaira/source/ui/menus/main_menu.cpp +++ b/sphaira/source/ui/menus/main_menu.cpp @@ -1,6 +1,7 @@ #include "ui/menus/main_menu.hpp" #include "ui/menus/irs_menu.hpp" #include "ui/menus/themezer.hpp" +#include "ui/menus/ghdl.hpp" #include "ui/sidebar.hpp" #include "ui/popup_list.hpp" @@ -297,6 +298,10 @@ MainMenu::MainMenu() { App::Push(std::make_shared()); })); + options->Add(std::make_shared("GitHub"_i18n, [](){ + App::Push(std::make_shared()); + })); + options->Add(std::make_shared("Irs"_i18n, [](){ App::Push(std::make_shared()); })); diff --git a/sphaira/source/ui/popup_list.cpp b/sphaira/source/ui/popup_list.cpp index 6898f2e..a5be3c8 100644 --- a/sphaira/source/ui/popup_list.cpp +++ b/sphaira/source/ui/popup_list.cpp @@ -157,4 +157,14 @@ auto PopupList::Draw(NVGcontext* vg, Theme* theme) -> void { Widget::Draw(vg, theme); } +auto PopupList::OnFocusGained() noexcept -> void { + Widget::OnFocusGained(); + SetHidden(false); +} + +auto PopupList::OnFocusLost() noexcept -> void { + Widget::OnFocusLost(); + SetHidden(true); +} + } // namespace sphaira::ui