Skip to content

43. Languages

amadeOS speaks English, German, French, Spanish and Italian, and so does every program. This chapter covers the list of languages, how a program decides which one it speaks, the desktop's and the program's language settings, the texts and their formats, menu letters, and the test that keeps every translation complete.

The five languages: Language

Package i18n · introduced in 10. Pad · source: Language

What it is for. The languages amadeOS is translated into, as an enum: ENGLISH, GERMAN, FRENCH, SPANISH, ITALIAN. English is the source language and the fallback for every missing text.

Method Example
code() de, the value in settings files
nativeName() Deutsch, shown in language menus
locale(), posixLocale() de-DE, de_DE.UTF-8
ofCode(code), of(locale) The language, or empty if amadeOS is not translated into it
parseLocale(value) A Locale from de, de_AT, de_AT.UTF-8@euro or de-AT

How it works. parseLocale reads every form a locale is written in on Linux and keeps the region, so an Austrian desktop gets German texts with Austrian dates. It returns empty for C, POSIX, garbage and languages without a translation, so the caller goes on to the next setting:

amade-commons · i18n/Language.java
    /**
     * Parses a locale as found in {@code LANG}, {@code locale.conf} or a language setting: {@code de},
     * {@code de_AT}, {@code de_AT.UTF-8}, {@code de_AT.UTF-8@euro} or {@code de-AT}. Keeps the region, so
     * that dates and numbers are formatted as in Austria. Empty for {@code C}, {@code POSIX}, garbage and
     * languages amadeOS is not translated into.
     */
    public static Optional<Locale> parseLocale(String value) {
        if (value == null) return Optional.empty();
        String s = value.strip();
        int cut = indexOfAny(s, '.', '@');
        if (cut >= 0) s = s.substring(0, cut);
        String[] parts = s.split("[_-]", -1);
        if (parts.length == 0 || parts.length > 2) return Optional.empty();
        Optional<Language> language = ofCode(parts[0]);
        if (language.isEmpty()) return Optional.empty();
        if (parts.length == 2) {
            String country = parts[1].toUpperCase(Locale.ROOT);
            if (!country.matches("[A-Z]{2}|[0-9]{3}")) return Optional.empty();
            return Optional.of(Locale.of(language.get().code, country));
        }
        return Optional.of(language.get().locale());
    }

Tip: a sixth language

A new language is a new constant here, a file commons_xx.properties with amade-commons' own texts, and a file per program. TranslationCheck then reports every program that still lacks it (chapter 10).

Which language: LocaleResolution

Package i18n · introduced in 10. Pad · source: LocaleResolution

What it is for. Deciding the language at start-up, and remembering why. Four places are asked in order, and the first that names a language amadeOS is translated into wins:

  1. The program's own setting, ~/.config/amadeos/<app>/language.conf.
  2. The desktop's language, ~/.config/amadeos/locale.conf.
  3. The environment, as gettext reads it: LANGUAGE, then LC_ALL, LC_MESSAGES, LANG. This is how a program speaks the right language on another desktop.
  4. English.
amade-commons · i18n/LocaleResolution.java
    /**
     * Decides the locale: the application's own setting, else the desktop's, else the environment, else
     * English. A setting whose language amadeOS is not translated into is skipped.
     *
     * @param application the application's own setting; empty means "follow the desktop"
     * @param desktop     the locale of {@code locale.conf}, see {@link LocaleStore#current()}
     * @param environment the process environment
     */
    public static LocaleResolution resolve(Optional<Language> application, Optional<Locale> desktop,
                                           Map<String, String> environment) {
        Optional<Locale> fromEnvironment = fromEnvironment(environment);
        if (application.isPresent()) {
            Language language = application.get();
            // Keep the region of the desktop or the environment if it is the same language (de-AT, fr-CH).
            Locale locale = desktop.filter(l -> Language.of(l).equals(application))
                    .or(() -> fromEnvironment.filter(l -> Language.of(l).equals(application)))
                    .orElse(language.locale());
            return new LocaleResolution(locale, language, Source.APPLICATION);
        }
        if (desktop.isPresent()) return of(desktop.get(), Source.DESKTOP);
        if (fromEnvironment.isPresent()) return of(fromEnvironment.get(), Source.ENVIRONMENT);
        return new LocaleResolution(Language.ENGLISH.locale(), Language.ENGLISH, Source.DEFAULT);
    }

When to choose it. LocaleSettings calls it; a program reads the result from LocaleSettings.applied(). Its source() (APPLICATION, DESKTOP, ENVIRONMENT, DEFAULT) is logged at start-up, which answers "why does it speak Italian?" at once.

How it works. A program's own language keeps the region of the desktop or the environment when the language is the same: French chosen in the program on a Swiss desktop (fr_CH) gives Swiss French. LANGUAGE is a priority list (de:fr) and is ignored when the locale is C, as gettext does.

