Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Selective translation loading #5390

Open
wants to merge 10 commits into
base: develop
Choose a base branch
from
124 changes: 109 additions & 15 deletions lang/lang.go
Original file line number Diff line number Diff line change
@@ -1,12 +1,18 @@
// Package lang introduces a translation and localisation API for Fyne applications
//
// By default, a few custom localisation keys for a few default languages are included.
// They can be overwritten with custom localisations.
// Without further intervention, the default language will be English. This is overwritten
// with the language of the first translation that is being registered explicitly.
//
// Since 2.5
package lang

import (
"embed"
"encoding/json"
"log"
"path"
"strings"
"text/template"

Expand Down Expand Up @@ -35,12 +41,15 @@ var (
// More info available on the `LocalizePluralKey` function.
XN = LocalizePluralKey

bundle *i18n.Bundle
localizer *i18n.Localizer
bundle *i18n.Bundle
localizer *i18n.Localizer
bundleIsDefault bool
unmarshallers map[string]i18n.UnmarshalFunc

//go:embed translations
translations embed.FS
translated []language.Tag
defaultTranslationFS embed.FS
defaultTranslations map[language.Tag]*i18n.MessageFile
translated []language.Tag
)

// Localize asks the translation engine to translate a string, this behaves like the gettext "_" function.
Expand Down Expand Up @@ -137,7 +146,7 @@ func AddTranslationsFS(fs embed.FS, dir string) (retErr error) {

for _, f := range files {
name := f.Name()
data, err := fs.ReadFile(dir + "/" + name)
data, err := fs.ReadFile(path.Join(dir, name))
if err != nil {
if retErr == nil {
retErr = err
Expand All @@ -159,25 +168,107 @@ func AddTranslationsFS(fs embed.FS, dir string) (retErr error) {
return retErr
}

// RegisteredLanguages returns the list of locales we have localization for.
func RegisteredLanguages() []fyne.Locale {
var ret []fyne.Locale
for _, l := range translated {
ret = append(ret, localeFromTag(l))
}
return ret
}

func addLanguage(data []byte, name string) error {
f, err := bundle.ParseMessageFileBytes(data, name)
mf, err := i18n.ParseMessageFileBytes(data, name, unmarshallers)
if err != nil {
return err
}

translated = append(translated, f.Tag)
return addLanguageByMessages(mf.Messages, mf.Tag)
}

func addLanguageByMessages(msgs []*i18n.Message, tag language.Tag) error {
if bundleIsDefault {
// This is the first time, the user adds a language on their own. This implies, that
// they are aware of localizations and how to use them.
// To prevent half-translated applications, we "forget" our base translations and only
// use languages for resolution, that the user supplied.
// The bundle will still contain our base translations; if a language overlaps with those,
// our base strings will be there unless they explicitly get overwritten.
bundleIsDefault = false
if err := setupBundle(tag); err != nil {
return err
}
translated = []language.Tag{tag}
}

languageKnown := false
for _, t := range translated {
if t == tag {
languageKnown = true
break
}
}
if err := bundle.AddMessages(tag, msgs...); err != nil {
return err
}
if !languageKnown {
translated = append(translated, tag)
}
return nil
}

func init() {
bundle = i18n.NewBundle(language.English)
bundle.RegisterUnmarshalFunc("json", json.Unmarshal)
unmarshallers = map[string]i18n.UnmarshalFunc{
"json": json.Unmarshal,
}

translated = []language.Tag{language.Make("en")} // the first item in this list will be the fallback if none match
err := AddTranslationsFS(translations, "translations")
if err != nil {
// Put English first as our preferred fallback language.
translated = []language.Tag{language.English}
if err := loadDefaultTranslations(); err != nil {
fyne.LogError("Error occurred loading built-in translations", err)
}
if err := setupBundle(language.English); err != nil {
fyne.LogError("Error occurred loading built-in translations", err)
}
bundleIsDefault = true
}

func loadDefaultTranslations() error {
defaultTranslations = map[language.Tag]*i18n.MessageFile{}
const dir = "translations"
files, err := defaultTranslationFS.ReadDir(dir)
if err != nil {
return err
}
for _, f := range files {
name := f.Name()
data, err := defaultTranslationFS.ReadFile(path.Join(dir, name))
if err != nil {
return err
}

mf, err := i18n.ParseMessageFileBytes(data, name, unmarshallers)
if err != nil {
return err
}
defaultTranslations[mf.Tag] = mf
}
return nil
}

func setupBundle(lng language.Tag) error {
bundle = i18n.NewBundle(lng)
bundle.RegisterUnmarshalFunc("json", json.Unmarshal)

translated = []language.Tag{lng} // the first item in this list will be the fallback if none match
for tag, mf := range defaultTranslations {
if err := addLanguageByMessages(mf.Messages, tag); err != nil {
return err
}
}
updateLocalizer()

return nil
}

func fallbackWithData(key, fallback string, data any) string {
Expand All @@ -198,10 +289,13 @@ func setupLang(lang string) {

// updateLocalizer Finds the closest translation from the user's locale list and sets it up
func updateLocalizer() {
var languageStr string
all, err := locale.GetLocales()
if err != nil {
if err == nil {
languageStr = closestSupportedLocale(all).LanguageString()
} else {
fyne.LogError("Failed to load user locales", err)
all = []string{"en"}
languageStr = translated[0].String()
}
setupLang(closestSupportedLocale(all).LanguageString())
setupLang(languageStr)
}
57 changes: 55 additions & 2 deletions lang/lang_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,10 @@ package lang
import (
"testing"

"fyne.io/fyne/v2"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"fyne.io/fyne/v2"
"golang.org/x/text/language"
)

func TestAddTranslations(t *testing.T) {
Expand Down Expand Up @@ -85,3 +85,56 @@ func TestLocalizePluralKey_Fallback(t *testing.T) {
assert.Equal(t, "Apple", XN("appleID", "Apple", 1))
assert.Equal(t, "Apples", XN("appleID", "Apple", 2))
}

func TestDefaultLocalizations(t *testing.T) {
// Not ideal, but other tests might manipulate the global state.
// Reset it manually.
require.NoError(t, setupBundle(language.English))
bundleIsDefault = true

t.Run("base localizations are loaded by default", func(t *testing.T) {
languages := RegisteredLanguages()

translationFiles, err := defaultTranslationFS.ReadDir("translations")
require.NoError(t, err)
assert.Len(t, languages, len(translationFiles))

// Two samples for sanity check.
assert.Contains(t, languages, fyne.Locale("en-US-Latn"))
assert.Contains(t, languages, fyne.Locale("de-DE-Latn"))
})

t.Run("registering custom localizations should wipe default localizations first", func(t *testing.T) {
err := AddTranslations(fyne.NewStaticResource("de.json", []byte(`{
"Test": "Passt"
}`)))
require.NoError(t, err)

err = AddTranslations(fyne.NewStaticResource("en_GB.json", []byte(`{
"Test": "Match"
}`)))
require.NoError(t, err)

languages := RegisteredLanguages()
assert.Equal(t, []fyne.Locale{"de-DE-Latn", "en-GB-Latn"}, languages)

setupLang("de")
assert.Equal(t, "Passt", L("Test"))

setupLang("en-GB")
assert.Equal(t, "Match", L("Test"))

t.Run("first registered language becomes default", func(t *testing.T) {
setupLang("it")
assert.Equal(t, "Passt", L("Test"))
})

t.Run("base translations are still present", func(t *testing.T) {
setupLang("de")
assert.Equal(t, "Beenden", L("Quit"))

setupLang("en")
assert.Equal(t, "Name", X("file.name", "nope"))
})
})
}