i18n implementation basics

This commit is contained in:
Ken Van Hoeylandt 2025-08-24 00:48:37 +02:00
parent 6c524ac191
commit f28c76eb53
20 changed files with 334 additions and 5 deletions

View File

@ -0,0 +1,7 @@
OK
Yes
No
Cancel
Retry
Close
Open

View File

@ -0,0 +1,7 @@
OK
Yes
No
Cancel
Retry
Close
Open

View File

@ -0,0 +1,7 @@
OK
Oui
Non
Annuler
Réessayer
Fermer
Ouvrir

View File

@ -0,0 +1,7 @@
OK
Ja
Nee
Annuleren
Opnieuw
Sluiten
Openen

View File

@ -0,0 +1,7 @@
OK
Ja
Nee
Annuleren
Opnieuw
Sluiten
Openen

View File

@ -0,0 +1,3 @@
Apps
Files
Settings

View File

@ -0,0 +1,3 @@
Apps
Files
Settings

View File

@ -0,0 +1,3 @@
Appli
Fichiers
Réglages

View File

@ -0,0 +1,3 @@
Apps
Bestanden
Instellingen

View File

@ -0,0 +1,3 @@
Apps
Bestanden
Instellingen

View File

@ -0,0 +1,17 @@
#pragma once
// WARNING: This file is auto-generated. Do not edit manually.
namespace tt::i18n::core {
enum class Text {
OK = 0,
YES = 1,
NO = 2,
CANCEL = 3,
RETRY = 4,
CLOSE = 5,
OPEN = 6,
};
}

View File

@ -0,0 +1,24 @@
#pragma once
#include <string>
#include <memory>
namespace tt::i18n {
class IndexedText {
public:
virtual ~IndexedText() = default;
virtual const std::string& get(int index) const = 0;
template <typename EnumType>
const std::string& get(EnumType value) const { return get(static_cast<int>(value)); }
const std::string& operator[](const int index) const { return get(index); }
};
std::shared_ptr<IndexedText> loadIndexedText(const std::string& path);
}

View File

@ -0,0 +1,13 @@
#pragma once
// WARNING: This file is auto-generated. Do not edit manually.
namespace tt::i18n::launcher {
enum class Text {
APPS = 0,
FILES = 1,
SETTINGS = 2,
};
}

View File

