diff --git a/src/core/fullscreen_ui.cpp b/src/core/fullscreen_ui.cpp index cdc45ab2d..e3cbf1844 100644 --- a/src/core/fullscreen_ui.cpp +++ b/src/core/fullscreen_ui.cpp @@ -6882,7 +6882,7 @@ void FullscreenUI::DrawGameList(const ImVec2& heading_size) const bool display_as_language = (selected_entry->dbentry && selected_entry->dbentry->HasAnyLanguage()); ImGui::TextUnformatted(display_as_language ? FSUI_CSTR("Language: ") : FSUI_CSTR("Region: ")); ImGui::SameLine(); - ImGui::Image(GetCachedTexture(selected_entry->GetLanguageIconFileName(), 23, 16), LayoutScale(23.0f, 16.0f)); + ImGui::Image(GetCachedTexture(selected_entry->GetLanguageIconName(), 23, 16), LayoutScale(23.0f, 16.0f)); ImGui::SameLine(); if (display_as_language) { diff --git a/src/core/game_database.cpp b/src/core/game_database.cpp index 8ed973e14..6d81b5da9 100644 --- a/src/core/game_database.cpp +++ b/src/core/game_database.cpp @@ -21,6 +21,7 @@ #include "ryml.hpp" +#include #include #include #include @@ -312,6 +313,24 @@ std::optional GameDatabase::ParseLanguageName(std::strin return std::nullopt; } +TinyString GameDatabase::GetLanguageFlagResourceName(std::string_view language_name) +{ + return TinyString::from_format("images/flags/{}.svg", language_name); +} + +std::string_view GameDatabase::Entry::GetLanguageFlagName(DiscRegion region) const +{ + // If there's only one language, this is the flag we want to use. + // Except if it's English, then we want to use the disc region's flag. + std::string_view ret; + if (languages.count() == 1 && !languages.test(static_cast(GameDatabase::Language::English))) + ret = GameDatabase::GetLanguageName(static_cast(std::countr_zero(languages.to_ulong()))); + else + ret = Settings::GetDiscRegionName(region); + + return ret; +} + SmallString GameDatabase::Entry::GetLanguagesString() const { SmallString ret; diff --git a/src/core/game_database.h b/src/core/game_database.h index 6a17746c5..7ac805e86 100644 --- a/src/core/game_database.h +++ b/src/core/game_database.h @@ -130,8 +130,9 @@ struct Entry ALWAYS_INLINE bool HasTrait(Trait trait) const { return traits[static_cast(trait)]; } ALWAYS_INLINE bool HasLanguage(Language language) const { return languages.test(static_cast(language)); } - ALWAYS_INLINE bool HasAnyLanguage() const { return !languages.none(); } + ALWAYS_INLINE bool HasAnyLanguage() const { return languages.any(); } + std::string_view GetLanguageFlagName(DiscRegion region) const; SmallString GetLanguagesString() const; void ApplySettings(Settings& settings, bool display_osd_messages) const; @@ -156,6 +157,7 @@ const char* GetCompatibilityRatingDisplayName(CompatibilityRating rating); const char* GetLanguageName(Language language); std::optional ParseLanguageName(std::string_view str); +TinyString GetLanguageFlagResourceName(std::string_view language_name); /// Map of track hashes for image verification struct TrackData diff --git a/src/core/game_list.cpp b/src/core/game_list.cpp index 0ed468509..773cd457f 100644 --- a/src/core/game_list.cpp +++ b/src/core/game_list.cpp @@ -117,6 +117,8 @@ static PlayedTimeEntry UpdatePlayedTimeFile(const std::string& path, const std:: std::time_t add_time); static std::string GetCustomPropertiesFile(); +static bool PutCustomPropertiesField(INISettingsInterface& ini, const std::string& path, const char* field, + const char* value); static FileSystem::ManagedCFilePtr OpenMemoryCardTimestampCache(bool for_write); static bool UpdateMemcardTimestampCache(const MemcardTimestampCacheEntry& entry); @@ -627,6 +629,21 @@ void GameList::ApplyCustomAttributes(const std::string& path, Entry* entry, WARNING_LOG("Invalid region '{}' in custom attributes for '{}'", custom_region_str.value(), path); } } + const std::optional custom_language_str = + custom_attributes_ini.GetOptionalTinyStringValue(path.c_str(), "Language"); + if (custom_language_str.has_value()) + { + const std::optional custom_region = + GameDatabase::ParseLanguageName(custom_region_str.value()); + if (custom_region.has_value()) + { + entry->custom_language = custom_region.value(); + } + else + { + WARNING_LOG("Invalid language '{}' in custom attributes for '{}'", custom_region_str.value(), path); + } + } } std::unique_lock GameList::GetLock() @@ -990,26 +1007,20 @@ std::string GameList::GetNewCoverImagePathForEntry(const Entry* entry, const cha std::string_view GameList::Entry::GetLanguageIcon() const { - // If there's only one language, this is the flag we want to use. - // Except if it's English, then we want to use the disc region's flag. std::string_view ret; - if (dbentry && dbentry->languages.count() == 1 && - !dbentry->languages.test(static_cast(GameDatabase::Language::English))) - { - ret = GameDatabase::GetLanguageName( - static_cast(std::countr_zero(dbentry->languages.to_ulong()))); - } + if (custom_language != GameDatabase::Language::MaxCount) + ret = GameDatabase::GetLanguageName(custom_language); + else if (dbentry) + ret = dbentry->GetLanguageFlagName(region); else - { ret = Settings::GetDiscRegionName(region); - } return ret; } -TinyString GameList::Entry::GetLanguageIconFileName() const +TinyString GameList::Entry::GetLanguageIconName() const { - return TinyString::from_format("images/flags/{}.svg", GetLanguageIcon()); + return GameDatabase::GetLanguageFlagResourceName(GetLanguageIcon()); } TinyString GameList::Entry::GetCompatibilityIconFileName() const @@ -1518,28 +1529,37 @@ std::string GameList::GetCustomPropertiesFile() return Path::Combine(EmuFolders::DataRoot, "custom_properties.ini"); } -void GameList::SaveCustomTitleForPath(const std::string& path, const std::string& custom_title) +bool GameList::PutCustomPropertiesField(INISettingsInterface& ini, const std::string& path, const char* field, + const char* value) { - INISettingsInterface custom_attributes_ini(GetCustomPropertiesFile()); - custom_attributes_ini.Load(); + ini.Load(); - if (!custom_title.empty()) + if (value && *value != '\0') { - custom_attributes_ini.SetStringValue(path.c_str(), "Title", custom_title.c_str()); + ini.SetStringValue(path.c_str(), field, value); } else { - custom_attributes_ini.DeleteValue(path.c_str(), "Title"); - custom_attributes_ini.RemoveEmptySections(); + ini.DeleteValue(path.c_str(), field); + ini.RemoveEmptySections(); } Error error; - if (!custom_attributes_ini.Save(&error)) + if (!ini.Save(&error)) { ERROR_LOG("Failed to save custom attributes: {}", error.GetDescription()); - return; + return false; } + return true; +} + +bool GameList::SaveCustomTitleForPath(const std::string& path, const std::string& custom_title) +{ + INISettingsInterface custom_attributes_ini(GetCustomPropertiesFile()); + if (!PutCustomPropertiesField(custom_attributes_ini, path, "Title", custom_title.c_str())) + return false; + if (!custom_title.empty()) { // Can skip the rescan and just update the value directly. @@ -1556,28 +1576,18 @@ void GameList::SaveCustomTitleForPath(const std::string& path, const std::string // Let the cache update by rescanning. Only need to do this on deletion, to get the original value. RescanCustomAttributesForPath(path, custom_attributes_ini); } + + return true; } -void GameList::SaveCustomRegionForPath(const std::string& path, const std::optional custom_region) +bool GameList::SaveCustomRegionForPath(const std::string& path, const std::optional custom_region) { INISettingsInterface custom_attributes_ini(GetCustomPropertiesFile()); - custom_attributes_ini.Load(); - - if (custom_region.has_value()) + if (!PutCustomPropertiesField(custom_attributes_ini, path, "Region", + custom_region.has_value() ? Settings::GetDiscRegionName(custom_region.value()) : + nullptr)) { - custom_attributes_ini.SetStringValue(path.c_str(), "Region", Settings::GetDiscRegionName(custom_region.value())); - } - else - { - custom_attributes_ini.DeleteValue(path.c_str(), "Region"); - custom_attributes_ini.RemoveEmptySections(); - } - - Error error; - if (!custom_attributes_ini.Save(&error)) - { - ERROR_LOG("Failed to save custom attributes: {}", error.GetDescription()); - return; + return false; } if (custom_region.has_value()) @@ -1596,6 +1606,28 @@ void GameList::SaveCustomRegionForPath(const std::string& path, const std::optio // Let the cache update by rescanning. Only need to do this on deletion, to get the original value. RescanCustomAttributesForPath(path, custom_attributes_ini); } + + return true; +} + +bool GameList::SaveCustomLanguageForPath(const std::string& path, + const std::optional custom_language) +{ + INISettingsInterface custom_attributes_ini(GetCustomPropertiesFile()); + if (!PutCustomPropertiesField(custom_attributes_ini, path, "Language", + custom_language.has_value() ? GameDatabase::GetLanguageName(custom_language.value()) : + nullptr)) + { + return false; + } + + // Don't need to rescan, since there's no original value to restore. + auto lock = GetLock(); + Entry* entry = GetMutableEntryForPath(path); + if (entry) + entry->custom_language = custom_language.value_or(GameDatabase::Language::MaxCount); + + return true; } std::string GameList::GetCustomTitleForPath(const std::string_view path) diff --git a/src/core/game_list.h b/src/core/game_list.h index 1d87635b2..beb697f99 100644 --- a/src/core/game_list.h +++ b/src/core/game_list.h @@ -40,6 +40,7 @@ struct Entry bool disc_set_member = false; bool has_custom_title = false; bool has_custom_region = false; + GameDatabase::Language custom_language = GameDatabase::Language::MaxCount; std::string path; std::string serial; @@ -57,13 +58,14 @@ struct Entry std::string_view GetLanguageIcon() const; - TinyString GetLanguageIconFileName() const; + TinyString GetLanguageIconName() const; TinyString GetCompatibilityIconFileName() const; TinyString GetReleaseDateString() const; ALWAYS_INLINE bool IsDisc() const { return (type == EntryType::Disc); } ALWAYS_INLINE bool IsDiscSet() const { return (type == EntryType::DiscSet); } + ALWAYS_INLINE bool HasCustomLanguage() const { return (custom_language != GameDatabase::Language::MaxCount); } ALWAYS_INLINE EntryType GetSortType() const { return (type == EntryType::DiscSet) ? EntryType::Disc : type; } }; @@ -128,8 +130,9 @@ bool DownloadCovers(const std::vector& url_templates, bool use_seri std::function save_callback = {}); // Custom properties support -void SaveCustomTitleForPath(const std::string& path, const std::string& custom_title); -void SaveCustomRegionForPath(const std::string& path, const std::optional custom_region); +bool SaveCustomTitleForPath(const std::string& path, const std::string& custom_title); +bool SaveCustomRegionForPath(const std::string& path, const std::optional custom_region); +bool SaveCustomLanguageForPath(const std::string& path, const std::optional custom_language); std::string GetCustomTitleForPath(const std::string_view path); std::optional GetCustomRegionForPath(const std::string_view path); diff --git a/src/duckstation-qt/gamelistmodel.cpp b/src/duckstation-qt/gamelistmodel.cpp index ce496b384..3653600f4 100644 --- a/src/duckstation-qt/gamelistmodel.cpp +++ b/src/duckstation-qt/gamelistmodel.cpp @@ -294,7 +294,7 @@ const QPixmap& GameListModel::getFlagPixmapForEntry(const GameList::Entry* ge) c if (it != m_flag_pixmap_cache.end()) return it->second; - const QIcon icon(QString::fromStdString(QtHost::GetResourcePath(ge->GetLanguageIconFileName(), true))); + const QIcon icon(QString::fromStdString(QtHost::GetResourcePath(ge->GetLanguageIconName(), true))); it = m_flag_pixmap_cache.emplace(name, icon.pixmap(FLAG_PIXMAP_WIDTH, FLAG_PIXMAP_HEIGHT)).first; return it->second; } diff --git a/src/duckstation-qt/gamesummarywidget.cpp b/src/duckstation-qt/gamesummarywidget.cpp index 549f06112..aa72ccbba 100644 --- a/src/duckstation-qt/gamesummarywidget.cpp +++ b/src/duckstation-qt/gamesummarywidget.cpp @@ -52,6 +52,15 @@ GameSummaryWidget::GameSummaryWidget(const std::string& path, const std::string& static_cast(i)))); } + // I hate this so much. + m_ui.customLanguage->addItem(QtUtils::GetIconForLanguage(entry->GetLanguageFlagName(region)), + tr("Show Default Flag")); + for (u32 i = 0; i < static_cast(GameDatabase::Language::MaxCount); i++) + { + const char* language_name = GameDatabase::GetLanguageName(static_cast(i)); + m_ui.customLanguage->addItem(QtUtils::GetIconForLanguage(language_name), QString::fromUtf8(language_name)); + } + populateUi(path, serial, region, entry); connect(m_ui.compatibilityComments, &QToolButton::clicked, this, &GameSummaryWidget::onCompatibilityCommentsClicked); @@ -69,6 +78,7 @@ GameSummaryWidget::GameSummaryWidget(const std::string& path, const std::string& connect(m_ui.restoreTitle, &QAbstractButton::clicked, this, [this]() { setCustomTitle(std::string()); }); connect(m_ui.region, &QComboBox::currentIndexChanged, this, [this](int index) { setCustomRegion(index); }); connect(m_ui.restoreRegion, &QAbstractButton::clicked, this, [this]() { setCustomRegion(-1); }); + connect(m_ui.customLanguage, &QComboBox::currentIndexChanged, this, &GameSummaryWidget::onCustomLanguageChanged); } GameSummaryWidget::~GameSummaryWidget() = default; @@ -147,6 +157,8 @@ void GameSummaryWidget::populateUi(const std::string& path, const std::string& s else m_ui.releaseInfo->setText(tr("Unknown")); + m_ui.languages->setText(QtUtils::StringViewToQString(entry->GetLanguagesString())); + QString controllers; if (entry->supported_controllers != 0 && entry->supported_controllers != static_cast(-1)) { @@ -201,7 +213,10 @@ void GameSummaryWidget::populateCustomAttributes() auto lock = GameList::GetLock(); const GameList::Entry* entry = GameList::GetEntryForPath(m_path); if (!entry || entry->IsDiscSet()) + { + m_ui.customLanguage->setEnabled(false); return; + } { QSignalBlocker sb(m_ui.title); @@ -214,6 +229,12 @@ void GameSummaryWidget::populateCustomAttributes() m_ui.region->setCurrentIndex(static_cast(entry->region)); m_ui.restoreRegion->setEnabled(entry->has_custom_region); } + + { + QSignalBlocker sb(m_ui.customLanguage); + m_ui.customLanguage->setCurrentIndex(entry->HasCustomLanguage() ? (static_cast(entry->custom_language) + 1) : + 0); + } } void GameSummaryWidget::updateWindowTitle() @@ -238,7 +259,15 @@ void GameSummaryWidget::setCustomRegion(int region) GameList::SaveCustomRegionForPath(m_path, (region >= 0) ? std::optional(static_cast(region)) : std::optional()); populateCustomAttributes(); - updateWindowTitle(); + g_main_window->refreshGameListModel(); +} + +void GameSummaryWidget::onCustomLanguageChanged(int language) +{ + GameList::SaveCustomLanguageForPath( + m_path, (language > 0) ? std::optional(static_cast(language - 1)) : + std::optional()); + populateCustomAttributes(); g_main_window->refreshGameListModel(); } diff --git a/src/duckstation-qt/gamesummarywidget.h b/src/duckstation-qt/gamesummarywidget.h index f9866362e..a96fa4404 100644 --- a/src/duckstation-qt/gamesummarywidget.h +++ b/src/duckstation-qt/gamesummarywidget.h @@ -27,6 +27,7 @@ public: void reloadGameSettings(); private Q_SLOTS: + void onCustomLanguageChanged(int language); void onCompatibilityCommentsClicked(); void onInputProfileChanged(int index); void onEditInputProfileClicked(); diff --git a/src/duckstation-qt/gamesummarywidget.ui b/src/duckstation-qt/gamesummarywidget.ui index c4f21e620..b8049a780 100644 --- a/src/duckstation-qt/gamesummarywidget.ui +++ b/src/duckstation-qt/gamesummarywidget.ui @@ -30,67 +30,36 @@ 0 - - + + true - - - - - - Clear the line to restore the original title... - - - - - - - false - - - Restore - - - - - - - - - - - - 0 - 0 - - - - - - - - false - - - Restore - - - - - - - - - Image Path: + + + + true - - + + + + Tracks: + + + + + + + Input Profile: + + + + + true @@ -103,7 +72,35 @@ - + + + + true + + + + + + + true + + + + + + + Controllers: + + + + + + + Release Info: + + + + QAbstractItemView::EditTrigger::NoEditTriggers @@ -146,77 +143,7 @@ - - - - Region: - - - - - - - Developer: - - - - - - - Controllers: - - - - - - - Tracks: - - - - - - - true - - - - - - - false - - - - - - - Release Info: - - - - - - - Input Profile: - - - - - - - true - - - - - - - Genre: - - - - + @@ -253,42 +180,42 @@ - - - - true - - + + + + + + Clear the line to restore the original title... + + + + + + + false + + + Restore + + + + - - + + - Type: + Image Path: - - - - Title: + + + + false - - - - true - - - - - - - Compatibility: - - - - + @@ -309,7 +236,28 @@ - + + + + Genre: + + + + + + + Developer: + + + + + + + Region: + + + + @@ -323,6 +271,79 @@ + + + + Compatibility: + + + + + + + Type: + + + + + + + + + + 0 + 0 + + + + + + + + false + + + Restore + + + + + + + + + true + + + + + + + Title: + + + + + + + Languages: + + + + + + + + + true + + + + + + + + diff --git a/src/duckstation-qt/qtutils.cpp b/src/duckstation-qt/qtutils.cpp index d2082af0f..89a561eef 100644 --- a/src/duckstation-qt/qtutils.cpp +++ b/src/duckstation-qt/qtutils.cpp @@ -312,6 +312,12 @@ QIcon QtUtils::GetIconForCompatibility(GameDatabase::CompatibilityRating rating) QtHost::GetResourcePath(TinyString::from_format("images/star-{}.svg", static_cast(rating)), true))); } +QIcon QtUtils::GetIconForLanguage(std::string_view language_name) +{ + return QIcon( + QString::fromStdString(QtHost::GetResourcePath(GameDatabase::GetLanguageFlagResourceName(language_name), true))); +} + qreal QtUtils::GetDevicePixelRatioForWidget(const QWidget* widget) { const QScreen* screen_for_ratio = widget->screen(); diff --git a/src/duckstation-qt/qtutils.h b/src/duckstation-qt/qtutils.h index 2999d1f6f..83b2563cf 100644 --- a/src/duckstation-qt/qtutils.h +++ b/src/duckstation-qt/qtutils.h @@ -113,6 +113,7 @@ QIcon GetIconForRegion(DiscRegion region); /// Returns icon for entry type. QIcon GetIconForEntryType(GameList::EntryType type); QIcon GetIconForCompatibility(GameDatabase::CompatibilityRating rating); +QIcon GetIconForLanguage(std::string_view language_name); /// Returns the pixel ratio/scaling factor for a widget. qreal GetDevicePixelRatioForWidget(const QWidget* widget);