Test: the order of the four places
amade-commons · test/i18n/I18nTest.java
    @Test
    void applicationSettingWinsThenDesktopThenEnvironmentThenEnglish() {
        Map<String, String> env = Map.of("LANG", "it_IT.UTF-8");
        Optional<Locale> desktop = Optional.of(Locale.of("de", "DE"));

        LocaleResolution app = LocaleResolution.resolve(Optional.of(Language.FRENCH), desktop, env);
        assertEquals(Language.FRENCH, app.language());
        assertEquals(LocaleResolution.Source.APPLICATION, app.source());
        assertEquals(Locale.of("fr", "FR"), app.locale());

        LocaleResolution fromDesktop = LocaleResolution.resolve(Optional.empty(), desktop, env);
        assertEquals(Language.GERMAN, fromDesktop.language());
        assertEquals(LocaleResolution.Source.DESKTOP, fromDesktop.source());

        LocaleResolution fromEnv = LocaleResolution.resolve(Optional.empty(), Optional.empty(), env);
        assertEquals(Locale.of("it", "IT"), fromEnv.locale());
        assertEquals(LocaleResolution.Source.ENVIRONMENT, fromEnv.source());

        LocaleResolution fallback = LocaleResolution.resolve(Optional.empty(), Optional.empty(),
                Map.of("LANG", "pt_BR.UTF-8"));
        assertEquals(Language.ENGLISH, fallback.language());
        assertEquals(LocaleResolution.Source.DEFAULT, fallback.source());
    }

The desktop language: LocaleStore

Package i18n · introduced in 10. Pad · source: LocaleStore

What it is for. The language of the desktop, in ~/.config/amadeos/locale.conf: one line with a POSIX locale such as de_DE.UTF-8. The login greeter exports it as LANG for the session, so programs outside amadeOS follow it too.

When to choose it. Programs read it through LocaleSettings. Only the desktop's own language switcher calls write(language), the one method that changes it. watch() and the listeners exist for the desktop, which can relabel itself; a program's language changes only at its next start (below).

How it works. Like ThemeStore: it reads the file once, never fails (a missing or unknown locale is empty), writes atomically, and follows the file with a ConfigWatcher, calling its listeners on the watching thread.

The language setting: LocaleSettings and LanguageChooser

Package i18n · introduced in 10. Pad, LanguageChooser in 21. Calendar · source: LocaleSettings, LanguageChooser

What it is for. A program's own language, which the user may set to differ from the desktop's: language.conf holds desktop or a language code. LocaleSettings reads it, resolves the language with the desktop's and the environment's, and knows whether a changed setting needs a restart. LanguageChooser is the combo box for a settings dialog.

When to choose it. CommonsModule binds LocaleSettings. A program with an options dialog puts a LanguageChooser on it, as Calendar, Mail and Audio do; Pad, without a dialog, builds a menu from the same parts.

amade-commons · i18n/LanguageChooser.java
    public LanguageChooser(LocaleSettings settings, Messages commons) {
        Language desktop = settings.desktopResolution().language();
        addItem(new Choice(Optional.empty(), commons.format("language.follow-desktop", desktop.nativeName())));
        for (Language language : Language.values()) {
            addItem(new Choice(Optional.of(language), language.nativeName()));
        }
        setSelectedLanguage(settings.applicationLanguage());
        getAccessibleContext().setAccessibleName(Mnemonics.strip(commons.get("language.label")));
    }

The first entry follows the desktop and says which language that is now. Every language is listed under its own name, Français, not French: a user who switched to a language they cannot read must still find their own.

How it works. The resolution at start-up is kept as applied(). After the user stored a new choice with setApplicationLanguage, restartRequired() compares it with the settings as they are now:

amade-commons · i18n/LocaleSettings.java
    /** True if the settings now lead to another language than the application started with. */
    public boolean restartRequired() {
        return resolve().language() != applied.language();
    }

If it is true, the program says so with the text language.restart from amade-commons ("The new language is used the next time Mailer starts."). Changing the language of a running Swing program would mean rebuilding every window, menu and dialog, including the JDK's own file chooser; a restart is simpler and the user is told.

Texts: Messages

Package i18n · introduced in 10. Pad · source: Messages

What it is for. A program's texts in one language, from .properties files in UTF-8: messages.properties in English, messages_de.properties, _fr, _es and _it next to it.

When to choose it. Inject it where the program binds it, or reach it through Texts. Messages.commons(locale) gives amade-commons' own texts: the language menu, the start-up error and the date dialog.

How it works. Loading uses a ResourceBundle.Control without fallback to the JVM's default locale: a language without a file gets English, never the language of the developer's computer.

