From 26cfc45cc03dac32034591d3a698710f9fba36c4 Mon Sep 17 00:00:00 2001 From: Ryan McClelland Date: Fri, 20 Feb 2026 23:59:38 -0800 Subject: [PATCH 01/19] low level work here --- es-app/src/FileData.cpp | 100 ++++++++++++++++-- es-app/src/FileData.h | 35 ++++++- es-app/src/Gamelist.cpp | 223 ++++++++++++++++++++++++++++++++++++++-- 3 files changed, 339 insertions(+), 19 deletions(-) diff --git a/es-app/src/FileData.cpp b/es-app/src/FileData.cpp index 1aa4f0f076..8632ad7879 100644 --- a/es-app/src/FileData.cpp +++ b/es-app/src/FileData.cpp @@ -24,6 +24,13 @@ FileData::FileData(FileType type, const std::string& path, SystemEnvironmentData if(metadata.get("name").empty()) metadata.set("name", getDisplayName()); mSystemName = system->getName(); + if(mType == GAME) + { + RomData rom; + rom.path = path; + rom.preferred = true; + mRoms.push_back(rom); + } metadata.resetChangedFlag(); } @@ -54,12 +61,22 @@ std::string FileData::getCleanName() const const std::string FileData::getThumbnailPath() const { - std::string thumbnail = metadata.get("thumbnail"); + return getEffectiveThumbnailPath(getPreferredRom()); +} + +const std::string FileData::getEffectiveThumbnailPath(const RomData* rom) const +{ + std::string thumbnail; + if(rom) + thumbnail = rom->thumbnail; + + if(thumbnail.empty()) + thumbnail = metadata.get("thumbnail"); // no thumbnail, try image if(thumbnail.empty()) { - thumbnail = metadata.get("image"); + thumbnail = getEffectiveImagePath(rom); // no image, try to use local image if(thumbnail.empty() && Settings::getInstance()->getBool("LocalArt")) @@ -115,7 +132,17 @@ const std::vector& FileData::getChildrenListToDisplay() { const std::string FileData::getVideoPath() const { - std::string video = metadata.get("video"); + return getEffectiveVideoPath(getPreferredRom()); +} + +const std::string FileData::getEffectiveVideoPath(const RomData* rom) const +{ + std::string video; + if(rom) + video = rom->video; + + if(video.empty()) + video = metadata.get("video"); // no video, try to use local video if(video.empty() && Settings::getInstance()->getBool("LocalArt")) @@ -130,7 +157,17 @@ const std::string FileData::getVideoPath() const const std::string FileData::getMarqueePath() const { - std::string marquee = metadata.get("marquee"); + return getEffectiveMarqueePath(getPreferredRom()); +} + +const std::string FileData::getEffectiveMarqueePath(const RomData* rom) const +{ + std::string marquee; + if(rom) + marquee = rom->marquee; + + if(marquee.empty()) + marquee = metadata.get("marquee"); // no marquee, try to use local marquee if(marquee.empty() && Settings::getInstance()->getBool("LocalArt")) @@ -152,7 +189,17 @@ const std::string FileData::getMarqueePath() const const std::string FileData::getImagePath() const { - std::string image = metadata.get("image"); + return getEffectiveImagePath(getPreferredRom()); +} + +const std::string FileData::getEffectiveImagePath(const RomData* rom) const +{ + std::string image; + if(rom) + image = rom->image; + + if(image.empty()) + image = metadata.get("image"); // no image, try to use local image if(image.empty()) @@ -172,6 +219,40 @@ const std::string FileData::getImagePath() const return image; } +const RomData* FileData::getPreferredRom() const +{ + if(mRoms.empty()) + return NULL; + + for(auto it = mRoms.cbegin(); it != mRoms.cend(); ++it) + { + if(it->preferred) + return &(*it); + } + + return &mRoms.front(); +} + +const RomData* FileData::getRomByPath(const std::string& path) const +{ + for(auto it = mRoms.cbegin(); it != mRoms.cend(); ++it) + { + if(it->path == path) + return &(*it); + } + + return NULL; +} + +const std::string FileData::getLaunchRomPath() const +{ + const RomData* preferred = getPreferredRom(); + if(preferred != NULL && !preferred->path.empty()) + return preferred->path; + + return getPath(); +} + std::vector FileData::getFilesRecursive(unsigned int typeMask, bool displayedOnly) const { std::vector out; @@ -287,9 +368,10 @@ void FileData::launchGame(Window* window) std::string command = mEnvData->mLaunchCommand; - const std::string rom = Utils::FileSystem::getEscapedPath(getPath()); - const std::string basename = Utils::FileSystem::getStem(getPath()); - const std::string rom_raw = Utils::FileSystem::getPreferredPath(getPath()); + const std::string launchPath = getLaunchRomPath(); + const std::string rom = Utils::FileSystem::getEscapedPath(launchPath); + const std::string basename = Utils::FileSystem::getStem(launchPath); + const std::string rom_raw = Utils::FileSystem::getPreferredPath(launchPath); const std::string name = getName(); command = Utils::String::replace(command, "%ROM%", rom); @@ -335,6 +417,7 @@ CollectionFileData::CollectionFileData(FileData* file, SystemData* system) refreshMetadata(); mParent = NULL; metadata = mSourceFileData->metadata; + getRomsMutable() = mSourceFileData->getRoms(); mSystemName = mSourceFileData->getSystem()->getName(); } @@ -358,6 +441,7 @@ FileData* CollectionFileData::getSourceFileData() void CollectionFileData::refreshMetadata() { metadata = mSourceFileData->metadata; + getRomsMutable() = mSourceFileData->getRoms(); mDirty = true; } diff --git a/es-app/src/FileData.h b/es-app/src/FileData.h index e075775445..17bb57cc82 100644 --- a/es-app/src/FileData.h +++ b/es-app/src/FileData.h @@ -10,9 +10,30 @@ class SystemData; class Window; struct SystemEnvironmentData; +struct RomData +{ + std::string path; + std::string romName; + + std::vector regions; + std::vector languages; + + std::string releaseDate; + std::string revision; + + std::string image; + std::string video; + std::string thumbnail; + std::string marquee; + + bool preferred; + + RomData() : preferred(false) {} +}; + enum FileType { - GAME = 1, // Cannot have children. + GAME = 1, // Cannot have FileData children (ROM variants are stored in mRoms, not as FileData nodes). FOLDER = 2, PLACEHOLDER = 3 }; @@ -49,6 +70,17 @@ class FileData virtual const std::string getVideoPath() const; virtual const std::string getMarqueePath() const; virtual const std::string getImagePath() const; + const std::string getEffectiveImagePath(const RomData* rom) const; + const std::string getEffectiveVideoPath(const RomData* rom) const; + const std::string getEffectiveThumbnailPath(const RomData* rom) const; + const std::string getEffectiveMarqueePath(const RomData* rom) const; + + inline const std::vector& getRoms() const { return mRoms; } + // Mutable access for parser/migration code that needs to rebuild ROM variants in-place. + inline std::vector& getRomsMutable() { return mRoms; } + const RomData* getPreferredRom() const; + const RomData* getRomByPath(const std::string& path) const; + const std::string getLaunchRomPath() const; const std::vector& getChildrenListToDisplay(); std::vector getFilesRecursive(unsigned int typeMask, bool displayedOnly = false) const; @@ -104,6 +136,7 @@ class FileData std::unordered_map mChildrenByFilename; std::vector mChildren; std::vector mFilteredChildren; + std::vector mRoms; std::string mSortDesc; }; diff --git a/es-app/src/Gamelist.cpp b/es-app/src/Gamelist.cpp index 51a6d9e2ba..3d28546fe0 100644 --- a/es-app/src/Gamelist.cpp +++ b/es-app/src/Gamelist.cpp @@ -3,6 +3,7 @@ #include #include "utils/FileSystemUtil.h" +#include "utils/StringUtil.h" #include "FileData.h" #include "FileFilterIndex.h" #include "Log.h" @@ -10,6 +11,117 @@ #include "SystemData.h" #include +namespace +{ + // Parses either nested (e.g. en) + // or repeated flat tags (e.g. enfr) + // into a normalized vector. + std::vector parseRomMultiValue(const pugi::xml_node& romNode, const char* singularTag, const char* containerTag) + { + std::vector values; + + pugi::xml_node container = romNode.child(containerTag); + if(container) + { + for(pugi::xml_node node = container.child(singularTag); node; node = node.next_sibling(singularTag)) + { + const std::string value = Utils::String::trim(node.text().get()); + if(!value.empty()) + values.push_back(value); + } + } + + if(values.empty()) + { + for(pugi::xml_node node = romNode.child(singularTag); node; node = node.next_sibling(singularTag)) + { + const std::string value = Utils::String::trim(node.text().get()); + if(!value.empty()) + values.push_back(value); + } + } + + return values; + } + + bool parsePreferredRomAttribute(const pugi::xml_node& romNode) + { + pugi::xml_attribute preferredAttr = romNode.attribute("preferred"); + if(!preferredAttr) + return false; + + std::string val = Utils::String::toLower(Utils::String::trim(preferredAttr.as_string())); + if(val == "true") + return true; + if(val == "false") + return false; + + LOG(LogWarning) << "Invalid preferred value '" << preferredAttr.as_string() << "' on ; expected 'true' or 'false'. Treating as false."; + return false; + } + + RomData parseRomNode(const pugi::xml_node& romNode, const std::string& relativeTo) + { + RomData rom; + rom.path = Utils::FileSystem::resolveRelativePath(romNode.child("path").text().get(), relativeTo, false, true); + rom.romName = romNode.child("romname").text().get(); + rom.regions = parseRomMultiValue(romNode, "region", "regions"); + rom.languages = parseRomMultiValue(romNode, "language", "languages"); + rom.releaseDate = romNode.child("releasedate").text().get(); + rom.revision = romNode.child("revision").text().get(); + rom.image = romNode.child("image").text().get(); + rom.video = romNode.child("video").text().get(); + rom.thumbnail = romNode.child("thumbnail").text().get(); + rom.marquee = romNode.child("marquee").text().get(); + rom.preferred = parsePreferredRomAttribute(romNode); + return rom; + } + + void appendMultiValueNode(pugi::xml_node& parent, const char* containerTag, const char* childTag, const std::vector& values) + { + if(values.empty()) + return; + + pugi::xml_node container = parent.append_child(containerTag); + for(auto it = values.cbegin(); it != values.cend(); ++it) + container.append_child(childTag).text().set(it->c_str()); + } + + void appendOptionalTextNode(pugi::xml_node& parent, const char* tag, const std::string& value) + { + if(!value.empty()) + parent.append_child(tag).text().set(value.c_str()); + } + + std::string getGamePathForNode(const pugi::xml_node& gameNode, const std::string& relativeTo) + { + // New format can omit top-level . In that case we choose the preferred + // ROM path as the canonical lookup key for findOrCreateFile. + std::string path = gameNode.child("path").text().get(); + if(!path.empty()) + return Utils::FileSystem::resolveRelativePath(path, relativeTo, false, true); + + pugi::xml_node romsNode = gameNode.child("roms"); + if(!romsNode) + return ""; + + pugi::xml_node firstRom; + for(pugi::xml_node romNode = romsNode.child("rom"); romNode; romNode = romNode.next_sibling("rom")) + { + if(!firstRom) + firstRom = romNode; + + if(parsePreferredRomAttribute(romNode)) + return Utils::FileSystem::resolveRelativePath(romNode.child("path").text().get(), relativeTo, false, true); + } + + if(firstRom) + return Utils::FileSystem::resolveRelativePath(firstRom.child("path").text().get(), relativeTo, false, true); + + return ""; + } +} + FileData* findOrCreateFile(SystemData* system, const std::string& path, FileType type) { FileData* root = system->getRootFolder(); @@ -138,8 +250,13 @@ void parseGamelist(SystemData* system) FileType type = typeList[i]; for(pugi::xml_node fileNode = root.child(tag); fileNode; fileNode = fileNode.next_sibling(tag)) { - std::string path = fileNode.child("path").text().get(); - path = Utils::FileSystem::resolveRelativePath(path, relativeTo, false, true); + std::string path = (type == GAME) ? getGamePathForNode(fileNode, relativeTo) : Utils::FileSystem::resolveRelativePath(fileNode.child("path").text().get(), relativeTo, false, true); + + if(path.empty()) + { + LOG(LogWarning) << "Missing path for <" << tag << "> entry, skipping."; + continue; + } if(!trustGamelist && !Utils::FileSystem::exists(path)) { @@ -164,6 +281,59 @@ void parseGamelist(SystemData* system) { std::string defaultName = file->metadata.get("name"); file->metadata = MetaDataList::createFromXML(file->getType() == GAME ? GAME_METADATA : FOLDER_METADATA, fileNode, relativeTo); + if(type == GAME) + { + // Rebuild ROM variants from XML every parse so in-memory state reflects + // both old flat and new hierarchical formats consistently. + std::vector& roms = file->getRomsMutable(); + roms.clear(); + + pugi::xml_node romsNode = fileNode.child("roms"); + if(romsNode) + { + for(pugi::xml_node romNode = romsNode.child("rom"); romNode; romNode = romNode.next_sibling("rom")) + { + RomData rom = parseRomNode(romNode, relativeTo); + if(!rom.path.empty()) + roms.push_back(rom); + } + } + + if(roms.empty()) + { + // Backward-compat: old flat entries become one game + one rom. + RomData rom; + rom.path = path; + rom.romName = fileNode.child("romname").text().as_string(fileNode.child("name").text().get()); + rom.regions = parseRomMultiValue(fileNode, "region", "regions"); + rom.languages = parseRomMultiValue(fileNode, "language", "languages"); + rom.releaseDate = fileNode.child("releasedate").text().get(); + rom.revision = fileNode.child("revision").text().get(); + rom.image = fileNode.child("image").text().get(); + rom.video = fileNode.child("video").text().get(); + rom.thumbnail = fileNode.child("thumbnail").text().get(); + rom.marquee = fileNode.child("marquee").text().get(); + rom.preferred = true; + roms.push_back(rom); + } + + bool hasPreferred = false; + for(auto rit = roms.cbegin(); rit != roms.cend(); ++rit) + { + if(rit->preferred) + { + hasPreferred = true; + break; + } + } + if(!hasPreferred) + // Deterministic fallback when preferred is absent in source XML. + roms.front().preferred = true; + + // Preserve legacy behavior where releasedate was expected on game-level metadata. + if((file->metadata.get("releasedate").empty() || file->metadata.get("releasedate") == "not-a-date-time") && !roms.front().releaseDate.empty()) + file->metadata.set("releasedate", roms.front().releaseDate); + } //make sure name gets set if one didn't exist if(file->metadata.get("name").empty()) @@ -177,6 +347,38 @@ void parseGamelist(SystemData* system) void addFileDataNode(pugi::xml_node& parent, const FileData* file, const char* tag, SystemData* system) { + if(file->getType() == GAME) + { + pugi::xml_node newNode = parent.append_child(tag); + + file->metadata.appendToXML(newNode, true, system->getStartPath()); + // New hierarchical format stores launch paths and release dates per ROM. + newNode.remove_child("path"); + newNode.remove_child("releasedate"); + + pugi::xml_node romsNode = newNode.append_child("roms"); + for(auto it = file->getRoms().cbegin(); it != file->getRoms().cend(); ++it) + { + pugi::xml_node romNode = romsNode.append_child("rom"); + if(it->preferred) + romNode.append_attribute("preferred") = "true"; + + std::string relPath = Utils::FileSystem::createRelativePath(it->path, system->getStartPath(), false, true); + romNode.append_child("path").text().set(relPath.c_str()); + + appendOptionalTextNode(romNode, "romname", it->romName); + appendMultiValueNode(romNode, "regions", "region", it->regions); + appendMultiValueNode(romNode, "languages", "language", it->languages); + appendOptionalTextNode(romNode, "releasedate", it->releaseDate); + appendOptionalTextNode(romNode, "revision", it->revision); + appendOptionalTextNode(romNode, "image", it->image); + appendOptionalTextNode(romNode, "video", it->video); + appendOptionalTextNode(romNode, "thumbnail", it->thumbnail); + appendOptionalTextNode(romNode, "marquee", it->marquee); + } + return; + } + //create game and add to parent node pugi::xml_node newNode = parent.append_child(tag); @@ -276,6 +478,7 @@ void updateGamelist(SystemData* system) for(int i = 0; i < 2; i++) { const char* tag = tagList[i]; + FileType nodeType = typeList[i]; std::vector changes = changedList[i]; // check for changed items of this type @@ -284,21 +487,21 @@ void updateGamelist(SystemData* system) // if it does, remove all corresponding items before adding for(pugi::xml_node fileNode = root.child(tag); fileNode; ) { - pugi::xml_node pathNode = fileNode.child("path"); - // we need this as we were deleting the iterator and things would become inconsistent pugi::xml_node nextNode = fileNode.next_sibling(tag); - if(!pathNode) + std::string xmlpath; + if(nodeType == GAME) + xmlpath = getGamePathForNode(fileNode, relativeTo); + else + xmlpath = Utils::FileSystem::resolveRelativePath(fileNode.child("path").text().get(), relativeTo, false, true); + + if(xmlpath.empty()) { - LOG(LogError) << "<" << tag << "> node contains no child!"; + fileNode = nextNode; continue; } - std::string xmlpath = pathNode.text().get(); - // apply the same transformation as in Gamelist::parseGamelist - xmlpath = Utils::FileSystem::resolveRelativePath(xmlpath, relativeTo, false, true); - for(std::vector::const_iterator cfit = changes.cbegin(); cfit != changes.cend(); ++cfit) { if(xmlpath == (*cfit)->getPath()) From e2500019bf9e8f065cac3160b10534fd64a011e6 Mon Sep 17 00:00:00 2001 From: Ryan McClelland Date: Sat, 21 Feb 2026 00:16:04 -0800 Subject: [PATCH 02/19] more ai slop cleanup --- es-app/src/Gamelist.cpp | 51 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/es-app/src/Gamelist.cpp b/es-app/src/Gamelist.cpp index 3d28546fe0..dbae484d47 100644 --- a/es-app/src/Gamelist.cpp +++ b/es-app/src/Gamelist.cpp @@ -10,6 +10,7 @@ #include "Settings.h" #include "SystemData.h" #include +#include namespace { @@ -213,6 +214,34 @@ FileData* findOrCreateFile(SystemData* system, const std::string& path, FileType return NULL; } +FileData* findFile(SystemData* system, const std::string& path) +{ + FileData* root = system->getRootFolder(); + bool contains = false; + const std::string systemPath = root->getPath(); + + std::string relative = Utils::FileSystem::removeCommonPath(path, systemPath, contains, true); + if(!contains) + return NULL; + + Utils::FileSystem::stringList pathList = Utils::FileSystem::getPathList(relative); + if(pathList.empty()) + return NULL; + + FileData* treeNode = root; + for(auto path_it = pathList.cbegin(); path_it != pathList.cend(); ++path_it) + { + const std::unordered_map& children = treeNode->getChildrenByFilename(); + auto candidate = children.find(*path_it); + if(candidate == children.cend()) + return NULL; + + treeNode = candidate->second; + } + + return treeNode; +} + void parseGamelist(SystemData* system) { bool trustGamelist = Settings::getInstance()->getBool("ParseGamelistOnly"); @@ -242,6 +271,9 @@ void parseGamelist(SystemData* system) std::string relativeTo = system->getStartPath(); + std::unordered_set canonicalGamePaths; + std::unordered_set romVariantPaths; + const char* tagList[2] = { "game", "folder" }; FileType typeList[2] = { GAME, FOLDER }; for(int i = 0; i < 2; i++) @@ -279,6 +311,9 @@ void parseGamelist(SystemData* system) } else if(!file->isArcadeAsset()) { + if(type == GAME) + canonicalGamePaths.insert(path); + std::string defaultName = file->metadata.get("name"); file->metadata = MetaDataList::createFromXML(file->getType() == GAME ? GAME_METADATA : FOLDER_METADATA, fileNode, relativeTo); if(type == GAME) @@ -295,7 +330,10 @@ void parseGamelist(SystemData* system) { RomData rom = parseRomNode(romNode, relativeTo); if(!rom.path.empty()) + { roms.push_back(rom); + romVariantPaths.insert(rom.path); + } } } @@ -315,6 +353,7 @@ void parseGamelist(SystemData* system) rom.marquee = fileNode.child("marquee").text().get(); rom.preferred = true; roms.push_back(rom); + romVariantPaths.insert(rom.path); } bool hasPreferred = false; @@ -343,6 +382,18 @@ void parseGamelist(SystemData* system) } } } + + // Hierarchical entries keep one canonical FileData; remove sibling FileData nodes + // created from filesystem scanning for non-canonical ROM variants. + for(auto it = romVariantPaths.cbegin(); it != romVariantPaths.cend(); ++it) + { + if(canonicalGamePaths.find(*it) != canonicalGamePaths.cend()) + continue; + + FileData* duplicate = findFile(system, *it); + if(duplicate != NULL && duplicate->getType() == GAME) + delete duplicate; + } } void addFileDataNode(pugi::xml_node& parent, const FileData* file, const char* tag, SystemData* system) From d9558bd067ce06fc59194bb71a7f2d268501538c Mon Sep 17 00:00:00 2001 From: Ryan McClelland Date: Sat, 21 Feb 2026 00:51:30 -0800 Subject: [PATCH 03/19] agent created a new window for each --- es-app/src/Gamelist.cpp | 91 +++++++++++-------- .../src/views/gamelist/BasicGameListView.cpp | 2 +- .../src/views/gamelist/GridGameListView.cpp | 2 +- .../views/gamelist/ISimpleGameListView.cpp | 36 +++++++- 4 files changed, 84 insertions(+), 47 deletions(-) diff --git a/es-app/src/Gamelist.cpp b/es-app/src/Gamelist.cpp index dbae484d47..a15b1cc91d 100644 --- a/es-app/src/Gamelist.cpp +++ b/es-app/src/Gamelist.cpp @@ -10,7 +10,7 @@ #include "Settings.h" #include "SystemData.h" #include -#include +#include namespace { @@ -214,36 +214,24 @@ FileData* findOrCreateFile(SystemData* system, const std::string& path, FileType return NULL; } -FileData* findFile(SystemData* system, const std::string& path) +std::unordered_map buildGamePathIndex(SystemData* system) { - FileData* root = system->getRootFolder(); - bool contains = false; - const std::string systemPath = root->getPath(); - - std::string relative = Utils::FileSystem::removeCommonPath(path, systemPath, contains, true); - if(!contains) - return NULL; - - Utils::FileSystem::stringList pathList = Utils::FileSystem::getPathList(relative); - if(pathList.empty()) - return NULL; - - FileData* treeNode = root; - for(auto path_it = pathList.cbegin(); path_it != pathList.cend(); ++path_it) - { - const std::unordered_map& children = treeNode->getChildrenByFilename(); - auto candidate = children.find(*path_it); - if(candidate == children.cend()) - return NULL; + std::unordered_map index; + FileData* rootFolder = system->getRootFolder(); + if(rootFolder == NULL) + return index; - treeNode = candidate->second; - } + const std::vector games = rootFolder->getFilesRecursive(GAME); + for(auto it = games.cbegin(); it != games.cend(); ++it) + index[(*it)->getPath()] = *it; - return treeNode; + return index; } void parseGamelist(SystemData* system) { + const auto parseStartTs = std::chrono::steady_clock::now(); + bool trustGamelist = Settings::getInstance()->getBool("ParseGamelistOnly"); std::string xmlpath = system->getGamelistPath(false); const std::vector allowedExtensions = system->getExtensions(); @@ -270,9 +258,14 @@ void parseGamelist(SystemData* system) } std::string relativeTo = system->getStartPath(); + const auto indexStartTs = std::chrono::steady_clock::now(); + std::unordered_map gamePathIndex = buildGamePathIndex(system); + const auto indexEndTs = std::chrono::steady_clock::now(); - std::unordered_set canonicalGamePaths; - std::unordered_set romVariantPaths; + size_t hierarchicalGameCount = 0; + size_t romVariantCount = 0; + size_t duplicateRemovedCount = 0; + const auto parseLoopStartTs = std::chrono::steady_clock::now(); const char* tagList[2] = { "game", "folder" }; FileType typeList[2] = { GAME, FOLDER }; @@ -312,7 +305,7 @@ void parseGamelist(SystemData* system) else if(!file->isArcadeAsset()) { if(type == GAME) - canonicalGamePaths.insert(path); + gamePathIndex[file->getPath()] = file; std::string defaultName = file->metadata.get("name"); file->metadata = MetaDataList::createFromXML(file->getType() == GAME ? GAME_METADATA : FOLDER_METADATA, fileNode, relativeTo); @@ -326,14 +319,16 @@ void parseGamelist(SystemData* system) pugi::xml_node romsNode = fileNode.child("roms"); if(romsNode) { + hierarchicalGameCount++; for(pugi::xml_node romNode = romsNode.child("rom"); romNode; romNode = romNode.next_sibling("rom")) { RomData rom = parseRomNode(romNode, relativeTo); if(!rom.path.empty()) { roms.push_back(rom); - romVariantPaths.insert(rom.path); + romVariantCount++; } + } } @@ -353,7 +348,7 @@ void parseGamelist(SystemData* system) rom.marquee = fileNode.child("marquee").text().get(); rom.preferred = true; roms.push_back(rom); - romVariantPaths.insert(rom.path); + romVariantCount++; } bool hasPreferred = false; @@ -372,6 +367,24 @@ void parseGamelist(SystemData* system) // Preserve legacy behavior where releasedate was expected on game-level metadata. if((file->metadata.get("releasedate").empty() || file->metadata.get("releasedate") == "not-a-date-time") && !roms.front().releaseDate.empty()) file->metadata.set("releasedate", roms.front().releaseDate); + + // Keep one canonical FileData entry per hierarchical game. Any other + // ROM paths in this same game node are variants and should not remain + // as standalone top-level entries discovered from filesystem scanning. + for(auto rit = roms.cbegin(); rit != roms.cend(); ++rit) + { + if(rit->path.empty() || rit->path == file->getPath()) + continue; + + auto duplicateIt = gamePathIndex.find(rit->path); + FileData* duplicate = (duplicateIt != gamePathIndex.cend()) ? duplicateIt->second : NULL; + if(duplicate != NULL && duplicate->getType() == GAME) + { + gamePathIndex.erase(rit->path); + delete duplicate; + duplicateRemovedCount++; + } + } } //make sure name gets set if one didn't exist @@ -383,17 +396,15 @@ void parseGamelist(SystemData* system) } } - // Hierarchical entries keep one canonical FileData; remove sibling FileData nodes - // created from filesystem scanning for non-canonical ROM variants. - for(auto it = romVariantPaths.cbegin(); it != romVariantPaths.cend(); ++it) - { - if(canonicalGamePaths.find(*it) != canonicalGamePaths.cend()) - continue; - - FileData* duplicate = findFile(system, *it); - if(duplicate != NULL && duplicate->getType() == GAME) - delete duplicate; - } + const auto parseLoopEndTs = std::chrono::steady_clock::now(); + const auto parseEndTs = std::chrono::steady_clock::now(); + LOG(LogInfo) << "Parsed gamelist performance for system '" << system->getName() << "': " + << "index=" << std::chrono::duration_cast(indexEndTs - indexStartTs).count() << "ms, " + << "parse-loop=" << std::chrono::duration_cast(parseLoopEndTs - parseLoopStartTs).count() << "ms, " + << "total=" << std::chrono::duration_cast(parseEndTs - parseStartTs).count() << "ms, " + << "hierarchical-games=" << hierarchicalGameCount << ", " + << "rom-variants=" << romVariantCount << ", " + << "duplicates-removed=" << duplicateRemovedCount; } void addFileDataNode(pugi::xml_node& parent, const FileData* file, const char* tag, SystemData* system) diff --git a/es-app/src/views/gamelist/BasicGameListView.cpp b/es-app/src/views/gamelist/BasicGameListView.cpp index 605c8bad2b..c0a0549350 100644 --- a/es-app/src/views/gamelist/BasicGameListView.cpp +++ b/es-app/src/views/gamelist/BasicGameListView.cpp @@ -218,7 +218,7 @@ std::vector BasicGameListView::getHelpPrompts() if(!UIModeController::getInstance()->isUIModeKid()) prompts.push_back(HelpPrompt("select", "options")); if(mRoot->getSystem()->isGameSystem()) - prompts.push_back(HelpPrompt("x", "random")); + prompts.push_back(HelpPrompt("x", "roms")); if(mRoot->getSystem()->isGameSystem() && !UIModeController::getInstance()->isUIModeKid()) { std::string prompt = CollectionSystemManager::get()->getEditingCollection(); diff --git a/es-app/src/views/gamelist/GridGameListView.cpp b/es-app/src/views/gamelist/GridGameListView.cpp index c013fa3284..f69afbc52a 100644 --- a/es-app/src/views/gamelist/GridGameListView.cpp +++ b/es-app/src/views/gamelist/GridGameListView.cpp @@ -482,7 +482,7 @@ std::vector GridGameListView::getHelpPrompts() if(!UIModeController::getInstance()->isUIModeKid()) prompts.push_back(HelpPrompt("select", "options")); if(mRoot->getSystem()->isGameSystem()) - prompts.push_back(HelpPrompt("x", "random")); + prompts.push_back(HelpPrompt("x", "roms")); if(mRoot->getSystem()->isGameSystem() && !UIModeController::getInstance()->isUIModeKid()) { std::string prompt = CollectionSystemManager::get()->getEditingCollection(); diff --git a/es-app/src/views/gamelist/ISimpleGameListView.cpp b/es-app/src/views/gamelist/ISimpleGameListView.cpp index c81ff0502f..b611b08d6f 100644 --- a/es-app/src/views/gamelist/ISimpleGameListView.cpp +++ b/es-app/src/views/gamelist/ISimpleGameListView.cpp @@ -1,5 +1,7 @@ #include "views/gamelist/ISimpleGameListView.h" +#include "guis/GuiSettings.h" +#include "components/TextComponent.h" #include "views/UIModeController.h" #include "views/ViewController.h" #include "CollectionSystemManager.h" @@ -7,6 +9,31 @@ #include "Settings.h" #include "Sound.h" #include "SystemData.h" +#include "Window.h" + +namespace +{ + void openRomSelectionMenu(Window* window, FileData* game) + { + if(game == nullptr || game->getType() != GAME) + return; + + const std::vector& roms = game->getRoms(); + if(roms.empty()) + return; + + GuiSettings* menu = new GuiSettings(window, "ROMS"); + for(auto it = roms.cbegin(); it != roms.cend(); ++it) + { + ComponentListRow row; + std::string romName = it->romName.empty() ? game->getName() : it->romName; + row.addElement(std::make_shared(window, romName, Font::get(FONT_SIZE_MEDIUM), 0x777777FF), true); + menu->addRow(row); + } + + window->pushGui(menu); + } +} ISimpleGameListView::ISimpleGameListView(Window* window, FileData* root) : IGameListView(window, root), mHeaderText(window), mHeaderImage(window), mBackground(window) @@ -139,13 +166,12 @@ bool ISimpleGameListView::input(InputConfig* config, Input input) { if (mRoot->getSystem()->isGameSystem()) { - // go to random system game - FileData* randomGame = getCursor()->getSystem()->getRandomGame(); - if (randomGame) + FileData* cursor = getCursor(); + if(cursor->getType() == GAME) { - setCursor(randomGame); + openRomSelectionMenu(mWindow, cursor); + return true; } - return true; } }else if (config->isMappedTo("y", input) && !UIModeController::getInstance()->isUIModeKid()) { From d99e512b5c10d6b6e07f970ab4279f4c27b6d841 Mon Sep 17 00:00:00 2001 From: Ryan McClelland Date: Sat, 21 Feb 2026 01:05:29 -0800 Subject: [PATCH 04/19] add preferred star and launch --- es-app/src/FileData.cpp | 4 +- es-app/src/FileData.h | 2 +- .../views/gamelist/ISimpleGameListView.cpp | 155 ++++++++++++++++-- 3 files changed, 148 insertions(+), 13 deletions(-) diff --git a/es-app/src/FileData.cpp b/es-app/src/FileData.cpp index 8632ad7879..00daf6bc79 100644 --- a/es-app/src/FileData.cpp +++ b/es-app/src/FileData.cpp @@ -357,7 +357,7 @@ void FileData::sort(const SortType& type) mSortDesc = type.description; } -void FileData::launchGame(Window* window) +void FileData::launchGame(Window* window, const std::string& romPathOverride) { LOG(LogInfo) << "Attempting to launch game..."; @@ -368,7 +368,7 @@ void FileData::launchGame(Window* window) std::string command = mEnvData->mLaunchCommand; - const std::string launchPath = getLaunchRomPath(); + const std::string launchPath = romPathOverride.empty() ? getLaunchRomPath() : romPathOverride; const std::string rom = Utils::FileSystem::getEscapedPath(launchPath); const std::string basename = Utils::FileSystem::getStem(launchPath); const std::string rom_raw = Utils::FileSystem::getPreferredPath(launchPath); diff --git a/es-app/src/FileData.h b/es-app/src/FileData.h index 17bb57cc82..5021009e42 100644 --- a/es-app/src/FileData.h +++ b/es-app/src/FileData.h @@ -105,7 +105,7 @@ class FileData // As above, but also remove parenthesis std::string getCleanName() const; - void launchGame(Window* window); + void launchGame(Window* window, const std::string& romPathOverride = ""); typedef bool ComparisonFunction(const FileData* a, const FileData* b); struct SortType diff --git a/es-app/src/views/gamelist/ISimpleGameListView.cpp b/es-app/src/views/gamelist/ISimpleGameListView.cpp index b611b08d6f..7b232c9bdd 100644 --- a/es-app/src/views/gamelist/ISimpleGameListView.cpp +++ b/es-app/src/views/gamelist/ISimpleGameListView.cpp @@ -1,6 +1,7 @@ #include "views/gamelist/ISimpleGameListView.h" #include "guis/GuiSettings.h" +#include "components/ImageComponent.h" #include "components/TextComponent.h" #include "views/UIModeController.h" #include "views/ViewController.h" @@ -13,6 +14,149 @@ namespace { + class RomSelectionMenu : public GuiSettings + { + public: + RomSelectionMenu(Window* window, FileData* game) + : GuiSettings(window, "ROMS"), mGame(game), mPreferredChanged(false) + { + buildRows(); + } + + ~RomSelectionMenu() + { + if(!mPreferredChanged || mGame == nullptr) + return; + + ViewController::get()->onFileChanged(mGame, FILE_METADATA_CHANGED); + FileData* source = mGame->getSourceFileData(); + if(source != mGame) + ViewController::get()->onFileChanged(source, FILE_METADATA_CHANGED); + } + + std::vector getHelpPrompts() override + { + std::vector prompts = GuiSettings::getHelpPrompts(); + prompts.push_back(HelpPrompt("y", "preferred")); + return prompts; + } + + private: + struct RomRowWidgets + { + std::shared_ptr star; + std::shared_ptr name; + }; + + void buildRows() + { + if(mGame == nullptr) + return; + + const std::vector& roms = mGame->getRoms(); + for(unsigned int i = 0; i < roms.size(); ++i) + { + ComponentListRow row; + + auto star = std::make_shared(mWindow); + const float starSize = Font::get(FONT_SIZE_MEDIUM)->getLetterHeight(); + star->setImage(":/star_filled.svg"); + star->setResize(starSize, starSize); + star->setVisible(roms[i].preferred); + + std::string romName = roms[i].romName.empty() ? mGame->getName() : roms[i].romName; + auto label = std::make_shared(mWindow, romName, Font::get(FONT_SIZE_MEDIUM), 0x777777FF); + + row.addElement(star, false, false); + row.addElement(label, true); + row.input_handler = [this, i](InputConfig* config, Input input) -> bool { + if(input.value == 0) + return false; + + if(config->isMappedTo("a", input)) + { + launchRom(i); + return true; + } + + if(config->isMappedTo("y", input)) + { + setPreferredRom(i); + return true; + } + + return false; + }; + + addRow(row); + + RomRowWidgets widgets; + widgets.star = star; + widgets.name = label; + mRows.push_back(widgets); + } + + updatePreferredIndicator(); + } + + void setPreferredRom(unsigned int index) + { + if(mGame == nullptr) + return; + + FileData* source = mGame->getSourceFileData(); + std::vector& roms = source->getRomsMutable(); + if(index >= roms.size()) + return; + + for(unsigned int i = 0; i < roms.size(); ++i) + roms[i].preferred = (i == index); + + source->metadata.set("name", source->metadata.get("name")); + source->getSystem()->onMetaDataSavePoint(); + + if(mGame != source) + mGame->refreshMetadata(); + + mPreferredChanged = true; + updatePreferredIndicator(); + } + + void launchRom(unsigned int index) + { + if(mGame == nullptr) + return; + + FileData* selectedEntry = mGame; + FileData* source = selectedEntry->getSourceFileData(); + const std::vector& roms = source->getRoms(); + if(index >= roms.size() || roms[index].path.empty()) + return; + + const std::string launchPath = roms[index].path; + delete this; + + source->launchGame(mWindow, launchPath); + ViewController::get()->onFileChanged(source, FILE_METADATA_CHANGED); + if(selectedEntry != source) + ViewController::get()->onFileChanged(selectedEntry, FILE_METADATA_CHANGED); + } + + void updatePreferredIndicator() + { + if(mGame == nullptr) + return; + + const std::vector& roms = mGame->getRoms(); + for(unsigned int i = 0; i < roms.size() && i < mRows.size(); ++i) + mRows[i].star->setVisible(roms[i].preferred); + } + + FileData* mGame; + bool mPreferredChanged; + std::vector mRows; + }; + void openRomSelectionMenu(Window* window, FileData* game) { if(game == nullptr || game->getType() != GAME) @@ -22,16 +166,7 @@ namespace if(roms.empty()) return; - GuiSettings* menu = new GuiSettings(window, "ROMS"); - for(auto it = roms.cbegin(); it != roms.cend(); ++it) - { - ComponentListRow row; - std::string romName = it->romName.empty() ? game->getName() : it->romName; - row.addElement(std::make_shared(window, romName, Font::get(FONT_SIZE_MEDIUM), 0x777777FF), true); - menu->addRow(row); - } - - window->pushGui(menu); + window->pushGui(new RomSelectionMenu(window, game)); } } From a91f59b9758e1e2553e17c64067509a98202c56d Mon Sep 17 00:00:00 2001 From: Ryan McClelland Date: Sat, 21 Feb 2026 16:11:45 -0800 Subject: [PATCH 05/19] fix metadata display for roms on a nas --- es-app/src/Gamelist.cpp | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/es-app/src/Gamelist.cpp b/es-app/src/Gamelist.cpp index a15b1cc91d..1db169c6c7 100644 --- a/es-app/src/Gamelist.cpp +++ b/es-app/src/Gamelist.cpp @@ -70,10 +70,10 @@ namespace rom.languages = parseRomMultiValue(romNode, "language", "languages"); rom.releaseDate = romNode.child("releasedate").text().get(); rom.revision = romNode.child("revision").text().get(); - rom.image = romNode.child("image").text().get(); - rom.video = romNode.child("video").text().get(); - rom.thumbnail = romNode.child("thumbnail").text().get(); - rom.marquee = romNode.child("marquee").text().get(); + rom.image = Utils::FileSystem::resolveRelativePath(romNode.child("image").text().get(), relativeTo, true, true); + rom.video = Utils::FileSystem::resolveRelativePath(romNode.child("video").text().get(), relativeTo, true, true); + rom.thumbnail = Utils::FileSystem::resolveRelativePath(romNode.child("thumbnail").text().get(), relativeTo, true, true); + rom.marquee = Utils::FileSystem::resolveRelativePath(romNode.child("marquee").text().get(), relativeTo, true, true); rom.preferred = parsePreferredRomAttribute(romNode); return rom; } @@ -342,10 +342,10 @@ void parseGamelist(SystemData* system) rom.languages = parseRomMultiValue(fileNode, "language", "languages"); rom.releaseDate = fileNode.child("releasedate").text().get(); rom.revision = fileNode.child("revision").text().get(); - rom.image = fileNode.child("image").text().get(); - rom.video = fileNode.child("video").text().get(); - rom.thumbnail = fileNode.child("thumbnail").text().get(); - rom.marquee = fileNode.child("marquee").text().get(); + rom.image = Utils::FileSystem::resolveRelativePath(fileNode.child("image").text().get(), relativeTo, true, true); + rom.video = Utils::FileSystem::resolveRelativePath(fileNode.child("video").text().get(), relativeTo, true, true); + rom.thumbnail = Utils::FileSystem::resolveRelativePath(fileNode.child("thumbnail").text().get(), relativeTo, true, true); + rom.marquee = Utils::FileSystem::resolveRelativePath(fileNode.child("marquee").text().get(), relativeTo, true, true); rom.preferred = true; roms.push_back(rom); romVariantCount++; From 297248d712bed8e23b86c71ef6c5fb3865968237 Mon Sep 17 00:00:00 2001 From: Ryan McClelland Date: Sat, 21 Feb 2026 23:20:44 -0800 Subject: [PATCH 06/19] fix help for rom list --- .../views/gamelist/ISimpleGameListView.cpp | 26 +++++++++++++------ 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/es-app/src/views/gamelist/ISimpleGameListView.cpp b/es-app/src/views/gamelist/ISimpleGameListView.cpp index 7b232c9bdd..313fd0b806 100644 --- a/es-app/src/views/gamelist/ISimpleGameListView.cpp +++ b/es-app/src/views/gamelist/ISimpleGameListView.cpp @@ -14,6 +14,23 @@ namespace { + class RomPromptTextComponent : public TextComponent + { + public: + RomPromptTextComponent(Window* window, const std::string& text, const std::shared_ptr& font, unsigned int color) + : TextComponent(window, text, font, color) + { + } + + std::vector getHelpPrompts() override + { + std::vector prompts; + prompts.push_back(HelpPrompt("a", "launch")); + prompts.push_back(HelpPrompt("y", "preferred")); + return prompts; + } + }; + class RomSelectionMenu : public GuiSettings { public: @@ -34,13 +51,6 @@ namespace ViewController::get()->onFileChanged(source, FILE_METADATA_CHANGED); } - std::vector getHelpPrompts() override - { - std::vector prompts = GuiSettings::getHelpPrompts(); - prompts.push_back(HelpPrompt("y", "preferred")); - return prompts; - } - private: struct RomRowWidgets { @@ -65,7 +75,7 @@ namespace star->setVisible(roms[i].preferred); std::string romName = roms[i].romName.empty() ? mGame->getName() : roms[i].romName; - auto label = std::make_shared(mWindow, romName, Font::get(FONT_SIZE_MEDIUM), 0x777777FF); + auto label = std::make_shared(mWindow, romName, Font::get(FONT_SIZE_MEDIUM), 0x777777FF); row.addElement(star, false, false); row.addElement(label, true); From 0c414ae2da34c0f6a4430a3c0dc3cca082cb5d67 Mon Sep 17 00:00:00 2001 From: Ryan McClelland Date: Sat, 21 Feb 2026 23:49:27 -0800 Subject: [PATCH 07/19] fix flicker on ui when favoriting --- es-app/src/views/gamelist/BasicGameListView.cpp | 3 +-- es-app/src/views/gamelist/ISimpleGameListView.cpp | 5 ++++- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/es-app/src/views/gamelist/BasicGameListView.cpp b/es-app/src/views/gamelist/BasicGameListView.cpp index c0a0549350..ae953c98c1 100644 --- a/es-app/src/views/gamelist/BasicGameListView.cpp +++ b/es-app/src/views/gamelist/BasicGameListView.cpp @@ -31,8 +31,7 @@ void BasicGameListView::onFileChanged(FileData* file, FileChangeType change) { if(change == FILE_METADATA_CHANGED) { - // might switch to a detailed view - ViewController::get()->reloadGameListView(this); + ISimpleGameListView::onFileChanged(file, change); return; } diff --git a/es-app/src/views/gamelist/ISimpleGameListView.cpp b/es-app/src/views/gamelist/ISimpleGameListView.cpp index 313fd0b806..c280cc66c4 100644 --- a/es-app/src/views/gamelist/ISimpleGameListView.cpp +++ b/es-app/src/views/gamelist/ISimpleGameListView.cpp @@ -233,8 +233,11 @@ void ISimpleGameListView::onThemeChanged(const std::shared_ptr& theme } } -void ISimpleGameListView::onFileChanged(FileData* /*file*/, FileChangeType /*change*/) +void ISimpleGameListView::onFileChanged(FileData* /*file*/, FileChangeType change) { + if(change == FILE_METADATA_CHANGED) + return; + // we could be tricky here to be efficient; // but this shouldn't happen very often so we'll just always repopulate FileData* cursor = getCursor(); From fc34cf00977e8ef0e17e75ea637fb55f24e55252 Mon Sep 17 00:00:00 2001 From: Ryan McClelland Date: Sun, 22 Feb 2026 00:19:06 -0800 Subject: [PATCH 08/19] fix reloading when chaning preferred rom --- es-app/src/views/gamelist/ISimpleGameListView.cpp | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/es-app/src/views/gamelist/ISimpleGameListView.cpp b/es-app/src/views/gamelist/ISimpleGameListView.cpp index c280cc66c4..d68d6489be 100644 --- a/es-app/src/views/gamelist/ISimpleGameListView.cpp +++ b/es-app/src/views/gamelist/ISimpleGameListView.cpp @@ -45,6 +45,8 @@ namespace if(!mPreferredChanged || mGame == nullptr) return; + // Preferred ROM changes alter effective media/launch selection, so notify both + // the visible entry and canonical source entry to refresh info panels immediately. ViewController::get()->onFileChanged(mGame, FILE_METADATA_CHANGED); FileData* source = mGame->getSourceFileData(); if(source != mGame) @@ -122,6 +124,7 @@ namespace for(unsigned int i = 0; i < roms.size(); ++i) roms[i].preferred = (i == index); + // Mark metadata dirty so onMetaDataSavePoint() persists updated ROM preference. source->metadata.set("name", source->metadata.get("name")); source->getSystem()->onMetaDataSavePoint(); @@ -236,7 +239,14 @@ void ISimpleGameListView::onThemeChanged(const std::shared_ptr& theme void ISimpleGameListView::onFileChanged(FileData* /*file*/, FileChangeType change) { if(change == FILE_METADATA_CHANGED) + { + FileData* cursor = getCursor(); + // Keep metadata updates lightweight (avoid full list repopulate/flicker) while + // still forcing current row/panel refresh for preferred-ROM media changes. + if(cursor != nullptr && !cursor->isPlaceHolder()) + setCursor(cursor, true); return; + } // we could be tricky here to be efficient; // but this shouldn't happen very often so we'll just always repopulate From 9081be7c6e156e69651d7e0110369da4b6ac7ad2 Mon Sep 17 00:00:00 2001 From: Ryan McClelland Date: Sun, 22 Feb 2026 18:38:00 -0800 Subject: [PATCH 09/19] fix writing back releative paths for rom level --- es-app/src/Gamelist.cpp | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/es-app/src/Gamelist.cpp b/es-app/src/Gamelist.cpp index 1db169c6c7..1bba8fb5b7 100644 --- a/es-app/src/Gamelist.cpp +++ b/es-app/src/Gamelist.cpp @@ -94,6 +94,15 @@ namespace parent.append_child(tag).text().set(value.c_str()); } + void appendOptionalPathNode(pugi::xml_node& parent, const char* tag, const std::string& value, const std::string& relativeTo) + { + if(value.empty()) + return; + + std::string relValue = Utils::FileSystem::createRelativePath(value, relativeTo, true, true); + parent.append_child(tag).text().set(relValue.c_str()); + } + std::string getGamePathForNode(const pugi::xml_node& gameNode, const std::string& relativeTo) { // New format can omit top-level . In that case we choose the preferred @@ -433,10 +442,10 @@ void addFileDataNode(pugi::xml_node& parent, const FileData* file, const char* t appendMultiValueNode(romNode, "languages", "language", it->languages); appendOptionalTextNode(romNode, "releasedate", it->releaseDate); appendOptionalTextNode(romNode, "revision", it->revision); - appendOptionalTextNode(romNode, "image", it->image); - appendOptionalTextNode(romNode, "video", it->video); - appendOptionalTextNode(romNode, "thumbnail", it->thumbnail); - appendOptionalTextNode(romNode, "marquee", it->marquee); + appendOptionalPathNode(romNode, "image", it->image, system->getStartPath()); + appendOptionalPathNode(romNode, "video", it->video, system->getStartPath()); + appendOptionalPathNode(romNode, "thumbnail", it->thumbnail, system->getStartPath()); + appendOptionalPathNode(romNode, "marquee", it->marquee, system->getStartPath()); } return; } From 9c279e1f3aa7b5d5899d85c744d501d5907ce824 Mon Sep 17 00:00:00 2001 From: Ryan McClelland Date: Sun, 22 Feb 2026 18:40:47 -0800 Subject: [PATCH 10/19] maybe fix segfault when launch rom from list --- es-app/src/views/gamelist/ISimpleGameListView.cpp | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/es-app/src/views/gamelist/ISimpleGameListView.cpp b/es-app/src/views/gamelist/ISimpleGameListView.cpp index d68d6489be..221045ca1f 100644 --- a/es-app/src/views/gamelist/ISimpleGameListView.cpp +++ b/es-app/src/views/gamelist/ISimpleGameListView.cpp @@ -147,9 +147,10 @@ namespace return; const std::string launchPath = roms[index].path; + Window* window = mWindow; delete this; - source->launchGame(mWindow, launchPath); + source->launchGame(window, launchPath); ViewController::get()->onFileChanged(source, FILE_METADATA_CHANGED); if(selectedEntry != source) ViewController::get()->onFileChanged(selectedEntry, FILE_METADATA_CHANGED); From 19a871034cbc3d49a2ce072b2e7be8dc0fa8aec8 Mon Sep 17 00:00:00 2001 From: Ryan McClelland Date: Sun, 22 Feb 2026 18:45:52 -0800 Subject: [PATCH 11/19] keep popup whening launch rom from list --- es-app/src/views/gamelist/ISimpleGameListView.cpp | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/es-app/src/views/gamelist/ISimpleGameListView.cpp b/es-app/src/views/gamelist/ISimpleGameListView.cpp index 221045ca1f..7bcb6164ab 100644 --- a/es-app/src/views/gamelist/ISimpleGameListView.cpp +++ b/es-app/src/views/gamelist/ISimpleGameListView.cpp @@ -147,10 +147,9 @@ namespace return; const std::string launchPath = roms[index].path; - Window* window = mWindow; - delete this; - - source->launchGame(window, launchPath); + // Keep this popup alive so returning from gameplay lands back on the + // same ROM selection screen and highlighted row. + source->launchGame(mWindow, launchPath); ViewController::get()->onFileChanged(source, FILE_METADATA_CHANGED); if(selectedEntry != source) ViewController::get()->onFileChanged(selectedEntry, FILE_METADATA_CHANGED); From a3f4188edf548ba84c603deb1e0bdf4207e7830f Mon Sep 17 00:00:00 2001 From: Ryan McClelland Date: Sun, 22 Feb 2026 18:55:07 -0800 Subject: [PATCH 12/19] fix metadata images reloading after launching rom from lists --- es-app/src/views/gamelist/ISimpleGameListView.cpp | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/es-app/src/views/gamelist/ISimpleGameListView.cpp b/es-app/src/views/gamelist/ISimpleGameListView.cpp index 7bcb6164ab..14a05c3999 100644 --- a/es-app/src/views/gamelist/ISimpleGameListView.cpp +++ b/es-app/src/views/gamelist/ISimpleGameListView.cpp @@ -147,12 +147,22 @@ namespace return; const std::string launchPath = roms[index].path; + std::shared_ptr currentGameList = ViewController::get()->getGameListView(selectedEntry->getSystem()); + if(currentGameList) + currentGameList->onHide(); + // Keep this popup alive so returning from gameplay lands back on the // same ROM selection screen and highlighted row. source->launchGame(mWindow, launchPath); ViewController::get()->onFileChanged(source, FILE_METADATA_CHANGED); if(selectedEntry != source) ViewController::get()->onFileChanged(selectedEntry, FILE_METADATA_CHANGED); + + if(currentGameList) + { + currentGameList->setCursor(selectedEntry, true); + currentGameList->onShow(); + } } void updatePreferredIndicator() From 967c3102cc681ed3dcea17a95b4f0e2961a6f335 Mon Sep 17 00:00:00 2001 From: Ryan McClelland Date: Wed, 25 Feb 2026 14:29:23 -0800 Subject: [PATCH 13/19] start work on getting a rom name in meta data keep trying fix async reading for other views almost working here we are almost there almost here --- .../views/gamelist/DetailedGameListView.cpp | 73 ++++++++++++++++--- .../src/views/gamelist/DetailedGameListView.h | 4 + .../src/views/gamelist/GridGameListView.cpp | 58 ++++++++++++++- es-app/src/views/gamelist/GridGameListView.h | 3 + .../src/views/gamelist/VideoGameListView.cpp | 60 ++++++++++++++- es-app/src/views/gamelist/VideoGameListView.h | 3 + .../src/components/ScrollableContainer.cpp | 10 ++- es-core/src/components/ScrollableContainer.h | 2 +- 8 files changed, 190 insertions(+), 23 deletions(-) diff --git a/es-app/src/views/gamelist/DetailedGameListView.cpp b/es-app/src/views/gamelist/DetailedGameListView.cpp index ecac95c3bd..a29720512d 100644 --- a/es-app/src/views/gamelist/DetailedGameListView.cpp +++ b/es-app/src/views/gamelist/DetailedGameListView.cpp @@ -1,6 +1,7 @@ #include "views/gamelist/DetailedGameListView.h" #include "animations/LambdaAnimation.h" +#include "utils/FileSystemUtil.h" #include "views/ViewController.h" DetailedGameListView::DetailedGameListView(Window* window, FileData* root) : @@ -15,7 +16,9 @@ DetailedGameListView::DetailedGameListView(Window* window, FileData* root) : mRating(window), mReleaseDate(window), mDeveloper(window), mPublisher(window), mGenre(window), mPlayers(window), mLastPlayed(window), mPlayCount(window), - mName(window) + mName(window), + + mLblRomName(window), mRomNameContainer(window), mRomNameText(window) { //mHeaderImage.setPosition(mSize.x() * 0.25f, 0); @@ -77,7 +80,6 @@ DetailedGameListView::DetailedGameListView(Window* window, FileData* root) : mLblPlayCount.setText("Times played: "); addChild(&mLblPlayCount); addChild(&mPlayCount); - mName.setPosition(mSize.x(), mSize.y()); mName.setDefaultZIndex(40); mName.setColor(0xAAAAAAFF); @@ -85,6 +87,16 @@ DetailedGameListView::DetailedGameListView(Window* window, FileData* root) : mName.setHorizontalAlignment(ALIGN_CENTER); addChild(&mName); + mLblRomName.setText("Rom Name: "); + mLblRomName.setColor(0x777777FF); // default visible color; overridden by theme + mLblRomName.setDefaultZIndex(40); + addChild(&mLblRomName); + + mRomNameContainer.setAutoScroll(true, true); // horizontal marquee + mRomNameContainer.setDefaultZIndex(40); + addChild(&mRomNameContainer); + mRomNameContainer.addChild(&mRomNameText); + mDescContainer.setPosition(mSize.x() * padding, mSize.y() * 0.65f); mDescContainer.setSize(mSize.x() * (0.50f - 2*padding), mSize.y() - mDescContainer.getPosition().y()); mDescContainer.setAutoScroll(true); @@ -124,6 +136,10 @@ void DetailedGameListView::onThemeChanged(const std::shared_ptr& them labels[i]->applyTheme(theme, getName(), lblElements[i], ALL); } + // Apply desc container position/size from theme BEFORE initMDValues() so that + // initMDValues() can nudge the y below the ROM name row while still inheriting + // the theme's x and width. + mDescContainer.applyTheme(theme, getName(), "md_description", POSITION | ThemeFlags::SIZE | Z_INDEX | VISIBLE); initMDValues(); std::vector values = getMDValues(); @@ -138,9 +154,16 @@ void DetailedGameListView::onThemeChanged(const std::shared_ptr& them values[i]->applyTheme(theme, getName(), valElements[i], ALL ^ ThemeFlags::TEXT); } - mDescContainer.applyTheme(theme, getName(), "md_description", POSITION | ThemeFlags::SIZE | Z_INDEX | VISIBLE); + // Apply publisher-row colors + z-index to the ROM name row so it sorts correctly. + mLblRomName.applyTheme(theme, getName(), "md_lbl_publisher", ThemeFlags::COLOR | ThemeFlags::Z_INDEX); + mRomNameText.applyTheme(theme, getName(), "md_publisher", ThemeFlags::COLOR | ThemeFlags::Z_INDEX); + // Container has no matching theme element; set z-index directly. + mRomNameContainer.setZIndex(mLblRomName.getZIndex()); + mDescription.setSize(mDescContainer.getSize().x(), 0); mDescription.applyTheme(theme, getName(), "md_description", ALL ^ (POSITION | ThemeFlags::SIZE | ThemeFlags::ORIGIN | TEXT | ROTATION)); + mLblRomName.applyTheme(theme, getName(), "md_lbl_playcount", ALL ^ (POSITION | ThemeFlags::SIZE | ThemeFlags::ORIGIN | ROTATION)); + mRomNameText.applyTheme(theme, getName(), "md_playcount", ALL ^ (POSITION | ThemeFlags::SIZE | ThemeFlags::ORIGIN | TEXT | ROTATION)); sortChildren(); } @@ -190,6 +213,8 @@ void DetailedGameListView::initMDValues() mPlayers.setFont(defaultFont); mLastPlayed.setFont(defaultFont); mPlayCount.setFont(defaultFont); + mLblRomName.setFont(defaultFont); + mRomNameText.setFont(defaultFont); float bottom = 0.0f; @@ -204,10 +229,34 @@ void DetailedGameListView::initMDValues() float testBot = values[i]->getPosition().y() + values[i]->getSize().y(); if(testBot > bottom) bottom = testBot; + + float labelBot = labels[i]->getPosition().y() + labels[i]->getSize().y(); + if(labelBot > bottom) + bottom = labelBot; } - mDescContainer.setPosition(mDescContainer.getPosition().x(), bottom + mSize.y() * 0.01f); - mDescContainer.setSize(mDescContainer.getSize().x(), mSize.y() - mDescContainer.getPosition().y()); + const float rowY = bottom; + const float lineH = defaultFont->getHeight(); + + mLblRomName.setPosition(Vector3f(mLblPublisher.getPosition().x(), rowY, 0.0f)); + mLblRomName.setDefaultZIndex(40); + const float labelW = mLblRomName.getSize().x(); + mRomNameContainer.setPosition(mLblRomName.getPosition() + Vector3f(labelW, 0.0f, 0.0f)); + mRomNameContainer.setSize(mSize.x() * 0.48f - labelW, lineH); + + mRomNameText.setPosition(Vector3f(0, 0, 0)); + mRomNameText.setSize(0, lineH); + mRomNameText.setDefaultZIndex(40); + + const float rowPadding = 0.01f * mSize.y(); + const float rowBottom = rowY + mLblRomName.getSize().y(); + + // Preserve theme bottom edge: capture it after applyTheme, shrink top to fit ROM name row. + const float descBottom = mDescContainer.getPosition().y() + mDescContainer.getSize().y(); + const float newDescY = rowBottom + rowPadding; + mDescContainer.setPosition(mDescContainer.getPosition().x(), newDescY); + mDescContainer.setSize(mDescContainer.getSize().x(), descBottom - newDescY); + } void DetailedGameListView::updateInfoPanel() @@ -234,12 +283,12 @@ void DetailedGameListView::updateInfoPanel() mGenre.setValue(file->metadata.get("genre")); mPlayers.setValue(file->metadata.get("players")); mName.setValue(file->metadata.get("name")); - - if(file->getType() == GAME) - { - mLastPlayed.setValue(file->metadata.get("lastplayed")); - mPlayCount.setValue(file->metadata.get("playcount")); - } + const RomData* preferred = file->getPreferredRom(); + if(preferred != NULL && !preferred->romName.empty()) + mRomNameText.setValue(preferred->romName); + else + mRomNameText.setValue(file->getName()); + mRomNameContainer.reset(); fadingOut = false; } @@ -250,6 +299,8 @@ void DetailedGameListView::updateInfoPanel() comps.push_back(&mImage); comps.push_back(&mDescription); comps.push_back(&mName); + comps.push_back(&mRomNameText); + comps.push_back(&mLblRomName); std::vector labels = getMDLabels(); comps.insert(comps.cend(), labels.cbegin(), labels.cend()); diff --git a/es-app/src/views/gamelist/DetailedGameListView.h b/es-app/src/views/gamelist/DetailedGameListView.h index 6add975f4d..9dcc9a0f6c 100644 --- a/es-app/src/views/gamelist/DetailedGameListView.h +++ b/es-app/src/views/gamelist/DetailedGameListView.h @@ -45,6 +45,10 @@ class DetailedGameListView : public BasicGameListView std::vector getMDLabels(); std::vector getMDValues(); + TextComponent mLblRomName; + ScrollableContainer mRomNameContainer; + TextComponent mRomNameText; + ScrollableContainer mDescContainer; TextComponent mDescription; }; diff --git a/es-app/src/views/gamelist/GridGameListView.cpp b/es-app/src/views/gamelist/GridGameListView.cpp index f69afbc52a..209d05e7bd 100644 --- a/es-app/src/views/gamelist/GridGameListView.cpp +++ b/es-app/src/views/gamelist/GridGameListView.cpp @@ -20,10 +20,11 @@ GridGameListView::GridGameListView(Window* window, FileData* root) : mDescContainer(window, DESCRIPTION_SCROLL_DELAY), mDescription(window), mLblRating(window), mLblReleaseDate(window), mLblDeveloper(window), mLblPublisher(window), - mLblGenre(window), mLblPlayers(window), mLblLastPlayed(window), mLblPlayCount(window), + mLblGenre(window), mLblPlayers(window), mLblLastPlayed(window), mLblPlayCount(window), mLblRomName(window), mRating(window), mReleaseDate(window), mDeveloper(window), mPublisher(window), mGenre(window), mPlayers(window), mLastPlayed(window), mPlayCount(window), + mRomNameContainer(window), mRomNameText(window), mName(window) { const float padding = 0.01f; @@ -71,6 +72,17 @@ GridGameListView::GridGameListView(Window* window, FileData* root) : mLblPlayCount.setText("Times played: "); addChild(&mLblPlayCount); addChild(&mPlayCount); + mLblRomName.setText("Rom Name: "); + mLblRomName.setColor(0x777777FF); + mLblRomName.setDefaultZIndex(40); + addChild(&mLblRomName); + + mRomNameContainer.setAutoScroll(true, true); // horizontal marquee + mRomNameContainer.setDefaultZIndex(40); + addChild(&mRomNameContainer); + mRomNameText.setColor(0xFFFFFFFF); + mRomNameText.setDefaultZIndex(40); + mRomNameContainer.addChild(&mRomNameText); mName.setPosition(mSize.x(), mSize.y()); mName.setDefaultZIndex(40); @@ -212,6 +224,9 @@ void GridGameListView::onThemeChanged(const std::shared_ptr& theme) labels[i]->applyTheme(theme, getName(), lblElements[i], ALL); } + // Apply desc container position/size from theme BEFORE initMDValues() so that + // initMDValues() can nudge the y below the ROM name row while preserving theme x/width. + mDescContainer.applyTheme(theme, getName(), "md_description", POSITION | ThemeFlags::SIZE | Z_INDEX | VISIBLE); initMDValues(); std::vector values = getMDValues(); @@ -226,9 +241,11 @@ void GridGameListView::onThemeChanged(const std::shared_ptr& theme) values[i]->applyTheme(theme, getName(), valElements[i], ALL ^ ThemeFlags::TEXT); } - mDescContainer.applyTheme(theme, getName(), "md_description", POSITION | ThemeFlags::SIZE | Z_INDEX | VISIBLE); mDescription.setSize(mDescContainer.getSize().x(), 0); mDescription.applyTheme(theme, getName(), "md_description", ALL ^ (POSITION | ThemeFlags::SIZE | ThemeFlags::ORIGIN | TEXT | ROTATION)); + mLblRomName.applyTheme(theme, getName(), "md_lbl_playcount", ALL ^ (POSITION | ThemeFlags::SIZE | ThemeFlags::ORIGIN | ROTATION) | ThemeFlags::Z_INDEX); + mRomNameText.applyTheme(theme, getName(), "md_playcount", ALL ^ (POSITION | ThemeFlags::SIZE | ThemeFlags::ORIGIN | TEXT | ROTATION) | ThemeFlags::Z_INDEX); + mRomNameContainer.setZIndex(mLblRomName.getZIndex()); // Repopulate list in case new theme is displaying a different image. Preserve selection. FileData* file = mGrid.getSelected(); @@ -283,6 +300,8 @@ void GridGameListView::initMDValues() mPlayers.setFont(defaultFont); mLastPlayed.setFont(defaultFont); mPlayCount.setFont(defaultFont); + mLblRomName.setFont(defaultFont); + mRomNameText.setFont(defaultFont); float bottom = 0.0f; @@ -297,10 +316,33 @@ void GridGameListView::initMDValues() float testBot = values[i]->getPosition().y() + values[i]->getSize().y(); if(testBot > bottom) bottom = testBot; + + float labelBot = labels[i]->getPosition().y() + labels[i]->getSize().y(); + if(labelBot > bottom) + bottom = labelBot; } - mDescContainer.setPosition(mDescContainer.getPosition().x(), bottom + mSize.y() * 0.01f); - mDescContainer.setSize(mDescContainer.getSize().x(), mSize.y() - mDescContainer.getPosition().y()); + const float rowY = bottom; + const float lineH = defaultFont->getHeight(); + + mLblRomName.setPosition(Vector3f(mLblPublisher.getPosition().x(), rowY, 0.0f)); + mLblRomName.setDefaultZIndex(40); + const float labelW = mLblRomName.getSize().x(); + mRomNameContainer.setPosition(mLblRomName.getPosition() + Vector3f(labelW, 0.0f, 0.0f)); + mRomNameContainer.setSize(mSize.x() * 0.48f - labelW, lineH); + + mRomNameText.setPosition(Vector3f(0, 0, 0)); + mRomNameText.setSize(0, lineH); + mRomNameText.setDefaultZIndex(40); + + const float rowPadding = 0.01f * mSize.y(); + const float rowBottom = rowY + mLblRomName.getSize().y(); + + // Preserve theme bottom edge: capture it after applyTheme, shrink top to fit ROM name row. + const float descBottom = mDescContainer.getPosition().y() + mDescContainer.getSize().y(); + const float newDescY = rowBottom + rowPadding; + mDescContainer.setPosition(mDescContainer.getPosition().x(), newDescY); + mDescContainer.setSize(mDescContainer.getSize().x(), descBottom - newDescY); } void GridGameListView::updateInfoPanel() @@ -337,6 +379,12 @@ void GridGameListView::updateInfoPanel() mGenre.setValue(file->metadata.get("genre")); mPlayers.setValue(file->metadata.get("players")); mName.setValue(file->metadata.get("name")); + const RomData* preferred = file->getPreferredRom(); + if(preferred != NULL && !preferred->romName.empty()) + mRomNameText.setValue(preferred->romName); + else + mRomNameText.setValue(file->getName()); + mRomNameContainer.reset(); if(file->getType() == GAME) { @@ -353,6 +401,8 @@ void GridGameListView::updateInfoPanel() comps.push_back(&mMarquee); comps.push_back(mVideo); comps.push_back(&mImage); + comps.push_back(&mLblRomName); + comps.push_back(&mRomNameText); std::vector labels = getMDLabels(); comps.insert(comps.cend(), labels.cbegin(), labels.cend()); diff --git a/es-app/src/views/gamelist/GridGameListView.h b/es-app/src/views/gamelist/GridGameListView.h index 7239afffb7..84ef0e28a5 100644 --- a/es-app/src/views/gamelist/GridGameListView.h +++ b/es-app/src/views/gamelist/GridGameListView.h @@ -51,6 +51,7 @@ class GridGameListView : public ISimpleGameListView void initMDValues(); TextComponent mLblRating, mLblReleaseDate, mLblDeveloper, mLblPublisher, mLblGenre, mLblPlayers, mLblLastPlayed, mLblPlayCount; + TextComponent mLblRomName; ImageComponent mMarquee; VideoComponent* mVideo; @@ -64,6 +65,8 @@ class GridGameListView : public ISimpleGameListView TextComponent mPlayers; DateTimeComponent mLastPlayed; TextComponent mPlayCount; + ScrollableContainer mRomNameContainer; + TextComponent mRomNameText; TextComponent mName; std::vector getMDLabels(); diff --git a/es-app/src/views/gamelist/VideoGameListView.cpp b/es-app/src/views/gamelist/VideoGameListView.cpp index e26715c774..7be97f06b1 100644 --- a/es-app/src/views/gamelist/VideoGameListView.cpp +++ b/es-app/src/views/gamelist/VideoGameListView.cpp @@ -21,10 +21,11 @@ VideoGameListView::VideoGameListView(Window* window, FileData* root) : mVideoPlaying(false), mLblRating(window), mLblReleaseDate(window), mLblDeveloper(window), mLblPublisher(window), - mLblGenre(window), mLblPlayers(window), mLblLastPlayed(window), mLblPlayCount(window), + mLblGenre(window), mLblPlayers(window), mLblLastPlayed(window), mLblPlayCount(window), mLblRomName(window), mRating(window), mReleaseDate(window), mDeveloper(window), mPublisher(window), mGenre(window), mPlayers(window), mLastPlayed(window), mPlayCount(window), + mRomNameContainer(window), mRomNameText(window), mName(window) { const float padding = 0.01f; @@ -105,6 +106,17 @@ VideoGameListView::VideoGameListView(Window* window, FileData* root) : mLblPlayCount.setText("Times played: "); addChild(&mLblPlayCount); addChild(&mPlayCount); + mLblRomName.setText("Rom Name: "); + mLblRomName.setColor(0x777777FF); + mLblRomName.setDefaultZIndex(40); + addChild(&mLblRomName); + + mRomNameContainer.setAutoScroll(true, true); // horizontal marquee + mRomNameContainer.setDefaultZIndex(40); + addChild(&mRomNameContainer); + mRomNameText.setColor(0xFFFFFFFF); + mRomNameText.setDefaultZIndex(40); + mRomNameContainer.addChild(&mRomNameText); mName.setPosition(mSize.x(), mSize.y()); mName.setDefaultZIndex(40); @@ -157,6 +169,10 @@ void VideoGameListView::onThemeChanged(const std::shared_ptr& theme) } + // Apply desc container position/size from theme BEFORE initMDValues() so that + // initMDValues() can nudge the y below the ROM name row while preserving theme x/width. + mDescContainer.applyTheme(theme, getName(), "md_description", POSITION | ThemeFlags::SIZE | Z_INDEX | VISIBLE); + initMDValues(); std::vector values = getMDValues(); assert(values.size() == 8); @@ -170,9 +186,11 @@ void VideoGameListView::onThemeChanged(const std::shared_ptr& theme) values[i]->applyTheme(theme, getName(), valElements[i], ALL ^ ThemeFlags::TEXT); } - mDescContainer.applyTheme(theme, getName(), "md_description", POSITION | ThemeFlags::SIZE | Z_INDEX | VISIBLE); mDescription.setSize(mDescContainer.getSize().x(), 0); mDescription.applyTheme(theme, getName(), "md_description", ALL ^ (POSITION | ThemeFlags::SIZE | ThemeFlags::ORIGIN | TEXT | ROTATION)); + mLblRomName.applyTheme(theme, getName(), "md_lbl_playcount", ALL ^ (POSITION | ThemeFlags::SIZE | ThemeFlags::ORIGIN | ROTATION) | ThemeFlags::Z_INDEX); + mRomNameText.applyTheme(theme, getName(), "md_playcount", ALL ^ (POSITION | ThemeFlags::SIZE | ThemeFlags::ORIGIN | TEXT | ROTATION) | ThemeFlags::Z_INDEX); + mRomNameContainer.setZIndex(mLblRomName.getZIndex()); sortChildren(); } @@ -222,6 +240,8 @@ void VideoGameListView::initMDValues() mPlayers.setFont(defaultFont); mLastPlayed.setFont(defaultFont); mPlayCount.setFont(defaultFont); + mLblRomName.setFont(defaultFont); + mRomNameText.setFont(defaultFont); float bottom = 0.0f; @@ -236,10 +256,34 @@ void VideoGameListView::initMDValues() float testBot = values[i]->getPosition().y() + values[i]->getSize().y(); if(testBot > bottom) bottom = testBot; + + float labelBot = labels[i]->getPosition().y() + labels[i]->getSize().y(); + if(labelBot > bottom) + bottom = labelBot; } - mDescContainer.setPosition(mDescContainer.getPosition().x(), bottom + mSize.y() * 0.01f); - mDescContainer.setSize(mDescContainer.getSize().x(), mSize.y() - mDescContainer.getPosition().y()); + + const float rowY = bottom; + const float lineH = defaultFont->getHeight(); + + mLblRomName.setPosition(Vector3f(mLblPublisher.getPosition().x(), rowY, 0.0f)); + mLblRomName.setDefaultZIndex(40); + const float labelW = mLblRomName.getSize().x(); + mRomNameContainer.setPosition(mLblRomName.getPosition() + Vector3f(labelW, 0.0f, 0.0f)); + mRomNameContainer.setSize(mSize.x() * 0.48f - labelW, lineH); + + mRomNameText.setPosition(Vector3f(0, 0, 0)); + mRomNameText.setSize(0, lineH); + mRomNameText.setDefaultZIndex(40); + + const float rowPadding = 0.01f * mSize.y(); + const float rowBottom = rowY + mLblRomName.getSize().y(); + + // Preserve theme bottom edge: capture it after applyTheme, shrink top to fit ROM name row. + const float descBottom = mDescContainer.getPosition().y() + mDescContainer.getSize().y(); + const float newDescY = rowBottom + rowPadding; + mDescContainer.setPosition(mDescContainer.getPosition().x(), newDescY); + mDescContainer.setSize(mDescContainer.getSize().x(), descBottom - newDescY); } @@ -280,6 +324,12 @@ void VideoGameListView::updateInfoPanel() mGenre.setValue(file->metadata.get("genre")); mPlayers.setValue(file->metadata.get("players")); mName.setValue(file->metadata.get("name")); + const RomData* preferred = file->getPreferredRom(); + if(preferred != NULL && !preferred->romName.empty()) + mRomNameText.setValue(preferred->romName); + else + mRomNameText.setValue(file->getName()); + mRomNameContainer.reset(); if(file->getType() == GAME) { @@ -297,6 +347,8 @@ void VideoGameListView::updateInfoPanel() comps.push_back(&mDescription); comps.push_back(&mImage); comps.push_back(&mName); + comps.push_back(&mLblRomName); + comps.push_back(&mRomNameText); std::vector labels = getMDLabels(); comps.insert(comps.cend(), labels.cbegin(), labels.cend()); diff --git a/es-app/src/views/gamelist/VideoGameListView.h b/es-app/src/views/gamelist/VideoGameListView.h index b5f36c6277..59423da50d 100644 --- a/es-app/src/views/gamelist/VideoGameListView.h +++ b/es-app/src/views/gamelist/VideoGameListView.h @@ -39,6 +39,7 @@ class VideoGameListView : public BasicGameListView ImageComponent mImage; TextComponent mLblRating, mLblReleaseDate, mLblDeveloper, mLblPublisher, mLblGenre, mLblPlayers, mLblLastPlayed, mLblPlayCount; + TextComponent mLblRomName; RatingComponent mRating; DateTimeComponent mReleaseDate; @@ -48,6 +49,8 @@ class VideoGameListView : public BasicGameListView TextComponent mPlayers; DateTimeComponent mLastPlayed; TextComponent mPlayCount; + ScrollableContainer mRomNameContainer; + TextComponent mRomNameText; TextComponent mName; std::vector getMDLabels(); diff --git a/es-core/src/components/ScrollableContainer.cpp b/es-core/src/components/ScrollableContainer.cpp index cacbf3867d..d537a1616f 100644 --- a/es-core/src/components/ScrollableContainer.cpp +++ b/es-core/src/components/ScrollableContainer.cpp @@ -34,11 +34,11 @@ void ScrollableContainer::render(const Transform4x4f& parentTrans) Renderer::popClipRect(); } -void ScrollableContainer::setAutoScroll(bool autoScroll) +void ScrollableContainer::setAutoScroll(bool autoScroll, bool horizontal) { if(autoScroll) { - mScrollDir = Vector2f(0, 1); + mScrollDir = horizontal ? Vector2f(1, 0) : Vector2f(0, 1); if (mAutoScrollDelay == 0) { mAutoScrollDelay = AUTO_SCROLL_DELAY; @@ -85,7 +85,11 @@ void ScrollableContainer::update(int deltaTime) mScrollPos[1] = 0; const Vector2f contentSize = getContentSize(); - if(mScrollPos.x() + getSize().x() > contentSize.x()) + if(contentSize.x() < getSize().x()) + { + mScrollPos[0] = 0; + } + else if(mScrollPos.x() + getSize().x() > contentSize.x()) { mScrollPos[0] = contentSize.x() - getSize().x(); mAtEnd = true; diff --git a/es-core/src/components/ScrollableContainer.h b/es-core/src/components/ScrollableContainer.h index 4563e9103f..b6b7479cf2 100644 --- a/es-core/src/components/ScrollableContainer.h +++ b/es-core/src/components/ScrollableContainer.h @@ -11,7 +11,7 @@ class ScrollableContainer : public GuiComponent Vector2f getScrollPos() const; void setScrollPos(const Vector2f& pos); - void setAutoScroll(bool autoScroll); + void setAutoScroll(bool autoScroll, bool horizontal = false); void reset(); void update(int deltaTime) override; From cfa798a1024f7600036a7a4fc31d739abf955dee Mon Sep 17 00:00:00 2001 From: Ryan McClelland Date: Thu, 26 Feb 2026 09:55:43 -0800 Subject: [PATCH 14/19] add theme for romname --- es-app/src/views/gamelist/DetailedGameListView.cpp | 12 ++++-------- es-app/src/views/gamelist/GridGameListView.cpp | 5 +++-- es-app/src/views/gamelist/VideoGameListView.cpp | 5 +++-- 3 files changed, 10 insertions(+), 12 deletions(-) diff --git a/es-app/src/views/gamelist/DetailedGameListView.cpp b/es-app/src/views/gamelist/DetailedGameListView.cpp index a29720512d..12e6f3ca84 100644 --- a/es-app/src/views/gamelist/DetailedGameListView.cpp +++ b/es-app/src/views/gamelist/DetailedGameListView.cpp @@ -154,16 +154,12 @@ void DetailedGameListView::onThemeChanged(const std::shared_ptr& them values[i]->applyTheme(theme, getName(), valElements[i], ALL ^ ThemeFlags::TEXT); } - // Apply publisher-row colors + z-index to the ROM name row so it sorts correctly. - mLblRomName.applyTheme(theme, getName(), "md_lbl_publisher", ThemeFlags::COLOR | ThemeFlags::Z_INDEX); - mRomNameText.applyTheme(theme, getName(), "md_publisher", ThemeFlags::COLOR | ThemeFlags::Z_INDEX); - // Container has no matching theme element; set z-index directly. - mRomNameContainer.setZIndex(mLblRomName.getZIndex()); - mDescription.setSize(mDescContainer.getSize().x(), 0); mDescription.applyTheme(theme, getName(), "md_description", ALL ^ (POSITION | ThemeFlags::SIZE | ThemeFlags::ORIGIN | TEXT | ROTATION)); - mLblRomName.applyTheme(theme, getName(), "md_lbl_playcount", ALL ^ (POSITION | ThemeFlags::SIZE | ThemeFlags::ORIGIN | ROTATION)); - mRomNameText.applyTheme(theme, getName(), "md_playcount", ALL ^ (POSITION | ThemeFlags::SIZE | ThemeFlags::ORIGIN | TEXT | ROTATION)); + mLblRomName.applyTheme(theme, getName(), "md_lbl_romname", ALL ^ ROTATION); + mRomNameContainer.applyTheme(theme, getName(), "md_romname", POSITION | ThemeFlags::SIZE | Z_INDEX | VISIBLE); + mRomNameText.applyTheme(theme, getName(), "md_romname", ALL ^ (POSITION | ThemeFlags::SIZE | ThemeFlags::ORIGIN | TEXT | ROTATION)); + mRomNameContainer.setZIndex(mLblRomName.getZIndex()); sortChildren(); } diff --git a/es-app/src/views/gamelist/GridGameListView.cpp b/es-app/src/views/gamelist/GridGameListView.cpp index 209d05e7bd..d9f9c02ea1 100644 --- a/es-app/src/views/gamelist/GridGameListView.cpp +++ b/es-app/src/views/gamelist/GridGameListView.cpp @@ -243,8 +243,9 @@ void GridGameListView::onThemeChanged(const std::shared_ptr& theme) mDescription.setSize(mDescContainer.getSize().x(), 0); mDescription.applyTheme(theme, getName(), "md_description", ALL ^ (POSITION | ThemeFlags::SIZE | ThemeFlags::ORIGIN | TEXT | ROTATION)); - mLblRomName.applyTheme(theme, getName(), "md_lbl_playcount", ALL ^ (POSITION | ThemeFlags::SIZE | ThemeFlags::ORIGIN | ROTATION) | ThemeFlags::Z_INDEX); - mRomNameText.applyTheme(theme, getName(), "md_playcount", ALL ^ (POSITION | ThemeFlags::SIZE | ThemeFlags::ORIGIN | TEXT | ROTATION) | ThemeFlags::Z_INDEX); + mLblRomName.applyTheme(theme, getName(), "md_lbl_romname", ALL ^ ROTATION); + mRomNameContainer.applyTheme(theme, getName(), "md_romname", POSITION | ThemeFlags::SIZE | Z_INDEX | VISIBLE); + mRomNameText.applyTheme(theme, getName(), "md_romname", ALL ^ (POSITION | ThemeFlags::SIZE | ThemeFlags::ORIGIN | TEXT | ROTATION)); mRomNameContainer.setZIndex(mLblRomName.getZIndex()); // Repopulate list in case new theme is displaying a different image. Preserve selection. diff --git a/es-app/src/views/gamelist/VideoGameListView.cpp b/es-app/src/views/gamelist/VideoGameListView.cpp index 7be97f06b1..4bdf2bc30d 100644 --- a/es-app/src/views/gamelist/VideoGameListView.cpp +++ b/es-app/src/views/gamelist/VideoGameListView.cpp @@ -188,8 +188,9 @@ void VideoGameListView::onThemeChanged(const std::shared_ptr& theme) mDescription.setSize(mDescContainer.getSize().x(), 0); mDescription.applyTheme(theme, getName(), "md_description", ALL ^ (POSITION | ThemeFlags::SIZE | ThemeFlags::ORIGIN | TEXT | ROTATION)); - mLblRomName.applyTheme(theme, getName(), "md_lbl_playcount", ALL ^ (POSITION | ThemeFlags::SIZE | ThemeFlags::ORIGIN | ROTATION) | ThemeFlags::Z_INDEX); - mRomNameText.applyTheme(theme, getName(), "md_playcount", ALL ^ (POSITION | ThemeFlags::SIZE | ThemeFlags::ORIGIN | TEXT | ROTATION) | ThemeFlags::Z_INDEX); + mLblRomName.applyTheme(theme, getName(), "md_lbl_romname", ALL ^ ROTATION); + mRomNameContainer.applyTheme(theme, getName(), "md_romname", POSITION | ThemeFlags::SIZE | Z_INDEX | VISIBLE); + mRomNameText.applyTheme(theme, getName(), "md_romname", ALL ^ (POSITION | ThemeFlags::SIZE | ThemeFlags::ORIGIN | TEXT | ROTATION)); mRomNameContainer.setZIndex(mLblRomName.getZIndex()); sortChildren(); From d6a2c176dbed7a824445f9cf54f1c4338c787b07 Mon Sep 17 00:00:00 2001 From: Ryan McClelland Date: Fri, 27 Feb 2026 22:24:02 -0800 Subject: [PATCH 15/19] Add ROM metadata editor accessible from SELECT menu - Remove `revision` field from RomData struct and all parse/serialize references in Gamelist.cpp - Add GuiRomSelector: lists all ROM variants for a game (cartridge icon marks preferred), pressing A opens the metadata editor - Add GuiRomMetaDataEd: edits per-ROM fields (romName, releaseDate, image, video, thumbnail, marquee, regions, languages) with SAVE/CANCEL buttons and B-press "SAVE CHANGES?" prompt, same pattern as GuiMetaDataEd - Add "EDIT ROM METADATA" option in GuiGamelistOptions SELECT menu, shown only for GAME entries with at least one ROM in full UI mode - Use cartridge.svg icon in RomSelectionMenu preferred-ROM indicator Co-Authored-By: Claude Sonnet 4.6 --- es-app/CMakeLists.txt | 4 + es-app/src/FileData.h | 1 - es-app/src/Gamelist.cpp | 3 - es-app/src/guis/GuiGamelistOptions.cpp | 22 ++ es-app/src/guis/GuiGamelistOptions.h | 1 + es-app/src/guis/GuiRomMetaDataEd.cpp | 276 ++++++++++++++++++ es-app/src/guis/GuiRomMetaDataEd.h | 51 ++++ es-app/src/guis/GuiRomSelector.cpp | 77 +++++ es-app/src/guis/GuiRomSelector.h | 35 +++ .../views/gamelist/ISimpleGameListView.cpp | 2 +- 10 files changed, 467 insertions(+), 5 deletions(-) create mode 100644 es-app/src/guis/GuiRomMetaDataEd.cpp create mode 100644 es-app/src/guis/GuiRomMetaDataEd.h create mode 100644 es-app/src/guis/GuiRomSelector.cpp create mode 100644 es-app/src/guis/GuiRomSelector.h diff --git a/es-app/CMakeLists.txt b/es-app/CMakeLists.txt index a828a462f3..c3fd27220f 100644 --- a/es-app/CMakeLists.txt +++ b/es-app/CMakeLists.txt @@ -37,6 +37,8 @@ set(ES_HEADERS ${CMAKE_CURRENT_SOURCE_DIR}/src/guis/GuiCollectionSystemsOptions.h ${CMAKE_CURRENT_SOURCE_DIR}/src/guis/GuiRandomCollectionOptions.h ${CMAKE_CURRENT_SOURCE_DIR}/src/guis/GuiInfoPopup.h + ${CMAKE_CURRENT_SOURCE_DIR}/src/guis/GuiRomSelector.h + ${CMAKE_CURRENT_SOURCE_DIR}/src/guis/GuiRomMetaDataEd.h # Scrapers ${CMAKE_CURRENT_SOURCE_DIR}/src/scrapers/Scraper.h @@ -96,6 +98,8 @@ set(ES_SOURCES ${CMAKE_CURRENT_SOURCE_DIR}/src/guis/GuiCollectionSystemsOptions.cpp ${CMAKE_CURRENT_SOURCE_DIR}/src/guis/GuiRandomCollectionOptions.cpp ${CMAKE_CURRENT_SOURCE_DIR}/src/guis/GuiInfoPopup.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/src/guis/GuiRomSelector.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/src/guis/GuiRomMetaDataEd.cpp # Scrapers ${CMAKE_CURRENT_SOURCE_DIR}/src/scrapers/Scraper.cpp diff --git a/es-app/src/FileData.h b/es-app/src/FileData.h index 5021009e42..1429913a16 100644 --- a/es-app/src/FileData.h +++ b/es-app/src/FileData.h @@ -19,7 +19,6 @@ struct RomData std::vector languages; std::string releaseDate; - std::string revision; std::string image; std::string video; diff --git a/es-app/src/Gamelist.cpp b/es-app/src/Gamelist.cpp index 1bba8fb5b7..3feb5af545 100644 --- a/es-app/src/Gamelist.cpp +++ b/es-app/src/Gamelist.cpp @@ -69,7 +69,6 @@ namespace rom.regions = parseRomMultiValue(romNode, "region", "regions"); rom.languages = parseRomMultiValue(romNode, "language", "languages"); rom.releaseDate = romNode.child("releasedate").text().get(); - rom.revision = romNode.child("revision").text().get(); rom.image = Utils::FileSystem::resolveRelativePath(romNode.child("image").text().get(), relativeTo, true, true); rom.video = Utils::FileSystem::resolveRelativePath(romNode.child("video").text().get(), relativeTo, true, true); rom.thumbnail = Utils::FileSystem::resolveRelativePath(romNode.child("thumbnail").text().get(), relativeTo, true, true); @@ -350,7 +349,6 @@ void parseGamelist(SystemData* system) rom.regions = parseRomMultiValue(fileNode, "region", "regions"); rom.languages = parseRomMultiValue(fileNode, "language", "languages"); rom.releaseDate = fileNode.child("releasedate").text().get(); - rom.revision = fileNode.child("revision").text().get(); rom.image = Utils::FileSystem::resolveRelativePath(fileNode.child("image").text().get(), relativeTo, true, true); rom.video = Utils::FileSystem::resolveRelativePath(fileNode.child("video").text().get(), relativeTo, true, true); rom.thumbnail = Utils::FileSystem::resolveRelativePath(fileNode.child("thumbnail").text().get(), relativeTo, true, true); @@ -441,7 +439,6 @@ void addFileDataNode(pugi::xml_node& parent, const FileData* file, const char* t appendMultiValueNode(romNode, "regions", "region", it->regions); appendMultiValueNode(romNode, "languages", "language", it->languages); appendOptionalTextNode(romNode, "releasedate", it->releaseDate); - appendOptionalTextNode(romNode, "revision", it->revision); appendOptionalPathNode(romNode, "image", it->image, system->getStartPath()); appendOptionalPathNode(romNode, "video", it->video, system->getStartPath()); appendOptionalPathNode(romNode, "thumbnail", it->thumbnail, system->getStartPath()); diff --git a/es-app/src/guis/GuiGamelistOptions.cpp b/es-app/src/guis/GuiGamelistOptions.cpp index 436b131c46..e847ae7720 100644 --- a/es-app/src/guis/GuiGamelistOptions.cpp +++ b/es-app/src/guis/GuiGamelistOptions.cpp @@ -1,6 +1,7 @@ #include "GuiGamelistOptions.h" #include "guis/GuiGamelistFilter.h" +#include "guis/GuiRomSelector.h" #include "scrapers/Scraper.h" #include "views/gamelist/IGameListView.h" #include "views/UIModeController.h" @@ -152,6 +153,17 @@ GuiGamelistOptions::GuiGamelistOptions(Window* window, SystemData* system) : Gui mMenu.addRow(row); } + // "EDIT ROM METADATA" — only for GAME entries that have ROM variants + if(UIModeController::getInstance()->isUIModeFull() && !mFromPlaceholder && + file->getType() == GAME && !file->getSourceFileData()->getRoms().empty()) + { + row.elements.clear(); + row.addElement(std::make_shared(mWindow, "EDIT ROM METADATA", Font::get(FONT_SIZE_MEDIUM), 0x777777FF), true); + row.addElement(makeArrow(mWindow), false); + row.makeAcceptInputHandler(std::bind(&GuiGamelistOptions::openRomMetaDataEd, this)); + mMenu.addRow(row); + } + // center the menu setSize((float)Renderer::getScreenWidth(), (float)Renderer::getScreenHeight()); mMenu.setPosition((mSize.x() - mMenu.getSize().x()) / 2, (mSize.y() - mMenu.getSize().y()) / 2); @@ -274,6 +286,16 @@ void GuiGamelistOptions::openMetaDataEd() mWindow->pushGui(new GuiMetaDataEd(mWindow, &file->metadata, file->metadata.getMDD(), p, Utils::FileSystem::getFileName(file->getPath()), saveBtnFunc, deleteBtnFunc)); } +void GuiGamelistOptions::openRomMetaDataEd() +{ + FileData* file = getGamelist()->getCursor()->getSourceFileData(); + mWindow->pushGui(new GuiRomSelector(mWindow, file, [this, file] { + ViewController::get()->getGameListView(mSystem)->setCursor(file, true); + mMetadataChanged = true; + ViewController::get()->getGameListView(file->getSystem())->onFileChanged(file, FILE_METADATA_CHANGED); + })); +} + void GuiGamelistOptions::jumpToLetter() { char letter = mJumpToLetterList->getSelected(); diff --git a/es-app/src/guis/GuiGamelistOptions.h b/es-app/src/guis/GuiGamelistOptions.h index dd14399b54..c8b3b8d243 100644 --- a/es-app/src/guis/GuiGamelistOptions.h +++ b/es-app/src/guis/GuiGamelistOptions.h @@ -24,6 +24,7 @@ class GuiGamelistOptions : public GuiComponent void openGamelistFilter(); bool launchSystemScreenSaver(); void openMetaDataEd(); + void openRomMetaDataEd(); void startEditMode(); void recreateCollection(); void exitEditMode(); diff --git a/es-app/src/guis/GuiRomMetaDataEd.cpp b/es-app/src/guis/GuiRomMetaDataEd.cpp new file mode 100644 index 0000000000..bcea5354af --- /dev/null +++ b/es-app/src/guis/GuiRomMetaDataEd.cpp @@ -0,0 +1,276 @@ +#include "guis/GuiRomMetaDataEd.h" + +#include +#include "components/ButtonComponent.h" +#include "components/ComponentList.h" +#include "components/DateTimeEditComponent.h" +#include "components/MenuComponent.h" +#include "components/TextComponent.h" +#include "guis/GuiMsgBox.h" +#include "guis/GuiTextEditPopup.h" +#include "resources/Font.h" +#include "utils/StringUtil.h" +#include "views/ViewController.h" +#include "FileData.h" +#include "SystemData.h" +#include "Window.h" + +// Join a vector of strings with ", " separator +static std::string joinStrings(const std::vector& vec) +{ + std::string result; + for(unsigned int i = 0; i < vec.size(); ++i) + { + if(i > 0) + result += ", "; + result += vec[i]; + } + return result; +} + +// Split a string on "," and trim each token, skip empties +static std::vector splitTrimmed(const std::string& str) +{ + std::vector result; + std::istringstream ss(str); + std::string token; + while(std::getline(ss, token, ',')) + { + token = Utils::String::trim(token); + if(!token.empty()) + result.push_back(token); + } + return result; +} + +GuiRomMetaDataEd::GuiRomMetaDataEd(Window* window, FileData* game, unsigned int romIndex, std::function savedCallback) + : GuiComponent(window), + mBackground(window, ":/frame.png"), + mGrid(window, Vector2i(1, 3)), + mGame(game), + mRomIndex(romIndex), + mSavedCallback(savedCallback) +{ + addChild(&mBackground); + addChild(&mGrid); + + // Header + mHeaderGrid = std::make_shared(mWindow, Vector2i(1, 5)); + mTitle = std::make_shared(mWindow, "EDIT ROM METADATA", Font::get(FONT_SIZE_LARGE), 0x555555FF, ALIGN_CENTER); + + FileData* source = mGame->getSourceFileData(); + const std::vector& roms = source->getRoms(); + std::string romLabel; + if(mRomIndex < roms.size()) + romLabel = roms[mRomIndex].romName.empty() ? Utils::FileSystem::getFileName(roms[mRomIndex].path) : roms[mRomIndex].romName; + else + romLabel = Utils::FileSystem::getFileName(source->getPath()); + + mSubtitle = std::make_shared(mWindow, "ROM: " + Utils::String::toUpper(romLabel), Font::get(FONT_SIZE_SMALL), 0x777777FF, ALIGN_CENTER); + mHeaderGrid->setEntry(mTitle, Vector2i(0, 1), false, true); + mHeaderGrid->setEntry(mSubtitle, Vector2i(0, 3), false, true); + mGrid.setEntry(mHeaderGrid, Vector2i(0, 0), false, true); + + mList = std::make_shared(mWindow); + mGrid.setEntry(mList, Vector2i(0, 1), true, true); + + // Helper lambda to add a text+arrow row backed by GuiTextEditPopup + auto addTextRow = [&](const std::string& label, const std::string& initialValue, bool multiLine) { + ComponentListRow row; + auto lbl = std::make_shared(mWindow, label, Font::get(FONT_SIZE_SMALL), 0x777777FF); + row.addElement(lbl, true); + + auto ed = std::make_shared(mWindow, "", Font::get(FONT_SIZE_SMALL, FONT_PATH_LIGHT), 0x777777FF, ALIGN_RIGHT); + const float height = lbl->getSize().y() * 0.71f; + ed->setSize(0, height); + row.addElement(ed, true); + + auto spacer = std::make_shared(mWindow); + spacer->setSize(Renderer::getScreenWidth() * 0.005f, 0); + row.addElement(spacer, false); + + auto bracket = std::make_shared(mWindow); + bracket->setImage(":/arrow.svg"); + bracket->setResize(Vector2f(0, lbl->getFont()->getLetterHeight())); + row.addElement(bracket, false); + + ed->setValue(initialValue); + + auto updateVal = [ed](const std::string& newVal) { ed->setValue(newVal); }; + row.makeAcceptInputHandler([this, label, ed, updateVal, multiLine] { + mWindow->pushGui(new GuiTextEditPopup(mWindow, label, ed->getValue(), updateVal, multiLine)); + }); + + mList->addRow(row); + mEditors.push_back(ed); + mOriginalValues.push_back(initialValue); + }; + + // Helper lambda to add a date row + auto addDateRow = [&](const std::string& label, const std::string& initialValue) { + ComponentListRow row; + auto lbl = std::make_shared(mWindow, label, Font::get(FONT_SIZE_SMALL), 0x777777FF); + row.addElement(lbl, true); + + auto ed = std::make_shared(mWindow); + row.addElement(ed, false); + + auto spacer = std::make_shared(mWindow); + spacer->setSize(Renderer::getScreenWidth() * 0.0025f, 0); + row.addElement(spacer, false); + + // Pass input to the DateTimeEditComponent + row.input_handler = std::bind(&GuiComponent::input, ed.get(), std::placeholders::_1, std::placeholders::_2); + + ed->setValue(initialValue); + + mList->addRow(row); + mEditors.push_back(ed); + mOriginalValues.push_back(initialValue); + }; + + // Populate fields from current ROM data + const RomData* rom = (mRomIndex < roms.size()) ? &roms[mRomIndex] : nullptr; + + std::string romName = rom ? rom->romName : ""; + std::string releaseDate = rom ? rom->releaseDate : ""; + std::string image = rom ? rom->image : ""; + std::string video = rom ? rom->video : ""; + std::string thumbnail = rom ? rom->thumbnail : ""; + std::string marquee = rom ? rom->marquee : ""; + std::string regions = rom ? joinStrings(rom->regions) : ""; + std::string languages = rom ? joinStrings(rom->languages) : ""; + + addTextRow("ROM NAME", romName, false); // editor index 0 + addDateRow("RELEASE DATE", releaseDate); // editor index 1 + addTextRow("IMAGE", image, false); // editor index 2 + addTextRow("VIDEO", video, false); // editor index 3 + addTextRow("THUMBNAIL", thumbnail, false); // editor index 4 + addTextRow("MARQUEE", marquee, false); // editor index 5 + addTextRow("REGIONS", regions, false); // editor index 6 + addTextRow("LANGUAGES", languages, false); // editor index 7 + + // Buttons: SAVE, CANCEL + std::vector> buttons; + buttons.push_back(std::make_shared(mWindow, "SAVE", "save", [&] { save(); delete this; })); + buttons.push_back(std::make_shared(mWindow, "CANCEL", "cancel", [&] { delete this; })); + + mButtons = makeButtonGrid(mWindow, buttons); + mGrid.setEntry(mButtons, Vector2i(0, 2), true, false); + + // Resize and center + float width = (float)Math::min(Renderer::getScreenHeight(), (int)(Renderer::getScreenWidth() * 0.90f)); + setSize(width, Renderer::getScreenHeight() * 0.82f); + setPosition((Renderer::getScreenWidth() - mSize.x()) / 2, (Renderer::getScreenHeight() - mSize.y()) / 2); +} + +void GuiRomMetaDataEd::onSizeChanged() +{ + mBackground.fitTo(mSize, Vector3f::Zero(), Vector2f(-32, -32)); + mGrid.setSize(mSize); + + const float titleHeight = mTitle->getFont()->getLetterHeight(); + const float subtitleHeight = mSubtitle->getFont()->getLetterHeight(); + const float titleSubtitleSpacing = mSize.y() * 0.03f; + + mGrid.setRowHeightPerc(0, (titleHeight + titleSubtitleSpacing + subtitleHeight + TITLE_VERT_PADDING) / mSize.y()); + mGrid.setRowHeightPerc(2, mButtons->getSize().y() / mSize.y()); + + mHeaderGrid->setRowHeightPerc(1, titleHeight / mHeaderGrid->getSize().y()); + mHeaderGrid->setRowHeightPerc(2, titleSubtitleSpacing / mHeaderGrid->getSize().y()); + mHeaderGrid->setRowHeightPerc(3, subtitleHeight / mHeaderGrid->getSize().y()); +} + +void GuiRomMetaDataEd::save() +{ + FileData* source = mGame->getSourceFileData(); + std::vector& roms = source->getRomsMutable(); + if(mRomIndex >= roms.size()) + return; + + RomData& rom = roms[mRomIndex]; + + // editor indices match the order in the constructor + rom.romName = mEditors[0]->getValue(); + rom.releaseDate = mEditors[1]->getValue(); + rom.image = mEditors[2]->getValue(); + rom.video = mEditors[3]->getValue(); + rom.thumbnail = mEditors[4]->getValue(); + rom.marquee = mEditors[5]->getValue(); + rom.regions = splitTrimmed(mEditors[6]->getValue()); + rom.languages = splitTrimmed(mEditors[7]->getValue()); + + // Mark dirty so the gamelist writer picks up the change + source->metadata.set("name", source->metadata.get("name")); + source->getSystem()->onMetaDataSavePoint(); + + ViewController::get()->onFileChanged(source, FILE_METADATA_CHANGED); + + if(mSavedCallback) + mSavedCallback(); +} + +void GuiRomMetaDataEd::close(bool closeAllWindows) +{ + bool dirty = hasChanges(); + + std::function closeFunc; + if(!closeAllWindows) + { + closeFunc = [this] { delete this; }; + } + else + { + Window* window = mWindow; + closeFunc = [window, this] { + while(window->peekGui() != ViewController::get()) + delete window->peekGui(); + }; + } + + if(dirty) + { + mWindow->pushGui(new GuiMsgBox(mWindow, + "SAVE CHANGES?", + "YES", [this, closeFunc] { save(); closeFunc(); }, + "NO", closeFunc + )); + } + else + { + closeFunc(); + } +} + +bool GuiRomMetaDataEd::hasChanges() +{ + for(unsigned int i = 0; i < mEditors.size() && i < mOriginalValues.size(); ++i) + { + if(mEditors[i]->getValue() != mOriginalValues[i]) + return true; + } + return false; +} + +bool GuiRomMetaDataEd::input(InputConfig* config, Input input) +{ + if(GuiComponent::input(config, input)) + return true; + + const bool isStart = config->isMappedTo("start", input); + if(input.value != 0 && (config->isMappedTo("b", input) || isStart)) + { + close(isStart); + return true; + } + + return false; +} + +std::vector GuiRomMetaDataEd::getHelpPrompts() +{ + std::vector prompts = mGrid.getHelpPrompts(); + prompts.push_back(HelpPrompt("b", "back")); + prompts.push_back(HelpPrompt("start", "close")); + return prompts; +} diff --git a/es-app/src/guis/GuiRomMetaDataEd.h b/es-app/src/guis/GuiRomMetaDataEd.h new file mode 100644 index 0000000000..b08484cb1a --- /dev/null +++ b/es-app/src/guis/GuiRomMetaDataEd.h @@ -0,0 +1,51 @@ +#pragma once +#ifndef ES_APP_GUIS_GUI_ROM_META_DATA_ED_H +#define ES_APP_GUIS_GUI_ROM_META_DATA_ED_H + +#include "components/ComponentGrid.h" +#include "components/NinePatchComponent.h" +#include "GuiComponent.h" +#include +#include +#include +#include + +class ComponentList; +class TextComponent; +class FileData; + +class GuiRomMetaDataEd : public GuiComponent +{ +public: + GuiRomMetaDataEd(Window* window, FileData* game, unsigned int romIndex, std::function savedCallback); + + bool input(InputConfig* config, Input input) override; + void onSizeChanged() override; + std::vector getHelpPrompts() override; + +private: + void save(); + void close(bool closeAllWindows); + bool hasChanges(); + + NinePatchComponent mBackground; + ComponentGrid mGrid; + + std::shared_ptr mTitle; + std::shared_ptr mSubtitle; + std::shared_ptr mHeaderGrid; + std::shared_ptr mList; + std::shared_ptr mButtons; + + FileData* mGame; + unsigned int mRomIndex; + std::function mSavedCallback; + + // Editors in order: romName, releaseDate, image, video, thumbnail, marquee, regions, languages + std::vector> mEditors; + + // Original values for change detection (same order as mEditors) + std::vector mOriginalValues; +}; + +#endif // ES_APP_GUIS_GUI_ROM_META_DATA_ED_H diff --git a/es-app/src/guis/GuiRomSelector.cpp b/es-app/src/guis/GuiRomSelector.cpp new file mode 100644 index 0000000000..6436cdd586 --- /dev/null +++ b/es-app/src/guis/GuiRomSelector.cpp @@ -0,0 +1,77 @@ +#include "guis/GuiRomSelector.h" + +#include "components/ImageComponent.h" +#include "components/TextComponent.h" +#include "guis/GuiRomMetaDataEd.h" +#include "resources/Font.h" +#include "FileData.h" +#include "Window.h" + +GuiRomSelector::GuiRomSelector(Window* window, FileData* game, std::function metadataChangedCallback) + : GuiSettings(window, "SELECT ROM"), mGame(game), mMetadataChangedCallback(metadataChangedCallback), mAnyEdited(false) +{ + buildRows(); +} + +GuiRomSelector::~GuiRomSelector() +{ + if(mAnyEdited && mMetadataChangedCallback) + mMetadataChangedCallback(); +} + +void GuiRomSelector::buildRows() +{ + if(mGame == nullptr) + return; + + const std::vector& roms = mGame->getRoms(); + for(unsigned int i = 0; i < roms.size(); ++i) + { + ComponentListRow row; + + auto star = std::make_shared(mWindow); + const float starSize = Font::get(FONT_SIZE_MEDIUM)->getLetterHeight(); + star->setImage(":/cartridge.svg"); + star->setResize(starSize, starSize); + star->setVisible(roms[i].preferred); + + std::string romName = roms[i].romName.empty() ? mGame->getName() : roms[i].romName; + auto label = std::make_shared(mWindow, romName, Font::get(FONT_SIZE_MEDIUM), 0x777777FF); + + auto bracket = std::make_shared(mWindow); + bracket->setImage(":/arrow.svg"); + bracket->setResize(Vector2f(0, Font::get(FONT_SIZE_MEDIUM)->getLetterHeight())); + + row.addElement(star, false, false); + row.addElement(label, true); + row.addElement(bracket, false); + + row.makeAcceptInputHandler([this, i] { openMetaEd(i); }); + + addRow(row); + + RomRowWidgets widgets; + widgets.star = star; + mRows.push_back(widgets); + } + + updatePreferredIndicator(); +} + +void GuiRomSelector::openMetaEd(unsigned int romIndex) +{ + mWindow->pushGui(new GuiRomMetaDataEd(mWindow, mGame, romIndex, [this] { + mAnyEdited = true; + updatePreferredIndicator(); + })); +} + +void GuiRomSelector::updatePreferredIndicator() +{ + if(mGame == nullptr) + return; + + const std::vector& roms = mGame->getRoms(); + for(unsigned int i = 0; i < roms.size() && i < mRows.size(); ++i) + mRows[i].star->setVisible(roms[i].preferred); +} diff --git a/es-app/src/guis/GuiRomSelector.h b/es-app/src/guis/GuiRomSelector.h new file mode 100644 index 0000000000..5052613601 --- /dev/null +++ b/es-app/src/guis/GuiRomSelector.h @@ -0,0 +1,35 @@ +#pragma once +#ifndef ES_APP_GUIS_GUI_ROM_SELECTOR_H +#define ES_APP_GUIS_GUI_ROM_SELECTOR_H + +#include "guis/GuiSettings.h" +#include "FileData.h" +#include +#include +#include + +class ImageComponent; + +class GuiRomSelector : public GuiSettings +{ +public: + GuiRomSelector(Window* window, FileData* game, std::function metadataChangedCallback); + virtual ~GuiRomSelector(); + +private: + struct RomRowWidgets + { + std::shared_ptr star; + }; + + void buildRows(); + void openMetaEd(unsigned int romIndex); + void updatePreferredIndicator(); + + FileData* mGame; + std::function mMetadataChangedCallback; + bool mAnyEdited; + std::vector mRows; +}; + +#endif // ES_APP_GUIS_GUI_ROM_SELECTOR_H diff --git a/es-app/src/views/gamelist/ISimpleGameListView.cpp b/es-app/src/views/gamelist/ISimpleGameListView.cpp index 14a05c3999..d19930611b 100644 --- a/es-app/src/views/gamelist/ISimpleGameListView.cpp +++ b/es-app/src/views/gamelist/ISimpleGameListView.cpp @@ -72,7 +72,7 @@ namespace auto star = std::make_shared(mWindow); const float starSize = Font::get(FONT_SIZE_MEDIUM)->getLetterHeight(); - star->setImage(":/star_filled.svg"); + star->setImage(":/cartridge.svg"); star->setResize(starSize, starSize); star->setVisible(roms[i].preferred); From 72714082ffd021b0c35df9d76e287bac631a312e Mon Sep 17 00:00:00 2001 From: Ryan McClelland Date: Fri, 27 Feb 2026 22:37:40 -0800 Subject: [PATCH 16/19] Move ROM selection into SELECT menu; restore X to random game - Extract RomSelectionMenu from ISimpleGameListView.cpp anonymous namespace into GuiRomSelectionMenu (GuiSettings subclass) so it can be reused from GuiGamelistOptions - Add "SELECT PREFERRED ROM" option in the SELECT menu (GuiGamelistOptions), shown only for GAME entries with at least one ROM in full UI mode - Revert X button in ISimpleGameListView back to original behavior: jump to a random game in the current system Co-Authored-By: Claude Sonnet 4.6 --- es-app/CMakeLists.txt | 2 + es-app/src/guis/GuiGamelistOptions.cpp | 18 ++ es-app/src/guis/GuiGamelistOptions.h | 1 + es-app/src/guis/GuiRomSelectionMenu.cpp | 164 +++++++++++++++ es-app/src/guis/GuiRomSelectionMenu.h | 36 ++++ .../views/gamelist/ISimpleGameListView.cpp | 191 +----------------- 6 files changed, 226 insertions(+), 186 deletions(-) create mode 100644 es-app/src/guis/GuiRomSelectionMenu.cpp create mode 100644 es-app/src/guis/GuiRomSelectionMenu.h diff --git a/es-app/CMakeLists.txt b/es-app/CMakeLists.txt index c3fd27220f..1316bc653a 100644 --- a/es-app/CMakeLists.txt +++ b/es-app/CMakeLists.txt @@ -37,6 +37,7 @@ set(ES_HEADERS ${CMAKE_CURRENT_SOURCE_DIR}/src/guis/GuiCollectionSystemsOptions.h ${CMAKE_CURRENT_SOURCE_DIR}/src/guis/GuiRandomCollectionOptions.h ${CMAKE_CURRENT_SOURCE_DIR}/src/guis/GuiInfoPopup.h + ${CMAKE_CURRENT_SOURCE_DIR}/src/guis/GuiRomSelectionMenu.h ${CMAKE_CURRENT_SOURCE_DIR}/src/guis/GuiRomSelector.h ${CMAKE_CURRENT_SOURCE_DIR}/src/guis/GuiRomMetaDataEd.h @@ -98,6 +99,7 @@ set(ES_SOURCES ${CMAKE_CURRENT_SOURCE_DIR}/src/guis/GuiCollectionSystemsOptions.cpp ${CMAKE_CURRENT_SOURCE_DIR}/src/guis/GuiRandomCollectionOptions.cpp ${CMAKE_CURRENT_SOURCE_DIR}/src/guis/GuiInfoPopup.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/src/guis/GuiRomSelectionMenu.cpp ${CMAKE_CURRENT_SOURCE_DIR}/src/guis/GuiRomSelector.cpp ${CMAKE_CURRENT_SOURCE_DIR}/src/guis/GuiRomMetaDataEd.cpp diff --git a/es-app/src/guis/GuiGamelistOptions.cpp b/es-app/src/guis/GuiGamelistOptions.cpp index e847ae7720..6e867c04ea 100644 --- a/es-app/src/guis/GuiGamelistOptions.cpp +++ b/es-app/src/guis/GuiGamelistOptions.cpp @@ -1,6 +1,7 @@ #include "GuiGamelistOptions.h" #include "guis/GuiGamelistFilter.h" +#include "guis/GuiRomSelectionMenu.h" #include "guis/GuiRomSelector.h" #include "scrapers/Scraper.h" #include "views/gamelist/IGameListView.h" @@ -153,6 +154,17 @@ GuiGamelistOptions::GuiGamelistOptions(Window* window, SystemData* system) : Gui mMenu.addRow(row); } + // "SELECT PREFERRED ROM" — only for GAME entries that have ROM variants + if(UIModeController::getInstance()->isUIModeFull() && !mFromPlaceholder && + file->getType() == GAME && !file->getSourceFileData()->getRoms().empty()) + { + row.elements.clear(); + row.addElement(std::make_shared(mWindow, "SELECT PREFERRED ROM", Font::get(FONT_SIZE_MEDIUM), 0x777777FF), true); + row.addElement(makeArrow(mWindow), false); + row.makeAcceptInputHandler(std::bind(&GuiGamelistOptions::openPreferredRomSelector, this)); + mMenu.addRow(row); + } + // "EDIT ROM METADATA" — only for GAME entries that have ROM variants if(UIModeController::getInstance()->isUIModeFull() && !mFromPlaceholder && file->getType() == GAME && !file->getSourceFileData()->getRoms().empty()) @@ -286,6 +298,12 @@ void GuiGamelistOptions::openMetaDataEd() mWindow->pushGui(new GuiMetaDataEd(mWindow, &file->metadata, file->metadata.getMDD(), p, Utils::FileSystem::getFileName(file->getPath()), saveBtnFunc, deleteBtnFunc)); } +void GuiGamelistOptions::openPreferredRomSelector() +{ + FileData* file = getGamelist()->getCursor()->getSourceFileData(); + mWindow->pushGui(new GuiRomSelectionMenu(mWindow, file)); +} + void GuiGamelistOptions::openRomMetaDataEd() { FileData* file = getGamelist()->getCursor()->getSourceFileData(); diff --git a/es-app/src/guis/GuiGamelistOptions.h b/es-app/src/guis/GuiGamelistOptions.h index c8b3b8d243..10281da263 100644 --- a/es-app/src/guis/GuiGamelistOptions.h +++ b/es-app/src/guis/GuiGamelistOptions.h @@ -25,6 +25,7 @@ class GuiGamelistOptions : public GuiComponent bool launchSystemScreenSaver(); void openMetaDataEd(); void openRomMetaDataEd(); + void openPreferredRomSelector(); void startEditMode(); void recreateCollection(); void exitEditMode(); diff --git a/es-app/src/guis/GuiRomSelectionMenu.cpp b/es-app/src/guis/GuiRomSelectionMenu.cpp new file mode 100644 index 0000000000..4a98962ab5 --- /dev/null +++ b/es-app/src/guis/GuiRomSelectionMenu.cpp @@ -0,0 +1,164 @@ +#include "guis/GuiRomSelectionMenu.h" + +#include "components/ImageComponent.h" +#include "components/TextComponent.h" +#include "views/gamelist/IGameListView.h" +#include "views/ViewController.h" +#include "FileData.h" +#include "SystemData.h" +#include "Window.h" + +// Local helper: shows "a: launch, y: preferred" in the help bar for each ROM row +namespace +{ + class RomPromptTextComponent : public TextComponent + { + public: + RomPromptTextComponent(Window* window, const std::string& text, const std::shared_ptr& font, unsigned int color) + : TextComponent(window, text, font, color) + { + } + + std::vector getHelpPrompts() override + { + std::vector prompts; + prompts.push_back(HelpPrompt("a", "launch")); + prompts.push_back(HelpPrompt("y", "preferred")); + return prompts; + } + }; +} + +GuiRomSelectionMenu::GuiRomSelectionMenu(Window* window, FileData* game) + : GuiSettings(window, "ROMS"), mGame(game), mPreferredChanged(false) +{ + buildRows(); +} + +GuiRomSelectionMenu::~GuiRomSelectionMenu() +{ + if(!mPreferredChanged || mGame == nullptr) + return; + + // Preferred ROM changes alter effective media/launch selection, so notify both + // the visible entry and canonical source entry to refresh info panels immediately. + ViewController::get()->onFileChanged(mGame, FILE_METADATA_CHANGED); + FileData* source = mGame->getSourceFileData(); + if(source != mGame) + ViewController::get()->onFileChanged(source, FILE_METADATA_CHANGED); +} + +void GuiRomSelectionMenu::buildRows() +{ + if(mGame == nullptr) + return; + + const std::vector& roms = mGame->getRoms(); + for(unsigned int i = 0; i < roms.size(); ++i) + { + ComponentListRow row; + + auto star = std::make_shared(mWindow); + const float starSize = Font::get(FONT_SIZE_MEDIUM)->getLetterHeight(); + star->setImage(":/cartridge.svg"); + star->setResize(starSize, starSize); + star->setVisible(roms[i].preferred); + + std::string romName = roms[i].romName.empty() ? mGame->getName() : roms[i].romName; + auto label = std::make_shared(mWindow, romName, Font::get(FONT_SIZE_MEDIUM), 0x777777FF); + + row.addElement(star, false, false); + row.addElement(label, true); + row.input_handler = [this, i](InputConfig* config, Input input) -> bool { + if(input.value == 0) + return false; + + if(config->isMappedTo("a", input)) + { + launchRom(i); + return true; + } + + if(config->isMappedTo("y", input)) + { + setPreferredRom(i); + return true; + } + + return false; + }; + + addRow(row); + + RomRowWidgets widgets; + widgets.star = star; + widgets.name = label; + mRows.push_back(widgets); + } + + updatePreferredIndicator(); +} + +void GuiRomSelectionMenu::setPreferredRom(unsigned int index) +{ + if(mGame == nullptr) + return; + + FileData* source = mGame->getSourceFileData(); + std::vector& roms = source->getRomsMutable(); + if(index >= roms.size()) + return; + + for(unsigned int i = 0; i < roms.size(); ++i) + roms[i].preferred = (i == index); + + // Mark metadata dirty so onMetaDataSavePoint() persists updated ROM preference. + source->metadata.set("name", source->metadata.get("name")); + source->getSystem()->onMetaDataSavePoint(); + + if(mGame != source) + mGame->refreshMetadata(); + + mPreferredChanged = true; + updatePreferredIndicator(); +} + +void GuiRomSelectionMenu::launchRom(unsigned int index) +{ + if(mGame == nullptr) + return; + + FileData* selectedEntry = mGame; + FileData* source = selectedEntry->getSourceFileData(); + const std::vector& roms = source->getRoms(); + if(index >= roms.size() || roms[index].path.empty()) + return; + + const std::string launchPath = roms[index].path; + std::shared_ptr currentGameList = ViewController::get()->getGameListView(selectedEntry->getSystem()); + if(currentGameList) + currentGameList->onHide(); + + // Keep this popup alive so returning from gameplay lands back on the + // same ROM selection screen and highlighted row. + source->launchGame(mWindow, launchPath); + ViewController::get()->onFileChanged(source, FILE_METADATA_CHANGED); + if(selectedEntry != source) + ViewController::get()->onFileChanged(selectedEntry, FILE_METADATA_CHANGED); + + if(currentGameList) + { + currentGameList->setCursor(selectedEntry, true); + currentGameList->onShow(); + } +} + +void GuiRomSelectionMenu::updatePreferredIndicator() +{ + if(mGame == nullptr) + return; + + const std::vector& roms = mGame->getRoms(); + for(unsigned int i = 0; i < roms.size() && i < mRows.size(); ++i) + mRows[i].star->setVisible(roms[i].preferred); +} diff --git a/es-app/src/guis/GuiRomSelectionMenu.h b/es-app/src/guis/GuiRomSelectionMenu.h new file mode 100644 index 0000000000..6ba9b2f1ab --- /dev/null +++ b/es-app/src/guis/GuiRomSelectionMenu.h @@ -0,0 +1,36 @@ +#pragma once +#ifndef ES_APP_GUIS_GUI_ROM_SELECTION_MENU_H +#define ES_APP_GUIS_GUI_ROM_SELECTION_MENU_H + +#include "guis/GuiSettings.h" +#include "FileData.h" +#include +#include + +class ImageComponent; +class TextComponent; + +class GuiRomSelectionMenu : public GuiSettings +{ +public: + GuiRomSelectionMenu(Window* window, FileData* game); + ~GuiRomSelectionMenu(); + +private: + struct RomRowWidgets + { + std::shared_ptr star; + std::shared_ptr name; + }; + + void buildRows(); + void setPreferredRom(unsigned int index); + void launchRom(unsigned int index); + void updatePreferredIndicator(); + + FileData* mGame; + bool mPreferredChanged; + std::vector mRows; +}; + +#endif // ES_APP_GUIS_GUI_ROM_SELECTION_MENU_H diff --git a/es-app/src/views/gamelist/ISimpleGameListView.cpp b/es-app/src/views/gamelist/ISimpleGameListView.cpp index d19930611b..b9ab3cb326 100644 --- a/es-app/src/views/gamelist/ISimpleGameListView.cpp +++ b/es-app/src/views/gamelist/ISimpleGameListView.cpp @@ -1,6 +1,5 @@ #include "views/gamelist/ISimpleGameListView.h" -#include "guis/GuiSettings.h" #include "components/ImageComponent.h" #include "components/TextComponent.h" #include "views/UIModeController.h" @@ -12,187 +11,6 @@ #include "SystemData.h" #include "Window.h" -namespace -{ - class RomPromptTextComponent : public TextComponent - { - public: - RomPromptTextComponent(Window* window, const std::string& text, const std::shared_ptr& font, unsigned int color) - : TextComponent(window, text, font, color) - { - } - - std::vector getHelpPrompts() override - { - std::vector prompts; - prompts.push_back(HelpPrompt("a", "launch")); - prompts.push_back(HelpPrompt("y", "preferred")); - return prompts; - } - }; - - class RomSelectionMenu : public GuiSettings - { - public: - RomSelectionMenu(Window* window, FileData* game) - : GuiSettings(window, "ROMS"), mGame(game), mPreferredChanged(false) - { - buildRows(); - } - - ~RomSelectionMenu() - { - if(!mPreferredChanged || mGame == nullptr) - return; - - // Preferred ROM changes alter effective media/launch selection, so notify both - // the visible entry and canonical source entry to refresh info panels immediately. - ViewController::get()->onFileChanged(mGame, FILE_METADATA_CHANGED); - FileData* source = mGame->getSourceFileData(); - if(source != mGame) - ViewController::get()->onFileChanged(source, FILE_METADATA_CHANGED); - } - - private: - struct RomRowWidgets - { - std::shared_ptr star; - std::shared_ptr name; - }; - - void buildRows() - { - if(mGame == nullptr) - return; - - const std::vector& roms = mGame->getRoms(); - for(unsigned int i = 0; i < roms.size(); ++i) - { - ComponentListRow row; - - auto star = std::make_shared(mWindow); - const float starSize = Font::get(FONT_SIZE_MEDIUM)->getLetterHeight(); - star->setImage(":/cartridge.svg"); - star->setResize(starSize, starSize); - star->setVisible(roms[i].preferred); - - std::string romName = roms[i].romName.empty() ? mGame->getName() : roms[i].romName; - auto label = std::make_shared(mWindow, romName, Font::get(FONT_SIZE_MEDIUM), 0x777777FF); - - row.addElement(star, false, false); - row.addElement(label, true); - row.input_handler = [this, i](InputConfig* config, Input input) -> bool { - if(input.value == 0) - return false; - - if(config->isMappedTo("a", input)) - { - launchRom(i); - return true; - } - - if(config->isMappedTo("y", input)) - { - setPreferredRom(i); - return true; - } - - return false; - }; - - addRow(row); - - RomRowWidgets widgets; - widgets.star = star; - widgets.name = label; - mRows.push_back(widgets); - } - - updatePreferredIndicator(); - } - - void setPreferredRom(unsigned int index) - { - if(mGame == nullptr) - return; - - FileData* source = mGame->getSourceFileData(); - std::vector& roms = source->getRomsMutable(); - if(index >= roms.size()) - return; - - for(unsigned int i = 0; i < roms.size(); ++i) - roms[i].preferred = (i == index); - - // Mark metadata dirty so onMetaDataSavePoint() persists updated ROM preference. - source->metadata.set("name", source->metadata.get("name")); - source->getSystem()->onMetaDataSavePoint(); - - if(mGame != source) - mGame->refreshMetadata(); - - mPreferredChanged = true; - updatePreferredIndicator(); - } - - void launchRom(unsigned int index) - { - if(mGame == nullptr) - return; - - FileData* selectedEntry = mGame; - FileData* source = selectedEntry->getSourceFileData(); - const std::vector& roms = source->getRoms(); - if(index >= roms.size() || roms[index].path.empty()) - return; - - const std::string launchPath = roms[index].path; - std::shared_ptr currentGameList = ViewController::get()->getGameListView(selectedEntry->getSystem()); - if(currentGameList) - currentGameList->onHide(); - - // Keep this popup alive so returning from gameplay lands back on the - // same ROM selection screen and highlighted row. - source->launchGame(mWindow, launchPath); - ViewController::get()->onFileChanged(source, FILE_METADATA_CHANGED); - if(selectedEntry != source) - ViewController::get()->onFileChanged(selectedEntry, FILE_METADATA_CHANGED); - - if(currentGameList) - { - currentGameList->setCursor(selectedEntry, true); - currentGameList->onShow(); - } - } - - void updatePreferredIndicator() - { - if(mGame == nullptr) - return; - - const std::vector& roms = mGame->getRoms(); - for(unsigned int i = 0; i < roms.size() && i < mRows.size(); ++i) - mRows[i].star->setVisible(roms[i].preferred); - } - - FileData* mGame; - bool mPreferredChanged; - std::vector mRows; - }; - - void openRomSelectionMenu(Window* window, FileData* game) - { - if(game == nullptr || game->getType() != GAME) - return; - - const std::vector& roms = game->getRoms(); - if(roms.empty()) - return; - - window->pushGui(new RomSelectionMenu(window, game)); - } -} - ISimpleGameListView::ISimpleGameListView(Window* window, FileData* root) : IGameListView(window, root), mHeaderText(window), mHeaderImage(window), mBackground(window) { @@ -334,12 +152,13 @@ bool ISimpleGameListView::input(InputConfig* config, Input input) { if (mRoot->getSystem()->isGameSystem()) { - FileData* cursor = getCursor(); - if(cursor->getType() == GAME) + // go to random system game + FileData* randomGame = getCursor()->getSystem()->getRandomGame(); + if (randomGame) { - openRomSelectionMenu(mWindow, cursor); - return true; + setCursor(randomGame); } + return true; } }else if (config->isMappedTo("y", input) && !UIModeController::getInstance()->isUIModeKid()) { From d2cedb28d84f3ca98ba21c4e9146d1efe5326fde Mon Sep 17 00:00:00 2001 From: Ryan McClelland Date: Fri, 27 Feb 2026 22:39:37 -0800 Subject: [PATCH 17/19] Show SELECT PREFERRED ROM only when game has more than one ROM Co-Authored-By: Claude Sonnet 4.6 --- es-app/src/guis/GuiGamelistOptions.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/es-app/src/guis/GuiGamelistOptions.cpp b/es-app/src/guis/GuiGamelistOptions.cpp index 6e867c04ea..29d574886c 100644 --- a/es-app/src/guis/GuiGamelistOptions.cpp +++ b/es-app/src/guis/GuiGamelistOptions.cpp @@ -154,9 +154,9 @@ GuiGamelistOptions::GuiGamelistOptions(Window* window, SystemData* system) : Gui mMenu.addRow(row); } - // "SELECT PREFERRED ROM" — only for GAME entries that have ROM variants + // "SELECT PREFERRED ROM" — only for GAME entries that have more than one ROM variant if(UIModeController::getInstance()->isUIModeFull() && !mFromPlaceholder && - file->getType() == GAME && !file->getSourceFileData()->getRoms().empty()) + file->getType() == GAME && file->getSourceFileData()->getRoms().size() > 1) { row.elements.clear(); row.addElement(std::make_shared(mWindow, "SELECT PREFERRED ROM", Font::get(FONT_SIZE_MEDIUM), 0x777777FF), true); From e1d9636e2ef7be814484d43d1b0944d4fb83a9c3 Mon Sep 17 00:00:00 2001 From: Ryan McClelland Date: Fri, 27 Feb 2026 22:42:07 -0800 Subject: [PATCH 18/19] revert help for x button to random in basic game list --- es-app/src/views/gamelist/BasicGameListView.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/es-app/src/views/gamelist/BasicGameListView.cpp b/es-app/src/views/gamelist/BasicGameListView.cpp index ae953c98c1..da4fae086f 100644 --- a/es-app/src/views/gamelist/BasicGameListView.cpp +++ b/es-app/src/views/gamelist/BasicGameListView.cpp @@ -217,7 +217,7 @@ std::vector BasicGameListView::getHelpPrompts() if(!UIModeController::getInstance()->isUIModeKid()) prompts.push_back(HelpPrompt("select", "options")); if(mRoot->getSystem()->isGameSystem()) - prompts.push_back(HelpPrompt("x", "roms")); + prompts.push_back(HelpPrompt("x", "random")); if(mRoot->getSystem()->isGameSystem() && !UIModeController::getInstance()->isUIModeKid()) { std::string prompt = CollectionSystemManager::get()->getEditingCollection(); From 6d5132bc34b23637a109b86fd9fe6564cce3102f Mon Sep 17 00:00:00 2001 From: Ryan McClelland Date: Fri, 27 Feb 2026 23:35:16 -0800 Subject: [PATCH 19/19] fix help for x to be random --- es-app/src/views/gamelist/GridGameListView.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/es-app/src/views/gamelist/GridGameListView.cpp b/es-app/src/views/gamelist/GridGameListView.cpp index d9f9c02ea1..f4ce0a942f 100644 --- a/es-app/src/views/gamelist/GridGameListView.cpp +++ b/es-app/src/views/gamelist/GridGameListView.cpp @@ -533,7 +533,7 @@ std::vector GridGameListView::getHelpPrompts() if(!UIModeController::getInstance()->isUIModeKid()) prompts.push_back(HelpPrompt("select", "options")); if(mRoot->getSystem()->isGameSystem()) - prompts.push_back(HelpPrompt("x", "roms")); + prompts.push_back(HelpPrompt("x", "random")); if(mRoot->getSystem()->isGameSystem() && !UIModeController::getInstance()->isUIModeKid()) { std::string prompt = CollectionSystemManager::get()->getEditingCollection();