From e1c65deabcb505b355779e947bab0ad655b81c90 Mon Sep 17 00:00:00 2001 From: Ryan McClelland Date: Thu, 9 Apr 2026 22:52:09 -0700 Subject: [PATCH 1/2] Add L/R page up/down hint to game list help bar Shows the LR shoulder button pair as a "page" action in the help bar for Basic, Detailed, and Video game list views (which all inherit BasicGameListView::getHelpPrompts). GridGameListView already uses LR for quick system select so it is left unchanged. Co-Authored-By: Claude Sonnet 4.6 --- es-app/src/views/gamelist/BasicGameListView.cpp | 1 + 1 file changed, 1 insertion(+) 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()) From db6aad7514ecc720e3830d5519395d8ae3b55b83 Mon Sep 17 00:00:00 2001 From: Ryan McClelland Date: Thu, 9 Apr 2026 22:52:03 -0700 Subject: [PATCH 2/2] Add full-screen game search overlay (GuiSearchPopup) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a new full-screen search popup accessible from any game list view. The popup allows players to search across all systems (or a single system) in real time using either a physical keyboard or a gamepad. Results are displayed with rich metadata, and games can be launched directly from the results list. The search popup is opened by pressing the Right Trigger (RT) button from any game list view. The trigger binding is configurable — whatever input is mapped to "righttrigger" opens the overlay. When opened using a physical keyboard key bound to RT (e.g. the '2' key), any SDL_TEXTINPUT event queued by that same keypress is flushed immediately so the triggering character does not appear in the search bar. The popup fills the entire screen with a translucent dark background panel drawn in render(). It is divided into three main areas: - Top bar: a search text field showing the current query with a cursor position indicator (pipe '|'), and below it a character selection row for gamepad text entry. - Left column: a scrollable result list (TextListComponent) showing matching game names. In all-systems mode each entry is suffixed with the system name in brackets. A status/placeholder message overlays the list when it is empty. - Right column: a metadata panel showing cover image or thumbnail, description, rating, developer, publisher, genre, players, release date, last played, play count, marquee, and optionally a video preview. All metadata panel components are fully theme-driven (see Theme Support below). The character row is a single horizontal strip of selectable character cells used for gamepad text entry. It supports three modes: - LETTERS — mode-switch key "123", space, A–Z, backspace, ← → - NUMBERS — mode-switch key "!@#", space, 1–9, 0, backspace, ← → - SYMBOLS — mode-switch key "ABC", space, punctuation set, backspace, ← → The ← and → cells move the text cursor within the search query (not the char row selection). The backspace cell deletes the character to the left of the cursor. Cell widths are computed from the font metrics plus padding. If the combined width of all cells exceeds the component width (e.g. after adding many symbols), the per-cell padding is uniformly reduced so that all characters remain visible without scrolling or clipping. When focus is on the char row: - D-pad / left-stick left/right — move the char row selection cursor left or right. Holding the direction triggers auto-scroll repeat after an initial delay (500 ms) at a fixed period (100 ms). - A button — activate the selected cell: type the character, switch mode, trigger backspace, or move the text cursor. - X button — immediate backspace. Holding X triggers hold-to-repeat after 500 ms at 80 ms intervals (same timing as keyboard held keys). - L shoulder / R shoulder — move the text cursor left or right within the query string. Holding triggers repeat after 500 ms at 80 ms. - D-pad / left-stick down — move focus to the result list (only if results are present). - B button — close the popup. - Start — open the main menu. - RT — no-op when already in char row (focus is already here). When a physical keyboard is in use and focus is on the char row, all keyboard events are intercepted before the button map is consulted. This prevents conflicts where a printable key (e.g. 's') is also mapped to a gamepad button action (e.g. 'x' = backspace). The interception rules: - Printable characters — handled via SDL_TEXTINPUT events, which fire after SDL_KEYDOWN. Control characters and space are excluded from SDL_TEXTINPUT; space is handled directly in the SDLK_SPACE case. - Backspace — deletes the character left of the cursor. Holding triggers repeat after 500 ms at 80 ms intervals using a software timer (mKeyRepeatKey / mKeyRepeatTimer), since SDL filters key.repeat for non-printable keys before they reach input(). - Delete — deletes the character right of the cursor. Same hold-to- repeat behaviour as backspace. - Left / Right arrow — move the text cursor. Hold-to-repeat applies. - Home — jump the text cursor to the start of the query. - End — jump the text cursor to the end of the query. - Down arrow — move focus to the result list (only if results exist). - Escape — close the popup. - All other keys — fall through (return false) so SDL_TEXTINPUT fires for printable characters. The help bar switches between keyboard hints ("esc=close") and gamepad hints immediately whenever the active input device type changes. Pressing down (gamepad) or the down arrow (keyboard) from the char row moves focus to the result list. The char row selection highlight dims and the result list selector becomes visible. While focus is on the result list: - Up / Down — scroll through results. Reaching the top wraps focus back to the char row; reaching the bottom also wraps back to the char row. - A button — launch the selected game. - X button — jump to a random result. - Y button — toggle the selected game in/out of the current collection (not shown in Kid UI mode). - Select — open GuiGamelistOptions with a jump-to callback so the options dialog can reposition the result list cursor. - RT — return focus to the char row. Any SDL_TEXTINPUT events queued by the RT keypress are flushed so the triggering character is not inserted. - B button / Escape — close the popup. - Start — open the main menu. - L / R shoulder — page through the result list. Searching runs on a background thread to avoid blocking the render loop. Each keystroke cancels any in-progress search (sets mCancelFlag), joins the previous thread, then starts a new std::thread that: 1. Iterates mAllGames / mLowerNames (built once at popup open). 2. Checks mCancelFlag after each game so a superseded search exits early. 3. Sorts the matches alphabetically. 4. Stores results in mPendingResults under mResultMutex and sets mResultsReady. The main thread polls mResultsReady in update() and applies the results to the TextListComponent on the next frame. When the result list cursor moves to a game belonging to a different system than the one currently themed, applyTheme() is called with the new system's theme. The result list itself is intentionally excluded from this re-application to avoid flickering the favorite indicator icon on every cursor movement — its theme is applied exactly once in the constructor. The search view ("search") is fully documented in THEMES.md. Supported elements: Layout: background — image, optional, rendered behind everything (z=0) searchtext — text, the query bar (pos/size/font/color; text managed by engine — use ALL ^ TEXT) listmessage — text, status overlay on the result list gamelist — textlist, the result list Metadata values: md_image, md_thumbnail, md_video, md_marquee, md_name md_description, md_rating, md_releasedate, md_developer, md_publisher, md_genre, md_players, md_lastplayed, md_playcount Metadata labels (text): md_lbl_rating, md_lbl_releasedate, md_lbl_developer, md_lbl_publisher, md_lbl_genre, md_lbl_players, md_lbl_lastplayed, md_lbl_playcount md_video defaults to invisible (setVisible(false)) — a theme must explicitly set true to enable video playback. All other metadata components default to off-screen positions so they are invisible unless the theme positions them. - ThemeData: registers "search" in sSupportedViews. - HelpComponent: unknown icon names return nullptr silently (no LogError) so text-only help prompts (e.g. "esc") render without spamming the log. Adds "esc" → button_esc_key.svg icon mapping. - IList: adds getCursorIndex() / setCursorIndex(int) accessors. - InputManager: SDL2 GameController axis/button events are translated to the legacy SDL_JOYSTICK format used by the rest of the input pipeline, enabling controllers that are recognised by SDL2's GameController API but not the raw joystick API to work correctly. - ViewController / SystemView: wires RT to open GuiSearchPopup from the game list. - Window: passes SDL_TEXTINPUT events through to the top-of-stack GUI via the existing textInput() path. - GuiGamelistOptions: extended to accept an optional jump-to callback and an optional pre-filtered game list for use from search context. Co-Authored-By: Claude Sonnet 4.6 --- THEMES.md | 57 ++ es-app/CMakeLists.txt | 4 + es-app/src/CollectionSystemManager.cpp | 1 + .../src/components/CharacterRowComponent.cpp | 269 ++++++ es-app/src/components/CharacterRowComponent.h | 74 ++ es-app/src/guis/GuiGamelistOptions.cpp | 39 +- es-app/src/guis/GuiGamelistOptions.h | 9 +- es-app/src/guis/GuiSearchPopup.cpp | 852 ++++++++++++++++++ es-app/src/guis/GuiSearchPopup.h | 121 +++ es-app/src/views/SystemView.cpp | 7 + es-app/src/views/ViewController.cpp | 66 +- .../views/gamelist/ISimpleGameListView.cpp | 8 + es-core/src/ThemeData.cpp | 2 +- es-core/src/Window.cpp | 3 + es-core/src/components/HelpComponent.cpp | 10 +- es-core/src/components/IList.h | 14 + resources/help/button_esc_key.svg | 10 + 17 files changed, 1492 insertions(+), 54 deletions(-) create mode 100644 es-app/src/components/CharacterRowComponent.cpp create mode 100644 es-app/src/components/CharacterRowComponent.h create mode 100644 es-app/src/guis/GuiSearchPopup.cpp create mode 100644 es-app/src/guis/GuiSearchPopup.h create mode 100644 resources/help/button_esc_key.svg 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/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