@ -2,15 +2,18 @@
#include "Tactility/app/AppRegistration.h"
#include "Tactility/service/loader/Loader.h"
#include "Tactility/i18n/Launcher.h"
#include <Tactility/Tactility.h>
#include <lvgl.h>
#include <Tactility/BootProperties.h>
#define TAG "launcher"
#include <Tactility/i18n/I18n.h>
namespace tt::app::launcher {
constexpr auto* TAG = "Launcher";
static void onAppPressed(TT_UNUSED lv_event_t* e) {
auto* appId = (const char*)lv_event_get_user_data(e);
service::loader::startApp(appId);
@ -52,8 +55,11 @@ static lv_obj_t* createAppButton(lv_obj_t* parent, const char* title, const char
}
class LauncherApp : public App {
std::shared_ptr<i18n::IndexedText> launcherText = i18n::loadIndexedText("/data/i18n/launcher");
void onCreate(TT_UNUSED AppContext& app) override {
assert(launcherText != nullptr);
BootProperties boot_properties;
if (loadBootProperties(boot_properties) && !boot_properties.autoStartAppId.empty()) {
TT_LOG_I(TAG, "Starting %s", boot_properties.autoStartAppId.c_str());
@ -87,9 +93,14 @@ class LauncherApp : public App {
auto apps_icon_path = paths->getSystemPathLvgl("icon_apps.png");
auto files_icon_path = paths->getSystemPathLvgl("icon_files.png");
auto settings_icon_path = paths->getSystemPathLvgl("icon_settings.png");
createAppButton(wrapper, "Apps", apps_icon_path.c_str(), "AppList", 0);
createAppButton(wrapper, "Files", files_icon_path.c_str(), "Files", padding);
createAppButton(wrapper, "Settings", settings_icon_path.c_str(), "Settings", padding);
const auto& apps_title = launcherText->get(i18n::launcher::Text::APPS);
const auto& files_title = launcherText->get(i18n::launcher::Text::FILES);
const auto& settings_title = launcherText->get(i18n::launcher::Text::SETTINGS);
createAppButton(wrapper, apps_title.c_str(), apps_icon_path.c_str(), "AppList", 0);
createAppButton(wrapper, files_title.c_str(), files_icon_path.c_str(), "Files", padding);
createAppButton(wrapper, settings_title.c_str(), settings_icon_path.c_str(), "Settings", padding);
}
};

View File

@ -0,0 +1,85 @@
#include "Tactility/i18n/I18n.h"
#include "Tactility/file/FileLock.h"
#include <cstring>
#include <vector>
namespace tt::i18n {
constexpr auto* TAG = "I18n";
static std::string ERROR_RESULT = "TRANSLATION_ERROR";
class IndexedTextImplementation : IndexedText {
std::vector<std::string> data;
public:
explicit IndexedTextImplementation(std::vector<std::string> data) : data(std::move(data)) {}
const std::string& get(const int index) const override {
if (index < data.size()) {
return data[index];
} else {
return ERROR_RESULT;
}
}
};
static std::string getDesiredLocale() {
// TODO: Implement locale settings
return "nl-NL";
}
static std::string getFallbackLocale() {
// TODO: Implement locale settings
return "en-GB";
}
static FILE* openI18nFile(const std::string& path) {
auto locale = getDesiredLocale();
auto desired_file_path = std::format("{}/{}.i18n", path, locale);
auto* file = fopen(desired_file_path.c_str(), "r");
if (file == nullptr) {
auto fallback_locale = getFallbackLocale();
TT_LOG_W(TAG, "Translations not found for %s at %s", locale.c_str(), desired_file_path.c_str());
auto fallback_file_path = std::format("{}/{}.i18n", path, getFallbackLocale());
file = fopen(fallback_file_path.c_str(), "r");
if (file == nullptr) {
TT_LOG_W(TAG, "Fallback translations not found for %s at %s", fallback_locale.c_str(), fallback_file_path.c_str());
}
}
return file;
}
std::shared_ptr<IndexedText> loadIndexedText(const std::string& path) {
std::vector<std::string> data;
// We lock on folder level, because file is TBD
file::withLock<void>(path, [&path, &data] {
auto* file = openI18nFile(path);
if (file != nullptr) {
char line[1024];
// TODO: move to file::readLines(filePath, skipEndline, callback)
while (fgets(line, sizeof(line), file) != nullptr) {
// Strip newline
size_t line_length = strlen(line);
if (line_length > 0 && line[line_length - 1] == '\n') {
line[line_length - 1] = '\0';
}
// Publish
data.push_back(line);
}
fclose(file);
}
});
if (data.empty()) {
return nullptr;
}
auto result = std::make_shared<IndexedTextImplementation>(data);
return std::reinterpret_pointer_cast<IndexedText>(result);
}
}

2
Translations/.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
*.csv
*.ods#

25
Translations/README.md Normal file
View File

@ -0,0 +1,25 @@
# Translations
## Requirements
- Install [LibreOffice Calc](https://libreoffice.org/)
- Ensure you have Python 3 installed (Python 2 is not supported)
## Steps
To add new translations or edit existing ones, please follow these steps:
1. Edit `Translations.ods` (see chapter below)
2. In LibreOffice Calc, select the relevant tab that you want to export
3. Click on `File` -> `Save a copy...` and save the file as `[tabname].csv` (without the "[]")
4. Repeat step 2 and 3 for all tabs that you updated
5. Run `python generate-all.py`
## Notes
- Do not commit the CSV files
- When editing the ODS file, make sure you don't paste in formatted data (use CTRL+Shift+V instead of CTRL+V)
- ODS export settings:
- Field delimiter: `,`
- String delimiter: `"`
- Encoding: `UTF-8`

Binary file not shown.

View File

@ -0,0 +1,12 @@
import os
def generate(csvFile, headerFile, headerNamespace, dataPath):
if os.path.isfile(csvFile):
print(f"Generating {headerFile}")
os.system(f"python generate.py {csvFile} {headerFile} {headerNamespace} {dataPath}")
else:
print(f"Skipping {headerFile} (not found)")
if __name__ == "__main__":
generate("system.csv", "../Tactility/Include/Tactility/i18n/Core.h", "tt::i18n::core", "../Data/data/i18n/core")
generate("launcher.csv", "../Tactility/Include/Tactility/i18n/Launcher.h", "tt::i18n::launcher", "../Data/data/i18n/launcher")

90
Translations/generate.py Normal file
View File

@ -0,0 +1,90 @@
import csv
import sys
from typing import List
def load_csv(path: str, delimiter: str = ",", quotechar: str = '"', encoding: str = "utf-8", skip_header: bool = False) -> List[List[str]]:
"""
Load a CSV file into a list of rows, where each row is a list of strings.
Args:
path: Path to the CSV file.
delimiter: Field delimiter character.
quotechar: Quote character.
encoding: File encoding.
skip_header: If True, skip the first row (header).
Returns:
List of rows (list of lists of strings).
"""
rows: List[List[str]] = []
with open(path, "r", encoding=encoding, newline="") as f:
reader = csv.reader(f, delimiter=delimiter, quotechar=quotechar)
if skip_header:
next(reader, None)
for row in reader:
rows.append(row)
return rows
def print_help():
print("Usage: python generate.py [csv_file] [header_file_path] [header_namespace] [i18n_directory]\n\n")
print("\t[csv_file] the CSV file containing the translations, exported from the .ods file")
print("\t[header_file_path] the path to the header file to be generated")
print("\t[header_namespace] the C++ namespace to use for the generated header file")
print("\t[i18n_directory] the directory where the .i18n files will be generated")
def open_i18n_files(row, i18n_path):
result = []
for i in range(1, len(row)):
filepath = f"{i18n_path}/{row[i]}.i18n"
print(f"Opening {filepath}")
file = open(filepath, "w")
result.append(file)
return result
def close_i18n_files(files):
for file in files:
file.close()
def generate_header(filepath, rows, namespace):
file = open(filepath, "w")
file.write("#pragma once\n\n")
file.write("// WARNING: This file is auto-generated. Do not edit manually.\n\n")
file.write(f"namespace {namespace}")
file.write(" {\n\n")
file.write("enum class Text {\n")
for i in range(1, len(rows)):
key = rows[i][0].upper()
file.write(f" {key} = {i - 1},\n")
file.write("};\n")
file.write("\n}\n")
file.close()
def translate(rows, language_index, file):
for i in range(1, len(rows)):
value = rows[i][language_index]
if value == "":
value = f"{rows[i][0]}_untranslated"
file.write(value)
file.write("\n")
if __name__ == "__main__":
if "--help" in sys.argv:
print_help()
sys.exit()
if len(sys.argv) != 5:
print_help()
sys.exit()
csv_file = sys.argv[1]
header_path = sys.argv[2]
header_namespace = sys.argv[3]
i18n_path = sys.argv[4]
rows = load_csv(csv_file)
if len(rows) == 0:
print("Error: CSV file is empty.")
sys.exit(1)
generate_header(header_path, rows, header_namespace)
i18n_files = open_i18n_files(rows[0], i18n_path)
for i in range(0, len(i18n_files)):
i18n_file = i18n_files[i]
translate(rows, i + 1, i18n_file)
close_i18n_files(i18n_files)