diff --git a/CMakeLists.txt b/CMakeLists.txt index 3aeb594..3f6b620 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -120,6 +120,15 @@ add_test( COMMAND hyprutils_beziercurve "beziercurve") add_dependencies(tests hyprutils_beziercurve) +add_executable(hyprutils_i18n "tests/i18n.cpp") +target_link_libraries(hyprutils_i18n PRIVATE hyprutils PkgConfig::deps) +add_test( + NAME "I18n" + WORKING_DIRECTORY ${CMAKE_SOURCE_DIR}/tests + COMMAND hyprutils_i18n "i18n") +add_dependencies(tests hyprutils_i18n) + + # Installation install(TARGETS hyprutils) install(DIRECTORY "include/hyprutils" DESTINATION ${CMAKE_INSTALL_INCLUDEDIR}) diff --git a/include/hyprutils/i18n/I18nEngine.hpp b/include/hyprutils/i18n/I18nEngine.hpp new file mode 100644 index 0000000..f04b129 --- /dev/null +++ b/include/hyprutils/i18n/I18nEngine.hpp @@ -0,0 +1,55 @@ +#pragma once + +#include "../memory/UniquePtr.hpp" + +#include +#include +#include +#include + +namespace Hyprutils::I18n { + struct SI18nEngineImpl; + + typedef std::unordered_map translationVarMap; + typedef std::function translationFn; + + class CI18nLocale { + public: + ~CI18nLocale() = default; + + std::string locale(); + std::string stem(); + std::string full(); + + private: + CI18nLocale(std::string fullLocale); + + std::string m_locale, m_rawFullLocale; + + friend class CI18nEngine; + }; + + class CI18nEngine { + public: + CI18nEngine(); + ~CI18nEngine(); + /* + Register a translation entry. The internal translation db is kept as a vector, + so make sure your keys are linear, don't use e.g. 2 billion as that will call + .resize() on the vec to 2 billion. + + If you pass a Fn, you can do logic, e.g. "1 point" vs "2 points". + */ + void registerEntry(const std::string& locale, uint64_t key, std::string&& translationUTF8); + void registerEntry(const std::string& locale, uint64_t key, translationFn&& translationFn); + + void setFallbackLocale(const std::string& locale); + + std::string localizeEntry(const std::string& locale, uint64_t key, const translationVarMap& map); + + CI18nLocale getSystemLocale(); + + private: + Memory::CUniquePointer m_impl; + }; +} \ No newline at end of file diff --git a/src/i18n/I18nEngine.cpp b/src/i18n/I18nEngine.cpp new file mode 100644 index 0000000..ba82840 --- /dev/null +++ b/src/i18n/I18nEngine.cpp @@ -0,0 +1,110 @@ +#include "I18nEngine.hpp" + +#include +#include +#include +#include + +using namespace Hyprutils::I18n; +using namespace Hyprutils; +using namespace Hyprutils::String; + +CI18nEngine::CI18nEngine() : m_impl(Memory::makeUnique()) { + ; +} +CI18nEngine::~CI18nEngine() = default; + +void CI18nEngine::registerEntry(const std::string& locale, uint64_t key, std::string&& translationUTF8) { + auto& entryVec = m_impl->entries[locale]; + + if (entryVec.size() <= key) + entryVec.resize(key + 1); + + entryVec[key].entry = std::move(translationUTF8); + entryVec[key].exists = true; +} + +void CI18nEngine::registerEntry(const std::string& locale, uint64_t key, translationFn&& translationFn) { + auto& entryVec = m_impl->entries[locale]; + + if (entryVec.size() <= key) + entryVec.resize(key + 1); + + entryVec[key].fn = std::move(translationFn); + entryVec[key].exists = true; +} + +void CI18nEngine::setFallbackLocale(const std::string& locale) { + m_impl->fallbackLocale = locale; +} + +std::string CI18nEngine::localizeEntry(const std::string& locale, uint64_t key, const translationVarMap& map) { + SI18nTranslationEntry* entry = nullptr; + + if (m_impl->entries.contains(locale) && m_impl->entries[locale].size() > key) + entry = &m_impl->entries[locale][key]; + + if (locale.contains('_')) { + + if (!entry || !entry->exists) { + // try to fall back to lang_LANG + auto stem = locale.substr(0, locale.find('_')); + auto stemUpper = stem; + std::ranges::transform(stemUpper, stemUpper.begin(), ::toupper); + auto newLocale = std::format("{}_{}", stem, stemUpper); + if (m_impl->entries.contains(newLocale) && m_impl->entries[newLocale].size() > key) + entry = &m_impl->entries[newLocale][key]; + } + + if (!entry || !entry->exists) { + // try to fall back to any lang prefixed with our prefix + + auto stem = locale.substr(0, locale.find('_') + 1); + for (const auto& [k, v] : m_impl->entries) { + if (k.starts_with(stem) || k == stem) { + if (m_impl->entries.contains(k) && m_impl->entries[k].size() > key) + entry = &m_impl->entries[k][key]; + + if (entry && entry->exists) + break; + } + } + } + } else { + // locale doesn't have a _, e.g. pl + // find any locale that has the same stem + for (const auto& [k, v] : m_impl->entries) { + if (k.starts_with(locale + "_") || k == locale) { + if (m_impl->entries.contains(k) && m_impl->entries[k].size() > key) + entry = &m_impl->entries[k][key]; + + if (entry && entry->exists) + break; + } + } + } + + if (!entry || !entry->exists) { + // fall back to general fallback + if (m_impl->entries.contains(m_impl->fallbackLocale) && m_impl->entries[m_impl->fallbackLocale].size() > key) + entry = &m_impl->entries[m_impl->fallbackLocale][key]; + } + + if (!entry || !entry->exists) + return ""; + + std::string rawStr = entry->entry; + + if (entry->fn) + rawStr = entry->fn(map); + + for (const auto& e : map) { + replaceInString(rawStr, "{" + e.first + "}", e.second); + } + + return rawStr; +} + +CI18nLocale CI18nEngine::getSystemLocale() { + return CI18nLocale(std::locale("").name()); +} diff --git a/src/i18n/I18nEngine.hpp b/src/i18n/I18nEngine.hpp new file mode 100644 index 0000000..1b2c297 --- /dev/null +++ b/src/i18n/I18nEngine.hpp @@ -0,0 +1,16 @@ +#pragma once + +#include + +namespace Hyprutils::I18n { + struct SI18nTranslationEntry { + bool exists = false; + std::string entry = ""; + translationFn fn = nullptr; + }; + + struct SI18nEngineImpl { + std::unordered_map> entries; + std::string fallbackLocale = "en_US"; + }; +}; \ No newline at end of file diff --git a/src/i18n/I18nLocale.cpp b/src/i18n/I18nLocale.cpp new file mode 100644 index 0000000..260b386 --- /dev/null +++ b/src/i18n/I18nLocale.cpp @@ -0,0 +1,44 @@ +#include + +using namespace Hyprutils::I18n; + +static std::string extractLocale(std::string locale) { + // localeStr is very arbitrary... from my testing, it can be: + // en_US.UTF-8 + // LC_CTYPE=en_US + // POSIX + // * + // + // We only return e.g. en_US or pl_PL, or pl + + if (locale == "POSIX") + return "en_US"; + if (locale == "*") + return "en_US"; + + if (locale.contains('=')) + locale = locale.substr(locale.find('=') + 1); + + if (locale.contains('.')) + locale = locale.substr(0, locale.find('.')); + + return locale; +} + +CI18nLocale::CI18nLocale(std::string fullLocale) : m_rawFullLocale(std::move(fullLocale)) { + m_locale = extractLocale(m_rawFullLocale); +} + +std::string CI18nLocale::locale() { + return m_locale; +} + +std::string CI18nLocale::stem() { + if (m_locale.contains('_')) + return m_locale.substr(0, m_locale.find('_')); + return m_locale; +} + +std::string CI18nLocale::full() { + return m_rawFullLocale; +} diff --git a/tests/i18n.cpp b/tests/i18n.cpp new file mode 100644 index 0000000..0cb8703 --- /dev/null +++ b/tests/i18n.cpp @@ -0,0 +1,67 @@ +#include +#include "shared.hpp" +#include + +using namespace Hyprutils::I18n; + +enum eTxtKeys : uint64_t { + TXT_KEY_HELLO, + TXT_KEY_I_HAVE_APPLES, + TXT_KEY_FALLBACK, +}; + +int main(int argc, char** argv, char** envp) { + int ret = 0; + + CI18nEngine engine; + + std::println("System locale: {}, stem: {}", engine.getSystemLocale().locale(), engine.getSystemLocale().stem()); + + engine.setFallbackLocale("en_US"); + + engine.registerEntry("en_US", TXT_KEY_HELLO, "Hello World!"); + engine.registerEntry("en_US", TXT_KEY_I_HAVE_APPLES, [](const translationVarMap& m) { + if (std::stoi(m.at("count")) == 1) + return "I have {count} apple."; + else + return "I have {count} apples."; + }); + engine.registerEntry("en_US", TXT_KEY_FALLBACK, "Fallback string!"); + + engine.registerEntry("pl_PL", TXT_KEY_HELLO, "Witaj świecie!"); + engine.registerEntry("pl_PL", TXT_KEY_I_HAVE_APPLES, [](const translationVarMap& m) { + const auto COUNT = std::stoi(m.at("count")); + if (COUNT == 1) + return "Mam {count} jabłko."; + else if (COUNT < 5) + return "Mam {count} jabłka."; + else + return "Mam {count} jabłek."; + }); + + engine.registerEntry("es_XX", TXT_KEY_FALLBACK, "I don't speak spanish"); + engine.registerEntry("es_ES", TXT_KEY_FALLBACK, "I don't speak spanish here either"); + + EXPECT(engine.localizeEntry("en_US", TXT_KEY_HELLO, {}), "Hello World!"); + EXPECT(engine.localizeEntry("pl_PL", TXT_KEY_HELLO, {}), "Witaj świecie!"); + EXPECT(engine.localizeEntry("de_DE", TXT_KEY_HELLO, {}), "Hello World!"); + + EXPECT(engine.localizeEntry("en_US", TXT_KEY_I_HAVE_APPLES, {{"count", "1"}}), "I have 1 apple."); + EXPECT(engine.localizeEntry("en_US", TXT_KEY_I_HAVE_APPLES, {{"count", "2"}}), "I have 2 apples."); + + EXPECT(engine.localizeEntry("pl_PL", TXT_KEY_I_HAVE_APPLES, {{"count", "1"}}), "Mam 1 jabłko."); + EXPECT(engine.localizeEntry("pl_PL", TXT_KEY_I_HAVE_APPLES, {{"count", "2"}}), "Mam 2 jabłka."); + EXPECT(engine.localizeEntry("pl_PL", TXT_KEY_I_HAVE_APPLES, {{"count", "5"}}), "Mam 5 jabłek."); + + EXPECT(engine.localizeEntry("pl", TXT_KEY_I_HAVE_APPLES, {{"count", "5"}}), "Mam 5 jabłek."); + + EXPECT(engine.localizeEntry("pl_XX", TXT_KEY_I_HAVE_APPLES, {{"count", "5"}}), "Mam 5 jabłek."); + EXPECT(engine.localizeEntry("en_XX", TXT_KEY_I_HAVE_APPLES, {{"count", "2"}}), "I have 2 apples."); + + EXPECT(engine.localizeEntry("es_YY", TXT_KEY_FALLBACK, {}), "I don't speak spanish here either"); + EXPECT(engine.localizeEntry("es_XX", TXT_KEY_FALLBACK, {}), "I don't speak spanish"); + + EXPECT(engine.localizeEntry("pl_PL", TXT_KEY_FALLBACK, {}), "Fallback string!"); + + return ret; +} \ No newline at end of file