i18n: add i18n engine (#83)

Adds a translation engine
This commit is contained in:
Vaxry 2025-11-09 14:18:33 +00:00 committed by GitHub
parent 926689ddb9
commit 968f881222
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 301 additions and 0 deletions

View file

@ -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})

View file

@ -0,0 +1,55 @@
#pragma once
#include "../memory/UniquePtr.hpp"
#include <cstdint>
#include <string>
#include <unordered_map>
#include <functional>
namespace Hyprutils::I18n {
struct SI18nEngineImpl;
typedef std::unordered_map<std::string, std::string> translationVarMap;
typedef std::function<std::string(const translationVarMap&)> 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<SI18nEngineImpl> m_impl;
};
}

110
src/i18n/I18nEngine.cpp Normal file
View file

@ -0,0 +1,110 @@
#include "I18nEngine.hpp"
#include <algorithm>
#include <format>
#include <locale>
#include <hyprutils/string/String.hpp>
using namespace Hyprutils::I18n;
using namespace Hyprutils;
using namespace Hyprutils::String;
CI18nEngine::CI18nEngine() : m_impl(Memory::makeUnique<SI18nEngineImpl>()) {
;
}
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());
}

16
src/i18n/I18nEngine.hpp Normal file
View file

@ -0,0 +1,16 @@
#pragma once
#include <hyprutils/i18n/I18nEngine.hpp>
namespace Hyprutils::I18n {
struct SI18nTranslationEntry {
bool exists = false;
std::string entry = "";
translationFn fn = nullptr;
};
struct SI18nEngineImpl {
std::unordered_map<std::string, std::vector<SI18nTranslationEntry>> entries;
std::string fallbackLocale = "en_US";
};
};

44
src/i18n/I18nLocale.cpp Normal file
View file

@ -0,0 +1,44 @@
#include <hyprutils/i18n/I18nEngine.hpp>
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;
}

67
tests/i18n.cpp Normal file
View file

@ -0,0 +1,67 @@
#include <hyprutils/i18n/I18nEngine.hpp>
#include "shared.hpp"
#include <print>
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;
}