amade-commons · i18n/Messages.java
    /**
     * Loads the texts for the locale. A language without a file falls back to the base file (English), never
     * to the JVM's default locale.
     *
     * @param anchor   a class of the application; its class loader finds the files
     * @param baseName e.g. {@code eu.amadeos.audio.messages} for
     *                 {@code eu/amadeos/audio/messages.properties}
     */
    public static Messages load(Class<?> anchor, String baseName, Locale locale) {
        Objects.requireNonNull(locale, "locale");
        ResourceBundle bundle = ResourceBundle.getBundle(baseName, locale, anchor.getClassLoader(), CONTROL);
        return new Messages(bundle, locale, baseName);
    }

format fills in {0}, {1}, ... with MessageFormat in the locale, so numbers, dates and {0,choice,...} plurals follow the language. Before formatting it doubles every apostrophe, so translators write l'album instead of MessageFormat's l''album. A missing key shows as !key! and is logged once:

amade-commons · i18n/Messages.java
    /** The text as written in the file. */
    public String get(String key) {
        try {
            return bundle.getString(key);
        } catch (MissingResourceException e) {
            if (reported.add(key)) LOG.warn("Missing text {} in {}", key, baseName);
            return "!" + key + "!";
        }
    }

    /** The text with {@code {0}}, {@code {1}}, ... replaced by the arguments, formatted in the locale. */
    public String format(String key, Object... arguments) {
        String pattern = get(key);
        if (arguments.length == 0) return pattern;
        try {
            return new MessageFormat(pattern.replace("'", "''"), locale).format(arguments);
        } catch (IllegalArgumentException e) {
            if (reported.add(key + "#format")) LOG.warn("Bad pattern {} in {}: {}", key, baseName, e.getMessage());
            return pattern;
        }
    }
Method What it does
load(anchor, baseName, locale), load(anchor, baseName) The texts next to the class anchor; the second in the default locale
commons(locale) amade-commons' own texts
get(key), format(key, args...) A text as written, or with its arguments filled in
has(key), locale() Whether a text exists, in which locale they were loaded

Pitfall: a literal brace

Because apostrophes are plain, MessageFormat's quoting is gone: a text that is formatted cannot show a literal {. Such a text is logged as a bad pattern and shown unformatted. Pass the brace in as an argument instead, or use get for a text without arguments.

Test: English as the fallback, never the JVM's language
amade-commons · test/i18n/I18nTest.java
    @Test
    void messagesFallBackToEnglishNotToTheDefaultLocale() {
        Locale previous = Locale.getDefault();
        try {
            Locale.setDefault(Locale.GERMAN);
            Messages portuguese = Messages.commons(Locale.of("pt", "BR"));
            assertEquals("Desktop setting (Deutsch)", portuguese.format("language.follow-desktop", "Deutsch"));
        } finally {
            Locale.setDefault(previous);
        }
        Messages german = Messages.commons(Locale.of("de", "AT"));
        assertEquals("Desktop-Einstellung (Français)", german.format("language.follow-desktop", "Français"));
        assertEquals("!no.such.key!", german.get("no.such.key"));
        assertFalse(german.has("no.such.key"));
    }

Texts without injection: Texts

Package i18n · introduced in 22. Mail · source: Texts

What it is for. The program's texts for code that gets nothing injected: dialogs, table models, renderers, records with a label. A static import gives every class tr:

import static eu.amadeos.commons.i18n.Texts.tr;

new JButton(tr("compose.send"));                 // Send
status.setText(tr("status.fetched", count));     // {0} new messages

When to choose it. In every program since Mail. It is a global, which is acceptable for texts because they are read-only and the same everywhere in a program; anything that changes stays injected.

How it works. The program names its text files in one resource, META-INF/amadeos/texts.properties:

src/main/resources/META-INF/amadeos/texts.properties
bundle=eu.amadeos.apps.mail.messages

A file instead of a call in main means that the program and its tests find the texts the same way, without set-up code in every test. There must be exactly one such file on the class path; none or two is an error that says so.

amade-commons · i18n/Texts.java
    /** Reads {@value #DECLARATION}; there must be exactly one, the application's. */
    static Source declared(ClassLoader loader) {
        List<URL> found;
        try {
            found = Collections.list(loader.getResources(DECLARATION));
        } catch (IOException e) {
            throw new UncheckedIOException("Cannot look for " + DECLARATION, e);
        }
        if (found.isEmpty()) {
            throw new IllegalStateException("No " + DECLARATION + ": the application has not named its text files");
        }
        if (found.size() > 1) throw new IllegalStateException("Several " + DECLARATION + " files: " + found);
        Properties properties = new Properties();
        try (InputStream in = found.getFirst().openStream()) {
            properties.load(in);
        } catch (IOException e) {
            throw new UncheckedIOException("Cannot read " + found.getFirst(), e);
        }
        String bundle = properties.getProperty("bundle", "").strip();
        if (bundle.isEmpty()) throw new IllegalStateException(found.getFirst() + " names no bundle");
        return new Source(Texts.class, bundle);
    }

