Files
noctalia-shell/Commons/I18n.qml
T
Marco Migozzi 57bd9f9c99 feat(i18n): add full Italian locale support and enable it in language selector
This commit introduces complete Italian localization for Noctalia shell and wires the locale into the runtime language list.

Changes included:
- Added new translation file: Assets/Translations/it.json
- Localized the full string tree used by the shell UI, including:
  - common labels and shared terms
  - bar and widget settings
  - launcher, lock screen, notifications, wallpaper, weather, setup, tooltips
  - all settings panels and sub-panels (audio, display, dock, plugins, user interface, hooks, OSD, desktop widgets, color scheme, etc.)
- Preserved all runtime placeholders and markup tokens (e.g. {count}, {name}, <br>, <i>, <b>, )
- Enabled Italian in the language registry by updating Commons/I18n.qml availableLanguages

Validation and consistency checks:
- Verified JSON validity of Assets/Translations/it.json
- Verified placeholder parity against English source to avoid interpolation regressions
- Ensured translation key structure matches en.json so fallback behavior remains intact

Result:
- Italian (it) is now selectable from Region settings and loads as a first-class locale.
2026-02-23 09:26:03 +01:00

345 lines
11 KiB
QML

pragma Singleton
import QtQuick
import Quickshell
import Quickshell.Io
import qs.Commons
Singleton {
id: root
property bool isLoaded: false
property string langCode: ""
property var locale: Qt.locale()
property string systemDetectedLangCode: ""
property string fullLocaleCode: "" // Preserves regional locale variants
// Static list of available translations — update when adding/removing translation files
property var availableLanguages: ["en", "de", "es", "fr", "hu", "it", "ja", "ko-KR", "ku", "nl", "nn-HN", "nn-NO", "pl", "pt", "ru", "sv", "tr", "uk-UA", "zh-CN", "zh-TW"]
property var translations: ({})
property var fallbackTranslations: ({})
// Signals for reactive updates
signal languageChanged(string newLanguage)
signal translationsLoaded
// FileView to load translation files
property FileView translationFile: FileView {
id: fileView
printErrors: false
watchChanges: true
onFileChanged: reload()
onLoaded: {
try {
var data = JSON.parse(text());
root.translations = data;
Logger.i("I18n", `Loaded translations for "${root.langCode}"`);
root.isLoaded = true;
root.translationsLoaded();
// Load English fallback for non-English languages (only after main file succeeds)
if (root.langCode !== "en") {
fallbackFileView.path = `file://${Quickshell.shellDir}/Assets/Translations/en.json`;
}
} catch (e) {
Logger.e("I18n", `Failed to parse translation file: ${e}`);
setLanguage("en");
}
}
onLoadFailed: function (error) {
if (root.langCode === "en") {
Logger.e("I18n", `Failed to load English translation file: ${error}`);
// English also failed - still emit signal to unblock startup
root.isLoaded = true;
root.translationsLoaded();
return;
}
// Qt.callLater is needed because FileView doesn't re-trigger when path
// is changed inside its own onLoadFailed handler
var strippedCode = stripScript(root.langCode);
if (strippedCode !== root.langCode) {
Logger.d("I18n", `Translation file for "${root.langCode}" not found, trying "${strippedCode}"`);
root.langCode = strippedCode;
Qt.callLater(loadTranslations);
} else {
// Try language-only code (e.g. "zh-CN" → "zh")
var shortCode = root.langCode.substring(0, 2);
if (shortCode !== root.langCode) {
Logger.d("I18n", `Translation file for "${root.langCode}" not found, trying "${shortCode}"`);
root.langCode = shortCode;
Qt.callLater(loadTranslations);
} else {
Logger.w("I18n", `Translation file for "${root.langCode}" not found, falling back to English`);
root.langCode = "en";
root.fullLocaleCode = "en";
root.locale = Qt.locale("en");
Qt.callLater(loadTranslations);
}
}
}
}
// FileView to load fallback translation files
property FileView fallbackTranslationFile: FileView {
id: fallbackFileView
watchChanges: true
onFileChanged: reload()
onLoaded: {
try {
var data = JSON.parse(text());
root.fallbackTranslations = data;
Logger.d("I18n", `Loaded english fallback translations`);
} catch (e) {
Logger.e("I18n", `Failed to parse fallback translation file: ${e}`);
}
}
onLoadFailed: function (error) {
Logger.e("I18n", `Failed to load fallback translation file: ${error}`);
}
}
// Correct language when settings finish loading from disk (or user changes it)
Connections {
target: Settings.data.general
function onLanguageChanged() {
var userLang = Settings.data.general.language;
if (userLang !== "" && userLang !== root.langCode && availableLanguages.includes(userLang)) {
Logger.i("I18n", `Applying user language preference: "${userLang}"`);
setLanguage(userLang);
} else if (userLang === "" && root.systemDetectedLangCode !== "" && root.systemDetectedLangCode !== root.langCode) {
Logger.i("I18n", `Language reset to default, reverting to system language: "${root.systemDetectedLangCode}"`);
setLanguage(root.systemDetectedLangCode);
}
}
}
Component.onCompleted: {
Logger.i("I18n", "Service started");
var lang = determineFastLanguage();
langCode = lang.code;
fullLocaleCode = lang.fullLocale;
locale = Qt.locale(lang.fullLocale);
systemDetectedLangCode = lang.code;
Logger.i("I18n", `Loading "${lang.code}" (locale: "${lang.fullLocale}")`);
loadTranslations();
}
// Strip 4-letter script subtag from a BCP 47 tag (e.g. "fr-Latn-FR" → "fr-FR")
function stripScript(tag) {
var parts = tag.split("-");
var result = [];
for (var i = 0; i < parts.length; i++) {
if (parts[i].length === 4 && /^[A-Za-z]{4}$/.test(parts[i])) {
continue;
}
result.push(parts[i]);
}
return result.join("-");
}
// Determine the best language match against availableLanguages
function determineFastLanguage() {
// User preference from Settings (defaults to "" if not yet loaded from disk)
var userLang = Settings.data.general.language;
if (userLang !== "" && availableLanguages.includes(userLang)) {
return {
code: userLang,
fullLocale: userLang
};
}
// Match system locale against available translations
for (var i = 0; i < Qt.locale().uiLanguages.length; i++) {
var fullLang = Qt.locale().uiLanguages[i];
// Exact match (e.g. "zh-CN")
if (availableLanguages.includes(fullLang)) {
return {
code: fullLang,
fullLocale: fullLang
};
}
// Script-stripped match (e.g. "zh-Hans-CN" → "zh-CN")
var stripped = stripScript(fullLang);
if (stripped !== fullLang && availableLanguages.includes(stripped)) {
return {
code: stripped,
fullLocale: fullLang
};
}
// Language-only match (e.g. "fr-FR" → "fr")
var short_ = fullLang.substring(0, 2);
if (availableLanguages.includes(short_)) {
return {
code: short_,
fullLocale: fullLang
};
}
}
return {
code: "en",
fullLocale: "en"
};
}
// -------------------------------------------
function setLanguage(newLangCode, fullLocale) {
if (typeof fullLocale === "undefined") {
fullLocale = newLangCode;
}
if (newLangCode !== langCode && availableLanguages.includes(newLangCode)) {
langCode = newLangCode;
fullLocaleCode = fullLocale;
locale = Qt.locale(fullLocale);
Logger.i("I18n", `Language set to "${langCode}" with locale "${fullLocale}"`);
languageChanged(langCode);
loadTranslations();
} else if (!availableLanguages.includes(newLangCode)) {
Logger.w("I18n", `Language "${newLangCode}" is not available`);
}
}
// -------------------------------------------
function loadTranslations() {
if (langCode === "")
return;
const filePath = `file://${Quickshell.shellDir}/Assets/Translations/${langCode}.json`;
fileView.path = filePath;
isLoaded = false;
}
// -------------------------------------------
// Check if a translation exists
function hasTranslation(key) {
if (!isLoaded)
return false;
const keys = key.split(".");
var value = translations;
for (var i = 0; i < keys.length; i++) {
if (value && typeof value === "object" && keys[i] in value) {
value = value[keys[i]];
} else {
return false;
}
}
return typeof value === "string";
}
// -------------------------------------------
// Get all translation keys (useful for debugging)
function getAllKeys(obj, prefix) {
if (typeof obj === "undefined")
obj = translations;
if (typeof prefix === "undefined")
prefix = "";
var keys = [];
for (var key in (obj || {})) {
const value = obj[key];
const fullKey = prefix ? `${prefix}.${key}` : key;
if (typeof value === "object" && value !== null) {
keys = keys.concat(getAllKeys(value, fullKey));
} else if (typeof value === "string") {
keys.push(fullKey);
}
}
return keys;
}
// -------------------------------------------
// Reload translations (useful for development)
function reload() {
Logger.d("I18n", "Reloading translations");
loadTranslations();
}
// -------------------------------------------
// Main translation function
function tr(key, interpolations) {
if (typeof interpolations === "undefined")
interpolations = {};
if (!isLoaded) {
//Logger.d("I18n", "Translations not loaded yet")
return key;
}
// Navigate nested keys (e.g., "menu.file.open")
const keys = key.split(".");
// Look-up translation in the active language
var value = translations;
var notFound = false;
for (var i = 0; i < keys.length; i++) {
if (value && typeof value === "object" && keys[i] in value) {
value = value[keys[i]];
} else {
Logger.d("I18n", `Translation key "${key}" not found at part "${keys[i]}"`);
Logger.d("I18n", `Available keys: ${Object.keys(value || {}).join(", ")}`);
notFound = true;
break;
}
}
// Fallback to english if not found
if (notFound && availableLanguages.includes("en") && langCode !== "en") {
value = fallbackTranslations;
for (var i = 0; i < keys.length; i++) {
if (value && typeof value === "object" && keys[i] in value) {
value = value[keys[i]];
} else {
// Indicate this key does not even exists in the english fallback
return `!!${key}!!`;
}
}
} else if (notFound) {
// No fallback available
return `!!${key}!!`;
}
if (typeof value !== "string") {
Logger.d("I18n", `Translation key "${key}" is not a string`);
return key;
}
// Handle interpolations (e.g., "Hello {name}!")
var result = value;
for (var placeholder in interpolations) {
const regex = new RegExp(`\\{${placeholder}\\}`, 'g');
result = result.replace(regex, interpolations[placeholder]);
}
return result;
}
// -------------------------------------------
// Plural translation function
function trp(key, count, interpolations) {
if (typeof interpolations === "undefined") {
interpolations = {};
}
// Use key for singular, key-plural for plural
const realKey = count === 1 ? key : `${key}-plural`;
// Merge interpolations with count
var finalInterpolations = {
"count": count
};
for (var prop in interpolations) {
finalInterpolations[prop] = interpolations[prop];
}
return tr(realKey, finalInterpolations);
}
}