mirror of
https://github.com/noctalia-dev/noctalia-shell.git
synced 2026-05-11 17:08:27 +08:00
First stab at i18n
This commit is contained in:
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 = `<i>${value}</i>`
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -41,7 +41,7 @@ ColumnLayout {
|
||||
}
|
||||
|
||||
NHeader {
|
||||
label: "Appearance"
|
||||
label: "settings.appearance"
|
||||
description: "Customize the bar's appearance and position."
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+1
-5
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user