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