diff --git a/THEMES.md b/THEMES.md
index 5399b03143..3b2d3fcc97 100644
--- a/THEMES.md
+++ b/THEMES.md
@@ -514,6 +514,63 @@ Reference
---
+#### search
+The search popup is a full-screen overlay launched from the game list. All metadata elements are positioned off-screen and invisible by default — define them in your theme to enable them.
+
+* `image name="background"` - ALL
+ - Optional background image rendered behind all other elements. Not present by default; omit to keep the built-in translucent dark panel.
+* `text name="searchtext"` - ALL ^ TEXT
+ - The search query input bar at the top of the popup (position/size/font/color only — text content is managed by the engine).
+* `text name="listmessage"` - ALL ^ TEXT
+ - Status/placeholder message shown over the result list (e.g. "TYPE TO SEARCH…", "NO RESULTS FOUND").
+* `textlist name="gamelist"` - ALL
+ - The result list on the left. `primaryColor` for game entries, `secondaryColor` for other entries.
+
+* Metadata
+ * Labels
+ * `text name="md_lbl_rating"` - ALL
+ * `text name="md_lbl_releasedate"` - ALL
+ * `text name="md_lbl_developer"` - ALL
+ * `text name="md_lbl_publisher"` - ALL
+ * `text name="md_lbl_genre"` - ALL
+ * `text name="md_lbl_players"` - ALL
+ * `text name="md_lbl_lastplayed"` - ALL
+ * `text name="md_lbl_playcount"` - ALL
+
+ * Values
+ * All values will follow to the right of their labels if a position isn't specified.
+
+ * `image name="md_image"` - POSITION | SIZE | Z_INDEX | ROTATION | VISIBLE
+ - Path is the "image" metadata for the currently selected game.
+ * `image name="md_thumbnail"` - POSITION | SIZE | Z_INDEX | ROTATION | VISIBLE
+ - Path is the "thumbnail" metadata for the currently selected game.
+ * `image name="md_marquee"` - POSITION | SIZE | Z_INDEX | ROTATION | VISIBLE
+ - Path is the "marquee" metadata. Hidden by default; set `visible` to show.
+ * `video name="md_video"` - POSITION | SIZE | DELAY | Z_INDEX | ROTATION | VISIBLE
+ - Path is the "video" metadata. **Hidden by default** — you must explicitly set `true` (or define a position/size) to enable video playback. To disable video entirely, omit this element or set `false`.
+ * `rating name="md_rating"` - ALL
+ - The "rating" metadata.
+ * `datetime name="md_releasedate"` - ALL
+ - The "releasedate" metadata.
+ * `text name="md_developer"` - ALL
+ - The "developer" metadata.
+ * `text name="md_publisher"` - ALL
+ - The "publisher" metadata.
+ * `text name="md_genre"` - ALL
+ - The "genre" metadata.
+ * `text name="md_players"` - ALL
+ - The "players" metadata.
+ * `datetime name="md_lastplayed"` - ALL
+ - The "lastplayed" metadata. Displayed relative to now (e.g. "3 hours ago").
+ * `text name="md_playcount"` - ALL
+ - The "playcount" metadata.
+ * `text name="md_description"` - POSITION | SIZE | FONT_PATH | FONT_SIZE | COLOR | Z_INDEX
+ - Text is the "desc" metadata.
+ * `text name="md_name"` - ALL
+ - The "name" metadata (game title). Positioned off-screen by default.
+
+---
+
#### grid
* `helpsystem name="help"` - ALL
- The help system style for this view.
diff --git a/es-app/CMakeLists.txt b/es-app/CMakeLists.txt
index a828a462f3..45de51ed9b 100644
--- a/es-app/CMakeLists.txt
+++ b/es-app/CMakeLists.txt
@@ -18,6 +18,7 @@ set(ES_HEADERS
${CMAKE_CURRENT_SOURCE_DIR}/src/components/AsyncReqComponent.h
${CMAKE_CURRENT_SOURCE_DIR}/src/components/RatingComponent.h
${CMAKE_CURRENT_SOURCE_DIR}/src/components/ScraperSearchComponent.h
+ ${CMAKE_CURRENT_SOURCE_DIR}/src/components/CharacterRowComponent.h
${CMAKE_CURRENT_SOURCE_DIR}/src/components/TextListComponent.h
# Guis
@@ -50,6 +51,7 @@ set(ES_HEADERS
${CMAKE_CURRENT_SOURCE_DIR}/src/views/gamelist/IGameListView.h
${CMAKE_CURRENT_SOURCE_DIR}/src/views/gamelist/ISimpleGameListView.h
${CMAKE_CURRENT_SOURCE_DIR}/src/views/gamelist/GridGameListView.h
+ ${CMAKE_CURRENT_SOURCE_DIR}/src/guis/GuiSearchPopup.h
${CMAKE_CURRENT_SOURCE_DIR}/src/views/gamelist/VideoGameListView.h
${CMAKE_CURRENT_SOURCE_DIR}/src/views/SystemView.h
${CMAKE_CURRENT_SOURCE_DIR}/src/views/ViewController.h
@@ -77,6 +79,7 @@ set(ES_SOURCES
# GuiComponents
${CMAKE_CURRENT_SOURCE_DIR}/src/components/AsyncReqComponent.cpp
${CMAKE_CURRENT_SOURCE_DIR}/src/components/RatingComponent.cpp
+ ${CMAKE_CURRENT_SOURCE_DIR}/src/components/CharacterRowComponent.cpp
${CMAKE_CURRENT_SOURCE_DIR}/src/components/ScraperSearchComponent.cpp
# Guis
@@ -109,6 +112,7 @@ set(ES_SOURCES
${CMAKE_CURRENT_SOURCE_DIR}/src/views/gamelist/IGameListView.cpp
${CMAKE_CURRENT_SOURCE_DIR}/src/views/gamelist/ISimpleGameListView.cpp
${CMAKE_CURRENT_SOURCE_DIR}/src/views/gamelist/GridGameListView.cpp
+ ${CMAKE_CURRENT_SOURCE_DIR}/src/guis/GuiSearchPopup.cpp
${CMAKE_CURRENT_SOURCE_DIR}/src/views/gamelist/VideoGameListView.cpp
${CMAKE_CURRENT_SOURCE_DIR}/src/views/SystemView.cpp
${CMAKE_CURRENT_SOURCE_DIR}/src/views/ViewController.cpp
diff --git a/es-app/src/CollectionSystemManager.cpp b/es-app/src/CollectionSystemManager.cpp
index 440abf7fa8..8f65904e61 100644
--- a/es-app/src/CollectionSystemManager.cpp
+++ b/es-app/src/CollectionSystemManager.cpp
@@ -75,6 +75,7 @@ CollectionSystemManager::~CollectionSystemManager()
}
delete it->second.system;
}
+
sInstance = NULL;
}
diff --git a/es-app/src/components/CharacterRowComponent.cpp b/es-app/src/components/CharacterRowComponent.cpp
new file mode 100644
index 0000000000..f7ec9a08c1
--- /dev/null
+++ b/es-app/src/components/CharacterRowComponent.cpp
@@ -0,0 +1,269 @@
+#include "components/CharacterRowComponent.h"
+
+#include "renderers/Renderer.h"
+#include "InputConfig.h"
+
+const std::string CharacterRowComponent::MODE_SWITCH_123 = "123";
+const std::string CharacterRowComponent::MODE_SWITCH_ABC = "ABC";
+const std::string CharacterRowComponent::MODE_SWITCH_SYMBOLS = "!@#";
+const std::string CharacterRowComponent::CHAR_SPACE = "SPC";
+const std::string CharacterRowComponent::CHAR_BACKSPACE = "\xe2\x8c\xab"; // ⌫ U+232B
+const std::string CharacterRowComponent::CHAR_CURSOR_LEFT = "\xe2\x86\x90"; // ← U+2190
+const std::string CharacterRowComponent::CHAR_CURSOR_RIGHT = "\xe2\x86\x92"; // → U+2192
+
+CharacterRowComponent::CharacterRowComponent(Window* window)
+ : GuiComponent(window), mMode(LETTERS), mCursor(2), mFocused(true),
+ mSelectorColor(0x000050FF), mTextColor(0xFFFFFFFF), mTotalWidth(0),
+ mScrollDir(0), mScrollTimer(0), mBackspaceHeld(false), mBackspaceTimer(0)
+{
+ mFont = Font::get(FONT_SIZE_MEDIUM);
+ buildCharList();
+}
+
+void CharacterRowComponent::buildCharList()
+{
+ mChars.clear();
+
+ switch (mMode)
+ {
+ case LETTERS:
+ mChars.push_back(MODE_SWITCH_123);
+ mChars.push_back(CHAR_SPACE);
+ for (char c = 'A'; c <= 'Z'; c++)
+ mChars.push_back(std::string(1, c));
+ break;
+
+ case NUMBERS:
+ mChars.push_back(MODE_SWITCH_SYMBOLS);
+ mChars.push_back(CHAR_SPACE);
+ for (char c = '1'; c <= '9'; c++)
+ mChars.push_back(std::string(1, c));
+ mChars.push_back("0");
+ break;
+
+ case SYMBOLS:
+ mChars.push_back(MODE_SWITCH_ABC);
+ mChars.push_back(CHAR_SPACE);
+ {
+ const char* syms = "`'\";:~=*+-_,.?!@#$%^&|/\\()[]{}<>";
+ for (int i = 0; syms[i]; i++)
+ mChars.push_back(std::string(1, syms[i]));
+ }
+ break;
+ }
+
+ mChars.push_back(CHAR_BACKSPACE);
+ mChars.push_back(CHAR_CURSOR_LEFT);
+ mChars.push_back(CHAR_CURSOR_RIGHT);
+
+ if (mCursor >= (int)mChars.size())
+ mCursor = (int)mChars.size() - 1;
+
+ // Cache per-char widths — rebuilt when chars, font, or size changes
+ // Compute natural padding, then shrink it if all chars don't fit in mSize.x()
+ float padding = Math::round(mSize.y() * 0.2f);
+
+ // First pass: measure raw text widths
+ std::vector textWidths;
+ float totalTextWidth = 0;
+ for (const auto& ch : mChars)
+ {
+ float tw = mFont->sizeText(ch).x();
+ textWidths.push_back(tw);
+ totalTextWidth += tw;
+ }
+
+ // Shrink padding if needed so everything fits
+ if (mSize.x() > 0)
+ {
+ float maxPadding = (mSize.x() - totalTextWidth) / (2.0f * (float)mChars.size());
+ if (maxPadding < padding)
+ padding = std::max(1.0f, maxPadding);
+ }
+
+ mCharWidths.clear();
+ mTotalWidth = 0;
+ for (float tw : textWidths)
+ {
+ float w = tw + padding * 2;
+ mCharWidths.push_back(w);
+ mTotalWidth += w;
+ }
+}
+
+void CharacterRowComponent::scrollStep(int dir)
+{
+ if (dir < 0)
+ {
+ if (mCursor > 0) mCursor--;
+ else mCursor = (int)mChars.size() - 1;
+ }
+ else
+ {
+ if (mCursor < (int)mChars.size() - 1) mCursor++;
+ else mCursor = 0;
+ }
+}
+
+void CharacterRowComponent::update(int deltaTime)
+{
+ if (mScrollDir != 0)
+ {
+ mScrollTimer += deltaTime;
+ while (mScrollTimer >= SCROLL_DELAY_MS)
+ {
+ mScrollTimer -= SCROLL_REPEAT_MS;
+ scrollStep(mScrollDir);
+ }
+ }
+ if (mBackspaceHeld)
+ {
+ mBackspaceTimer += deltaTime;
+ while (mBackspaceTimer >= SCROLL_DELAY_MS)
+ {
+ mBackspaceTimer -= SCROLL_REPEAT_MS;
+ if (mBackspaceCb) mBackspaceCb();
+ }
+ }
+ GuiComponent::update(deltaTime);
+}
+
+bool CharacterRowComponent::input(InputConfig* config, Input input)
+{
+ if (input.value != 0)
+ {
+ if (config->isMappedLike("left", input))
+ {
+ mScrollDir = -1;
+ mScrollTimer = 0;
+ scrollStep(-1);
+ return true;
+ }
+ else if (config->isMappedLike("right", input))
+ {
+ mScrollDir = 1;
+ mScrollTimer = 0;
+ scrollStep(1);
+ return true;
+ }
+ else if (config->isMappedTo("x", input))
+ {
+ mBackspaceHeld = true;
+ mBackspaceTimer = 0;
+ if (mBackspaceCb) mBackspaceCb();
+ return true;
+ }
+ else if (config->isMappedTo("a", input))
+ {
+ const std::string& selected = mChars[mCursor];
+
+ if (selected == MODE_SWITCH_123)
+ {
+ mMode = NUMBERS;
+ buildCharList();
+ }
+ else if (selected == MODE_SWITCH_SYMBOLS)
+ {
+ mMode = SYMBOLS;
+ buildCharList();
+ }
+ else if (selected == MODE_SWITCH_ABC)
+ {
+ mMode = LETTERS;
+ buildCharList();
+ }
+ else if (selected == CHAR_BACKSPACE)
+ {
+ if (mBackspaceCb)
+ mBackspaceCb();
+ }
+ else if (selected == CHAR_CURSOR_LEFT)
+ {
+ if (mCursorLeftCb)
+ mCursorLeftCb();
+ }
+ else if (selected == CHAR_CURSOR_RIGHT)
+ {
+ if (mCursorRightCb)
+ mCursorRightCb();
+ }
+ else if (selected == CHAR_SPACE)
+ {
+ if (mCharSelectedCb)
+ mCharSelectedCb(" ");
+ }
+ else
+ {
+ if (mCharSelectedCb)
+ mCharSelectedCb(selected);
+ }
+ return true;
+ }
+ }
+
+ if (input.value == 0)
+ {
+ if (mBackspaceHeld && config->isMappedTo("x", input))
+ {
+ mBackspaceHeld = false;
+ return true;
+ }
+ if ((mScrollDir < 0 && config->isMappedLike("left", input)) ||
+ (mScrollDir > 0 && config->isMappedLike("right", input)))
+ {
+ mScrollDir = 0;
+ return true;
+ }
+ }
+
+ return GuiComponent::input(config, input);
+}
+
+void CharacterRowComponent::onSizeChanged()
+{
+ buildCharList();
+}
+
+void CharacterRowComponent::render(const Transform4x4f& parentTrans)
+{
+ Transform4x4f trans = parentTrans * getTransform();
+
+ if (mChars.empty())
+ return;
+
+ const float height = mSize.y();
+
+ // Rebuild cache if invalidated (shouldn't normally happen at render time)
+ if (mCharWidths.size() != mChars.size())
+ buildCharList();
+
+ float startX = Math::round((mSize.x() - mTotalWidth) / 2.0f);
+ if (startX < 0)
+ startX = 0;
+
+ float x = startX;
+ const float textY = Math::round((height - mFont->getHeight(1.0f)) / 2.0f);
+
+ Renderer::setMatrix(trans);
+
+ for (int i = 0; i < (int)mChars.size(); i++)
+ {
+ float cellWidth = mCharWidths[i];
+ float cellPadding = Math::round(cellWidth - mFont->sizeText(mChars[i]).x()) / 2.0f;
+
+ if (mFocused && i == mCursor)
+ {
+ Renderer::drawRect(Math::round(x), 0, Math::round(cellWidth), Math::round(height),
+ mSelectorColor, mSelectorColor);
+ }
+
+ auto textCache = std::unique_ptr(
+ mFont->buildTextCache(mChars[i], Math::round(x + cellPadding), Math::round(textY),
+ (i == mCursor) ? 0xFFFFFFFF : mTextColor));
+ mFont->renderTextCache(textCache.get());
+
+ x += cellWidth;
+ }
+
+ renderChildren(trans);
+}
diff --git a/es-app/src/components/CharacterRowComponent.h b/es-app/src/components/CharacterRowComponent.h
new file mode 100644
index 0000000000..ff0365becb
--- /dev/null
+++ b/es-app/src/components/CharacterRowComponent.h
@@ -0,0 +1,74 @@
+#pragma once
+#ifndef ES_APP_COMPONENTS_CHARACTER_ROW_COMPONENT_H
+#define ES_APP_COMPONENTS_CHARACTER_ROW_COMPONENT_H
+
+#include "GuiComponent.h"
+#include "resources/Font.h"
+#include
+#include
+#include
+
+class CharacterRowComponent : public GuiComponent
+{
+public:
+ CharacterRowComponent(Window* window);
+
+ bool input(InputConfig* config, Input input) override;
+ void update(int deltaTime) override;
+ void onSizeChanged() override;
+ void render(const Transform4x4f& parentTrans) override;
+
+ void setCharSelectedCallback(const std::function& cb) { mCharSelectedCb = cb; }
+ void setBackspaceCallback(const std::function& cb) { mBackspaceCb = cb; }
+ void setCursorLeftCallback(const std::function& cb) { mCursorLeftCb = cb; }
+ void setCursorRightCallback(const std::function& cb) { mCursorRightCb = cb; }
+
+ enum Mode { LETTERS, NUMBERS, SYMBOLS };
+ Mode getMode() const { return mMode; }
+
+ void setFont(const std::shared_ptr& font) { mFont = font; buildCharList(); }
+ void setSelectorColor(unsigned int color) { mSelectorColor = color; }
+ void setTextColor(unsigned int color) { mTextColor = color; }
+ void setFocused(bool focused) { mFocused = focused; }
+
+ int getCursor() const { return mCursor; }
+
+private:
+ void buildCharList();
+
+ void scrollStep(int dir); // dir: -1 = left, +1 = right
+
+ static const int SCROLL_DELAY_MS = 500;
+ static const int SCROLL_REPEAT_MS = 100;
+
+ Mode mMode;
+ int mCursor;
+ bool mFocused;
+ int mScrollDir; // -1, 0, or 1
+ int mScrollTimer;
+ bool mBackspaceHeld;
+ int mBackspaceTimer;
+ std::vector mChars;
+
+ std::function mCharSelectedCb;
+ std::function mBackspaceCb;
+ std::function mCursorLeftCb;
+ std::function mCursorRightCb;
+
+ std::shared_ptr mFont;
+ unsigned int mSelectorColor;
+ unsigned int mTextColor;
+
+ std::vector mCharWidths;
+ float mTotalWidth;
+
+ static const std::string MODE_SWITCH_123;
+ static const std::string MODE_SWITCH_ABC;
+ static const std::string MODE_SWITCH_SYMBOLS;
+ static const std::string CHAR_SPACE;
+ static const std::string CHAR_BACKSPACE; // ⌫
+ static const std::string CHAR_CURSOR_LEFT;
+ static const std::string CHAR_CURSOR_RIGHT;
+};
+
+#endif // ES_APP_COMPONENTS_CHARACTER_ROW_COMPONENT_H
diff --git a/es-app/src/guis/GuiGamelistOptions.cpp b/es-app/src/guis/GuiGamelistOptions.cpp
index 436b131c46..544bb71ed7 100644
--- a/es-app/src/guis/GuiGamelistOptions.cpp
+++ b/es-app/src/guis/GuiGamelistOptions.cpp
@@ -12,15 +12,19 @@
#include "SystemData.h"
#include "components/TextListComponent.h"
-GuiGamelistOptions::GuiGamelistOptions(Window* window, SystemData* system) : GuiComponent(window),
+GuiGamelistOptions::GuiGamelistOptions(Window* window, SystemData* system,
+ const std::vector& jumpFiles, std::function jumpCallback)
+ : GuiComponent(window),
mSystem(system), mMenu(window, "OPTIONS"), mFromPlaceholder(false), mFiltersChanged(false),
- mJumpToSelected(false), mMetadataChanged(false)
+ mJumpToSelected(false), mMetadataChanged(false),
+ mJumpFiles(jumpFiles), mJumpCallback(jumpCallback)
{
addChild(&mMenu);
// check it's not a placeholder folder - if it is, only show "Filter Options"
- FileData* file = getGamelist()->getCursor();
- mFromPlaceholder = file->isPlaceHolder();
+ // When jump files are provided (e.g. from search popup), treat as non-placeholder
+ FileData* file = mJumpFiles.empty() ? getGamelist()->getCursor() : mJumpFiles.front();
+ mFromPlaceholder = mJumpFiles.empty() ? file->isPlaceHolder() : false;
ComponentListRow row;
if (!mFromPlaceholder) {
@@ -29,10 +33,14 @@ GuiGamelistOptions::GuiGamelistOptions(Window* window, SystemData* system) : Gui
std::string currentSort = mSystem->getRootFolder()->getSortDescription();
std::string reqSort = FileSorts::SortTypes.at(0).description;
- // "jump to letter" menuitem only available (and correct jumping) on sort order "name, asc"
- if (currentSort == reqSort) {
+ // "jump to letter" menuitem available when sort is "name, asc" OR when override files provided
+ if (currentSort == reqSort || !mJumpFiles.empty()) {
+ const std::vector& jumpList = mJumpFiles.empty()
+ ? getGamelist()->getCursor()->getParent()->getChildrenListToDisplay()
+ : mJumpFiles;
+
bool outOfRange = false;
- char curChar = (char)toupper(getGamelist()->getCursor()->getSortName()[0]);
+ char curChar = (char)toupper(jumpList.empty() ? '!' : jumpList.front()->getSortName()[0]);
// define supported character range
// this range includes all numbers, capital letters, and most reasonable symbols
char startChar = '!';
@@ -46,11 +54,9 @@ GuiGamelistOptions::GuiGamelistOptions(Window* window, SystemData* system) : Gui
mJumpToLetterList = std::make_shared(mWindow, "JUMP TO ...", false);
for (char c = startChar; c <= endChar; c++)
{
- // check if c is a valid first letter in current list
- const std::vector& files = getGamelist()->getCursor()->getParent()->getChildrenListToDisplay();
- for (auto file : files)
+ for (auto f : jumpList)
{
- char candidate = (char)toupper(file->getSortName()[0]);
+ char candidate = (char)toupper(f->getSortName()[0]);
if (c == candidate)
{
mJumpToLetterList->add(std::string(1, c), c, (c == curChar) || outOfRange);
@@ -277,10 +283,10 @@ void GuiGamelistOptions::openMetaDataEd()
void GuiGamelistOptions::jumpToLetter()
{
char letter = mJumpToLetterList->getSelected();
- IGameListView* gamelist = getGamelist();
- // this is a really shitty way to get a list of files
- const std::vector& files = gamelist->getCursor()->getParent()->getChildrenListToDisplay();
+ const std::vector& files = mJumpFiles.empty()
+ ? getGamelist()->getCursor()->getParent()->getChildrenListToDisplay()
+ : mJumpFiles;
long min = 0;
long max = (long)files.size() - 1;
@@ -304,7 +310,10 @@ void GuiGamelistOptions::jumpToLetter()
break; //exact match found
}
- gamelist->setCursor(files.at(mid));
+ if (mJumpCallback)
+ mJumpCallback((int)mid);
+ else
+ getGamelist()->setCursor(files.at(mid));
// flag to force default sort order "name, asc", if user changed the sortorder in the options dialog
mJumpToSelected = true;
diff --git a/es-app/src/guis/GuiGamelistOptions.h b/es-app/src/guis/GuiGamelistOptions.h
index dd14399b54..e765dae625 100644
--- a/es-app/src/guis/GuiGamelistOptions.h
+++ b/es-app/src/guis/GuiGamelistOptions.h
@@ -6,6 +6,8 @@
#include "components/OptionListComponent.h"
#include "FileData.h"
#include "GuiComponent.h"
+#include
+#include
class IGameListView;
class SystemData;
@@ -13,7 +15,9 @@ class SystemData;
class GuiGamelistOptions : public GuiComponent
{
public:
- GuiGamelistOptions(Window* window, SystemData* system);
+ GuiGamelistOptions(Window* window, SystemData* system,
+ const std::vector& jumpFiles = {},
+ std::function jumpCallback = nullptr);
virtual ~GuiGamelistOptions();
virtual bool input(InputConfig* config, Input input) override;
@@ -43,6 +47,9 @@ class GuiGamelistOptions : public GuiComponent
bool mFiltersChanged;
bool mJumpToSelected;
bool mMetadataChanged;
+
+ std::vector mJumpFiles;
+ std::function mJumpCallback;
};
#endif // ES_APP_GUIS_GUI_GAME_LIST_OPTIONS_H
diff --git a/es-app/src/guis/GuiSearchPopup.cpp b/es-app/src/guis/GuiSearchPopup.cpp
new file mode 100644
index 0000000000..5d1f94fe7f
--- /dev/null
+++ b/es-app/src/guis/GuiSearchPopup.cpp
@@ -0,0 +1,852 @@
+#include "guis/GuiSearchPopup.h"
+
+#include "components/RatingComponent.h"
+#ifdef _OMX_
+#include "components/VideoPlayerComponent.h"
+#endif
+#include "components/VideoVlcComponent.h"
+#include "guis/GuiGamelistOptions.h"
+#include "guis/GuiMenu.h"
+#include "renderers/Renderer.h"
+#include "utils/StringUtil.h"
+#include "views/UIModeController.h"
+#include "views/ViewController.h"
+#include "CollectionSystemManager.h"
+#include "FileData.h"
+#include "Log.h"
+#include "Settings.h"
+#include "SystemData.h"
+#include "ThemeData.h"
+#include "Window.h"
+#include
+#include
+#include
+#include // rand()
+
+GuiSearchPopup::GuiSearchPopup(Window* window, SystemData* scope)
+ : GuiComponent(window),
+ mScope(scope), mThemeSystem(nullptr),
+ mSearchText(window), mCharRow(window),
+ mResultList(window), mListMessage(window),
+ mImage(window), mThumbnail(window),
+ mDescContainer(window, 1500), mDescription(window),
+ mRating(window),
+ mDeveloper(window), mPublisher(window), mGenre(window), mPlayers(window),
+ mLblRating(window), mLblDeveloper(window), mLblPublisher(window),
+ mLblGenre(window), mLblPlayers(window),
+ mMarquee(window), mName(window),
+ mReleaseDate(window), mLastPlayed(window), mPlayCount(window),
+ mLblReleaseDate(window), mLblLastPlayed(window), mLblPlayCount(window),
+ mBackground(window),
+ mCursorPos(0), mCancelFlag(false), mResultsReady(false),
+ mFocus(FOCUS_CHAR_ROW), mLastInputWasKeyboard(false),
+ mKeyRepeatKey(0), mKeyRepeatTimer(0), mShoulderRepeatDir(0), mShoulderRepeatTimer(0),
+ mResultListSelectorColor(0x000050FF), mResultListSelectorColorEnd(0x000050FF)
+{
+ const float sw = (float)Renderer::getScreenWidth();
+ const float sh = (float)Renderer::getScreenHeight();
+ setSize(sw, sh);
+
+ // ── background (optional, theme-driven, rendered first / behind everything) ─
+ mBackground.setDefaultZIndex(0);
+ addChild(&mBackground);
+
+ // ── search text ──────────────────────────────────────────────
+ mSearchText.setText("");
+ mSearchText.setPosition(0, 0);
+ mSearchText.setSize(sw, sh * 0.06f);
+ mSearchText.setHorizontalAlignment(ALIGN_CENTER);
+ mSearchText.setColor(0xCCCCCCFF);
+ mSearchText.setFont(Font::get(FONT_SIZE_MEDIUM));
+ mSearchText.setDefaultZIndex(40);
+ addChild(&mSearchText);
+
+ // ── char row ─────────────────────────────────────────────────
+ mCharRow.setPosition(0, sh * 0.06f);
+ mCharRow.setSize(sw, sh * 0.07f);
+ mCharRow.setDefaultZIndex(40);
+ addChild(&mCharRow);
+
+ mCharRow.setCharSelectedCallback([this](const std::string& ch) {
+ mQuery.insert(mCursorPos, ch);
+ mCursorPos += ch.size();
+ updateSearchDisplay();
+ startSearch(mQuery);
+ });
+
+ mCharRow.setBackspaceCallback([this]() { editBackspace(); });
+ mCharRow.setCursorLeftCallback([this]() { editCursorLeft(); });
+ mCharRow.setCursorRightCallback([this]() { editCursorRight(); });
+
+ // ── result list (left column) ─────────────────────────────────
+ mResultList.setPosition(0, sh * 0.13f);
+ mResultList.setSize(sw * 0.50f, sh * 0.80f);
+ mResultList.setFont(Font::get(FONT_SIZE_SMALL));
+ mResultList.setCursorChangedCallback([this](CursorState /*state*/) { updateInfoPanel(); });
+ mResultList.setDefaultZIndex(20);
+ addChild(&mResultList);
+
+ // ── list message overlay (shown when list is empty) ───────────
+ mListMessage.setPosition(0, sh * 0.13f);
+ mListMessage.setSize(sw * 0.50f, sh * 0.80f);
+ mListMessage.setHorizontalAlignment(ALIGN_CENTER);
+ mListMessage.setVerticalAlignment(ALIGN_CENTER);
+ mListMessage.setFont(Font::get(FONT_SIZE_SMALL));
+ mListMessage.setColor(0x999999FF);
+ mListMessage.setDefaultZIndex(25);
+ addChild(&mListMessage);
+
+ // ── metadata panel (right column) ────────────────────────────
+ const float rx = sw * 0.53f;
+
+ mImage.setOrigin(0.5f, 0.0f);
+ mImage.setPosition(rx + sw * 0.225f, sh * 0.13f);
+ mImage.setMaxSize(sw * 0.44f, sh * 0.50f);
+ mImage.setDefaultZIndex(30);
+ addChild(&mImage);
+
+ mThumbnail.setOrigin(0.5f, 0.0f);
+ mThumbnail.setPosition(2.0f, 2.0f);
+ mThumbnail.setMaxSize(sw * 0.44f, sh * 0.50f);
+ mThumbnail.setVisible(false);
+ mThumbnail.setDefaultZIndex(35);
+ addChild(&mThumbnail);
+
+ // ── video (off-screen / invisible by default; theme must enable it) ───────
+#ifdef _OMX_
+ if (Settings::getInstance()->getBool("VideoOmxPlayer"))
+ mVideo = new VideoPlayerComponent(window, "");
+ else
+ mVideo = new VideoVlcComponent(window, "");
+#else
+ mVideo = new VideoVlcComponent(window, "");
+#endif
+ mVideo->setOrigin(0.5f, 0.0f);
+ mVideo->setPosition(2.0f, 2.0f);
+ mVideo->setVisible(false);
+ mVideo->setDefaultZIndex(30);
+ addChild(mVideo);
+
+ // ── marquee (off-screen / invisible by default) ───────────────────────────
+ mMarquee.setOrigin(0.5f, 0.0f);
+ mMarquee.setPosition(2.0f, 2.0f);
+ mMarquee.setVisible(false);
+ mMarquee.setDefaultZIndex(35);
+ addChild(&mMarquee);
+
+ // ── name (off-screen by default, theme overrides position) ────────────────
+ mName.setPosition(sw, sh);
+ mName.setDefaultZIndex(40);
+ mName.setColor(0xAAAAAAFF);
+ mName.setFont(Font::get(FONT_SIZE_MEDIUM));
+ mName.setHorizontalAlignment(ALIGN_CENTER);
+ addChild(&mName);
+
+ mDescContainer.setPosition(rx, sh * 0.72f);
+ mDescContainer.setSize(sw * 0.44f, sh * 0.20f);
+ mDescContainer.setAutoScroll(true);
+ mDescContainer.setDefaultZIndex(40);
+ addChild(&mDescContainer);
+
+ mDescription.setFont(Font::get(FONT_SIZE_SMALL));
+ mDescription.setColor(0xDDDDDDFF);
+ mDescription.setSize(sw * 0.44f, 0);
+ mDescContainer.addChild(&mDescription);
+
+ // stacked metadata rows below description
+ const float mh = sh * 0.04f;
+ std::shared_ptr smallFont = Font::get(FONT_SIZE_SMALL);
+
+ auto placeLbl = [&](TextComponent& lbl, const char* text, float y) {
+ lbl.setText(text);
+ lbl.setFont(smallFont);
+ lbl.setColor(0x999999FF);
+ lbl.setPosition(rx, y);
+ lbl.setSize(sw * 0.12f, mh);
+ lbl.setDefaultZIndex(40);
+ addChild(&lbl);
+ };
+ auto placeVal = [&](GuiComponent& val, float y) {
+ val.setPosition(rx + sw * 0.13f, y);
+ val.setSize(sw * 0.30f, mh);
+ val.setDefaultZIndex(40);
+ addChild(&val);
+ };
+
+ float my = sh * 0.63f;
+ placeLbl(mLblRating, "Rating: ", my); placeVal(mRating, my); my += mh;
+ placeLbl(mLblDeveloper, "Developer: ", my); placeVal(mDeveloper, my); my += mh;
+ placeLbl(mLblGenre, "Genre: ", my); placeVal(mGenre, my); my += mh;
+ placeLbl(mLblPlayers, "Players: ", my); placeVal(mPlayers, my); my += mh;
+ placeLbl(mLblPublisher, "Publisher: ", my); placeVal(mPublisher, my); my += mh;
+ placeLbl(mLblReleaseDate, "Released: ", my); placeVal(mReleaseDate, my); my += mh;
+ placeLbl(mLblLastPlayed, "Last played: ", my); placeVal(mLastPlayed, my); my += mh;
+ placeLbl(mLblPlayCount, "Times played: ", my); placeVal(mPlayCount, my);
+
+ mLastPlayed.setDisplayRelative(true);
+
+ mDeveloper.setFont(smallFont);
+ mDeveloper.setColor(0xDDDDDDFF);
+ mGenre.setFont(smallFont);
+ mGenre.setColor(0xDDDDDDFF);
+ mPlayers.setFont(smallFont);
+ mPlayers.setColor(0xDDDDDDFF);
+ mPublisher.setFont(smallFont);
+ mPublisher.setColor(0xDDDDDDFF);
+ mReleaseDate.setFont(smallFont);
+ mReleaseDate.setColor(0xDDDDDDFF);
+ mLastPlayed.setFont(smallFont);
+ mLastPlayed.setColor(0xDDDDDDFF);
+ mPlayCount.setFont(smallFont);
+ mPlayCount.setColor(0xDDDDDDFF);
+
+ // Apply theme from scope system (or first available system for all-systems search)
+ SystemData* themeSource = mScope;
+ if (!themeSource && !SystemData::sSystemVector.empty())
+ themeSource = SystemData::sSystemVector.front();
+ if (themeSource)
+ {
+ applyTheme(themeSource);
+ // Apply result list theme once here — never re-applied so scrolling doesn't flicker
+ auto theme = themeSource->getTheme();
+ mResultList.applyTheme(theme, "search", "gamelist", ThemeFlags::ALL);
+ const ThemeData::ThemeElement* gamelistElem =
+ theme->getElement("search", "gamelist", "textlist");
+ if (gamelistElem)
+ {
+ if (gamelistElem->has("selectorColor"))
+ mResultListSelectorColor = gamelistElem->get("selectorColor");
+ if (gamelistElem->has("selectorColorEnd"))
+ mResultListSelectorColorEnd = gamelistElem->get("selectorColorEnd");
+ }
+ }
+
+ buildGameCache();
+ mListMessage.setText("TYPE TO SEARCH...");
+ updateFocusVisuals();
+
+ // Discard any SDL_TEXTINPUT events queued by the key that opened this popup
+ SDL_FlushEvent(SDL_TEXTINPUT);
+}
+
+GuiSearchPopup::~GuiSearchPopup()
+{
+ cancelSearch();
+ delete mVideo;
+}
+
+void GuiSearchPopup::applyTheme(SystemData* sys)
+{
+ mThemeSystem = sys;
+ if (!sys)
+ return;
+
+ auto theme = sys->getTheme();
+ using namespace ThemeFlags;
+
+ // Background (optional, renders behind everything)
+ mBackground.applyTheme(theme, "search", "background", ALL);
+
+ // Image / thumbnail
+ mImage.applyTheme( theme, "search", "md_image", POSITION | SIZE | Z_INDEX | ROTATION | VISIBLE);
+ mThumbnail.applyTheme(theme, "search", "md_thumbnail", POSITION | SIZE | Z_INDEX | ROTATION | VISIBLE);
+
+ // Video (invisible by default; theme must set visible=true to enable)
+ mVideo->applyTheme(theme, "search", "md_video",
+ POSITION | SIZE | DELAY | Z_INDEX | ROTATION | VISIBLE);
+
+ // Marquee
+ mMarquee.applyTheme(theme, "search", "md_marquee",
+ POSITION | SIZE | Z_INDEX | ROTATION | VISIBLE);
+
+ // Name
+ mName.applyTheme(theme, "search", "md_name", ALL);
+
+ // Description
+ mDescContainer.applyTheme(theme, "search", "md_description", POSITION | SIZE | Z_INDEX | VISIBLE);
+ mDescription.setSize(mDescContainer.getSize().x(), 0);
+ mDescription.applyTheme(theme, "search", "md_description",
+ ALL ^ (POSITION | SIZE | ORIGIN | TEXT | ROTATION));
+
+ // Metadata values
+ mRating.applyTheme( theme, "search", "md_rating", ALL ^ TEXT);
+ mDeveloper.applyTheme( theme, "search", "md_developer", ALL ^ TEXT);
+ mPublisher.applyTheme( theme, "search", "md_publisher", ALL ^ TEXT);
+ mGenre.applyTheme( theme, "search", "md_genre", ALL ^ TEXT);
+ mPlayers.applyTheme( theme, "search", "md_players", ALL ^ TEXT);
+ mReleaseDate.applyTheme(theme, "search", "md_releasedate", ALL ^ TEXT);
+ mLastPlayed.applyTheme( theme, "search", "md_lastplayed", ALL ^ TEXT);
+ mPlayCount.applyTheme( theme, "search", "md_playcount", ALL ^ TEXT);
+
+ // Labels
+ mLblRating.applyTheme( theme, "search", "md_lbl_rating", ALL);
+ mLblDeveloper.applyTheme( theme, "search", "md_lbl_developer", ALL);
+ mLblPublisher.applyTheme( theme, "search", "md_lbl_publisher", ALL);
+ mLblGenre.applyTheme( theme, "search", "md_lbl_genre", ALL);
+ mLblPlayers.applyTheme( theme, "search", "md_lbl_players", ALL);
+ mLblReleaseDate.applyTheme(theme, "search", "md_lbl_releasedate", ALL);
+ mLblLastPlayed.applyTheme( theme, "search", "md_lbl_lastplayed", ALL);
+ mLblPlayCount.applyTheme( theme, "search", "md_lbl_playcount", ALL);
+
+ // Layout elements (no-op if element not defined in theme)
+ mSearchText.applyTheme( theme, "search", "searchtext", ALL ^ TEXT);
+ mListMessage.applyTheme(theme, "search", "listmessage", ALL ^ TEXT);
+
+ // Note: mResultList theme is applied once in the constructor — not here —
+ // to avoid flickering the favorite icon on every cursor change.
+}
+
+void GuiSearchPopup::updateSearchDisplay()
+{
+ std::string display = mQuery.substr(0, mCursorPos) + "|" + mQuery.substr(mCursorPos);
+ mSearchText.setText(display);
+}
+
+void GuiSearchPopup::updateInfoPanel()
+{
+ if (mResultList.size() == 0)
+ return;
+
+ FileData* file = mResultList.getSelected();
+ if (!file || file->getType() != GAME)
+ {
+ clearInfoPanel();
+ return;
+ }
+
+ // Dynamic theme reload when cursor moves to a game from a different system.
+ // mResultList is intentionally excluded from applyTheme() to avoid flickering.
+ SystemData* sys = file->getSystem();
+ if (sys != mThemeSystem)
+ applyTheme(sys);
+
+ mImage.setImage(file->getImagePath());
+ mThumbnail.setImage(file->getThumbnailPath());
+
+ if (mVideo->isVisible())
+ {
+ if (!mVideo->setVideo(file->getVideoPath()))
+ mVideo->setDefaultVideo();
+ mVideo->setImage(file->getThumbnailPath());
+ }
+
+ mMarquee.setImage(file->getMarqueePath());
+ mName.setText(file->getName());
+
+ mDescription.setText(file->metadata.get("desc"));
+ mDescription.setSize(mDescription.getSize().x(), 0); // auto-height
+ mDescContainer.reset();
+
+ mRating.setValue(file->metadata.get("rating"));
+ mDeveloper.setValue(file->metadata.get("developer"));
+ mPublisher.setValue(file->metadata.get("publisher"));
+ mGenre.setValue(file->metadata.get("genre"));
+ mPlayers.setValue(file->metadata.get("players"));
+ mReleaseDate.setValue(file->metadata.get("releasedate"));
+ mLastPlayed.setValue(file->metadata.get("lastplayed"));
+ mPlayCount.setValue(file->metadata.get("playcount"));
+}
+
+void GuiSearchPopup::editBackspace()
+{
+ if (mCursorPos > 0)
+ {
+ size_t prev = Utils::String::prevCursor(mQuery, mCursorPos);
+ mQuery.erase(prev, mCursorPos - prev);
+ mCursorPos = prev;
+ }
+ updateSearchDisplay();
+ startSearch(mQuery);
+}
+
+void GuiSearchPopup::editCursorLeft()
+{
+ if (mCursorPos > 0)
+ mCursorPos = Utils::String::prevCursor(mQuery, mCursorPos);
+ updateSearchDisplay();
+}
+
+void GuiSearchPopup::editCursorRight()
+{
+ if (mCursorPos < mQuery.size())
+ mCursorPos = Utils::String::nextCursor(mQuery, mCursorPos);
+ updateSearchDisplay();
+}
+
+void GuiSearchPopup::buildGameCache()
+{
+ mAllGames.clear();
+ mLowerNames.clear();
+
+ auto addSystem = [&](SystemData* sys) {
+ if (!sys->isGameSystem() || sys->isCollection())
+ return;
+ for (auto game : sys->getRootFolder()->getFilesRecursive(GAME))
+ {
+ mAllGames.push_back(game);
+ mLowerNames.push_back(Utils::String::toLower(game->getName()));
+ }
+ };
+
+ if (mScope)
+ {
+ addSystem(mScope);
+ }
+ else
+ {
+ for (auto sys : SystemData::sSystemVector)
+ addSystem(sys);
+ }
+
+ LOG(LogInfo) << "GuiSearchPopup: cached " << mAllGames.size() << " games";
+}
+
+void GuiSearchPopup::startSearch(const std::string& query)
+{
+ cancelSearch();
+
+ if (query.empty())
+ {
+ mResultList.clear();
+ mCurrentResults.clear();
+ addPlaceholder("TYPE TO SEARCH...");
+ clearInfoPanel();
+ return;
+ }
+
+ mCancelFlag.store(false);
+ mResultsReady.store(false);
+
+ std::string lowerQuery = Utils::String::toLower(query);
+
+ mSearchThread = std::thread([this, lowerQuery]() {
+ std::vector results;
+ for (size_t i = 0; i < mAllGames.size(); i++)
+ {
+ if (mCancelFlag.load())
+ return;
+ if (mLowerNames[i].find(lowerQuery) != std::string::npos)
+ results.push_back(mAllGames[i]);
+ }
+ std::sort(results.begin(), results.end(), [](FileData* a, FileData* b) {
+ return Utils::String::toLower(a->getName()) < Utils::String::toLower(b->getName());
+ });
+ {
+ std::lock_guard lock(mResultMutex);
+ mPendingResults = std::move(results);
+ }
+ mResultsReady.store(true);
+ });
+}
+
+void GuiSearchPopup::cancelSearch()
+{
+ mCancelFlag.store(true);
+ if (mSearchThread.joinable())
+ mSearchThread.join();
+}
+
+void GuiSearchPopup::populateResultsList(const std::vector& results)
+{
+ mResultList.clear();
+ mCurrentResults.clear();
+
+ if (results.empty())
+ {
+ addPlaceholder("NO RESULTS FOUND");
+ clearInfoPanel();
+ return;
+ }
+
+ mListMessage.setText("");
+
+ for (auto game : results)
+ {
+ std::string displayName = game->getName();
+ if (!mScope)
+ displayName += " [" + Utils::String::toUpper(game->getSystem()->getName()) + "]";
+ mResultList.add(displayName, game, 0);
+ mCurrentResults.push_back(game);
+ }
+}
+
+void GuiSearchPopup::addPlaceholder(const std::string& text)
+{
+ mListMessage.setText(text);
+}
+
+void GuiSearchPopup::clearInfoPanel()
+{
+ mImage.setImage("");
+ mThumbnail.setImage("");
+ mVideo->setVideo("");
+ mVideo->setImage("");
+ mMarquee.setImage("");
+ mName.setText("");
+ mDescription.setText("");
+ mRating.setValue("0");
+ mDeveloper.setValue("");
+ mPublisher.setValue("");
+ mGenre.setValue("");
+ mPlayers.setValue("");
+ mReleaseDate.setValue("");
+ mLastPlayed.setValue("");
+ mPlayCount.setValue("");
+}
+
+void GuiSearchPopup::updateFocusVisuals()
+{
+ bool charRowFocused = (mFocus == FOCUS_CHAR_ROW);
+ mCharRow.setFocused(charRowFocused);
+ if (!charRowFocused)
+ {
+ mKeyRepeatKey = 0; // stop keyboard held-key repeat
+ mShoulderRepeatDir = 0; // stop gamepad shoulder cursor repeat
+ }
+
+ if (charRowFocused)
+ {
+ mResultList.setSelectorColor(0x00000000);
+ mResultList.setSelectorColorEnd(0x00000000);
+ }
+ else
+ {
+ mResultList.setSelectorColor(mResultListSelectorColor);
+ mResultList.setSelectorColorEnd(mResultListSelectorColorEnd);
+ }
+
+ // Refresh the help bar to reflect the current focus state
+ updateHelpPrompts();
+}
+
+void GuiSearchPopup::launch(FileData* game)
+{
+ ViewController::get()->launch(game);
+}
+
+void GuiSearchPopup::update(int deltaTime)
+{
+ // Keyboard held-key repeat (backspace / delete / cursor arrows)
+ if (mKeyRepeatKey != 0 && mFocus == FOCUS_CHAR_ROW)
+ {
+ mKeyRepeatTimer += deltaTime;
+ while (mKeyRepeatTimer >= KEY_REPEAT_DELAY_MS)
+ {
+ mKeyRepeatTimer -= KEY_REPEAT_PERIOD_MS;
+ if (mKeyRepeatKey == SDLK_BACKSPACE)
+ {
+ editBackspace();
+ }
+ else if (mKeyRepeatKey == SDLK_DELETE && mCursorPos < mQuery.size())
+ {
+ size_t next = Utils::String::nextCursor(mQuery, mCursorPos);
+ mQuery.erase(mCursorPos, next - mCursorPos);
+ updateSearchDisplay();
+ startSearch(mQuery);
+ }
+ else if (mKeyRepeatKey == SDLK_LEFT) editCursorLeft();
+ else if (mKeyRepeatKey == SDLK_RIGHT) editCursorRight();
+ }
+ }
+
+ // Gamepad shoulder button cursor repeat
+ if (mShoulderRepeatDir != 0 && mFocus == FOCUS_CHAR_ROW)
+ {
+ mShoulderRepeatTimer += deltaTime;
+ while (mShoulderRepeatTimer >= KEY_REPEAT_DELAY_MS)
+ {
+ mShoulderRepeatTimer -= KEY_REPEAT_PERIOD_MS;
+ if (mShoulderRepeatDir < 0) editCursorLeft();
+ else editCursorRight();
+ }
+ }
+
+ if (mResultsReady.load())
+ {
+ std::vector results;
+ {
+ std::lock_guard lock(mResultMutex);
+ results = std::move(mPendingResults);
+ }
+ mResultsReady.store(false);
+ populateResultsList(results);
+ updateInfoPanel();
+ }
+
+ mVideo->update(deltaTime);
+ GuiComponent::update(deltaTime);
+}
+
+bool GuiSearchPopup::input(InputConfig* config, Input input)
+{
+ const bool isKeyboard = (config->getDeviceId() == DEVICE_KEYBOARD);
+
+ // Refresh help bar whenever the active input device type changes
+ if (isKeyboard != mLastInputWasKeyboard)
+ {
+ mLastInputWasKeyboard = isKeyboard;
+ updateHelpPrompts();
+ }
+
+ if (input.value != 0)
+ {
+ // ── Keyboard in char row: intercept all keys to avoid button-map conflicts ─
+ // In the result list, keyboard uses the normal button map (same as gamepad).
+ if (isKeyboard && mFocus == FOCUS_CHAR_ROW)
+ {
+ if (input.id == SDLK_ESCAPE)
+ {
+ cancelSearch();
+ delete this;
+ return true;
+ }
+ if (input.id == SDLK_DOWN && !mCurrentResults.empty())
+ {
+ mFocus = FOCUS_RESULT_LIST;
+ mResultList.setCursorIndex(0);
+ updateFocusVisuals();
+ return true;
+ }
+ if (input.id == SDLK_SPACE)
+ {
+ mQuery.insert(mCursorPos, " ");
+ mCursorPos++;
+ updateSearchDisplay();
+ startSearch(mQuery);
+ return true;
+ }
+ if (input.id == SDLK_BACKSPACE)
+ {
+ mKeyRepeatKey = SDLK_BACKSPACE; mKeyRepeatTimer = 0;
+ editBackspace();
+ return true;
+ }
+ if (input.id == SDLK_LEFT) { mKeyRepeatKey = SDLK_LEFT; mKeyRepeatTimer = 0; editCursorLeft(); return true; }
+ if (input.id == SDLK_RIGHT) { mKeyRepeatKey = SDLK_RIGHT; mKeyRepeatTimer = 0; editCursorRight(); return true; }
+ if (input.id == SDLK_DELETE)
+ {
+ mKeyRepeatKey = SDLK_DELETE; mKeyRepeatTimer = 0;
+ if (mCursorPos < mQuery.size())
+ {
+ size_t next = Utils::String::nextCursor(mQuery, mCursorPos);
+ mQuery.erase(mCursorPos, next - mCursorPos);
+ }
+ updateSearchDisplay();
+ startSearch(mQuery);
+ return true;
+ }
+ if (input.id == SDLK_HOME) { mCursorPos = 0; updateSearchDisplay(); return true; }
+ if (input.id == SDLK_END) { mCursorPos = mQuery.size(); updateSearchDisplay(); return true; }
+ // All other keys: let SDL_TEXTINPUT handle printable chars
+ return false;
+ }
+
+ // ── Button-mapped handling (gamepad + keyboard in result list) ────────────
+
+ // Close on B (or Escape on keyboard — Escape isn't in the button map)
+ if (config->isMappedTo("b", input) || (isKeyboard && input.id == SDLK_ESCAPE))
+ {
+ cancelSearch();
+ delete this;
+ return true;
+ }
+
+ // RT: move focus back to char row
+ if (config->isMappedTo("righttrigger", input))
+ {
+ if (mFocus == FOCUS_RESULT_LIST)
+ {
+ mResultList.stopScrolling(true);
+ mFocus = FOCUS_CHAR_ROW;
+ updateFocusVisuals();
+ SDL_FlushEvent(SDL_TEXTINPUT); // discard text from the key that triggered this
+ }
+ return true;
+ }
+
+ // Main menu
+ if (config->isMappedTo("start", input) &&
+ !(UIModeController::getInstance()->isUIModeKid() &&
+ Settings::getInstance()->getBool("DisableKidStartMenu")))
+ {
+ mWindow->pushGui(new GuiMenu(mWindow));
+ return true;
+ }
+
+ // Result list actions
+ if (mFocus == FOCUS_RESULT_LIST)
+ {
+ FileData* selected = (mResultList.size() > 0) ? mResultList.getSelected() : nullptr;
+ bool hasGame = selected && selected->getType() == GAME;
+
+ if (config->isMappedTo("y", input) && !UIModeController::getInstance()->isUIModeKid())
+ {
+ if (hasGame)
+ CollectionSystemManager::get()->toggleGameInCollection(selected);
+ return true;
+ }
+ if (config->isMappedTo("x", input))
+ {
+ if (mResultList.size() > 1)
+ {
+ int idx = std::rand() % mResultList.size();
+ mResultList.setCursorIndex(idx);
+ }
+ return true;
+ }
+ if (config->isMappedTo("select", input) && !UIModeController::getInstance()->isUIModeKid())
+ {
+ SystemData* sys = hasGame ? selected->getSystem() : mScope;
+ if (!sys && !SystemData::sSystemVector.empty())
+ sys = SystemData::sSystemVector.front();
+ if (sys)
+ {
+ auto jumpFiles = mCurrentResults;
+ mWindow->pushGui(new GuiGamelistOptions(mWindow, sys, jumpFiles,
+ [this](int idx) { mResultList.setCursorIndex(idx); }));
+ }
+ return true;
+ }
+ }
+
+ if (mFocus == FOCUS_CHAR_ROW)
+ {
+ // Gamepad only here — keyboard char row is handled above
+ if (config->isMappedLike("leftshoulder", input)) { mShoulderRepeatDir = -1; mShoulderRepeatTimer = 0; editCursorLeft(); return true; }
+ if (config->isMappedLike("rightshoulder", input)) { mShoulderRepeatDir = 1; mShoulderRepeatTimer = 0; editCursorRight(); return true; }
+ if (config->isMappedLike("down", input) && !mCurrentResults.empty())
+ {
+ mFocus = FOCUS_RESULT_LIST;
+ updateFocusVisuals();
+ return true;
+ }
+ if (mCharRow.input(config, input))
+ return true;
+ }
+ else // FOCUS_RESULT_LIST
+ {
+ if (config->isMappedLike("up", input))
+ {
+ if (mResultList.getCursorIndex() == 0)
+ {
+ mResultList.stopScrolling(true);
+ mFocus = FOCUS_CHAR_ROW;
+ updateFocusVisuals();
+ return true;
+ }
+ mResultList.input(config, input);
+ return true;
+ }
+ if (config->isMappedLike("down", input))
+ {
+ if (mResultList.size() == 0 ||
+ mResultList.getCursorIndex() == (int)mResultList.size() - 1)
+ {
+ mResultList.stopScrolling(true);
+ mFocus = FOCUS_CHAR_ROW;
+ updateFocusVisuals();
+ return true;
+ }
+ mResultList.input(config, input);
+ return true;
+ }
+ if (config->isMappedTo("a", input))
+ {
+ FileData* cursor = mResultList.size() > 0 ? mResultList.getSelected() : nullptr;
+ if (cursor && cursor->getType() == GAME)
+ launch(cursor);
+ return true;
+ }
+ if (mResultList.input(config, input))
+ return true;
+ }
+ }
+ else // value == 0 (release)
+ {
+ if (isKeyboard && mFocus == FOCUS_CHAR_ROW)
+ {
+ if ((int)input.id == mKeyRepeatKey)
+ mKeyRepeatKey = 0;
+ }
+ else if (mFocus == FOCUS_RESULT_LIST)
+ mResultList.input(config, input);
+ else if (mFocus == FOCUS_CHAR_ROW)
+ {
+ if (mShoulderRepeatDir != 0 &&
+ (config->isMappedLike("leftshoulder", input) || config->isMappedLike("rightshoulder", input)))
+ mShoulderRepeatDir = 0;
+ mCharRow.input(config, input); // stops gamepad scroll/backspace repeat
+ }
+ }
+
+ return GuiComponent::input(config, input);
+}
+
+void GuiSearchPopup::textInput(const char* text)
+{
+ // Reject control characters and space (space is handled via SDLK_SPACE in input())
+ if ((unsigned char)text[0] <= 0x20)
+ return;
+
+ if (mFocus != FOCUS_CHAR_ROW)
+ return;
+
+ mQuery.insert(mCursorPos, text);
+ mCursorPos += strlen(text);
+ updateSearchDisplay();
+ startSearch(mQuery);
+}
+
+void GuiSearchPopup::render(const Transform4x4f& parentTrans)
+{
+ Transform4x4f trans = parentTrans * getTransform();
+
+ // Dark background panel
+ Renderer::setMatrix(trans);
+ Renderer::drawRect(0.0f, 0.0f, mSize.x(), mSize.y(), 0x000000E0, 0x000000E0);
+
+ renderChildren(trans);
+}
+
+std::vector GuiSearchPopup::getHelpPrompts()
+{
+ std::vector prompts;
+
+ if (mLastInputWasKeyboard && mFocus == FOCUS_CHAR_ROW)
+ {
+ // Keyboard char row: show keyboard-native hints
+ prompts.push_back(HelpPrompt("up/down", "results"));
+ prompts.push_back(HelpPrompt("esc", "close"));
+ }
+ else
+ {
+ // Gamepad, or keyboard in result list (uses button map)
+ if (mFocus == FOCUS_CHAR_ROW)
+ {
+ prompts.push_back(HelpPrompt("lr", "cursor"));
+ prompts.push_back(HelpPrompt("left/right", "choose"));
+ prompts.push_back(HelpPrompt("a", "type"));
+ prompts.push_back(HelpPrompt("x", "backspace"));
+ prompts.push_back(HelpPrompt("up/down", "results"));
+ }
+ else
+ {
+ prompts.push_back(HelpPrompt("up/down", "choose"));
+ prompts.push_back(HelpPrompt("lr", "page"));
+ prompts.push_back(HelpPrompt("a", "launch"));
+ prompts.push_back(HelpPrompt("x", "random"));
+ if (!UIModeController::getInstance()->isUIModeKid())
+ {
+ prompts.push_back(HelpPrompt("y", "favorite"));
+ prompts.push_back(HelpPrompt("select", "options"));
+ }
+ }
+ prompts.push_back(HelpPrompt("b", "close"));
+ if (!UIModeController::getInstance()->isUIModeKid())
+ prompts.push_back(HelpPrompt("start", "menu"));
+ if (mFocus == FOCUS_RESULT_LIST)
+ prompts.push_back(HelpPrompt("rt", "keyboard"));
+ }
+
+ return prompts;
+}
diff --git a/es-app/src/guis/GuiSearchPopup.h b/es-app/src/guis/GuiSearchPopup.h
new file mode 100644
index 0000000000..834c35ef61
--- /dev/null
+++ b/es-app/src/guis/GuiSearchPopup.h
@@ -0,0 +1,121 @@
+#pragma once
+#ifndef ES_APP_GUIS_GUI_SEARCH_POPUP_H
+#define ES_APP_GUIS_GUI_SEARCH_POPUP_H
+
+#include "components/CharacterRowComponent.h"
+#include "components/DateTimeComponent.h"
+#include "components/ImageComponent.h"
+#include "components/ScrollableContainer.h"
+#include "components/TextComponent.h"
+#include "components/TextListComponent.h"
+#include "components/RatingComponent.h"
+#include "components/VideoComponent.h"
+#include "GuiComponent.h"
+#include
+#include
+#include
+#include
+#include
+
+class FileData;
+class SystemData;
+
+class GuiSearchPopup : public GuiComponent
+{
+public:
+ // scope == nullptr → search all systems
+ // scope != nullptr → search within that system only
+ GuiSearchPopup(Window* window, SystemData* scope);
+ ~GuiSearchPopup();
+
+ bool input(InputConfig* config, Input input) override;
+ void update(int deltaTime) override;
+ void render(const Transform4x4f& parentTrans) override;
+ void textInput(const char* text) override;
+ std::vector getHelpPrompts() override;
+
+private:
+ void buildGameCache();
+ void updateSearchDisplay();
+ void updateInfoPanel();
+ void applyTheme(SystemData* sys);
+ void startSearch(const std::string& query);
+ void cancelSearch();
+ void populateResultsList(const std::vector& results);
+ void addPlaceholder(const std::string& text);
+ void updateFocusVisuals();
+ void launch(FileData* game);
+
+ // Edit operations — shared by char row callbacks and physical keyboard handlers
+ void editBackspace();
+ void editCursorLeft();
+ void editCursorRight();
+
+ SystemData* mScope; // nullptr = all systems
+ SystemData* mThemeSystem; // system whose theme is currently applied
+
+ void clearInfoPanel();
+
+ // Search input
+ TextComponent mSearchText;
+ CharacterRowComponent mCharRow;
+ TextListComponent mResultList;
+ TextComponent mListMessage;
+
+ // Metadata display (right panel)
+ ImageComponent mImage;
+ ImageComponent mThumbnail;
+ ScrollableContainer mDescContainer;
+ TextComponent mDescription;
+ RatingComponent mRating;
+ TextComponent mDeveloper;
+ TextComponent mPublisher;
+ TextComponent mGenre;
+ TextComponent mPlayers;
+ TextComponent mLblRating;
+ TextComponent mLblDeveloper;
+ TextComponent mLblPublisher;
+ TextComponent mLblGenre;
+ TextComponent mLblPlayers;
+
+ // Extended metadata (theme-driven, off-screen by default)
+ VideoComponent* mVideo;
+ ImageComponent mMarquee;
+ TextComponent mName;
+ DateTimeComponent mReleaseDate;
+ DateTimeComponent mLastPlayed;
+ TextComponent mPlayCount;
+ TextComponent mLblReleaseDate;
+ TextComponent mLblLastPlayed;
+ TextComponent mLblPlayCount;
+ ImageComponent mBackground;
+
+ // Search state
+ std::string mQuery;
+ size_t mCursorPos;
+ std::vector mAllGames;
+ std::vector mLowerNames;
+ std::vector mCurrentResults; // last populated result set (for jump-to)
+
+ // Threading
+ std::thread mSearchThread;
+ std::atomic mCancelFlag;
+ std::mutex mResultMutex;
+ std::vector mPendingResults;
+ std::atomic mResultsReady;
+
+ // Focus
+ enum FocusTarget { FOCUS_CHAR_ROW, FOCUS_RESULT_LIST };
+ FocusTarget mFocus;
+ bool mLastInputWasKeyboard;
+ int mKeyRepeatKey; // SDLK key held for repeat (backspace/delete/arrows), or 0
+ int mKeyRepeatTimer;
+ int mShoulderRepeatDir; // gamepad shoulder cursor repeat: -1 / 0 / +1
+ int mShoulderRepeatTimer;
+ static const int KEY_REPEAT_DELAY_MS = 500;
+ static const int KEY_REPEAT_PERIOD_MS = 80;
+ unsigned int mResultListSelectorColor;
+ unsigned int mResultListSelectorColorEnd;
+};
+
+#endif // ES_APP_GUIS_GUI_SEARCH_POPUP_H
diff --git a/es-app/src/views/SystemView.cpp b/es-app/src/views/SystemView.cpp
index 03b56bb35a..3b1aebb1de 100644
--- a/es-app/src/views/SystemView.cpp
+++ b/es-app/src/views/SystemView.cpp
@@ -2,6 +2,7 @@
#include "animations/LambdaAnimation.h"
#include "guis/GuiMsgBox.h"
+#include "guis/GuiSearchPopup.h"
#include "views/UIModeController.h"
#include "views/ViewController.h"
#include "Log.h"
@@ -190,6 +191,11 @@ bool SystemView::input(InputConfig* config, Input input)
setCursor(SystemData::getRandomSystem());
return true;
}
+ if (config->isMappedTo("righttrigger", input))
+ {
+ mWindow->pushGui(new GuiSearchPopup(mWindow, nullptr));
+ return true;
+ }
}else{
if(config->isMappedLike("left", input) ||
config->isMappedLike("right", input) ||
@@ -382,6 +388,7 @@ std::vector SystemView::getHelpPrompts()
prompts.push_back(HelpPrompt("left/right", "choose"));
prompts.push_back(HelpPrompt("a", "select"));
prompts.push_back(HelpPrompt("x", "random"));
+ prompts.push_back(HelpPrompt("rt", "search"));
if (!UIModeController::getInstance()->isUIModeKid() && Settings::getInstance()->getBool("ScreenSaverControls"))
prompts.push_back(HelpPrompt("select", "launch screensaver"));
diff --git a/es-app/src/views/ViewController.cpp b/es-app/src/views/ViewController.cpp
index 3d5079acec..1099692f60 100644
--- a/es-app/src/views/ViewController.cpp
+++ b/es-app/src/views/ViewController.cpp
@@ -359,45 +359,47 @@ std::shared_ptr ViewController::getGameListView(SystemData* syste
//if we didn't, make it, remember it, and return it
std::shared_ptr view;
- bool themeHasVideoView = system->getTheme()->hasView("video");
+ {
+ bool themeHasVideoView = system->getTheme()->hasView("video");
- //decide type
- GameListViewType selectedViewType = getGameListViewType();
+ //decide type
+ GameListViewType selectedViewType = getGameListViewType();
- if (selectedViewType == AUTOMATIC)
- {
- std::vector files = system->getRootFolder()->getFilesRecursive(GAME | FOLDER);
- for (auto it = files.cbegin(); it != files.cend(); it++)
+ if (selectedViewType == AUTOMATIC)
{
- if (themeHasVideoView && !(*it)->getVideoPath().empty())
- {
- selectedViewType = VIDEO;
- break;
- }
- else if (!(*it)->getThumbnailPath().empty())
+ std::vector files = system->getRootFolder()->getFilesRecursive(GAME | FOLDER);
+ for (auto it = files.cbegin(); it != files.cend(); it++)
{
- selectedViewType = DETAILED;
- // Don't break out in case any subsequent files have video
+ if (themeHasVideoView && !(*it)->getVideoPath().empty())
+ {
+ selectedViewType = VIDEO;
+ break;
+ }
+ else if (!(*it)->getThumbnailPath().empty())
+ {
+ selectedViewType = DETAILED;
+ // Don't break out in case any subsequent files have video
+ }
}
}
- }
- // Create the view
- switch (selectedViewType)
- {
- case VIDEO:
- view = std::shared_ptr(new VideoGameListView(mWindow, system->getRootFolder()));
- break;
- case DETAILED:
- view = std::shared_ptr(new DetailedGameListView(mWindow, system->getRootFolder()));
- break;
- case GRID:
- view = std::shared_ptr(new GridGameListView(mWindow, system->getRootFolder()));
- break;
- case BASIC:
- default:
- view = std::shared_ptr(new BasicGameListView(mWindow, system->getRootFolder()));
- break;
+ // Create the view
+ switch (selectedViewType)
+ {
+ case VIDEO:
+ view = std::shared_ptr(new VideoGameListView(mWindow, system->getRootFolder()));
+ break;
+ case DETAILED:
+ view = std::shared_ptr(new DetailedGameListView(mWindow, system->getRootFolder()));
+ break;
+ case GRID:
+ view = std::shared_ptr(new GridGameListView(mWindow, system->getRootFolder()));
+ break;
+ case BASIC:
+ default:
+ view = std::shared_ptr(new BasicGameListView(mWindow, system->getRootFolder()));
+ break;
+ }
}
view->setTheme(system->getTheme());
diff --git a/es-app/src/views/gamelist/BasicGameListView.cpp b/es-app/src/views/gamelist/BasicGameListView.cpp
index 605c8bad2b..43ef339868 100644
--- a/es-app/src/views/gamelist/BasicGameListView.cpp
+++ b/es-app/src/views/gamelist/BasicGameListView.cpp
@@ -213,6 +213,7 @@ std::vector BasicGameListView::getHelpPrompts()
if(Settings::getInstance()->getBool("QuickSystemSelect"))
prompts.push_back(HelpPrompt("left/right", "system"));
prompts.push_back(HelpPrompt("up/down", "choose"));
+ prompts.push_back(HelpPrompt("lr", "page"));
prompts.push_back(HelpPrompt("a", "launch"));
prompts.push_back(HelpPrompt("b", "back"));
if(!UIModeController::getInstance()->isUIModeKid())
diff --git a/es-app/src/views/gamelist/ISimpleGameListView.cpp b/es-app/src/views/gamelist/ISimpleGameListView.cpp
index c81ff0502f..996e8621b8 100644
--- a/es-app/src/views/gamelist/ISimpleGameListView.cpp
+++ b/es-app/src/views/gamelist/ISimpleGameListView.cpp
@@ -1,6 +1,10 @@
#include "views/gamelist/ISimpleGameListView.h"
+#include "components/ImageComponent.h"
+#include "components/TextComponent.h"
+#include "guis/GuiSearchPopup.h"
#include "views/UIModeController.h"
+#include "Window.h"
#include "views/ViewController.h"
#include "CollectionSystemManager.h"
#include "Scripting.h"
@@ -156,6 +160,10 @@ bool ISimpleGameListView::input(InputConfig* config, Input input)
return true;
}
}
+ }else if (config->isMappedTo("righttrigger", input) && !UIModeController::getInstance()->isUIModeKid())
+ {
+ mWindow->pushGui(new GuiSearchPopup(mWindow, mRoot->getSystem()));
+ return true;
}
}
diff --git a/es-core/src/ThemeData.cpp b/es-core/src/ThemeData.cpp
index 4f299adce8..ef00adc984 100644
--- a/es-core/src/ThemeData.cpp
+++ b/es-core/src/ThemeData.cpp
@@ -10,7 +10,7 @@
#include
#include
-std::vector ThemeData::sSupportedViews { { "system" }, { "basic" }, { "detailed" }, { "grid" }, { "video" } };
+std::vector ThemeData::sSupportedViews { { "system" }, { "basic" }, { "detailed" }, { "grid" }, { "video" }, { "search" } };
std::vector ThemeData::sSupportedFeatures { { "video" }, { "carousel" }, { "z-index" }, { "visible" } };
std::map> ThemeData::sElementMap {
diff --git a/es-core/src/Window.cpp b/es-core/src/Window.cpp
index 42c9bfa746..ccbbcc8ab5 100644
--- a/es-core/src/Window.cpp
+++ b/es-core/src/Window.cpp
@@ -219,6 +219,9 @@ void Window::render()
bottom->render(transform);
if(bottom != top)
{
+ // Render any intermediate GUIs (e.g. a search popup behind a menu)
+ for(auto it = mGuiStack.begin() + 1; it != mGuiStack.end() - 1; ++it)
+ (*it)->render(transform);
mBackgroundOverlay->render(transform);
top->render(transform);
}
diff --git a/es-core/src/components/HelpComponent.cpp b/es-core/src/components/HelpComponent.cpp
index 1d7ef55291..e2fb0a9de8 100644
--- a/es-core/src/components/HelpComponent.cpp
+++ b/es-core/src/components/HelpComponent.cpp
@@ -24,9 +24,12 @@ static const std::map ICON_PATH_MAP {
{ "y", ":/help/button_y.svg" },
{ "l", ":/help/button_l.svg" },
{ "r", ":/help/button_r.svg" },
+ { "lt", ":/help/button_lt.svg" },
+ { "rt", ":/help/button_rt.svg" },
{ "lr", ":/help/button_lr.svg" },
{ "start", ":/help/button_start.svg" },
- { "select", ":/help/button_select.svg" }
+ { "select", ":/help/button_select.svg" },
+ { "esc", ":/help/button_esc_key.svg" }
};
HelpComponent::HelpComponent(Window* window) : GuiComponent(window)
@@ -108,10 +111,7 @@ std::shared_ptr HelpComponent::getIconTexture(const char* name)
auto pathLookup = ICON_PATH_MAP.find(name);
if(pathLookup == ICON_PATH_MAP.cend())
- {
- LOG(LogError) << "Unknown help icon \"" << name << "\"!";
- return nullptr;
- }
+ return nullptr; // unknown name: text-only prompt, no icon
if(!ResourceManager::getInstance()->fileExists(pathLookup->second))
{
LOG(LogError) << "Help icon \"" << name << "\" - corresponding image file \"" << pathLookup->second << "\" misisng!";
diff --git a/es-core/src/components/IList.h b/es-core/src/components/IList.h
index 67f0ee22f9..404f81bf2f 100644
--- a/es-core/src/components/IList.h
+++ b/es-core/src/components/IList.h
@@ -172,6 +172,20 @@ class IList : public GuiComponent
return mViewportTop;
}
+ int getCursorIndex() const
+ {
+ return mCursor;
+ }
+
+ void setCursorIndex(int index)
+ {
+ if (index >= 0 && index < (int)mEntries.size())
+ {
+ mCursor = index;
+ onCursorChanged(CURSOR_STOPPED);
+ }
+ }
+
// entry management
void add(const Entry& e)
{
diff --git a/resources/help/button_esc_key.svg b/resources/help/button_esc_key.svg
new file mode 100644
index 0000000000..8d285d4d35
--- /dev/null
+++ b/resources/help/button_esc_key.svg
@@ -0,0 +1,10 @@
+
+
\ No newline at end of file