First stab at i18n

This commit is contained in:
ItsLemmy
2025-09-23 22:39:38 -04:00
parent 9a9d68c78d
commit 31db195087
8 changed files with 322 additions and 20 deletions
+33
View File
@@ -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."
}
}
}
}
}
+17
View File
@@ -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."
}
}
}
}
}
+252
View File
@@ -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)
}
}
+2
View File
@@ -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()
+3 -3
View File
@@ -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
+1 -1
View File
@@ -41,7 +41,7 @@ ColumnLayout {
}
NHeader {
label: "Appearance"
label: "settings.appearance"
description: "Customize the bar's appearance and position."
}
+13 -11
View File
@@ -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
View File
@@ -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
}