diff --git a/Assets/Translations/en.json b/Assets/Translations/en.json new file mode 100644 index 00000000..9da205e0 --- /dev/null +++ b/Assets/Translations/en.json @@ -0,0 +1,33 @@ +{ + "settings": { + "general": { + "title": "General", + + "profile": { + "section": { + "label": "Profile", + "description": "Edit your user details and avatar." + }, + "picture": { + "label": "{user}'s Profile picture", + "description": "Your profile picture that appears throughout the interface." + } + }, + + "ui": { + "section": { + "label": "User interface", + "description": "Customize the look, feel, and behavior of the interface." + }, + "dim-desktop": { + "label": "Dim desktop", + "description": "Dim the desktop when panels or menus are open." + }, + "border-radius": { + "label": "Border radius", + "description": "Controls the corner roundness of windows, buttons, and other elements." + } + } + } + } +} diff --git a/Assets/Translations/fr.json b/Assets/Translations/fr.json new file mode 100644 index 00000000..92fa46a5 --- /dev/null +++ b/Assets/Translations/fr.json @@ -0,0 +1,17 @@ +{ + "settings": { + "general": { + "title": "General", + "profile": { + "section": { + "label": "Profile", + "description": "Modifiez vos informations d'utilisateur et votre avatar." + }, + "picture": { + "label": "Image de profile de {user}", + "description": "Votre photo de profil qui apparaƮt tout au long de l'interface." + } + } + } + } +} \ No newline at end of file diff --git a/Commons/I18n.qml b/Commons/I18n.qml new file mode 100644 index 00000000..8674150b --- /dev/null +++ b/Commons/I18n.qml @@ -0,0 +1,252 @@ +pragma Singleton + +import QtQuick +import Quickshell +import Quickshell.Io +import qs.Commons + +Singleton { + id: root + + property bool debug: true + property string debugForceLanguage: "" + + property bool isLoaded: false + property string langCode: "" + readonly property var availableLanguages: ["en", "fr"] + 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 + watchChanges: true + onFileChanged: reload() + onLoaded: { + try { + var data = JSON.parse(text()) + root.translations = data + root.isLoaded = true + root.translationsLoaded() + Logger.log("I18n", `Loaded translations for "${root.langCode}"`) + } catch (e) { + Logger.error("I18n", `Failed to parse translation file: ${e}`) + setLanguage("en") + } + } + onLoadFailed: function (error) { + setLanguage("en") + Logger.error("I18n", `Failed to load translation file: ${error}`) + } + } + + // FileView to load translation files + property FileView fallbackTranslationFile: FileView { + id: fallbackFileView + watchChanges: true + onFileChanged: reload() + onLoaded: { + try { + var data = JSON.parse(text()) + root.fallbackTranslations = data + Logger.log("I18n", `Loaded english fallback translations`) + } catch (e) { + Logger.error("I18n", `Failed to parse fallback translation file: ${e}`) + } + } + onLoadFailed: function (error) { + Logger.error("I18n", `Failed to load fallback translation file: ${error}`) + } + } + + // ------------------------------------------- + function init() { + Logger.log("I18n", "Service started") + detectLanguage() + } + + // ------------------------------------------- + function detectLanguage() { + + if (debug && debugForceLanguage !== "") { + setLanguage(debugForceLanguage) + return + } + + // Detect user's favorite locale - languages + for (var i = 0; i < Qt.locale().uiLanguages.length; i++) { + const userLang = Qt.locale().uiLanguages[i].substring(0, 2) + if (availableLanguages.includes(userLang)) { + setLanguage(userLang) + return + } + } + + // Fallback to english + setLanguage("en") + } + + // ------------------------------------------- + function setLanguage(newLangCode) { + if (newLangCode !== langCode && availableLanguages.includes(newLangCode)) { + langCode = newLangCode + Logger.log("I18n", `Language set to "${langCode}"`) + languageChanged(langCode) + loadTranslations() + } + } + + // ------------------------------------------- + function loadTranslations() { + if (langCode === "") + return + + const filePath = `file://${Quickshell.shellDir}/Assets/Translations/${langCode}.json` + fileView.path = filePath + isLoaded = false + Logger.log("I18n", `Loading translations from: ${filePath}`) + + // Only load fallback translations if we are not using enlgish + if (langCode !== "en") { + fallbackFileView.path = `file://${Quickshell.shellDir}/Assets/Translations/en.json` + } + } + + // ------------------------------------------- + // 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.log("I18n", "Reloading translations") + loadTranslations() + } + + // ------------------------------------------- + // Main translation function + function tr(key, interpolations) { + if (typeof interpolations === "undefined") + interpolations = {} + + if (!isLoaded) { + Logger.warn("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 { + if (debug) { + Logger.warn("I18n", `Translation key "${key}" not found`) + } + notFound = true + break + } + } + + // Fallback to english if not found + if (notFound) { + 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} ##` + } + } + + // Make untranslated string easy to spot + value = `${value}` + } + + if (typeof value !== "string") { + if (debug) { + Logger.warn("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, defaultSingular, defaultPlural, interpolations) { + if (typeof defaultSingular === "undefined") + defaultSingular = "" + if (typeof defaultPlural === "undefined") + defaultPlural = "" + if (typeof interpolations === "undefined") + interpolations = {} + + const pluralKey = count === 1 ? key : `${key}_plural` + const defaultValue = count === 1 ? defaultSingular : defaultPlural + + // Merge interpolations with count (QML doesn't support spread operator) + var finalInterpolations = { + "count": count + } + for (var prop in interpolations) { + finalInterpolations[prop] = interpolations[prop] + } + + return t(pluralKey, defaultValue, finalInterpolations) + } +} diff --git a/Commons/Settings.qml b/Commons/Settings.qml index 45b3018c..fd2a1ed1 100644 --- a/Commons/Settings.qml +++ b/Commons/Settings.qml @@ -494,6 +494,8 @@ Singleton { // ----------------------------------------------------- // Kickoff essential services function kickOffServices() { + I18n.init() + // Ensure our location singleton is created as soon as possible so we start fetching weather asap LocationService.init() diff --git a/Modules/Settings/SettingsPanel.qml b/Modules/Settings/SettingsPanel.qml index 418f4223..566f6a33 100644 --- a/Modules/Settings/SettingsPanel.qml +++ b/Modules/Settings/SettingsPanel.qml @@ -111,7 +111,7 @@ NPanel { function updateTabsModel() { let newTabs = [{ "id": SettingsPanel.Tab.General, - "label": "General", + "label": "settings.general.title", "icon": "settings-general", "source": generalTab }, { @@ -391,7 +391,7 @@ NPanel { // Tab label NText { - text: modelData.label + text: I18n.tr(modelData.label) color: tabTextColor font.pointSize: Style.fontSizeM * scaling font.weight: Style.fontWeightBold @@ -451,7 +451,7 @@ NPanel { // Main title NText { - text: root.tabsModel[currentTabIndex]?.label || "" + text: I18n.tr(root.tabsModel[currentTabIndex]?.label) || "" font.pointSize: Style.fontSizeXL * scaling font.weight: Style.fontWeightBold color: Color.mPrimary diff --git a/Modules/Settings/Tabs/BarTab.qml b/Modules/Settings/Tabs/BarTab.qml index 2049959c..daefde9c 100644 --- a/Modules/Settings/Tabs/BarTab.qml +++ b/Modules/Settings/Tabs/BarTab.qml @@ -41,7 +41,7 @@ ColumnLayout { } NHeader { - label: "Appearance" + label: "settings.appearance" description: "Customize the bar's appearance and position." } diff --git a/Modules/Settings/Tabs/GeneralTab.qml b/Modules/Settings/Tabs/GeneralTab.qml index 0e0d8932..a0286c38 100644 --- a/Modules/Settings/Tabs/GeneralTab.qml +++ b/Modules/Settings/Tabs/GeneralTab.qml @@ -10,8 +10,8 @@ ColumnLayout { id: root NHeader { - label: "Profile" - description: "Edit your user details and avatar." + label: I18n.tr("settings.general.profile.section.label") + description: I18n.tr("settings.general.profile.section.description") } // Profile section @@ -31,8 +31,10 @@ ColumnLayout { } NTextInputButton { - label: `${Quickshell.env("USER") || "user"}'s profile picture` - description: "Your profile picture that appears throughout the interface." + label: I18n.tr("settings.general.profile.picture.label", { + "user": Quickshell.env("USER" || "User") + }) + description: I18n.tr("settings.general.profile.picture.description") text: Settings.data.general.avatarImage placeholderText: "/home/user/.face" buttonIcon: "photo" @@ -47,7 +49,7 @@ ColumnLayout { NFilePicker { id: filePicker pickerType: "file" - title: "Select avatar image" + title: I18n.tr("settings.general.profile.select-avatar") //Select avatar image" initialPath: Settings.data.general.avatarImage.substr(0, Settings.data.general.avatarImage.lastIndexOf("/")) || Quickshell.env("HOME") nameFilters: ["Image files (*.jpg *.jpeg *.png *.gif *.pnm *.bmp *.face)", "All files (*)"] onAccepted: paths => Settings.data.general.avatarImage = paths[0] @@ -65,13 +67,13 @@ ColumnLayout { Layout.fillWidth: true NHeader { - label: "User interface" - description: "Customize the look, feel, and behavior of the interface." + label: I18n.tr("settings.general.ui.section.label") + description: I18n.tr("settings.general.ui.section.description") } NToggle { - label: "Dim desktop" - description: "Dim the desktop when panels or menus are open." + label: I18n.tr("settings.general.ui.dim-desktop.label") + description: I18n.tr("settings.general.ui.dim-desktop.description") checked: Settings.data.general.dimDesktop onToggled: checked => Settings.data.general.dimDesktop = checked } @@ -81,8 +83,8 @@ ColumnLayout { Layout.fillWidth: true NLabel { - label: "Border radius" - description: "Controls the corner roundness of windows, buttons, and other elements." + label: I18n.tr("settings.general.ui.border-radius.label") + description: I18n.tr("settings.general.ui.border-radius.description") } NValueSlider { diff --git a/Widgets/NText.qml b/Widgets/NText.qml index 82d533a5..a0434821 100644 --- a/Widgets/NText.qml +++ b/Widgets/NText.qml @@ -6,15 +6,11 @@ import qs.Widgets Text { id: root - font.family: Settings.data.ui.fontDefault font.pointSize: Style.fontSizeM * scaling font.weight: Style.fontWeightMedium - font.hintingPreference: Font.PreferNoHinting - font.kerning: true color: Color.mOnSurface - renderType: Text.QtRendering - verticalAlignment: Text.AlignVCenter elide: Text.ElideRight wrapMode: Text.NoWrap + verticalAlignment: Text.AlignVCenter }