From 6554b68efa08582ff42370e3fa1de2102fafd544 Mon Sep 17 00:00:00 2001 From: ITotalJustice <47043333+ITotalJustice@users.noreply.github.com> Date: Sat, 2 Aug 2025 22:19:48 +0100 Subject: [PATCH] menu: add filepicker, sidebar: add file picker entry. the filepicker is a stripped down version of the file browser. only file picking is supported atm, no "select folder" yet. --- sphaira/CMakeLists.txt | 1 + sphaira/include/ui/menus/file_picker.hpp | 216 +++++++++ sphaira/include/ui/sidebar.hpp | 54 ++- sphaira/source/ui/menus/file_picker.cpp | 573 +++++++++++++++++++++++ sphaira/source/ui/menus/homebrew.cpp | 2 +- sphaira/source/ui/sidebar.cpp | 165 +++---- 6 files changed, 904 insertions(+), 107 deletions(-) create mode 100644 sphaira/include/ui/menus/file_picker.hpp create mode 100644 sphaira/source/ui/menus/file_picker.cpp diff --git a/sphaira/CMakeLists.txt b/sphaira/CMakeLists.txt index 4a7533f..b8972a8 100644 --- a/sphaira/CMakeLists.txt +++ b/sphaira/CMakeLists.txt @@ -36,6 +36,7 @@ add_executable(sphaira source/ui/menus/appstore.cpp source/ui/menus/file_viewer.cpp source/ui/menus/filebrowser.cpp + source/ui/menus/file_picker.cpp source/ui/menus/homebrew.cpp source/ui/menus/irs_menu.cpp source/ui/menus/main_menu.cpp diff --git a/sphaira/include/ui/menus/file_picker.hpp b/sphaira/include/ui/menus/file_picker.hpp new file mode 100644 index 0000000..9909ca2 --- /dev/null +++ b/sphaira/include/ui/menus/file_picker.hpp @@ -0,0 +1,216 @@ +#pragma once + +#include "ui/menus/menu_base.hpp" +#include "ui/scrolling_text.hpp" +#include "ui/list.hpp" +#include "fs.hpp" +#include "option.hpp" +#include + +namespace sphaira::ui::menu::filepicker { + +enum FsEntryFlag { + FsEntryFlag_None, + // write protected. + FsEntryFlag_ReadOnly = 1 << 0, + // supports file assoc. + FsEntryFlag_Assoc = 1 << 1, +}; + +enum class FsType { + Sd, + ImageNand, + ImageSd, + Stdio, +}; + +enum SortType { + SortType_Size, + SortType_Alphabetical, +}; + +enum OrderType { + OrderType_Descending, + OrderType_Ascending, +}; + +struct FsEntry { + fs::FsPath name{}; + fs::FsPath root{}; + FsType type{}; + u32 flags{FsEntryFlag_None}; + + auto IsReadOnly() const -> bool { + return flags & FsEntryFlag_ReadOnly; + } + + auto IsAssoc() const -> bool { + return flags & FsEntryFlag_Assoc; + } + + auto IsSame(const FsEntry& e) const { + return root == e.root && type == e.type; + } +}; + +// roughly 1kib in size per entry +struct FileEntry : FsDirectoryEntry { + std::string extension{}; // if any + std::string internal_name{}; // if any + std::string internal_extension{}; // if any + s64 file_count{-1}; // number of files in a folder, non-recursive + s64 dir_count{-1}; // number folders in a folder, non-recursive + FsTimeStampRaw time_stamp{}; + bool checked_extension{}; // did we already search for an ext? + bool checked_internal_extension{}; // did we already search for an ext? + + auto IsFile() const -> bool { + return type == FsDirEntryType_File; + } + + auto IsDir() const -> bool { + return !IsFile(); + } + + auto IsHidden() const -> bool { + return name[0] == '.'; + } + + auto GetName() const -> std::string { + return name; + } + + auto GetExtension() const -> std::string { + return extension; + } + + auto GetInternalName() const -> std::string { + if (!internal_name.empty()) { + return internal_name; + } + return GetName(); + } + + auto GetInternalExtension() const -> std::string { + if (!internal_extension.empty()) { + return internal_extension; + } + return GetExtension(); + } +}; + +struct LastFile { + fs::FsPath name{}; + s64 index{}; + float offset{}; + s64 entries_count{}; +}; + +using Callback = std::function; + +struct Menu final : MenuBase { + explicit Menu(const Callback& cb, const std::vector& filter = {}, const fs::FsPath& path = {}); + ~Menu(); + + auto GetShortTitle() const -> const char* override { return "Picker"; }; + void Update(Controller* controller, TouchInfo* touch) override; + void Draw(NVGcontext* vg, Theme* theme) override; + void OnFocusGained() override; + + static auto GetNewPath(const fs::FsPath& root_path, const fs::FsPath& file_path) -> fs::FsPath { + return fs::AppendPath(root_path, file_path); + } + +private: + auto GetFs() { + return m_fs.get(); + } + + auto& GetFsEntry() const { + return m_fs_entry; + } + + void SetIndex(s64 index); + + auto Scan(const fs::FsPath& new_path, bool is_walk_up = false) -> Result; + + auto GetNewPath(const FileEntry& entry) const -> fs::FsPath { + return GetNewPath(m_path, entry.name); + } + + auto GetNewPath(s64 index) const -> fs::FsPath { + return GetNewPath(m_path, GetEntry(index).name); + } + + auto GetNewPathCurrent() const -> fs::FsPath { + return GetNewPath(m_index); + } + + auto GetEntry(u32 index) -> FileEntry& { + return m_entries[m_entries_current[index]]; + } + + auto GetEntry(u32 index) const -> const FileEntry& { + return m_entries[m_entries_current[index]]; + } + + auto GetEntry() -> FileEntry& { + return GetEntry(m_index); + } + + auto GetEntry() const -> const FileEntry& { + return GetEntry(m_index); + } + + auto IsSd() const -> bool { + return m_fs_entry.type == FsType::Sd; + } + + void Sort(); + void SortAndFindLastFile(bool scan = false); + void SetIndexFromLastFile(const LastFile& last_file); + + void SetFs(const fs::FsPath& new_path, const FsEntry& new_entry); + + auto GetNative() -> fs::FsNative* { + return (fs::FsNative*)m_fs.get(); + } + + void DisplayOptions(); + + void UpdateSubheading(); + void PromptIfShouldExit(); + +private: + static constexpr inline const char* INI_SECTION = "filepicker"; + + Callback m_callback; + std::vector m_filter; + + std::unique_ptr m_fs{}; + FsEntry m_fs_entry{}; + fs::FsPath m_path{}; + std::vector m_entries{}; + std::vector m_entries_index{}; // files not including hidden + std::vector m_entries_index_hidden{}; // includes hidden files + std::span m_entries_current{}; + + std::unique_ptr m_list{}; + + // this keeps track of the highlighted file before opening a folder + // if the user presses B to go back to the previous dir + // this vector is popped, then, that entry is checked if it still exists + // if it does, the index becomes that file. + std::vector m_previous_highlighted_file{}; + s64 m_index{}; + ScrollingText m_scroll_name{}; + + option::OptionLong m_sort{INI_SECTION, "sort", SortType::SortType_Alphabetical, false}; + option::OptionLong m_order{INI_SECTION, "order", OrderType::OrderType_Descending, false}; + option::OptionBool m_show_hidden{INI_SECTION, "show_hidden", false, false}; + option::OptionBool m_folders_first{INI_SECTION, "folders_first", true, false}; + option::OptionBool m_hidden_last{INI_SECTION, "hidden_last", false, false}; + option::OptionBool m_ignore_read_only{INI_SECTION, "ignore_read_only", false, false}; +}; + +} // namespace sphaira::ui::menu::filepicker diff --git a/sphaira/include/ui/sidebar.hpp b/sphaira/include/ui/sidebar.hpp index 20e2666..ca279c5 100644 --- a/sphaira/include/ui/sidebar.hpp +++ b/sphaira/include/ui/sidebar.hpp @@ -20,6 +20,10 @@ public: using Widget::Draw; virtual void Draw(NVGcontext* vg, Theme* theme, const Vec4& root_pos, bool left); + auto OnFocusGained() noexcept -> void override; + auto OnFocusLost() noexcept -> void override; + + void DrawEntry(NVGcontext* vg, Theme* theme, const std::string& left, const std::string& right, bool use_selected); void Depends(const DependsCallback& callback, const std::string& depends_info, const DependsClickCallback& depends_click = {}) { m_depends_callback = callback; @@ -63,6 +67,7 @@ private: DependsCallback m_depends_callback{}; DependsClickCallback m_depends_click{}; ScrollingText m_scolling_title{}; + ScrollingText m_scolling_value{}; }; template @@ -110,44 +115,67 @@ public: explicit SidebarEntryArray(const std::string& title, const Items& items, Callback cb, s64 index = 0, const std::string& info = ""); explicit SidebarEntryArray(const std::string& title, const Items& items, Callback cb, const std::string& index, const std::string& info = ""); explicit SidebarEntryArray(const std::string& title, const Items& items, std::string& index, const std::string& info = ""); - void Draw(NVGcontext* vg, Theme* theme, const Vec4& root_pos, bool left) override; - auto OnFocusGained() noexcept -> void override; - auto OnFocusLost() noexcept -> void override; private: Items m_items; ListCallback m_list_callback; Callback m_callback; s64 m_index; - s64 m_tick{}; - float m_text_yoff{}; }; -class SidebarEntryTextInput final : public SidebarEntryBase { +// single text entry. +// the callback is called when the entry is clicked. +// usually, the within the callback the text will be changed, use SetText(). +class SidebarEntryTextBase : public SidebarEntryBase { public: - using Callback = std::function; + using Callback = std::function; public: - explicit SidebarEntryTextInput(const std::string& text, const std::string& guide = {}, const std::string& info = ""); + explicit SidebarEntryTextBase(const std::string& title, const std::string& value, const Callback& cb, const std::string& info = ""); void Draw(NVGcontext* vg, Theme* theme, const Vec4& root_pos, bool left) override; - auto GetText() const { - return m_title; + void SetCallback(const Callback& cb) { + m_callback = cb; } - void SetText(const std::string& text) { - m_title = text; + auto GetValue() const { + return m_value; } + void SetValue(const std::string& value) { + m_value = value; + } + +private: + std::string m_value; + Callback m_callback; +}; + +class SidebarEntryTextInput final : public SidebarEntryTextBase { +public: + explicit SidebarEntryTextInput(const std::string& title, const std::string& value, const std::string& guide = {}, const std::string& info = ""); + void SetGuide(const std::string& guide) { m_guide = guide; } private: std::string m_guide; - ScrollingText m_scolling_title{}; +}; + +class SidebarEntryFilePicker final : public SidebarEntryTextBase { +public: + explicit SidebarEntryFilePicker(const std::string& title, const std::string& value, const std::vector& filter, const std::string& info = ""); + + // extension filter. + void SetFilter(const std::vector& filter) { + m_filter = filter; + } + +private: + std::vector m_filter{}; }; class Sidebar final : public Widget { diff --git a/sphaira/source/ui/menus/file_picker.cpp b/sphaira/source/ui/menus/file_picker.cpp new file mode 100644 index 0000000..e929b15 --- /dev/null +++ b/sphaira/source/ui/menus/file_picker.cpp @@ -0,0 +1,573 @@ +#include "ui/menus/file_picker.hpp" +#include "ui/sidebar.hpp" +#include "ui/option_box.hpp" +#include "ui/popup_list.hpp" +#include "ui/error_box.hpp" + +#include "log.hpp" +#include "app.hpp" +#include "ui/nvg_util.hpp" +#include "fs.hpp" +#include "defines.hpp" +#include "i18n.hpp" +#include "location.hpp" +#include "minizip_helper.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace sphaira::ui::menu::filepicker { +namespace { + +constexpr FsEntry FS_ENTRY_DEFAULT{ + "microSD card", "/", FsType::Sd, FsEntryFlag_Assoc, +}; + +constexpr FsEntry FS_ENTRIES[]{ + FS_ENTRY_DEFAULT, +}; + +constexpr std::string_view AUDIO_EXTENSIONS[] = { + "mp3", "ogg", "flac", "wav", "aac" "ac3", "aif", "asf", "bfwav", + "bfsar", "bfstm", +}; +constexpr std::string_view VIDEO_EXTENSIONS[] = { + "mp4", "mkv", "m3u", "m3u8", "hls", "vob", "avi", "dv", "flv", "m2ts", + "m2v", "m4a", "mov", "mpeg", "mpg", "mts", "swf", "ts", "vob", "wma", "wmv", +}; +constexpr std::string_view IMAGE_EXTENSIONS[] = { + "png", "jpg", "jpeg", "bmp", "gif", +}; +constexpr std::string_view INSTALL_EXTENSIONS[] = { + "nsp", "xci", "nsz", "xcz", +}; +constexpr std::string_view ZIP_EXTENSIONS[] = { + "zip", +}; + +// case insensitive check +auto IsSamePath(std::string_view a, std::string_view b) -> bool { + return a.length() == b.length() && !strncasecmp(a.data(), b.data(), a.length()); +} + +auto IsExtension(std::string_view ext, std::span list) -> bool { + for (auto e : list) { + if (e.length() == ext.length() && !strncasecmp(ext.data(), e.data(), ext.length())) { + return true; + } + } + return false; +} + +auto IsExtension(std::string_view ext1, std::string_view ext2) -> bool { + return ext1.length() == ext2.length() && !strncasecmp(ext1.data(), ext2.data(), ext1.length()); +} + +} // namespace + +void Menu::SetIndex(s64 index) { + m_index = index; + if (!m_index) { + m_list->SetYoff(); + } + + if (IsSd() && !m_entries_current.empty() && !GetEntry().checked_internal_extension && IsSamePath(GetEntry().extension, "zip")) { + GetEntry().checked_internal_extension = true; + + if (auto zfile = unzOpen64(GetNewPathCurrent())) { + ON_SCOPE_EXIT(unzClose(zfile)); + + // only check first entry (i think RA does the same) + fs::FsPath filename_inzip{}; + unz_file_info64 file_info{}; + if (UNZ_OK == unzOpenCurrentFile(zfile)) { + ON_SCOPE_EXIT(unzCloseCurrentFile(zfile)); + if (UNZ_OK == unzGetCurrentFileInfo64(zfile, &file_info, filename_inzip, sizeof(filename_inzip), NULL, 0, NULL, 0)) { + if (auto ext = std::strrchr(filename_inzip, '.')) { + GetEntry().internal_name = filename_inzip.toString(); + GetEntry().internal_extension = ext+1; + } + } + } + } + } + + UpdateSubheading(); +} + +auto Menu::Scan(const fs::FsPath& new_path, bool is_walk_up) -> Result { + App::SetBoostMode(true); + ON_SCOPE_EXIT(App::SetBoostMode(false)); + + log_write("new scan path: %s\n", new_path.s); + if (!is_walk_up && !m_path.empty() && !m_entries_current.empty()) { + const LastFile f(GetEntry().name, m_index, m_list->GetYoff(), m_entries_current.size()); + m_previous_highlighted_file.emplace_back(f); + } + + m_path = new_path; + m_entries.clear(); + m_index = 0; + m_list->SetYoff(0); + SetTitleSubHeading(m_path); + + fs::Dir d; + R_TRY(m_fs->OpenDirectory(new_path, FsDirOpenMode_ReadDirs | FsDirOpenMode_ReadFiles, &d)); + + // we won't run out of memory here (tm) + std::vector dir_entries; + R_TRY(d.ReadAll(dir_entries)); + + const auto count = dir_entries.size(); + m_entries.reserve(count); + + m_entries_index.clear(); + m_entries_index_hidden.clear(); + + m_entries_index.reserve(count); + m_entries_index_hidden.reserve(count); + + u32 i = 0; + for (const auto& e : dir_entries) { + m_entries_index_hidden.emplace_back(i); + + bool hidden = false; + // check if we have a filter. + if (e.type == FsDirEntryType_File && !m_filter.empty()) { + hidden = true; + if (const auto ext = std::strrchr(e.name, '.')) { + for (const auto& filter : m_filter) { + if (IsExtension(ext, filter)) { + hidden = false; + break; + } + } + } + } + + if (!hidden) { + m_entries_index.emplace_back(i); + } + + m_entries.emplace_back(e); + i++; + } + + Sort(); + SetIndex(0); + + // find previous entry + if (is_walk_up && !m_previous_highlighted_file.empty()) { + ON_SCOPE_EXIT(m_previous_highlighted_file.pop_back()); + SetIndexFromLastFile(m_previous_highlighted_file.back()); + } + + log_write("finished scan\n"); + R_SUCCEED(); +} + +void Menu::Sort() { + // returns true if lhs should be before rhs + const auto sort = m_sort.Get(); + const auto order = m_order.Get(); + const auto folders_first = m_folders_first.Get(); + const auto hidden_last = m_hidden_last.Get(); + + const auto sorter = [this, sort, order, folders_first, hidden_last](u32 _lhs, u32 _rhs) -> bool { + const auto& lhs = m_entries[_lhs]; + const auto& rhs = m_entries[_rhs]; + + if (hidden_last) { + if (lhs.IsHidden() && !rhs.IsHidden()) { + return false; + } else if (!lhs.IsHidden() && rhs.IsHidden()) { + return true; + } + } + + if (folders_first) { + if (lhs.type == FsDirEntryType_Dir && !(rhs.type == FsDirEntryType_Dir)) { // left is folder + return true; + } else if (!(lhs.type == FsDirEntryType_Dir) && rhs.type == FsDirEntryType_Dir) { // right is folder + return false; + } + } + + switch (sort) { + case SortType_Size: { + if (lhs.file_size == rhs.file_size) { + return strncasecmp(lhs.name, rhs.name, sizeof(lhs.name)) < 0; + } else if (order == OrderType_Descending) { + return lhs.file_size > rhs.file_size; + } else { + return lhs.file_size < rhs.file_size; + } + } break; + case SortType_Alphabetical: { + if (order == OrderType_Descending) { + return strncasecmp(lhs.name, rhs.name, sizeof(lhs.name)) < 0; + } else { + return strncasecmp(lhs.name, rhs.name, sizeof(lhs.name)) > 0; + } + } break; + } + + std::unreachable(); + }; + + if (m_show_hidden.Get()) { + m_entries_current = m_entries_index_hidden; + } else { + m_entries_current = m_entries_index; + } + + std::sort(m_entries_current.begin(), m_entries_current.end(), sorter); +} + +void Menu::SortAndFindLastFile(bool scan) { + std::optional last_file; + if (!m_path.empty() && !m_entries_current.empty()) { + last_file = LastFile(GetEntry().name, m_index, m_list->GetYoff(), m_entries_current.size()); + } + + if (scan) { + Scan(m_path); + } else { + Sort(); + } + + if (last_file.has_value()) { + SetIndexFromLastFile(*last_file); + } +} + +void Menu::SetIndexFromLastFile(const LastFile& last_file) { + SetIndex(0); + + s64 index = -1; + for (u64 i = 0; i < m_entries_current.size(); i++) { + if (last_file.name == GetEntry(i).name) { + index = i; + break; + } + } + if (index >= 0) { + if (index == last_file.index && m_entries_current.size() == last_file.entries_count) { + m_list->SetYoff(last_file.offset); + log_write("index is the same as last time\n"); + } else { + // file position changed! + log_write("file position changed\n"); + // guesstimate where the position is + if (index >= 8) { + m_list->SetYoff(((index - 8) + 1) * m_list->GetMaxY()); + } else { + m_list->SetYoff(0); + } + } + SetIndex(index); + } +} + +void Menu::SetFs(const fs::FsPath& new_path, const FsEntry& new_entry) { + if (m_fs && m_fs_entry.root == new_entry.root && m_fs_entry.type == new_entry.type) { + log_write("same fs, ignoring\n"); + return; + } + + // m_fs.reset(); + m_path = new_path; + m_entries.clear(); + m_entries_index.clear(); + m_entries_index_hidden.clear(); + m_entries_current = {}; + m_previous_highlighted_file.clear(); + m_fs_entry = new_entry; + + switch (new_entry.type) { + case FsType::Sd: + m_fs = std::make_unique(m_ignore_read_only.Get()); + break; + case FsType::ImageNand: + m_fs = std::make_unique(FsImageDirectoryId_Nand); + break; + case FsType::ImageSd: + m_fs = std::make_unique(FsImageDirectoryId_Sd); + break; + case FsType::Stdio: + m_fs = std::make_unique(true, new_entry.root); + break; + } + + if (HasFocus()) { + if (m_path.empty()) { + Scan(m_fs->Root()); + } else { + Scan(m_path); + } + } +} + +void Menu::DisplayOptions() { + auto options = std::make_unique("File Options"_i18n, Sidebar::Side::RIGHT); + ON_SCOPE_EXIT(App::Push(std::move(options))); + + SidebarEntryArray::Items mount_items; + std::vector fs_entries; + + const auto stdio_locations = location::GetStdio(false); + for (const auto& e: stdio_locations) { + u32 flags{}; + if (e.write_protect) { + flags |= FsEntryFlag_ReadOnly; + } + + fs_entries.emplace_back(e.name, e.mount, FsType::Stdio, flags); + mount_items.push_back(e.name); + } + + for (const auto& e: FS_ENTRIES) { + fs_entries.emplace_back(e); + mount_items.push_back(i18n::get(e.name)); + } + + options->Add("Mount"_i18n, mount_items, [this, fs_entries](s64& index_out){ + App::PopToMenu(); + SetFs(fs_entries[index_out].root, fs_entries[index_out]); + }, i18n::get(m_fs_entry.name)); +} + +Menu::Menu(const Callback& cb, const std::vector& filter, const fs::FsPath& path) +: MenuBase{"FilePicker"_i18n, MenuFlag_None} +, m_callback{cb} +, m_filter{filter} { + FsEntry entry = FS_ENTRY_DEFAULT; + + if (!IsTab()) { + SetAction(Button::SELECT, Action{"Close"_i18n, [this](){ + PromptIfShouldExit(); + }}); + } + + this->SetActions( + std::make_pair(Button::A, Action{"Open"_i18n, [this](){ + if (m_entries_current.empty()) { + return; + } + + const auto& entry = GetEntry(); + + if (entry.type == FsDirEntryType_Dir) { + // todo: add support for folder picker. + Scan(GetNewPathCurrent()); + } else { + if (m_callback(GetNewPathCurrent())) { + SetPop(); + } + } + }}), + + std::make_pair(Button::B, Action{"Back"_i18n, [this](){ + if (!IsTab() && App::GetApp()->m_controller.GotHeld(Button::R2)) { + PromptIfShouldExit(); + return; + } + + std::string_view view{m_path}; + if (view != m_fs->Root()) { + const auto end = view.find_last_of('/'); + assert(end != view.npos); + + if (end == 0) { + Scan(m_fs->Root(), true); + } else { + Scan(view.substr(0, end), true); + } + } else { + if (!IsTab()) { + PromptIfShouldExit(); + } + } + }}), + + std::make_pair(Button::X, Action{"Options"_i18n, [this](){ + DisplayOptions(); + }}) + ); + + const Vec4 v{75, GetY() + 1.f + 42.f, 1220.f-45.f*2, 60}; + m_list = std::make_unique(1, 8, m_pos, v); + + auto buf = path; + if (path.empty()) { + ini_gets(INI_SECTION, "last_path", entry.root, buf, sizeof(buf), App::CONFIG_PATH); + } + + SetFs(buf, entry); +} + +Menu::~Menu() { + // don't store mount points for non-sd card paths. + if (IsSd()) { + ini_puts(INI_SECTION, "last_path", m_path, App::CONFIG_PATH); + + // save last selected file. + if (!m_entries.empty()) { + ini_puts(INI_SECTION, "last_file", GetEntry().name, App::CONFIG_PATH); + } + } +} + +void Menu::Update(Controller* controller, TouchInfo* touch) { + MenuBase::Update(controller, touch); + + m_list->OnUpdate(controller, touch, m_index, m_entries_current.size(), [this](bool touch, auto i) { + if (touch && m_index == i) { + FireAction(Button::A); + } else { + App::PlaySoundEffect(SoundEffect_Focus); + SetIndex(i); + } + }); +} + +void Menu::Draw(NVGcontext* vg, Theme* theme) { + MenuBase::Draw(vg, theme); + + const auto& text_col = theme->GetColour(ThemeEntryID_TEXT); + + if (m_entries_current.empty()) { + gfx::drawTextArgs(vg, GetX() + GetW() / 2.f, GetY() + GetH() / 2.f, 36.f, NVG_ALIGN_CENTER | NVG_ALIGN_MIDDLE, theme->GetColour(ThemeEntryID_TEXT_INFO), "Empty..."_i18n.c_str()); + return; + } + + constexpr float text_xoffset{15.f}; + + m_list->Draw(vg, theme, m_entries_current.size(), [this, text_col](auto* vg, auto* theme, auto v, auto i) { + const auto& [x, y, w, h] = v; + auto& e = GetEntry(i); + + if (e.IsDir()) { + // NOTE: make this native only if hdd dir scan is too slow. + // if (m_fs->IsNative() && e.file_count == -1 && e.dir_count == -1) { + if (e.file_count == -1 && e.dir_count == -1) { + m_fs->DirGetEntryCount(GetNewPath(e), &e.file_count, &e.dir_count); + } + } else if (!e.checked_extension) { + e.checked_extension = true; + if (auto ext = std::strrchr(e.name, '.')) { + e.extension = ext+1; + } + } + + auto text_id = ThemeEntryID_TEXT; + const auto selected = m_index == i; + if (selected) { + text_id = ThemeEntryID_TEXT_SELECTED; + gfx::drawRectOutline(vg, theme, 4.f, v); + } else { + if (i != m_entries_current.size() - 1) { + gfx::drawRect(vg, Vec4{x, y + h, w, 1.f}, theme->GetColour(ThemeEntryID_LINE_SEPARATOR)); + } + } + + if (e.IsDir()) { + DrawElement(x + text_xoffset, y + 5, 50, 50, ThemeEntryID_ICON_FOLDER); + } else { + auto icon = ThemeEntryID_ICON_FILE; + const auto ext = e.GetExtension(); + if (IsExtension(ext, AUDIO_EXTENSIONS)) { + icon = ThemeEntryID_ICON_AUDIO; + } else if (IsExtension(ext, VIDEO_EXTENSIONS)) { + icon = ThemeEntryID_ICON_VIDEO; + } else if (IsExtension(ext, IMAGE_EXTENSIONS)) { + icon = ThemeEntryID_ICON_IMAGE; + } else if (IsExtension(ext, INSTALL_EXTENSIONS)) { + // todo: maybe replace this icon with something else? + icon = ThemeEntryID_ICON_NRO; + } else if (IsExtension(ext, ZIP_EXTENSIONS)) { + icon = ThemeEntryID_ICON_ZIP; + } else if (IsExtension(ext, "nro")) { + icon = ThemeEntryID_ICON_NRO; + } + + DrawElement(x + text_xoffset, y + 5, 50, 50, icon); + } + + m_scroll_name.Draw(vg, selected, x + text_xoffset+65, y + (h / 2.f), w-(75+text_xoffset+65+50), 20, NVG_ALIGN_LEFT | NVG_ALIGN_MIDDLE, theme->GetColour(text_id), e.name); + + // NOTE: make this native only if i disable dir scan from above. + if (e.IsDir()) { + gfx::drawTextArgs(vg, x + w - text_xoffset, y + (h / 2.f) - 3, 16.f, NVG_ALIGN_RIGHT | NVG_ALIGN_BOTTOM, theme->GetColour(text_id), "%zd files"_i18n.c_str(), e.file_count); + gfx::drawTextArgs(vg, x + w - text_xoffset, y + (h / 2.f) + 3, 16.f, NVG_ALIGN_RIGHT | NVG_ALIGN_TOP, theme->GetColour(text_id), "%zd dirs"_i18n.c_str(), e.dir_count); + } else if (e.IsFile()) { + if (!e.time_stamp.is_valid) { + const auto path = GetNewPath(e); + if (m_fs->IsNative()) { + m_fs->GetFileTimeStampRaw(path, &e.time_stamp); + } else { + m_fs->FileGetSizeAndTimestamp(path, &e.time_stamp, &e.file_size); + } + } + + const auto t = (time_t)(e.time_stamp.modified); + struct tm tm{}; + localtime_r(&t, &tm); + gfx::drawTextArgs(vg, x + w - text_xoffset, y + (h / 2.f) + 3, 16.f, NVG_ALIGN_RIGHT | NVG_ALIGN_TOP, theme->GetColour(text_id), "%02u/%02u/%u", tm.tm_mday, tm.tm_mon + 1, tm.tm_year + 1900); + if ((double)e.file_size / 1024.0 / 1024.0 <= 0.009) { + gfx::drawTextArgs(vg, x + w - text_xoffset, y + (h / 2.f) - 3, 16.f, NVG_ALIGN_RIGHT | NVG_ALIGN_BOTTOM, theme->GetColour(text_id), "%.2f KiB", (double)e.file_size / 1024.0); + } else { + gfx::drawTextArgs(vg, x + w - text_xoffset, y + (h / 2.f) - 3, 16.f, NVG_ALIGN_RIGHT | NVG_ALIGN_BOTTOM, theme->GetColour(text_id), "%.2f MiB", (double)e.file_size / 1024.0 / 1024.0); + } + } + }); +} + +void Menu::OnFocusGained() { + MenuBase::OnFocusGained(); + + if (m_entries.empty()) { + if (m_path.empty()) { + Scan(m_fs->Root()); + } else { + Scan(m_path); + } + + if (IsSd() && !m_entries.empty()) { + LastFile last_file{}; + if (ini_gets(INI_SECTION, "last_file", "", last_file.name, sizeof(last_file.name), App::CONFIG_PATH)) { + SetIndexFromLastFile(last_file); + } + } + } +} + +void Menu::UpdateSubheading() { + const auto index = m_entries_current.empty() ? 0 : m_index + 1; + this->SetSubHeading(std::to_string(index) + " / " + std::to_string(m_entries_current.size())); +} + +void Menu::PromptIfShouldExit() { + if (IsTab()) { + return; + } + + App::Push( + "Close File Picker?"_i18n, + "No"_i18n, "Yes"_i18n, 1, [this](auto op_index){ + if (op_index && *op_index) { + SetPop(); + } + } + ); +} + +} // namespace sphaira::ui::menu::filepicker diff --git a/sphaira/source/ui/menus/homebrew.cpp b/sphaira/source/ui/menus/homebrew.cpp index 5ab99a9..1ec2b86 100644 --- a/sphaira/source/ui/menus/homebrew.cpp +++ b/sphaira/source/ui/menus/homebrew.cpp @@ -489,7 +489,7 @@ void Menu::DisplayOptions() { }, GetEntry().image ); }, "Perminately delete the selected homebrew.\n\n" - "Files and folders created by the homebrew will still remain. " + "Files and folders created by the homebrew will still remain. " "Use the FileBrowser to delete them."_i18n); auto forwarder_entry = options->Add("Install Forwarder"_i18n, [this](){ diff --git a/sphaira/source/ui/sidebar.cpp b/sphaira/source/ui/sidebar.cpp index ef8bd65..a071486 100644 --- a/sphaira/source/ui/sidebar.cpp +++ b/sphaira/source/ui/sidebar.cpp @@ -1,4 +1,5 @@ #include "ui/sidebar.hpp" +#include "ui/menus/file_picker.hpp" #include "app.hpp" #include "ui/popup_list.hpp" #include "ui/nvg_util.hpp" @@ -9,14 +10,6 @@ namespace sphaira::ui { namespace { -auto GetTextScrollSpeed() -> float { - switch (App::GetTextScrollSpeed()) { - case 0: return 0.5; - default: case 1: return 1.0; - case 2: return 1.5; - } -} - auto DistanceBetweenY(Vec4 va, Vec4 vb) -> Vec4 { return Vec4{ va.x, va.y, @@ -79,6 +72,38 @@ void SidebarEntryBase::Draw(NVGcontext* vg, Theme* theme, const Vec4& root_pos, } } +auto SidebarEntryBase::OnFocusGained() noexcept -> void { + Widget::OnFocusGained(); +} + +auto SidebarEntryBase::OnFocusLost() noexcept -> void { + Widget::OnFocusLost(); + m_scolling_value.Reset(); +} + +void SidebarEntryBase::DrawEntry(NVGcontext* vg, Theme* theme, const std::string& left, const std::string& right, bool use_selected) { + const auto colour_id = IsEnabled() ? ThemeEntryID_TEXT : ThemeEntryID_TEXT_INFO; + + // scrolling text + float bounds[4]; + nvgFontSize(vg, 20); + nvgTextAlign(vg, NVG_ALIGN_LEFT | NVG_ALIGN_MIDDLE); + nvgTextBounds(vg, 0, 0, left.c_str(), nullptr, bounds); + const float start_x = bounds[2] + 50; + const float max_off = m_pos.w - start_x - 15.f; + + nvgTextBounds(vg, 0, 0, right.c_str(), nullptr, bounds); + + const Vec2 key_text_pos{m_pos.x + 15.f, m_pos.y + (m_pos.h / 2.f)}; + gfx::drawText(vg, key_text_pos, 20.f, theme->GetColour(colour_id), left.c_str(), NVG_ALIGN_LEFT | NVG_ALIGN_MIDDLE); + + const auto value_id = use_selected ? ThemeEntryID_TEXT_SELECTED : ThemeEntryID_TEXT; + const float xpos = m_pos.x + m_pos.w - 15.f - std::min(max_off, bounds[2]); + const float ypos = m_pos.y + (m_pos.h / 2.f); + + m_scolling_value.Draw(vg, HasFocus(), xpos, ypos, max_off, 20.f, NVG_ALIGN_LEFT | NVG_ALIGN_MIDDLE, theme->GetColour(value_id), right); +} + SidebarEntryBool::SidebarEntryBool(const std::string& title, bool option, Callback cb, const std::string& info, const std::string& true_str, const std::string& false_str) : SidebarEntryBase{title, info} , m_option{option} @@ -126,20 +151,7 @@ SidebarEntryBool::SidebarEntryBool(const std::string& title, option::OptionBool& void SidebarEntryBool::Draw(NVGcontext* vg, Theme* theme, const Vec4& root_pos, bool left) { SidebarEntryBase::Draw(vg, theme, root_pos, left); - - // if (HasFocus()) { - // gfx::drawText(vg, Vec2{m_pos.x + 15.f, m_pos.y + (m_pos.h / 2.f)}, 20.f, theme->GetColour(ThemeEntryID_TEXT_SELECTED), m_title.c_str(), NVG_ALIGN_LEFT | NVG_ALIGN_MIDDLE); - // } else { - // } - - const auto colour_id = IsEnabled() ? ThemeEntryID_TEXT : ThemeEntryID_TEXT_INFO; - gfx::drawText(vg, Vec2{m_pos.x + 15.f, m_pos.y + (m_pos.h / 2.f)}, 20.f, theme->GetColour(colour_id), m_title.c_str(), NVG_ALIGN_LEFT | NVG_ALIGN_MIDDLE); - - if (m_option == true) { - gfx::drawText(vg, Vec2{m_pos.x + m_pos.w - 15.f, m_pos.y + (m_pos.h / 2.f)}, 20.f, theme->GetColour(ThemeEntryID_TEXT_SELECTED), m_true_str.c_str(), NVG_ALIGN_RIGHT | NVG_ALIGN_MIDDLE); - } else { // text info - gfx::drawText(vg, Vec2{m_pos.x + m_pos.w - 15.f, m_pos.y + (m_pos.h / 2.f)}, 20.f, theme->GetColour(ThemeEntryID_TEXT), m_false_str.c_str(), NVG_ALIGN_RIGHT | NVG_ALIGN_MIDDLE); - } + SidebarEntryBase::DrawEntry(vg, theme, m_title, m_option ? m_true_str : m_false_str, m_option); } SidebarEntryCallback::SidebarEntryCallback(const std::string& title, Callback cb, bool pop_on_click, const std::string& info) @@ -167,11 +179,7 @@ void SidebarEntryCallback::Draw(NVGcontext* vg, Theme* theme, const Vec4& root_p SidebarEntryBase::Draw(vg, theme, root_pos, left); const auto colour_id = IsEnabled() ? ThemeEntryID_TEXT : ThemeEntryID_TEXT_INFO; - // if (HasFocus()) { - // gfx::drawText(vg, Vec2{m_pos.x + 15.f, m_pos.y + (m_pos.h / 2.f)}, 20.f, theme->GetColour(ThemeEntryID_TEXT_SELECTED), m_title.c_str(), NVG_ALIGN_LEFT | NVG_ALIGN_MIDDLE); - // } else { - gfx::drawText(vg, Vec2{m_pos.x + 15.f, m_pos.y + (m_pos.h / 2.f)}, 20.f, theme->GetColour(colour_id), m_title.c_str(), NVG_ALIGN_LEFT | NVG_ALIGN_MIDDLE); - // } + gfx::drawText(vg, Vec2{m_pos.x + 15.f, m_pos.y + (m_pos.h / 2.f)}, 20.f, theme->GetColour(colour_id), m_title.c_str(), NVG_ALIGN_LEFT | NVG_ALIGN_MIDDLE); } SidebarEntryArray::SidebarEntryArray(const std::string& title, const Items& items, std::string& index, const std::string& info) @@ -227,82 +235,53 @@ SidebarEntryArray::SidebarEntryArray(const std::string& title, const Items& item void SidebarEntryArray::Draw(NVGcontext* vg, Theme* theme, const Vec4& root_pos, bool left) { SidebarEntryBase::Draw(vg, theme, root_pos, left); - - const auto colour_id = IsEnabled() ? ThemeEntryID_TEXT : ThemeEntryID_TEXT_INFO; - const auto& text_entry = m_items[m_index]; - - // scrolling text - // todo: move below in a flexible class and use it for all text drawing. - float bounds[4]; - nvgFontSize(vg, 20); - nvgTextAlign(vg, NVG_ALIGN_LEFT | NVG_ALIGN_MIDDLE); - nvgTextBounds(vg, 0, 0, m_title.c_str(), nullptr, bounds); - const float start_x = bounds[2] + 50; - const float max_off = m_pos.w - start_x - 15.f; - - auto value_str = m_items[m_index]; - nvgTextBounds(vg, 0, 0, value_str.c_str(), nullptr, bounds); - - if (HasFocus()) { - const auto scroll_amount = GetTextScrollSpeed(); - if (bounds[2] > max_off) { - value_str += " "; - nvgTextBounds(vg, 0, 0, value_str.c_str(), nullptr, bounds); - - if (!m_text_yoff) { - m_tick++; - if (m_tick >= 90) { - m_tick = 0; - m_text_yoff += scroll_amount; - } - } else if (bounds[2] > m_text_yoff) { - m_text_yoff += std::min(scroll_amount, bounds[2] - m_text_yoff); - } else { - m_text_yoff = 0; - } - - value_str += text_entry; - } - } - - const Vec2 key_text_pos{m_pos.x + 15.f, m_pos.y + (m_pos.h / 2.f)}; - gfx::drawText(vg, key_text_pos, 20.f, theme->GetColour(colour_id), m_title.c_str(), NVG_ALIGN_LEFT | NVG_ALIGN_MIDDLE); - - nvgSave(vg); - const float xpos = m_pos.x + m_pos.w - 15.f - std::min(max_off, bounds[2]); - nvgIntersectScissor(vg, xpos, GetY(), max_off, GetH()); - const Vec2 value_text_pos{xpos - m_text_yoff, m_pos.y + (m_pos.h / 2.f)}; - gfx::drawText(vg, value_text_pos, 20.f, theme->GetColour(ThemeEntryID_TEXT_SELECTED), value_str.c_str(), NVG_ALIGN_LEFT | NVG_ALIGN_MIDDLE); - nvgRestore(vg); + SidebarEntryBase::DrawEntry(vg, theme, m_title, m_items[m_index], true); } -auto SidebarEntryArray::OnFocusGained() noexcept -> void { - Widget::OnFocusGained(); -} - -auto SidebarEntryArray::OnFocusLost() noexcept -> void { - Widget::OnFocusLost(); - m_text_yoff = 0; -} - -SidebarEntryTextInput::SidebarEntryTextInput(const std::string& text, const std::string& guide, const std::string& info) -: SidebarEntryBase{text, info}, m_guide{guide} { +SidebarEntryTextBase::SidebarEntryTextBase(const std::string& title, const std::string& value, const Callback& cb, const std::string& info) +: SidebarEntryBase{title, info} +, m_value{value} +, m_callback{cb} { SetAction(Button::A, Action{"OK"_i18n, [this](){ - std::string out; - if (R_SUCCEEDED(swkbd::ShowText(out, m_guide.c_str(), m_title.c_str()))) { - m_title = out; + if (m_callback) { + m_callback(); } }}); } -void SidebarEntryTextInput::Draw(NVGcontext* vg, Theme* theme, const Vec4& root_pos, bool left) { +void SidebarEntryTextBase::Draw(NVGcontext* vg, Theme* theme, const Vec4& root_pos, bool left) { SidebarEntryBase::Draw(vg, theme, root_pos, left); + SidebarEntryBase::DrawEntry(vg, theme, m_title, m_value, true); - const auto colour_id = IsEnabled() ? ThemeEntryID_TEXT : ThemeEntryID_TEXT_INFO; - const auto max_w = m_pos.w - 15.f * 2; + // const auto colour_id = IsEnabled() ? ThemeEntryID_TEXT : ThemeEntryID_TEXT_INFO; + // const auto max_w = m_pos.w - 15.f * 2; - m_scolling_title.Draw(vg, HasFocus(), m_pos.x + 15.f, m_pos.y + (m_pos.h / 2.f), max_w, 20.f, NVG_ALIGN_LEFT | NVG_ALIGN_MIDDLE, theme->GetColour(colour_id), m_title); - // gfx::drawText(vg, Vec2{m_pos.x + 15.f, m_pos.y + (m_pos.h / 2.f)}, 20.f, theme->GetColour(colour_id), m_title.c_str(), NVG_ALIGN_LEFT | NVG_ALIGN_MIDDLE); + // m_scolling_title.Draw(vg, HasFocus(), m_pos.x + 15.f, m_pos.y + (m_pos.h / 2.f), max_w, 20.f, NVG_ALIGN_LEFT | NVG_ALIGN_MIDDLE, theme->GetColour(colour_id), m_title); +} + +SidebarEntryTextInput::SidebarEntryTextInput(const std::string& title, const std::string& value, const std::string& guide, const std::string& info) +: SidebarEntryTextBase{title, value, {}, info}, m_guide{guide} { + + SetCallback([this](){ + std::string out; + if (R_SUCCEEDED(swkbd::ShowText(out, m_guide.c_str(), GetValue().c_str()))) { + SetValue(out); + } + }); +} + +SidebarEntryFilePicker::SidebarEntryFilePicker(const std::string& title, const std::string& value, const std::vector& filter, const std::string& info) +: SidebarEntryTextBase{title, value, {}, info}, m_filter{filter} { + + SetCallback([this](){ + App::Push( + [this](const fs::FsPath& path) { + SetValue(path); + return true; + }, + m_filter + ); + }); } Sidebar::Sidebar(const std::string& title, Side side, Items&& items)