Files
ppsspp/UI/GameBrowser.cpp
2026-05-03 17:41:18 +02:00

1155 lines
39 KiB
C++

// Copyright (c) 2013- PPSSPP Project.
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, version 2.0 or later versions.
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License 2.0 for more details.
// A copy of the GPL 2.0 should have been included with the program.
// If not, see http://www.gnu.org/licenses/
// Official git repository and contact information can be found at
// https://github.com/hrydgard/ppsspp and http://www.ppsspp.org/.
#include <algorithm>
#include <cmath>
#include "ppsspp_config.h"
#include "Common/System/Display.h"
#include "Common/UI/Context.h"
#include "Common/UI/View.h"
#include "Common/UI/ViewGroup.h"
#include "Common/UI/Root.h"
#include "Common/Math/curves.h"
#include "Common/Net/URL.h"
#include "Common/File/FileUtil.h"
#include "Common/TimeUtil.h"
#include "Common/StringUtils.h"
#include "Common/System/OSD.h"
#include "Common/Data/Encoding/Utf8.h"
#include "Core/System.h"
#include "Core/Util/RecentFiles.h"
#include "Core/HLE/sceCtrl.h"
#include "Core/ELF/PBPReader.h"
#include "UI/GameBrowser.h"
#include "UI/RemoteISOScreen.h"
#include "UI/SavedataScreen.h"
#include "UI/Store.h"
#include "UI/UploadScreen.h"
#include "UI/Background.h"
#include "Core/Config.h"
#include "Common/Data/Text/I18n.h"
#include "Core/Util/DarwinFileSystemServices.h" // For the browser
static void DrawIconWithText(UIContext &dc, ImageID image, std::string_view text, const Bounds &bounds, bool gridStyle, const UI::Style &style) {
float tw, th;
dc.MeasureText(dc.GetFontStyle(), gridStyle ? g_Config.fGameGridScale : 1.0, gridStyle ? g_Config.fGameGridScale : 1.0, text, &tw, &th, 0);
const bool compact = bounds.w < 180 * (gridStyle ? g_Config.fGameGridScale : 1.0);
if (compact) {
dc.PushScissor(bounds);
const FontStyle *fontStyle = GetTextStyle(dc, UI::TextSize::Small);
dc.SetFontStyle(*GetTextStyle(dc, UI::TextSize::Small));
int iconSize = image == ImageID("I_UP_DIRECTORY") ? (float)dc.Draw()->GetAtlas()->getImage(image)->h : bounds.h * 0.3f;
float textWidth = 0.0f;
float textHeight = 0;
dc.MeasureTextRect(*fontStyle, 1.0f, 1.0f, text, bounds.w - 10, &textWidth, &textHeight, ALIGN_HCENTER | FLAG_WRAP_TEXT);
int totalHeight = iconSize + (int)textHeight;
const float y = std::max(0.0f, bounds.h / 2.0f - totalHeight / 2.0f);
if (image.isValid()) {
const AtlasImage *img = dc.Draw()->GetAtlas()->getImage(image);
if (img && img->h > 0) {
dc.RebindTexture();
dc.Draw()->DrawImage(image, bounds.centerX(), bounds.y + y + 2, iconSize / (float)img->h, style.fgColor, ALIGN_TOP | ALIGN_HCENTER);
}
}
if (image != ImageID("I_UP_DIRECTORY") && image != ImageID("I_PIN") && image != ImageID("I_UNPIN")) {
dc.DrawTextRect(text, bounds.Inset(5, y + iconSize + 4, 5, 2), style.fgColor, ALIGN_HCENTER | FLAG_WRAP_TEXT);
}
dc.SetFontStyle(dc.GetTheme().uiFont);
dc.PopScissor();
} else {
dc.Draw()->DrawImage(image, bounds.x + 72, bounds.centerY(), 0.88f * (gridStyle ? g_Config.fGameGridScale : 1.0), style.fgColor, ALIGN_CENTER);
float tx = 150;
int availableWidth = bounds.w - 150;
float sineWidth = std::max(0.0f, (tw - availableWidth)) / 2.0f;
if (availableWidth < tw) {
tx -= (1.0f + sin(time_now_d() * 1.5f)) * sineWidth;
Bounds tb = bounds;
tb.x = bounds.x + 150;
tb.w = availableWidth;
dc.PushScissor(tb);
}
dc.DrawText(text, bounds.x + tx, bounds.centerY(), style.fgColor, ALIGN_VCENTER | FLAG_WRAP_TEXT);
if (availableWidth < tw) {
dc.PopScissor();
}
}
}
class GameButton : public UI::Clickable {
public:
GameButton(const Path &gamePath, bool gridStyle, UI::LayoutParams *layoutParams = nullptr)
: UI::Clickable(layoutParams), gridStyle_(gridStyle), gamePath_(gamePath) {}
void Draw(UIContext &dc) override;
std::string DescribeText() const override;
void GetContentDimensions(const UIContext &dc, float &w, float &h) const override {
if (gridStyle_) {
w = 144 * g_Config.fGameGridScale;
h = 80 * g_Config.fGameGridScale;
} else {
w = 500;
h = 50;
}
}
const Path &GamePath() const { return gamePath_; }
void SetHoldEnabled(bool hold) {
holdEnabled_ = hold;
}
bool Touch(const TouchInput &input) override {
const bool retval = UI::Clickable::Touch(input);
hovering_ = bounds_.Contains(input.x, input.y);
if (hovering_ && (input.flags & TouchInputFlags::DOWN)) {
holdStart_ = time_now_d();
}
if (input.flags & TouchInputFlags::UP) {
holdStart_ = 0;
}
return retval;
}
bool Key(const KeyInput &key) override {
bool showInfo = false;
if (HasFocus() && UI::IsInfoKey(key)) {
// If it's the button that's mapped to triangle, then show the info.
if (key.flags & KeyInputFlags::DOWN) {
infoKeyPressed_ = true;
}
if ((key.flags & KeyInputFlags::UP) && infoKeyPressed_) {
showInfo = true;
infoKeyPressed_ = false;
}
} else if (hovering_ && key.deviceId == DEVICE_ID_MOUSE && key.keyCode == NKCODE_EXT_MOUSEBUTTON_2) {
// If it's the right mouse button, and it's not otherwise mapped, show the info also.
if (key.flags & KeyInputFlags::DOWN) {
showInfoPressed_ = true;
}
if ((key.flags & KeyInputFlags::UP) && showInfoPressed_) {
showInfo = true;
showInfoPressed_ = false;
}
}
if (showInfo) {
TriggerOnHoldClick();
return true;
}
return Clickable::Key(key);
}
void Update() override {
// Hold button for 1.5 seconds to launch the game options
if (holdEnabled_ && holdStart_ != 0.0 && holdStart_ < time_now_d() - 1.5) {
TriggerOnHoldClick();
}
}
void FocusChanged(int focusFlags) override {
UI::Clickable::FocusChanged(focusFlags);
TriggerOnHighlight(focusFlags);
}
UI::Event OnHoldClick;
UI::Event OnHighlight;
private:
void TriggerOnHoldClick() {
holdStart_ = 0.0;
UI::EventParams e{};
e.v = this;
e.s = gamePath_.ToString();
down_ = false;
OnHoldClick.Trigger(e);
}
void TriggerOnHighlight(int focusFlags) {
UI::EventParams e{};
e.v = this;
e.s = gamePath_.ToString();
e.a = focusFlags;
OnHighlight.Trigger(e);
}
bool gridStyle_;
Path gamePath_;
std::string title_;
double holdStart_ = 0.0;
bool holdEnabled_ = true;
bool showInfoPressed_ = false;
bool infoKeyPressed_ = false;
bool hovering_ = false;
};
void GameButton::Draw(UIContext &dc) {
std::shared_ptr<GameInfo> ginfo = g_gameInfoCache->GetInfo(dc.GetDrawContext(), gamePath_, GameInfoFlags::PARAM_SFO | GameInfoFlags::ICON);
Draw::Texture *texture = nullptr;
u32 color = 0, shadowColor = 0;
using namespace UI;
UI::Style style = dc.GetTheme().itemStyle;
if (down_) {
style = dc.GetTheme().itemDownStyle;
}
// Some types we just draw a default icon for.
ImageID imageIcon = ImageID::invalid();
bool drawBackground = true;
switch (ginfo->fileType) {
case IdentifiedFileType::PSP_ELF: imageIcon = ImageID("I_APP"); drawBackground = false; break;
case IdentifiedFileType::UNKNOWN_ELF: imageIcon = ImageID("I_APP"); drawBackground = false; break;
case IdentifiedFileType::PPSSPP_GE_DUMP: imageIcon = ImageID("I_DISPLAY"); break;
case IdentifiedFileType::PSX_ISO:
case IdentifiedFileType::PSP_PS1_PBP: imageIcon = ImageID("I_PSX_ISO"); break;
case IdentifiedFileType::PS2_ISO: imageIcon = ImageID("I_PS2_ISO"); break;
case IdentifiedFileType::PS3_ISO: imageIcon = ImageID("I_PS3_ISO"); break;
case IdentifiedFileType::PSP_UMD_VIDEO_ISO: imageIcon = ImageID("I_UMD_VIDEO_ISO"); break;
case IdentifiedFileType::UNKNOWN_ISO: imageIcon = ImageID("I_UNKNOWN_ISO"); break;
case IdentifiedFileType::PPSSPP_SAVESTATE:
case IdentifiedFileType::ERROR_IDENTIFYING:
case IdentifiedFileType::UNKNOWN_BIN: imageIcon = ImageID("I_FILE"); break;
default: break;
}
if (ginfo->Ready(GameInfoFlags::ICON)) {
if (ginfo->icon.texture && drawBackground) {
texture = ginfo->icon.texture;
} else if (drawBackground) {
// No icon, but drawBackground is set. Let's show a plain icon depending on type.
if (ginfo->fileType == IdentifiedFileType::PSP_ISO || ginfo->fileType == IdentifiedFileType::PSP_ISO_NP) {
imageIcon = ImageID("I_UMD");
} else if (gamePath_.Type() == PathType::HTTP) {
imageIcon = ginfo->fileType == IdentifiedFileType::UNKNOWN ? ImageID("I_LINK_OUT_QUESTION") : ImageID("I_LINK_OUT");
} else {
imageIcon = ImageID("I_APP");
}
}
} else {
// Make sure that pending HTTP icons show something, instead of just blank.
if (gamePath_.Type() == PathType::HTTP) {
imageIcon = ImageID("I_LINK_OUT_QUESTION");
}
}
Bounds overlayBounds = bounds_;
u32 overlayColor = 0;
if (holdEnabled_ && holdStart_ != 0.0) {
double time_held = time_now_d() - holdStart_;
overlayColor = whiteAlpha(time_held / 2.5f);
if (holdStart_ != 0.0) {
double time_held = time_now_d() - holdStart_;
int holdFrameCount = (int)(time_held * 60.0f);
if (holdFrameCount > 60) {
// Blink before launching by holding
if (((holdFrameCount >> 3) & 1) == 0)
overlayColor = 0x0;
}
}
}
int x = bounds_.x;
int y = bounds_.y;
int w = gridStyle_ ? bounds_.w : 144;
int h = bounds_.h;
if (!gridStyle_ || !texture) {
if (HasFocus())
style = down_ ? dc.GetTheme().itemDownStyle : dc.GetTheme().itemFocusedStyle;
Drawable bg = style.background;
dc.Draw()->Flush();
dc.RebindTexture();
dc.FillRect(bg, bounds_);
dc.Draw()->Flush();
}
if (texture) {
color = whiteAlpha(ease((time_now_d() - ginfo->icon.timeLoaded) * 2));
shadowColor = blackAlpha(ease((time_now_d() - ginfo->icon.timeLoaded) * 2));
float tw = texture->Width();
float th = texture->Height();
// Adjust position so we don't stretch the image vertically or horizontally.
// Make sure it's not wider than 144 (like Doom Legacy homebrew), ugly in the grid mode.
float nw = std::min(h * tw / th, (float)w);
x += (w - nw) / 2.0f;
w = nw;
}
int txOffset = down_ ? 4 : 0;
if (!gridStyle_) txOffset = 0;
// Render button
int dropsize = 10;
if (texture) {
if (!gridStyle_) {
x += 4;
}
if (txOffset) {
dropsize = 3;
y += txOffset * 2;
overlayBounds.y += txOffset * 2;
}
if (HasFocus()) {
dc.Draw()->Flush();
dc.RebindTexture();
float pulse = sin(time_now_d() * 7.0) * 0.25 + 0.8;
dc.Draw()->DrawImage4Grid(dc.GetTheme().dropShadow4Grid, x - dropsize * 1.5f, y - dropsize * 1.5f, x + w + dropsize * 1.5f, y + h + dropsize * 1.5f, alphaMul(color, pulse), 1.0f);
dc.Draw()->Flush();
} else {
dc.Draw()->Flush();
dc.RebindTexture();
dc.Draw()->DrawImage4Grid(dc.GetTheme().dropShadow4Grid, x - dropsize, y - dropsize * 0.5f, x + w + dropsize, y + h + dropsize * 1.5, alphaMul(shadowColor, 0.5f), 1.0f);
dc.Draw()->Flush();
}
dc.Draw()->Flush();
dc.GetDrawContext()->BindTexture(0, texture);
dc.Draw()->DrawTexRect(x, y, x + w, y + h, 0, 0, 1, 1, color);
dc.Draw()->Flush();
}
if (gridStyle_ && g_Config.bShowIDOnGameIcon && ginfo->fileType != IdentifiedFileType::PSP_ELF && !ginfo->id_version.empty()) {
std::string_view idStr = ginfo->id_version;
if (ginfo->fileType == IdentifiedFileType::PSP_SAVEDATA_DIRECTORY) {
auto ga = GetI18NCategory(I18NCat::GAME);
idStr = ga->T("SaveData");
}
dc.SetFontScale(0.5f * g_Config.fGameGridScale, 0.5f * g_Config.fGameGridScale);
dc.DrawText(idStr, bounds_.x + 5, y + 1, 0xFF000000, ALIGN_TOPLEFT);
dc.DrawText(idStr, bounds_.x + 4, y, dc.GetTheme().infoStyle.fgColor, ALIGN_TOPLEFT);
dc.SetFontScale(1.0f, 1.0f);
}
if (imageIcon.isValid()) {
Style style = dc.GetTheme().itemStyle;
if (HasFocus()) style = dc.GetTheme().itemFocusedStyle;
if (down_) style = dc.GetTheme().itemDownStyle;
if (!IsEnabled()) style = dc.GetTheme().itemDisabledStyle;
DrawIconWithText(dc, imageIcon, ginfo->GetTitle(), bounds_, gridStyle_, style);
if (overlayColor) {
dc.FillRect(Drawable(overlayColor), overlayBounds);
}
} else {
char discNumInfo[8];
if (ginfo->disc_total > 1)
snprintf(discNumInfo, sizeof(discNumInfo), "-DISC%d", ginfo->disc_number);
else
discNumInfo[0] = '\0';
dc.Draw()->Flush();
dc.RebindTexture();
dc.SetFontStyle(dc.GetTheme().uiFont);
if (!gridStyle_) {
float tw, th;
dc.Draw()->Flush();
dc.PushScissor(bounds_);
const std::string currentTitle = ginfo->GetTitle();
if (!currentTitle.empty()) {
title_ = ReplaceAll(currentTitle, "\n", " ");
}
dc.MeasureText(dc.GetFontStyle(), 1.0f, 1.0f, title_, &tw, &th, 0);
int availableWidth = bounds_.w - 150;
if (g_Config.bShowIDOnGameIcon) {
float vw, vh;
dc.MeasureText(dc.GetFontStyle(), 0.7f, 0.7f, ginfo->id_version, &vw, &vh, 0);
availableWidth -= vw + 20;
dc.SetFontScale(0.7f, 0.7f);
dc.DrawText(ginfo->id_version, bounds_.x + availableWidth + 160, bounds_.centerY(), style.fgColor, ALIGN_VCENTER);
dc.SetFontScale(1.0f, 1.0f);
}
float sineWidth = std::max(0.0f, (tw - availableWidth)) / 2.0f;
float tx = 150;
if (availableWidth < tw) {
tx -= (1.0f + sin(time_now_d() * 1.5f)) * sineWidth;
Bounds tb = bounds_;
tb.x = bounds_.x + 150;
tb.w = availableWidth;
dc.PushScissor(tb);
}
dc.DrawText(title_, bounds_.x + tx, bounds_.centerY(), style.fgColor, ALIGN_VCENTER);
if (availableWidth < tw) {
dc.PopScissor();
}
dc.Draw()->Flush();
dc.PopScissor();
} else if (!texture) {
dc.Draw()->Flush();
dc.PushScissor(bounds_);
dc.DrawText(title_, bounds_.x + 4, bounds_.centerY(), style.fgColor, ALIGN_VCENTER);
dc.Draw()->Flush();
dc.PopScissor();
} else {
dc.Draw()->Flush();
}
}
if (ginfo->hasConfig && !ginfo->id.empty()) {
const AtlasImage *gearImage = dc.Draw()->GetAtlas()->getImage(ImageID("I_GEAR_SMALL"));
if (gearImage) {
if (gridStyle_) {
dc.Draw()->DrawImage(ImageID("I_GEAR_SMALL"), bounds_.x, y + h - gearImage->h * g_Config.fGameGridScale, g_Config.fGameGridScale);
} else {
dc.Draw()->DrawImage(ImageID("I_GEAR_SMALL"), bounds_.x + 4, y, 1.0f);
}
}
}
const int regionIndex = (int)ginfo->region;
if (g_Config.bShowRegionOnGameIcon && regionIndex >= 0 && regionIndex < (int)GameRegion::COUNT) {
const ImageID regionIcons[(int)GameRegion::COUNT] = {
ImageID("I_FLAG_JP"),
ImageID("I_FLAG_US"),
ImageID("I_FLAG_EU"),
ImageID("I_FLAG_HK"),
ImageID("I_FLAG_AS"),
ImageID("I_FLAG_KO"),
};
const AtlasImage *image = dc.Draw()->GetAtlas()->getImage(regionIcons[regionIndex]);
if (image) {
if (gridStyle_) {
dc.Draw()->DrawImage(regionIcons[regionIndex], bounds_.x + bounds_.w - (image->w + 5) * g_Config.fGameGridScale,
y + h - (image->h + 5) * g_Config.fGameGridScale, g_Config.fGameGridScale);
} else {
dc.Draw()->DrawImage(regionIcons[regionIndex], bounds_.x + 4, y + h - image->h - 5, 1.0f);
}
}
}
if (overlayColor) {
dc.FillRect(Drawable(overlayColor), overlayBounds);
}
dc.RebindTexture();
}
std::string GameButton::DescribeText() const {
std::shared_ptr<GameInfo> ginfo = g_gameInfoCache->GetInfo(nullptr, gamePath_, GameInfoFlags::PARAM_SFO);
if (!ginfo->Ready(GameInfoFlags::PARAM_SFO))
return "...";
auto u = GetI18NCategory(I18NCat::UI_ELEMENTS);
return ginfo->GetTitle();
}
class DirButton : public UI::Button {
public:
DirButton(const Path &path, bool gridStyle, UI::LayoutParams *layoutParams)
: UI::Button(path.ToString(), layoutParams), path_(path), gridStyle_(gridStyle), absolute_(false) {}
DirButton(const Path &path, const std::string &text, bool gridStyle, UI::LayoutParams *layoutParams = 0)
: UI::Button(text, layoutParams), path_(path), gridStyle_(gridStyle), absolute_(true) {}
void Draw(UIContext &dc) override;
const Path &GetPath() const {
return path_;
}
bool PathAbsolute() const {
return absolute_;
}
void SetPinned(bool pin) {
pinned_ = pin;
}
std::string DescribeText() const {
return path_.GetFilename();
}
private:
Path path_;
bool gridStyle_;
bool absolute_;
bool pinned_ = false;
};
void DirButton::Draw(UIContext &dc) {
using namespace UI;
std::string_view text(GetText());
ImageID image = ImageID(pinned_ ? "I_FOLDER_PINNED" : "I_FOLDER");
if (text == "..") {
image = ImageID("I_UP_DIRECTORY");
text = "";
}
Style style = dc.GetTheme().itemStyle;
if (HasFocus()) style = dc.GetTheme().itemFocusedStyle;
if (down_) style = dc.GetTheme().itemDownStyle;
if (!IsEnabled()) style = dc.GetTheme().itemDisabledStyle;
dc.FillRect(style.background, bounds_);
DrawIconWithText(dc, image, text, bounds_, gridStyle_, style);
}
GameBrowser::GameBrowser(int token, const Path &path, BrowseFlags browseFlags, bool portrait, bool *gridStyle, ScreenManager *screenManager, std::string_view lastText, std::string_view lastLink, UI::LayoutParams *layoutParams)
: LinearLayout(ORIENT_VERTICAL, layoutParams), gridStyle_(gridStyle), browseFlags_(browseFlags), portrait_(portrait), lastText_(lastText), lastLink_(lastLink), screenManager_(screenManager), token_(token) {
using namespace UI;
path_.SetUserAgent(StringFromFormat("PPSSPP/%s", PPSSPP_GIT_VERSION));
Path memstickRoot = GetSysDirectory(DIRECTORY_MEMSTICK_ROOT);
aliasMatch_ = memstickRoot;
if (memstickRoot == GetSysDirectory(DIRECTORY_PSP)) {
aliasDisplay_ = "ms:/PSP/";
} else {
aliasDisplay_ = "ms:/";
}
if (System_GetPropertyBool(SYSPROP_LIMITED_FILE_BROWSING) &&
(path.Type() == PathType::NATIVE || path.Type() == PathType::CONTENT_URI)) {
// Note: We don't restrict if the path is HTTPS, otherwise remote disc streaming breaks!
path_.RestrictToRoot(GetSysDirectory(DIRECTORY_MEMSTICK_ROOT));
}
path_.SetPath(path);
Refresh();
}
void GameBrowser::FocusGame(const Path &gamePath) {
focusGamePath_ = gamePath;
Refresh();
focusGamePath_.clear();
}
void GameBrowser::SetPath(const Path &path) {
path_.SetPath(path);
g_Config.currentDirectory = path_.GetPath();
Refresh();
}
bool GameBrowser::Key(const KeyInput &input) {
bool retval = LinearLayout::Key(input);
if (retval) {
return true;
}
return search_.Key(gameList_, input);
}
bool SearchEngine::Key(UI::ViewGroup *viewGroup, const KeyInput &input) {
bool retval = false;
// Only one is visible at a time, so we can just grab all Char input.
if (input.flags & KeyInputFlags::CHAR) {
const int unichar = input.keyCode;
if (unichar >= 0x20) {
// TODO: Save focus state here.
// Insert it! (todo: do it with a string insert)
char buf[8];
buf[u8_wc_toutf8(buf, unichar)] = '\0';
searchFilter += buf;
ApplySearchFilter(viewGroup, true);
retval = true;
}
} else if (input.flags & KeyInputFlags::DOWN) {
if (input.keyCode == NKCODE_DEL) {
if (!searchFilter.empty()) {
searchFilter.pop_back();
ApplySearchFilter(viewGroup, true);
retval = true;
if (searchFilter.empty()) {
// TODO: Restore focus state here.
UI::EnableFocusMovement(false);
}
} else {
// Empty search filter. Navigate upwards on backspace?
}
} else if (!searchFilter.empty() && input.keyCode == NKCODE_ESCAPE) {
searchFilter.clear();
ApplySearchFilter(viewGroup, false);
retval = true;
// TODO: Restore focus state here.
UI::EnableFocusMovement(false);
}
}
return retval;
}
void GameBrowser::SetSearchFilter(const std::string &filter, bool setKeyboardFocus) {
search_.searchFilter = filter;
// We don't refresh because game info loads asynchronously anyway.
search_.ApplySearchFilter(gameList_, setKeyboardFocus);
}
void SearchEngine::ApplySearchFilter(UI::ViewGroup *viewGroup, bool setKeyboardFocus) {
if (searchBar) {
searchBar->SetSearchFilter(searchFilter);
searchBar->SetVisibility(searchFilter.empty() ? UI::V_GONE : UI::V_VISIBLE);
}
if (searchFilter.empty() && searchStates.empty()) {
// We haven't hidden anything, and we're not searching, so do nothing.
searchPending = false;
return;
}
std::string filter = NormalizeForSearch(searchFilter);
searchPending = false;
// By default, everything is matching.
searchStates.resize(viewGroup->GetNumSubviews(), SearchState::MATCH);
if (filter.empty()) {
// Just quickly mark anything we hid as visible again.
for (int i = 0; i < viewGroup->GetNumSubviews(); ++i) {
UI::View *v = viewGroup->GetViewByIndex(i);
if (searchStates[i] != SearchState::MATCH)
v->SetVisibility(UI::V_VISIBLE);
}
searchStates.clear();
return;
}
UI::View *firstMatch = nullptr;
for (int i = 0; i < viewGroup->GetNumSubviews(); ++i) {
UI::View *v = viewGroup->GetViewByIndex(i);
std::string label = v->DescribeText();
// This is a bit of a hack to recognize a pending game title.
if (label == "...") {
searchPending = true;
// Hide anything pending while, we'll pop-in search results as they match.
// Note: we leave it at MATCH if gone before, so we don't show it again.
if (v->GetVisibility() == UI::V_VISIBLE) {
if (searchStates[i] == SearchState::MATCH)
v->SetVisibility(UI::V_GONE);
searchStates[i] = SearchState::PENDING;
}
continue;
}
label = NormalizeForSearch(label);
bool match = v->CanBeFocused() && label.find(filter) != label.npos;
if (match && !firstMatch) {
firstMatch = v;
}
if (match && searchStates[i] != SearchState::MATCH) {
// It was previously visible and force hidden, so show it again.
v->SetVisibility(UI::V_VISIBLE);
searchStates[i] = SearchState::MATCH;
} else if (!match && searchStates[i] == SearchState::MATCH && v->GetVisibility() == UI::V_VISIBLE) {
v->SetVisibility(UI::V_GONE);
searchStates[i] = SearchState::MISMATCH;
}
}
if (firstMatch) {
if (setKeyboardFocus) {
UI::EnableFocusMovement(true);
}
UI::SetFocusedView(firstMatch);
}
}
void GameBrowser::LayoutChange(UI::EventParams &e) {
*gridStyle_ = e.a == 0 ? true : false;
Refresh();
}
void GameBrowser::LastClick(UI::EventParams &e) {
System_LaunchUrl(LaunchUrlType::BROWSER_URL, lastLink_.c_str());
}
void GameBrowser::BrowseClick(UI::EventParams &e) {
auto mm = GetI18NCategory(I18NCat::MAINMENU);
System_BrowseForFolder(token_, mm->T("Choose folder"), path_.GetPath(), [this](const std::string &filename, int) {
this->SetPath(Path(filename));
});
}
void GameBrowser::StorageClick(UI::EventParams &e) {
std::vector<std::string> storageDirs = System_GetPropertyStringVec(SYSPROP_ADDITIONAL_STORAGE_DIRS);
if (storageDirs.empty()) {
// Shouldn't happen - this button shouldn't be clickable.
return;
}
if (storageDirs.size() == 1) {
SetPath(Path(storageDirs[0]));
} else {
// TODO: We should popup a dialog letting the user choose one.
SetPath(Path(storageDirs[0]));
}
}
void GameBrowser::OnHomeClick(UI::EventParams &e) {
if (path_.GetPath().Type() == PathType::CONTENT_URI) {
Path rootPath = path_.GetPath().GetRootVolume();
if (rootPath != path_.GetPath()) {
SetPath(rootPath);
return;
}
if (System_GetPropertyBool(SYSPROP_ANDROID_SCOPED_STORAGE)) {
// There'll be no sensible home, ignore.
return;
}
}
SetPath(HomePath());
}
// TODO: This doesn't make that much sense for Android, especially after scoped storage..
// Maybe we should have no home directory in this case. Or it should just navigate to the root
// of the current folder tree.
Path GameBrowser::HomePath() {
if (!homePath_.empty()) {
return homePath_;
}
#if PPSSPP_PLATFORM(ANDROID) || PPSSPP_PLATFORM(SWITCH) || defined(USING_WIN_UI) || PPSSPP_PLATFORM(UWP) || PPSSPP_PLATFORM(IOS)
return g_Config.memStickDirectory;
#else
return Path(getenv("HOME"));
#endif
}
void GameBrowser::PinToggleClick(UI::EventParams &e) {
auto &pinnedPaths = g_Config.vPinnedPaths;
const std::string path = File::ResolvePath(path_.GetPath().ToString());
if (IsCurrentPathPinned()) {
pinnedPaths.erase(std::remove(pinnedPaths.begin(), pinnedPaths.end(), path), pinnedPaths.end());
} else {
pinnedPaths.push_back(path);
}
Refresh();
}
bool GameBrowser::DisplayTopBar() {
return path_.GetPath().ToString() != "!RECENT";
}
bool GameBrowser::HasSpecialFiles(std::vector<Path> &filenames) {
if (path_.GetPath().ToString() == "!RECENT") {
filenames.clear();
for (auto &str : g_recentFiles.GetRecentFiles()) {
filenames.emplace_back(str);
}
return true;
}
return false;
}
void GameBrowser::Update() {
LinearLayout::Update();
if (refreshPending_) {
path_.Refresh();
}
if ((listingPending_ && path_.IsListingReady()) || refreshPending_) {
Refresh();
refreshPending_ = false;
}
if (search_.searchPending) {
search_.ApplySearchFilter(gameList_, false);
}
}
void GameBrowser::Draw(UIContext &dc) {
using namespace UI;
if (lastScale_ != g_Config.fGameGridScale || lastLayoutWasGrid_ != *gridStyle_) {
Refresh();
}
if (hasDropShadow_) {
// Darken things behind.
dc.FillRect(UI::Drawable(0x60000000), dc.GetBounds().Expand(dropShadowExpand_));
float dropsize = 30.0f;
dc.Draw()->DrawImage4Grid(dc.GetTheme().dropShadow4Grid,
bounds_.x - dropsize, bounds_.y,
bounds_.x2() + dropsize, bounds_.y2() + dropsize * 1.5f, 0xDF000000, 3.0f);
}
if (clip_) {
dc.PushScissor(bounds_);
}
dc.FillRect(bg_, bounds_);
for (View *view : views_) {
if (view->GetVisibility() == V_VISIBLE) {
// Check if bounds are in current scissor rectangle.
if (dc.GetScissorBounds().Intersects(dc.TransformBounds(view->GetBounds())))
view->Draw(dc);
}
}
if (clip_) {
dc.PopScissor();
}
}
void GameBrowser::SetSearchBar(SearchBar *searchBar) {
search_.searchBar = searchBar;
search_.searchBar->OnCancel.Add([this](UI::EventParams &) {
SetSearchFilter("", false);
});
}
static bool IsValidPBP(const Path &path, bool allowHomebrew) {
if (!File::Exists(path))
return false;
std::unique_ptr<FileLoader> loader(ConstructFileLoader(path));
PBPReader pbp(loader.get());
std::vector<u8> sfoData;
if (!pbp.GetSubFile(PBP_PARAM_SFO, &sfoData))
return false;
ParamSFOData sfo;
sfo.ReadSFO(sfoData);
if (!allowHomebrew && sfo.GetValueString("DISC_ID").empty())
return false;
if (sfo.GetValueString("CATEGORY") == "ME")
return false;
return true;
}
void GameBrowser::Refresh() {
using namespace UI;
lastScale_ = g_Config.fGameGridScale;
lastLayoutWasGrid_ = *gridStyle_;
// Kill all the contents
Clear();
search_.searchStates.clear();
Add(new Spacer(1.0f));
auto mm = GetI18NCategory(I18NCat::MAINMENU);
// No topbar on recent screen
gameList_ = nullptr;
if (DisplayTopBar()) {
LinearLayout *topBar = new LinearLayout(ORIENT_HORIZONTAL, new LinearLayoutParams(FILL_PARENT, WRAP_CONTENT, Margins(8, 0, 8, 0)));
Add(topBar);
const bool pathOnSeparateLine = g_display.dp_xres < 1050 || portrait_;
std::string pathStr = GetFriendlyPath(path_.GetPath(), aliasMatch_, aliasDisplay_);
if (pathOnSeparateLine) {
Add(new TextView(pathStr, ALIGN_VCENTER | FLAG_WRAP_TEXT, true, new LinearLayoutParams(FILL_PARENT, WRAP_CONTENT, Margins(8, 0, 8, 0))));
}
if (browseFlags_ & BrowseFlags::NAVIGATE) {
if (!pathOnSeparateLine) {
topBar->Add(new Spacer(2.0f));
topBar->Add(new TextView(pathStr, ALIGN_VCENTER | FLAG_WRAP_TEXT, true, new LinearLayoutParams(FILL_PARENT, 64.0f, 1.0f)));
}
topBar->Add(new Choice(ImageID("I_HOME"), new LayoutParams(WRAP_CONTENT, 64.0f)))->OnClick.Handle(this, &GameBrowser::OnHomeClick);
if (System_GetPropertyBool(SYSPROP_HAS_ADDITIONAL_STORAGE)) {
topBar->Add(new Choice(ImageID("I_SDCARD"), new LayoutParams(WRAP_CONTENT, 64.0f)))->OnClick.Handle(this, &GameBrowser::StorageClick);
}
#if PPSSPP_PLATFORM(IOS_APP_STORE)
// Don't show a browse button, not meaningful to browse outside the documents folder it seems,
// as we can't list things like document folders of another app, as far as I can tell.
// However, we do show a Load.. button for picking individual files, that seems to work.
#elif PPSSPP_PLATFORM(IOS) || PPSSPP_PLATFORM(MAC)
// on Darwin, we don't show the 'Browse' text alongside the image
// we show just the image, because we don't need to emphasize the button on Darwin
topBar->Add(new Choice(ImageID("I_FOLDER_OPEN"), new LayoutParams(WRAP_CONTENT, 64.0f)))->OnClick.Handle(this, &GameBrowser::BrowseClick);
#else
if ((browseFlags_ & BrowseFlags::BROWSE) && System_GetPropertyBool(SYSPROP_HAS_FOLDER_BROWSER)) {
// Collapse the button title on very small screens (Retroid Pocket) or portrait mode.
std::string_view browseTitle = (g_display.dp_xres <= 550 || portrait_) ? "" : mm->T("Browse");
topBar->Add(new Choice(browseTitle, ImageID("I_FOLDER_OPEN"), new LayoutParams(WRAP_CONTENT, 64.0f)))->OnClick.Handle(this, &GameBrowser::BrowseClick);
}
if (System_GetPropertyInt(SYSPROP_DEVICE_TYPE) == DEVICE_TYPE_TV) {
topBar->Add(new Choice(mm->T("Enter Path"), new LayoutParams(WRAP_CONTENT, 64.0f)))->OnClick.Add([=](UI::EventParams &) {
auto mm = GetI18NCategory(I18NCat::MAINMENU);
System_InputBoxGetString(token_, mm->T("Enter Path"), path_.GetPath().ToString(), false, [=](const char *responseString, int responseValue) {
this->SetPath(Path(responseString));
});
});
}
#endif
} else if (!pathOnSeparateLine) {
topBar->Add(new Spacer(new LinearLayoutParams(FILL_PARENT, 64.0f, 1.0f)));
}
if (browseFlags_ & BrowseFlags::HOMEBREW_STORE) {
topBar->Add(new Choice(mm->T("Homebrew store"), ImageID("I_HOMEBREW_STORE"), new UI::LinearLayoutParams(WRAP_CONTENT, 64.0f)))->OnClick.Handle(this, &GameBrowser::OnHomebrewStore);
}
if (browseFlags_ & BrowseFlags::UPLOAD_BUTTON) {
topBar->Add(new Choice(ImageID("I_FOLDER_UPLOAD"), new UI::LinearLayoutParams(WRAP_CONTENT, 64.0f)))->OnClick.Add([this](UI::EventParams &e) {
screenManager_->push(new UploadScreen(path_.GetPath()));
});
}
ChoiceStrip *layoutChoice = topBar->Add(new ChoiceStrip(ORIENT_HORIZONTAL));
layoutChoice->AddChoice(ImageID("I_GRID"));
layoutChoice->AddChoice(ImageID("I_LINES"));
layoutChoice->SetSelection(*gridStyle_ ? 0 : 1, false);
layoutChoice->OnChoice.Handle(this, &GameBrowser::LayoutChange);
topBar->Add(new Choice(ImageID("I_ROTATE_LEFT"), new LayoutParams(64.0f, 64.0f)))->OnClick.Add([=](UI::EventParams &e) {
path_.Refresh();
Refresh();
});
topBar->Add(new Choice(ImageID("I_GEAR"), new LayoutParams(64.0f, 64.0f)))->OnClick.Handle(this, &GameBrowser::GridSettingsClick);
if (*gridStyle_) {
gameList_ = new UI::GridLayoutList(UI::GridLayoutSettings(150 * g_Config.fGameGridScale, 85 * g_Config.fGameGridScale), new LinearLayoutParams(FILL_PARENT, WRAP_CONTENT, Margins(10, 0, 6, 0)));
} else {
UI::LinearLayout *gl = new UI::LinearLayoutList(ORIENT_VERTICAL, new LinearLayoutParams(FILL_PARENT, WRAP_CONTENT));
gl->SetSpacing(4.0f);
gameList_ = gl;
}
} else {
if (*gridStyle_) {
gameList_ = new UI::GridLayoutList(UI::GridLayoutSettings(150 * g_Config.fGameGridScale, 85 * g_Config.fGameGridScale), new LinearLayoutParams(FILL_PARENT, WRAP_CONTENT, Margins(10, 0, 6, 0)));
} else {
UI::LinearLayout *gl = new UI::LinearLayout(ORIENT_VERTICAL, new LinearLayoutParams(FILL_PARENT, WRAP_CONTENT));
gl->SetSpacing(4.0f);
gameList_ = gl;
}
// Until we can come up with a better space to put it (next to the tabs?) let's get rid of the icon config
// button on the Recent tab, it's ugly. You can use the button from the other tabs.
// LinearLayout *gridOptionColumn = new LinearLayout(ORIENT_VERTICAL, new LinearLayoutParams(64.0, 64.0f));
// gridOptionColumn->Add(new Spacer(12.0));
// gridOptionColumn->Add(new Choice(ImageID("I_GEAR"), new LayoutParams(64.0f, 64.0f)))->OnClick.Handle(this, &GameBrowser::GridSettingsClick);
// LinearLayout *grid = new LinearLayout(ORIENT_HORIZONTAL, new LinearLayoutParams(FILL_PARENT, WRAP_CONTENT));
// gameList_->ReplaceLayoutParams(new LinearLayoutParams(FILL_PARENT, WRAP_CONTENT, 0.75));
// grid->Add(gameList_);
// grid->Add(gridOptionColumn);
// Add(grid);
}
Add(gameList_);
// Find games in the current directory and create new ones.
std::vector<DirButton *> dirButtons;
std::vector<GameButton *> gameButtons;
listingPending_ = !path_.IsListingReady();
// TODO: If listing failed, show a special error message.
std::vector<Path> filenames;
if (HasSpecialFiles(filenames)) {
for (size_t i = 0; i < filenames.size(); i++) {
gameButtons.push_back(new GameButton(filenames[i], *gridStyle_, new UI::LinearLayoutParams(*gridStyle_ == true ? UI::WRAP_CONTENT : UI::FILL_PARENT, UI::WRAP_CONTENT)));
}
} else if (!listingPending_) {
std::vector<File::FileInfo> fileInfo;
path_.GetListing(fileInfo, "iso:cso:chd:pbp:elf:prx:ppdmp:");
for (size_t i = 0; i < fileInfo.size(); i++) {
bool isGame = !fileInfo[i].isDirectory;
bool isSaveData = false;
// Check if eboot directory
if (!isGame && path_.GetPath().size() >= 4 && IsValidPBP(path_.GetPath() / fileInfo[i].name / "EBOOT.PBP", true))
isGame = true;
else if (!isGame && File::Exists(path_.GetPath() / fileInfo[i].name / "PSP_GAME/SYSDIR"))
isGame = true;
else if (!isGame && File::Exists(path_.GetPath() / fileInfo[i].name / "PARAM.SFO"))
isSaveData = true;
if (!isGame && !isSaveData) {
if (browseFlags_ & BrowseFlags::NAVIGATE) {
dirButtons.push_back(new DirButton(fileInfo[i].fullName, fileInfo[i].name, *gridStyle_, new UI::LinearLayoutParams(UI::FILL_PARENT, UI::FILL_PARENT)));
}
} else {
gameButtons.push_back(new GameButton(fileInfo[i].fullName, *gridStyle_, new UI::LinearLayoutParams(*gridStyle_ == true ? UI::WRAP_CONTENT : UI::FILL_PARENT, UI::WRAP_CONTENT)));
}
}
// Put RAR/ZIP files at the end to get them out of the way. They're only shown so that people
// can click them and get an explanation that they need to unpack them. This is necessary due
// to a flood of support email...
if (browseFlags_ & BrowseFlags::ARCHIVES) {
fileInfo.clear();
path_.GetListing(fileInfo, "zip:rar:r01:7z:");
if (!fileInfo.empty()) {
UI::LinearLayout *zl = new UI::LinearLayoutList(ORIENT_VERTICAL, new LinearLayoutParams(FILL_PARENT, WRAP_CONTENT));
zl->SetSpacing(4.0f);
Add(zl);
for (size_t i = 0; i < fileInfo.size(); i++) {
if (!fileInfo[i].isDirectory) {
GameButton *b = zl->Add(new GameButton(fileInfo[i].fullName, false, new UI::LinearLayoutParams(UI::FILL_PARENT, UI::WRAP_CONTENT)));
b->OnClick.Handle(this, &GameBrowser::GameButtonClick);
b->SetHoldEnabled(false);
}
}
}
}
}
if (browseFlags_ & BrowseFlags::NAVIGATE) {
if (path_.CanNavigateUp()) {
gameList_->Add(new DirButton(Path(".."), *gridStyle_, new UI::LinearLayoutParams(UI::FILL_PARENT, UI::FILL_PARENT)))->
OnClick.Handle(this, &GameBrowser::NavigateClick);
}
// Add any pinned paths before other directories.
auto pinnedPaths = GetPinnedPaths();
for (const auto &pinnedPath : pinnedPaths) {
DirButton *pinnedDir = gameList_->Add(new DirButton(pinnedPath, pinnedPath.GetFilename(), *gridStyle_, new UI::LinearLayoutParams(UI::FILL_PARENT, UI::FILL_PARENT)));
pinnedDir->OnClick.Handle(this, &GameBrowser::NavigateClick);
pinnedDir->SetPinned(true);
}
}
if (listingPending_) {
gameList_->Add(new UI::TextView(mm->T("Loading..."), ALIGN_CENTER, false, new UI::LinearLayoutParams(UI::FILL_PARENT, UI::FILL_PARENT)));
}
for (size_t i = 0; i < dirButtons.size(); i++) {
gameList_->Add(dirButtons[i])->OnClick.Handle(this, &GameBrowser::NavigateClick);
}
for (size_t i = 0; i < gameButtons.size(); i++) {
GameButton *b = gameList_->Add(gameButtons[i]);
b->OnClick.Handle(this, &GameBrowser::GameButtonClick);
b->OnHoldClick.Handle(this, &GameBrowser::GameButtonHoldClick);
b->OnHighlight.Handle(this, &GameBrowser::GameButtonHighlight);
if (!focusGamePath_.empty() && b->GamePath() == focusGamePath_) {
b->SetFocus();
}
}
// Show a button to toggle pinning at the very end.
if ((browseFlags_ & BrowseFlags::PIN) && !path_.GetPath().empty()) {
std::string caption = ""; // IsCurrentPathPinned() ? "-" : "+";
if (!*gridStyle_) {
caption = IsCurrentPathPinned() ? mm->T("UnpinPath", "Unpin") : mm->T("PinPath", "Pin");
}
UI::Button *pinButton = gameList_->Add(new Button(caption, new UI::LinearLayoutParams(UI::FILL_PARENT, UI::FILL_PARENT)));
pinButton->OnClick.Handle(this, &GameBrowser::PinToggleClick);
pinButton->SetImageID(ImageID(IsCurrentPathPinned() ? "I_UNPIN" : "I_PIN"));
}
if (path_.GetPath().empty()) {
Add(new TextView(mm->T("UseBrowseOrLoad", "Use Browse to choose a folder, or Load to choose a file.")));
}
if (!lastText_.empty()) {
Add(new Spacer());
Add(new Choice(lastText_, ImageID("I_LINK_OUT"), new UI::LinearLayoutParams(UI::WRAP_CONTENT, UI::WRAP_CONTENT, Margins(10, 0, 0, 10))))->OnClick.Handle(this, &GameBrowser::LastClick);
}
}
bool GameBrowser::IsCurrentPathPinned() {
const auto &paths = g_Config.vPinnedPaths;
if (paths.empty()) {
return false;
}
std::string resolved = File::ResolvePath(path_.GetPath().ToString());
return std::find(paths.begin(), paths.end(), resolved) != paths.end();
}
std::vector<Path> GameBrowser::GetPinnedPaths() const {
#ifndef _WIN32
static const std::string sepChars = "/";
#else
static const std::string sepChars = "/\\";
#endif
if (g_Config.vPinnedPaths.empty()) {
// Early-out.
return std::vector<Path>();
}
const std::string currentPath = File::ResolvePath(path_.GetPath().ToString());
const std::vector<std::string> paths = g_Config.vPinnedPaths;
std::vector<Path> results;
for (size_t i = 0; i < paths.size(); ++i) {
// We want to exclude the current path, and its direct children.
if (paths[i] == currentPath) {
continue;
}
if (startsWith(paths[i], currentPath)) {
std::string descendant = paths[i].substr(currentPath.size());
// If there's only one separator (or none), its a direct child.
if (descendant.find_last_of(sepChars) == descendant.find_first_of(sepChars)) {
continue;
}
}
results.push_back(Path(paths[i]));
}
return results;
}
void GameBrowser::GameButtonClick(UI::EventParams &e) {
GameButton *button = static_cast<GameButton *>(e.v);
UI::EventParams e2{};
e2.s = button->GamePath().ToString();
OnChoice.Trigger(e2);
}
void GameBrowser::GameButtonHoldClick(UI::EventParams &e) {
GameButton *button = static_cast<GameButton *>(e.v);
UI::EventParams e2{};
e2.s = button->GamePath().ToString();
OnHoldChoice.Trigger(e2);
}
void GameBrowser::GameButtonHighlight(UI::EventParams &e) {
OnHighlight.Trigger(e);
}
void GameBrowser::NavigateClick(UI::EventParams &e) {
DirButton *button = static_cast<DirButton *>(e.v);
Path text = button->GetPath();
if (button->PathAbsolute()) {
path_.SetPath(text);
} else {
path_.Navigate(text.ToString());
}
g_Config.currentDirectory = path_.GetPath();
// Clear the search filter. This allow for smooth directory navigation using search
// (although there's no good way of going up...)
SetSearchFilter("", false);
Refresh();
}
void GameBrowser::GridSettingsClick(UI::EventParams &e) {
auto sy = GetI18NCategory(I18NCat::SYSTEM);
auto gridSettings = new GridSettingsPopupScreen(sy->T("Games list settings"));
gridSettings->OnRecentChanged.Handle(this, &GameBrowser::OnRecentClear);
if (e.v)
gridSettings->SetPopupOrigin(e.v);
screenManager_->push(gridSettings);
}
void GameBrowser::OnRecentClear(UI::EventParams &e) {
screenManager_->RecreateAllViews();
System_Notify(SystemNotification::UI);
}
void GameBrowser::OnHomebrewStore(UI::EventParams &e) {
screenManager_->push(new StoreScreen());
}