The texts are loaded on first use in the default locale, and loaded again when the default locale changes, which tests use to check a translation:

amade-commons · i18n/Texts.java
    /** The application's texts in the default locale. */
    public static Messages messages() {
        Source s = source();
        Loaded l = loaded;
        if (l == null || l.source() != s || !l.messages().locale().equals(Locale.getDefault())) {
            l = new Loaded(s, Messages.load(s.anchor(), s.bundle(), Locale.getDefault()));
            loaded = l;
        }
        return l.messages();
    }

Texts.use(anchor, bundle) points it at other files, for a library's own tests; Texts.bundle() gives the base name to TranslationCheck. amade-commons itself uses tr only in TransportButtons, whose tooltips are the program's texts.

Test: the declared texts follow the default locale
amade-commons · test/i18n/TextsTest.java
    @Test
    void theDeclaredTextsFollowTheDefaultLocale() {
        assertEquals("eu.amadeos.commons.i18n.texts-test", Texts.bundle());
        Locale.setDefault(Locale.ENGLISH);
        assertEquals("Hello", tr("greeting"));
        assertEquals("3 messages", tr("count", 3));
        Locale.setDefault(Locale.GERMAN);
        assertEquals("Hallo", tr("greeting"));
        assertEquals(Locale.GERMAN, Texts.messages().locale());
    }

Package i18n · introduced in 10. Pad · source: Mnemonics

What it is for. The underlined letter of a menu, a button or a label, chosen by each language inside its text: &File, &Datei, &Fichier. && is a literal ampersand.

amade-commons · i18n/Mnemonics.java
    /** Sets text and mnemonic of a button, menu or menu item; returns it for chaining. */
    public static <B extends AbstractButton> B apply(B button, String marked) {
        Text t = parse(marked);
        button.setText(t.text());
        button.setMnemonic(t.keyCode());
        if (t.index() >= 0) button.setDisplayedMnemonicIndex(t.index());
        return button;
    }
Method What it does
apply(button, marked) Text and mnemonic of a button, menu or menu item
apply(label, marked) The same for a label; pair it with setLabelFor
strip(marked) The text without markers, for tooltips and accessible names
parse(marked) The text and the index of the letter, -1 if none

How it works. The key code comes from KeyEvent.getExtendedKeyCodeForChar, so letters outside ASCII (&Öffnen) work as well.

A test for every translation: TranslationCheck

Package i18n · introduced in 10. Pad · source: TranslationCheck

What it is for. A unit test per program that compares every translation with the English file. A forgotten text is then found at build time, not by a user in a dialog that appears once a year:

assertEquals(Map.of(), TranslationCheck.problems(MainWindow.class, Texts.bundle()));

How it works. For each language it reports keys that are missing, keys English does not have (usually a typo), and texts whose {n} arguments differ from English. A language without problems is left out, so the expected result is an empty map, and a failing test prints exactly what to fix.

amade-commons · i18n/TranslationCheck.java
    /**
     * Per language the keys that are missing in its file (prefixed {@code missing: }), are not in the base
     * file ({@code unknown: }) or use other {@code {n}} arguments than English ({@code arguments: }).
     * Languages without problems are left out; a missing file counts every key as missing.
     */
    public static Map<Language, Set<String>> problems(Class<?> anchor, String baseName) {
        Properties base = read(anchor, baseName, null);
        if (base == null) throw new IllegalArgumentException("No base file for " + baseName);
        Map<Language, Set<String>> result = new EnumMap<>(Language.class);
        for (Language language : Language.values()) {
            if (language == Language.ENGLISH) continue;
            Properties translation = read(anchor, baseName, language);
            if (translation == null) translation = new Properties();
            Set<String> problems = new TreeSet<>();
            for (String key : base.stringPropertyNames()) {
                String text = translation.getProperty(key);
                if (text == null) {
                    problems.add("missing: " + key);
                } else if (!arguments(text).equals(arguments(base.getProperty(key)))) {
                    problems.add("arguments: " + key);
                }
            }
            for (String key : translation.stringPropertyNames()) {
                if (!base.containsKey(key)) problems.add("unknown: " + key);
            }
            if (!problems.isEmpty()) result.put(language, problems);
        }
        return result;
    }

Tip: check a few texts as a reader would

TranslationCheck knows that a text exists, not that it reads well. Each program's translation test also formats a few texts in each language and compares them with what a native speaker expects: plurals, numbers, the apostrophes of French and Italian.