diff --git a/assets/localization/ar-AE.json b/assets/localization/ar-AE.json new file mode 100644 index 000000000..7a2966fcd --- /dev/null +++ b/assets/localization/ar-AE.json @@ -0,0 +1,3 @@ +{ + "trophy.toast.earned": "تم كسب الكأس!" +} diff --git a/assets/localization/cs-CZ.json b/assets/localization/cs-CZ.json new file mode 100644 index 000000000..220d7f355 --- /dev/null +++ b/assets/localization/cs-CZ.json @@ -0,0 +1,3 @@ +{ + "trophy.toast.earned": "Trofej získána!" +} diff --git a/assets/localization/da-DK.json b/assets/localization/da-DK.json new file mode 100644 index 000000000..6cd65295c --- /dev/null +++ b/assets/localization/da-DK.json @@ -0,0 +1,3 @@ +{ + "trophy.toast.earned": "Trofæ optjent!" +} diff --git a/assets/localization/de-DE.json b/assets/localization/de-DE.json new file mode 100644 index 000000000..656714df9 --- /dev/null +++ b/assets/localization/de-DE.json @@ -0,0 +1,3 @@ +{ + "trophy.toast.earned": "Trophäe erhalten!" +} diff --git a/assets/localization/el-GR.json b/assets/localization/el-GR.json new file mode 100644 index 000000000..f5a79d5e8 --- /dev/null +++ b/assets/localization/el-GR.json @@ -0,0 +1,3 @@ +{ + "trophy.toast.earned": "Κερδίσατε ένα τρόπαιο!" +} diff --git a/assets/localization/en-GB.json b/assets/localization/en-GB.json new file mode 100644 index 000000000..60d5d98b9 --- /dev/null +++ b/assets/localization/en-GB.json @@ -0,0 +1,3 @@ +{ + "trophy.toast.earned": "Trophy earned!" +} diff --git a/assets/localization/en-US.json b/assets/localization/en-US.json new file mode 100644 index 000000000..60d5d98b9 --- /dev/null +++ b/assets/localization/en-US.json @@ -0,0 +1,3 @@ +{ + "trophy.toast.earned": "Trophy earned!" +} diff --git a/assets/localization/es-419.json b/assets/localization/es-419.json new file mode 100644 index 000000000..d0ee7dac2 --- /dev/null +++ b/assets/localization/es-419.json @@ -0,0 +1,3 @@ +{ + "trophy.toast.earned": "¡Ganaste un trofeo!" +} diff --git a/assets/localization/es-ES.json b/assets/localization/es-ES.json new file mode 100644 index 000000000..d0ee7dac2 --- /dev/null +++ b/assets/localization/es-ES.json @@ -0,0 +1,3 @@ +{ + "trophy.toast.earned": "¡Ganaste un trofeo!" +} diff --git a/assets/localization/fi-FI.json b/assets/localization/fi-FI.json new file mode 100644 index 000000000..c1b65a822 --- /dev/null +++ b/assets/localization/fi-FI.json @@ -0,0 +1,3 @@ +{ + "trophy.toast.earned": "Pokaali ansaittu!" +} diff --git a/assets/localization/fr-CA.json b/assets/localization/fr-CA.json new file mode 100644 index 000000000..374c3a402 --- /dev/null +++ b/assets/localization/fr-CA.json @@ -0,0 +1,3 @@ +{ + "trophy.toast.earned": "Trophée obtenu !" +} diff --git a/assets/localization/fr-FR.json b/assets/localization/fr-FR.json new file mode 100644 index 000000000..374c3a402 --- /dev/null +++ b/assets/localization/fr-FR.json @@ -0,0 +1,3 @@ +{ + "trophy.toast.earned": "Trophée obtenu !" +} diff --git a/assets/localization/hu-HU.json b/assets/localization/hu-HU.json new file mode 100644 index 000000000..6ba5f1b69 --- /dev/null +++ b/assets/localization/hu-HU.json @@ -0,0 +1,3 @@ +{ + "trophy.toast.earned": "Trófea megszerezve!" +} diff --git a/assets/localization/id-ID.json b/assets/localization/id-ID.json new file mode 100644 index 000000000..d350a7c9c --- /dev/null +++ b/assets/localization/id-ID.json @@ -0,0 +1,3 @@ +{ + "trophy.toast.earned": "Trofi diperoleh!" +} diff --git a/assets/localization/it-IT.json b/assets/localization/it-IT.json new file mode 100644 index 000000000..d18492708 --- /dev/null +++ b/assets/localization/it-IT.json @@ -0,0 +1,3 @@ +{ + "trophy.toast.earned": "Trofeo ottenuto!" +} diff --git a/assets/localization/ja-JP.json b/assets/localization/ja-JP.json new file mode 100644 index 000000000..ee8b0cbfb --- /dev/null +++ b/assets/localization/ja-JP.json @@ -0,0 +1,3 @@ +{ + "trophy.toast.earned": "トロフィーを獲得しました!" +} diff --git a/assets/localization/ko-KR.json b/assets/localization/ko-KR.json new file mode 100644 index 000000000..9a6cbd914 --- /dev/null +++ b/assets/localization/ko-KR.json @@ -0,0 +1,3 @@ +{ + "trophy.toast.earned": "트로피를 획득했습니다!" +} diff --git a/assets/localization/nl-NL.json b/assets/localization/nl-NL.json new file mode 100644 index 000000000..b13eb0e86 --- /dev/null +++ b/assets/localization/nl-NL.json @@ -0,0 +1,3 @@ +{ + "trophy.toast.earned": "Trofee behaald!" +} diff --git a/assets/localization/no-NO.json b/assets/localization/no-NO.json new file mode 100644 index 000000000..084cb7acc --- /dev/null +++ b/assets/localization/no-NO.json @@ -0,0 +1,3 @@ +{ + "trophy.toast.earned": "Trofé opptjent!" +} diff --git a/assets/localization/pl-PL.json b/assets/localization/pl-PL.json new file mode 100644 index 000000000..7d1c624e4 --- /dev/null +++ b/assets/localization/pl-PL.json @@ -0,0 +1,3 @@ +{ + "trophy.toast.earned": "Zdobyto trofeum!" +} diff --git a/assets/localization/pt-BR.json b/assets/localization/pt-BR.json new file mode 100644 index 000000000..d875ba3ec --- /dev/null +++ b/assets/localization/pt-BR.json @@ -0,0 +1,3 @@ +{ + "trophy.toast.earned": "Troféu conquistado!" +} diff --git a/assets/localization/pt-PT.json b/assets/localization/pt-PT.json new file mode 100644 index 000000000..d875ba3ec --- /dev/null +++ b/assets/localization/pt-PT.json @@ -0,0 +1,3 @@ +{ + "trophy.toast.earned": "Troféu conquistado!" +} diff --git a/assets/localization/ro-RO.json b/assets/localization/ro-RO.json new file mode 100644 index 000000000..e7ec3de59 --- /dev/null +++ b/assets/localization/ro-RO.json @@ -0,0 +1,3 @@ +{ + "trophy.toast.earned": "Trofeu câștigat!" +} diff --git a/assets/localization/ru-RU.json b/assets/localization/ru-RU.json new file mode 100644 index 000000000..fbf60fed5 --- /dev/null +++ b/assets/localization/ru-RU.json @@ -0,0 +1,3 @@ +{ + "trophy.toast.earned": "Трофей получен!" +} diff --git a/assets/localization/sv-SE.json b/assets/localization/sv-SE.json new file mode 100644 index 000000000..1c6fe8d4f --- /dev/null +++ b/assets/localization/sv-SE.json @@ -0,0 +1,3 @@ +{ + "trophy.toast.earned": "Trofé intjänad!" +} diff --git a/assets/localization/th-TH.json b/assets/localization/th-TH.json new file mode 100644 index 000000000..8adfadb9f --- /dev/null +++ b/assets/localization/th-TH.json @@ -0,0 +1,3 @@ +{ + "trophy.toast.earned": "ได้รับถ้วยรางวัลแล้ว!" +} diff --git a/assets/localization/tr-TR.json b/assets/localization/tr-TR.json new file mode 100644 index 000000000..db282fb07 --- /dev/null +++ b/assets/localization/tr-TR.json @@ -0,0 +1,3 @@ +{ + "trophy.toast.earned": "Kupa kazanıldı!" +} diff --git a/assets/localization/vi-VN.json b/assets/localization/vi-VN.json new file mode 100644 index 000000000..6bbe5b9ee --- /dev/null +++ b/assets/localization/vi-VN.json @@ -0,0 +1,3 @@ +{ + "trophy.toast.earned": "Đã nhận cúp!" +} diff --git a/assets/localization/zh-Hans.json b/assets/localization/zh-Hans.json new file mode 100644 index 000000000..2288c9293 --- /dev/null +++ b/assets/localization/zh-Hans.json @@ -0,0 +1,3 @@ +{ + "trophy.toast.earned": "已获得奖杯!" +} diff --git a/assets/localization/zh-Hant.json b/assets/localization/zh-Hant.json new file mode 100644 index 000000000..d62862330 --- /dev/null +++ b/assets/localization/zh-Hant.json @@ -0,0 +1,3 @@ +{ + "trophy.toast.earned": "已獲得獎盃!" +} diff --git a/docs/notification-localization.md b/docs/notification-localization.md new file mode 100644 index 000000000..8cf2694e6 --- /dev/null +++ b/docs/notification-localization.md @@ -0,0 +1,20 @@ +# Notification localization + +Trophy notification subtitles use the configured console language. Trophy names still come +from the game's metadata. The launcher trophy dialogs are unchanged. + +Edit `assets/localization/.json`, using flat UTF-8 string values: + +```json +{ + "trophy.toast.earned": "Trophy earned!" +} +``` + +CMake converts the catalogs into C++ byte arrays in the build directory. The emulator parses +the embedded catalogs once and falls back to `en-US.json` for missing translations. No language +files or translation service are needed at runtime. Rebuild after editing a catalog. + +For local testing, rebuild `kyty_emulator`, choose a game's Console Language in the launcher, +and unlock a trophy with notifications enabled. Installed system fonts provide non-Latin +glyphs; the selected language needs an appropriate font on the host. diff --git a/src/common/CMakeLists.txt b/src/common/CMakeLists.txt index d3617c077..b8a8865d6 100644 --- a/src/common/CMakeLists.txt +++ b/src/common/CMakeLists.txt @@ -12,9 +12,23 @@ endif() add_library(common STATIC ${common_src} ${common_headers}) +set(localization_dir "${PROJECT_SOURCE_DIR}/assets/localization") +file(GLOB localization_catalogs CONFIGURE_DEPENDS "${localization_dir}/*.json") +set(localization_data "${CMAKE_CURRENT_BINARY_DIR}/localizationData.h") +add_custom_command( + OUTPUT "${localization_data}" + COMMAND ${CMAKE_COMMAND} "-DINPUTS=${localization_catalogs}" "-DOUTPUT=${localization_data}" + -P "${KYTY_SOURCE_DIR}/embed_localization.cmake" + DEPENDS ${localization_catalogs} "${KYTY_SOURCE_DIR}/embed_localization.cmake" + COMMENT "Embed notification localization catalogs" + VERBATIM +) +target_sources(common PRIVATE "${localization_data}") + target_include_directories(common PUBLIC ${KYTY_SOURCE_DIR} PRIVATE + ${CMAKE_CURRENT_BINARY_DIR} ${KYTY_THIRD_PARTY_DIR}/cpuinfo/include ) diff --git a/src/common/localization.cpp b/src/common/localization.cpp new file mode 100644 index 000000000..bf010c083 --- /dev/null +++ b/src/common/localization.cpp @@ -0,0 +1,53 @@ +#include "common/localization.h" + +#include +#include +#include +#include + +#include "localizationData.h" + +namespace Common::Localization { +namespace { + +constexpr std::array Locales = { + "ja-JP", "en-US", "fr-FR", "es-ES", "de-DE", "it-IT", "nl-NL", "pt-PT", + "ru-RU", "ko-KR", "zh-Hant", "zh-Hans", "fi-FI", "sv-SE", "da-DK", "no-NO", + "pl-PL", "pt-BR", "en-GB", "tr-TR", "es-419", "ar-AE", "fr-CA", "cs-CZ", + "hu-HU", "el-GR", "ro-RO", "th-TH", "vi-VN", "id-ID"}; + +size_t LanguageIndex(int console_language) { + return console_language >= 0 && console_language < static_cast(Locales.size()) + ? static_cast(console_language) + : 1; +} + +} // namespace + +std::string_view LocaleName(int console_language) { + return Locales[LanguageIndex(console_language)]; +} + +std::string_view Get(int console_language, std::string_view key) { + static const auto catalogs = [] { + std::array result; + for (size_t i = 0; i < Locales.size(); ++i) { + const auto bytes = EmbeddedCatalog(Locales[i]); + result[i] = nlohmann::json::parse(bytes.begin(), bytes.end(), nullptr, false); + } + return result; + }(); + for (const auto index: std::array {LanguageIndex(console_language), size_t {1}}) { + const auto& catalog = catalogs[index]; + const auto found = catalog.find(key); + if (found != catalog.end() && found->is_string()) { + const auto& text = found->get_ref(); + if (!text.empty()) { + return text; + } + } + } + return key; +} + +} // namespace Common::Localization diff --git a/src/common/localization.h b/src/common/localization.h new file mode 100644 index 000000000..7e51dd21e --- /dev/null +++ b/src/common/localization.h @@ -0,0 +1,13 @@ +#pragma once + +#include + +namespace Common::Localization { + +// Console language indices follow the system configuration; invalid indices use English. +[[nodiscard]] std::string_view LocaleName(int console_language); + +// Translations have static lifetime. Missing translations use English, then the supplied key. +[[nodiscard]] std::string_view Get(int console_language, std::string_view key); + +} // namespace Common::Localization diff --git a/src/common/trophies.cpp b/src/common/trophies.cpp index cb7d89262..4bfdc532b 100644 --- a/src/common/trophies.cpp +++ b/src/common/trophies.cpp @@ -1,9 +1,9 @@ #include "common/trophies.h" #include "common/file.h" +#include "common/localization.h" #include -#include #include #include #include @@ -105,13 +105,7 @@ Package ParsePackage(std::span data, int console_language) { !conf["defaultLanguage"].is_string()) { return {}; } - static constexpr std::array locales = { - "ja-JP", "en-US", "fr-FR", "es-ES", "de-DE", "it-IT", "nl-NL", "pt-PT", - "ru-RU", "ko-KR", "zh-Hant", "zh-Hans", "fi-FI", "sv-SE", "da-DK", "no-NO", - "pl-PL", "pt-BR", "en-GB", "tr-TR", "es-419", "ar-AE", "fr-CA", "cs-CZ", - "hu-HU", "el-GR", "ro-RO", "th-TH", "vi-VN", "id-ID"}; - const auto locale = - locales[console_language >= 0 && console_language < locales.size() ? console_language : 1]; + const auto locale = Localization::LocaleName(console_language); auto meta = ReadJson(files, fmt::format("tropmeta_{}.json", locale)); if (meta.is_null()) { meta = ReadJson(files, fmt::format("tropmeta_{}.json", diff --git a/src/embed_localization.cmake b/src/embed_localization.cmake new file mode 100644 index 000000000..5cb98d3be --- /dev/null +++ b/src/embed_localization.cmake @@ -0,0 +1,62 @@ +if(NOT DEFINED INPUTS OR NOT DEFINED OUTPUT) + message(FATAL_ERROR "embed_localization.cmake requires INPUTS and OUTPUT") +endif() + +set(contents "// Generated from assets/localization; do not edit.\n#pragma once\n#include \n#include \n\nnamespace {\n") +set(lookup "std::span EmbeddedCatalog(std::string_view locale) {\n") +set(continuation "[89ab][0-9a-f]") +string(CONCAT utf8_character + "[0-7][0-9a-f]|(c[2-9a-f]|d[0-9a-f])${continuation}" + "|e0[ab][0-9a-f]${continuation}|e[1-9abcef]${continuation}${continuation}" + "|ed[89][0-9a-f]${continuation}|f0[9ab][0-9a-f]${continuation}${continuation}" + "|f[123]${continuation}${continuation}${continuation}|f48[0-9a-f]${continuation}${continuation}") +foreach(catalog IN LISTS INPUTS) + file(READ "${catalog}" bytes HEX) + # Validate UTF-8, excluding overlong sequences, surrogates, and values above U+10FFFF. + if(NOT bytes MATCHES "^(${utf8_character})*$") + message(FATAL_ERROR "Localization must be UTF-8: ${catalog}") + endif() + file(READ "${catalog}" json) + string(JSON type ERROR_VARIABLE error TYPE "${json}") + if(error OR NOT type STREQUAL "OBJECT") + message(FATAL_ERROR "Invalid localization object in ${catalog}: ${error}") + endif() + string(JSON count LENGTH "${json}") + if(count GREATER 0) + math(EXPR last "${count} - 1") + foreach(index RANGE ${last}) + string(JSON key MEMBER "${json}" ${index}) + string(JSON type TYPE "${json}" "${key}") + if(NOT type STREQUAL "STRING") + message(FATAL_ERROR "Localization value '${key}' must be a string: ${catalog}") + endif() + string(JSON text GET "${json}" "${key}") + if(key STREQUAL "" OR text STREQUAL "") + message(FATAL_ERROR "Localization keys and values must not be empty: ${catalog}") + endif() + endforeach() + endif() + get_filename_component(locale "${catalog}" NAME_WE) + if(locale STREQUAL "en-US") + string(JSON earned ERROR_VARIABLE error GET "${json}" "trophy.toast.earned") + if(error OR earned STREQUAL "") + message(FATAL_ERROR "English localization requires trophy.toast.earned: ${catalog}") + endif() + set(english_found TRUE) + endif() + string(MAKE_C_IDENTIFIER "${locale}" symbol) + string(REGEX REPLACE "(..)" "0x\\1," bytes "${bytes}") + string(APPEND contents "constexpr unsigned char catalog_${symbol}[] = {${bytes}};\n") + string(APPEND lookup "\tif (locale == \"${locale}\") return catalog_${symbol};\n") +endforeach() +if(NOT english_found) + message(FATAL_ERROR "Localization requires an en-US.json fallback") +endif() +string(APPEND contents "\n${lookup}\treturn {};\n}\n} // namespace\n") + +get_filename_component(output_dir "${OUTPUT}" DIRECTORY) +file(MAKE_DIRECTORY "${output_dir}") +file(WRITE "${OUTPUT}.tmp" "${contents}") +execute_process(COMMAND "${CMAKE_COMMAND}" -E copy_if_different "${OUTPUT}.tmp" "${OUTPUT}" + COMMAND_ERROR_IS_FATAL ANY) +file(REMOVE "${OUTPUT}.tmp") diff --git a/src/graphics/presentation/systemOverlay.cpp b/src/graphics/presentation/systemOverlay.cpp index 058f64b06..845780f84 100644 --- a/src/graphics/presentation/systemOverlay.cpp +++ b/src/graphics/presentation/systemOverlay.cpp @@ -5,6 +5,7 @@ #include "common/assert.h" #include "common/emulatorConfig.h" #include "common/file.h" +#include "common/localization.h" #include "common/logging/log.h" #include "common/stringUtils.h" #include "gpu_blit_shaders/gpu_imgui_hdr_spv.h" @@ -30,6 +31,7 @@ #include #include #include +#include #include #include #include @@ -280,6 +282,61 @@ std::vector WrapTrophyTitle(std::string_view text, float font_ } } +// ImGui's built-in font covers Latin only. Merge installed fonts for the selected console language. +void AddSystemFontFallbacks(ImGuiIO& io) { + io.Fonts->AddFontDefault(); + const auto add = [&](std::initializer_list paths) { + for (const char* path: paths) { + std::error_code error; + if (std::filesystem::is_regular_file(path, error)) { + ImFontConfig config; + config.MergeMode = true; + config.Flags = ImFontFlags_NoLoadError; + if (io.Fonts->AddFontFromFileTTF(path, 0.0f, &config) != nullptr) { + return true; + } + } + } + return false; + }; + bool found = + add({"C:/Windows/Fonts/segoeui.ttf", "/System/Library/Fonts/Supplemental/Arial Unicode.ttf", + "/Library/Fonts/Arial Unicode.ttf", "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", + "/usr/share/fonts/TTF/DejaVuSans.ttf", "/usr/share/fonts/noto/NotoSans-Regular.ttf"}); + const auto locale = Common::Localization::LocaleName(Config::GetConsoleLanguage()); + const char* noto_cjk[] = {"/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc", + "/usr/share/fonts/noto-cjk/NotoSansCJK-Regular.ttc", + "/usr/share/fonts/google-noto-cjk/NotoSansCJK-Regular.ttc"}; + if (locale == "ja-JP") { + found = add({"C:/Windows/Fonts/meiryo.ttc", "C:/Windows/Fonts/YuGothM.ttc", + "/System/Library/Fonts/ヒラギノ角ゴシック W3.ttc", noto_cjk[0], noto_cjk[1], + noto_cjk[2]}); + } else if (locale == "ko-KR") { + found = add({"C:/Windows/Fonts/malgun.ttf", "/System/Library/Fonts/AppleSDGothicNeo.ttc", + noto_cjk[0], noto_cjk[1], noto_cjk[2]}); + } else if (locale == "zh-Hans" || locale == "zh-Hant") { + found = + add({locale == "zh-Hans" ? "C:/Windows/Fonts/msyh.ttc" : "C:/Windows/Fonts/msjh.ttc", + "/System/Library/Fonts/PingFang.ttc", noto_cjk[0], noto_cjk[1], noto_cjk[2]}); + } else if (locale == "th-TH") { + found = add({"C:/Windows/Fonts/leelawui.ttf", "C:/Windows/Fonts/tahoma.ttf", + "/System/Library/Fonts/Supplemental/Ayuthaya.ttf", + "/usr/share/fonts/truetype/tlwg/Garuda.ttf", + "/usr/share/fonts/truetype/noto/NotoSansThai-Regular.ttf", + "/usr/share/fonts/noto/NotoSansThai-Regular.ttf"}); + } else if (locale == "ar-AE") { + found = add({"C:/Windows/Fonts/tahoma.ttf", "/System/Library/Fonts/Supplemental/Arial.ttf", + "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", + "/usr/share/fonts/TTF/DejaVuSans.ttf", + "/usr/share/fonts/truetype/noto/NotoSansArabic-Regular.ttf", + "/usr/share/fonts/noto/NotoSansArabic-Regular.ttf"}); + } + if (!found) { + LOGF("[Trophy] no installed fallback font for %.*s\n", static_cast(locale.size()), + locale.data()); + } +} + void ClearInputEvents() { std::scoped_lock lock(g_input_mutex); g_input_events.clear(); @@ -441,6 +498,63 @@ std::u16string Utf8ToUtf16(std::string_view text) { return result; } +// The Arabic notification is a single RTL run. Use presentation forms because ImGui does not +// shape Arabic. This intentionally applies only to the catalog subtitle, never game-provided names. +std::string PrepareTrophySubtitle(int language) { + const auto text = Common::Localization::Get(language, "trophy.toast.earned"); + if (Common::Localization::LocaleName(language) != "ar-AE") { + return std::string(text); + } + struct ArabicForm { + char16_t letter; + char16_t isolated; + bool joins_next; + }; + static constexpr ArabicForm FORMS[] = { + {0x0621, 0xfe80, false}, {0x0622, 0xfe81, false}, {0x0623, 0xfe83, false}, + {0x0624, 0xfe85, false}, {0x0625, 0xfe87, false}, {0x0626, 0xfe89, true}, + {0x0627, 0xfe8d, false}, {0x0628, 0xfe8f, true}, {0x0629, 0xfe93, false}, + {0x062a, 0xfe95, true}, {0x062b, 0xfe99, true}, {0x062c, 0xfe9d, true}, + {0x062d, 0xfea1, true}, {0x062e, 0xfea5, true}, {0x062f, 0xfea9, false}, + {0x0630, 0xfeab, false}, {0x0631, 0xfead, false}, {0x0632, 0xfeaf, false}, + {0x0633, 0xfeb1, true}, {0x0634, 0xfeb5, true}, {0x0635, 0xfeb9, true}, + {0x0636, 0xfebd, true}, {0x0637, 0xfec1, true}, {0x0638, 0xfec5, true}, + {0x0639, 0xfec9, true}, {0x063a, 0xfecd, true}, {0x0641, 0xfed1, true}, + {0x0642, 0xfed5, true}, {0x0643, 0xfed9, true}, {0x0644, 0xfedd, true}, + {0x0645, 0xfee1, true}, {0x0646, 0xfee5, true}, {0x0647, 0xfee9, true}, + {0x0648, 0xfeed, false}, {0x0649, 0xfeef, false}, {0x064a, 0xfef1, true}, + }; + const auto form = [](char16_t c) -> const ArabicForm* { + for (const auto& value: FORMS) { + if (value.letter == c) { + return &value; + } + } + return nullptr; + }; + const auto logical = Utf8ToUtf16(text); + if (std::none_of(logical.begin(), logical.end(), + [](char16_t c) { return c >= 0x0600 && c <= 0x06ff; })) { + return std::string(text); + } + auto visual = logical; + for (size_t i = 0; i < logical.size(); ++i) { + const auto* current = form(logical[i]); + if (current == nullptr) { + continue; + } + const auto* previous = i > 0 ? form(logical[i - 1]) : nullptr; + const auto* next = i + 1 < logical.size() ? form(logical[i + 1]) : nullptr; + const bool joins_previous = + previous != nullptr && previous->joins_next && current->letter != 0x0621; + const bool joins_next = current->joins_next && next != nullptr && next->letter != 0x0621; + visual[i] = static_cast(current->isolated + (joins_next ? 2 : 0) + + (joins_previous ? 1 : 0)); + } + std::reverse(visual.begin(), visual.end()); + return Common::Utf16ToUtf8(visual); +} + std::string VisibleText(const Ime::HostSnapshot& snapshot, size_t max_units) { const size_t cursor = std::min(snapshot.cursor, snapshot.text.size()); const size_t payload_units = max_units > 4 ? max_units - 4 : 1; @@ -794,6 +908,7 @@ struct SystemOverlay::Impl { io.ConfigNavCursorVisibleAlways = true; io.BackendFlags |= ImGuiBackendFlags_HasGamepad; io.BackendPlatformName = "Kyty system overlay input"; + AddSystemFontFallbacks(io); ImGui::StyleColorsDark(); auto& style = ImGui::GetStyle(); style.WindowRounding = 10.0f; @@ -1578,8 +1693,9 @@ struct SystemOverlay::Impl { trophy_notification = std::move(g_trophy_notifications.front()); g_trophy_notifications.pop_front(); } - trophy_notification_born = now; - trophy_notification_image = LoadTrophyIcon(trophy_notification->icon_png); + trophy_notification_subtitle = PrepareTrophySubtitle(Config::GetConsoleLanguage()); + trophy_notification_born = now; + trophy_notification_image = LoadTrophyIcon(trophy_notification->icon_png); PlayTrophySound(); } const float age = std::chrono::duration(now - trophy_notification_born).count(); @@ -1663,8 +1779,17 @@ struct SystemOverlay::Impl { line.data(), line.data() + line.size()); line_y += line_height; } - draw->AddText(nullptr, 20.0f * screen_scale, {text_left, line_y + 8.0f * screen_scale}, - fade(176, 180, 192, 255), "Trophy earned!"); + const float subtitle_size = 20.0f * screen_scale; + const float subtitle_width = + ImGui::GetFont() + ->CalcTextSizeA(subtitle_size, std::numeric_limits::max(), 0.0f, + trophy_notification_subtitle.c_str()) + .x; + const float available_width = std::max(1.0f, width - text_offset - text_padding); + const float fitted_size = std::max( + 1.0f, subtitle_size * std::min(1.0f, available_width / std::max(1.0f, subtitle_width))); + draw->AddText(nullptr, fitted_size, {text_left, line_y + 8.0f * screen_scale}, + fade(176, 180, 192, 255), trophy_notification_subtitle.c_str()); draw->PopClipRect(); } @@ -1782,6 +1907,7 @@ struct SystemOverlay::Impl { static constexpr std::size_t MAX_TOUCH_PARTICLES = 64; static constexpr std::size_t MAX_TOUCH_TRAIL_SEGMENTS = 128; std::optional trophy_notification; + std::string trophy_notification_subtitle; Clock::time_point trophy_notification_born; std::vector> retired_trophy_images; std::array, 2> splash_dds; diff --git a/tests/TrophySystemTests.cpp b/tests/TrophySystemTests.cpp index ce68a2fa1..c53edb502 100644 --- a/tests/TrophySystemTests.cpp +++ b/tests/TrophySystemTests.cpp @@ -1,4 +1,5 @@ #include "common/file.h" +#include "common/localization.h" #include "common/trophies.h" #include @@ -8,6 +9,7 @@ #include #include #include +#include #include namespace { @@ -20,6 +22,65 @@ void Check(bool value, const char *text) { } } +void TestLocalization() { + struct Translation { + std::string_view locale; + std::string_view earned; + }; + static constexpr Translation translations[] = { + {"ja-JP", "トロフィーを獲得しました!"}, + {"en-US", "Trophy earned!"}, + {"fr-FR", "Trophée obtenu !"}, + {"es-ES", "¡Ganaste un trofeo!"}, + {"de-DE", "Trophäe erhalten!"}, + {"it-IT", "Trofeo ottenuto!"}, + {"nl-NL", "Trofee behaald!"}, + {"pt-PT", "Troféu conquistado!"}, + {"ru-RU", "Трофей получен!"}, + {"ko-KR", "트로피를 획득했습니다!"}, + {"zh-Hant", "已獲得獎盃!"}, + {"zh-Hans", "已获得奖杯!"}, + {"fi-FI", "Pokaali ansaittu!"}, + {"sv-SE", "Trofé intjänad!"}, + {"da-DK", "Trofæ optjent!"}, + {"no-NO", "Trofé opptjent!"}, + {"pl-PL", "Zdobyto trofeum!"}, + {"pt-BR", "Troféu conquistado!"}, + {"en-GB", "Trophy earned!"}, + {"tr-TR", "Kupa kazanıldı!"}, + {"es-419", "¡Ganaste un trofeo!"}, + {"ar-AE", "تم كسب الكأس!"}, + {"fr-CA", "Trophée obtenu !"}, + {"cs-CZ", "Trofej získána!"}, + {"hu-HU", "Trófea megszerezve!"}, + {"el-GR", "Κερδίσατε ένα τρόπαιο!"}, + {"ro-RO", "Trofeu câștigat!"}, + {"th-TH", "ได้รับถ้วยรางวัลแล้ว!"}, + {"vi-VN", "Đã nhận cúp!"}, + {"id-ID", "Trofi diperoleh!"}, + }; + constexpr std::string_view key = "trophy.toast.earned"; + const auto japanese = Common::Localization::Get(0, key); + int language = 0; + for (const auto &translation : translations) { + Check(Common::Localization::LocaleName(language) == translation.locale, + "console language selects its locale"); + Check(Common::Localization::Get(language, key) == translation.earned, + "embedded notification preserves translated UTF-8 bytes"); + ++language; + } + Check(japanese == translations[0].earned, + "translation view survives other locale lookups"); + for (const int invalid : {-1, 30, 1000}) { + Check(Common::Localization::LocaleName(invalid) == "en-US" && + Common::Localization::Get(invalid, key) == "Trophy earned!", + "invalid console language falls back to English"); + } + Check(Common::Localization::Get(0, "missing.translation") == + "missing.translation", + "unknown translation returns its key"); +} + class TempDirectory { public: TempDirectory() { @@ -252,6 +313,7 @@ void TestUnlockPersistence(const std::filesystem::path &directory) { } // namespace int main() { + TestLocalization(); TempDirectory directory; TestEventExtraction(directory.Path()); TestProgress(directory.Path());