Skip to content

Add UI language selector (ui.language), fixes #444 - #465

Merged
kwart merged 3 commits into
masterfrom
feat/language-selector
Jul 26, 2026
Merged

Add UI language selector (ui.language), fixes #444#465
kwart merged 3 commits into
masterfrom
feat/language-selector

Conversation

@kwart

@kwart kwart commented Jul 25, 2026

Copy link
Copy Markdown
Member

Lets users pick the interface language explicitly instead of always following the auto-detected OS locale. System default stays the default, so existing users see no behaviour change. Closes #444.

Design notes: design-doc/3.2-language-selector.md.

Behaviour

  • New Language selector on the General tab of Preferences, listing the bundled translations, first entry System default (…). Persisted as ui.language in advanced.properties; empty removes the key.
  • Read once at startup → the label carries the existing restart hint.
  • CLI reads the same key for free: -o ui.language=de (e.g. jsignpdf --help -o ui.language=de prints the help in German).
  • DISPLAY-only scope: only interface text changes; number/date formatting — and therefore the signed output — keep following the OS locale.

Implementation

  • UiLocale (engines/api) — resolves the tag (raw-argv -o pre-scan, then advanced config), installs it via Locale.setDefault(DISPLAY, …) + ResourceBundle.clearCache() + Constants.RES.reload(), before Constants.RES / the commons-cli option descriptions capture any strings. Normalizes _-, maps legacy nonb, and falls back to the system default for empty/unknown/garbage values.
  • SupportedLanguages — static tag list (jar/jlink/jpackage-safe) + display names, guarded by a test against the actual messages*.properties files.
  • ResourceProvider.reload(Locale) — swaps the shared bundle with English-only fallback (a no-fallback Control), so a missing key never leaks OS-locale text; keeps the ~200 static-import call sites untouched.
  • FXML loaders (JSignPdfApp, PreferencesController) use UiLocale.bundle().

Norwegian bundle fix

messages_nb-NO.properties was never matched by ResourceBundle (it builds underscore candidate names), so the complete Norwegian translation silently rendered English. Renamed to messages_nb.properties, which serves both nb and nb-NO. No Weblate change required (its nb_NO language already aliases nb).

Tests

  • SupportedLanguagesTest — list vs. translation files (guard); Norwegian actually loads for nb and nb-NO.
  • UiLocaleTest — tag parsing, underscore/legacy-no normalization, unknown/garbage → system default, DISPLAY-only install (FORMAT untouched).
  • PreferencesViewModelTestui.language load/write round-trip, empty removes the key, reset-to-defaults clears it.
  • FxTranslationsTest — extended with nb_NO and a Preferences.fxml load asserting the Language label.

Docs

Advanced-config section in website/docs/JSignPdf.adoc, documented ui.language= in advanced.default.properties, and two distribution/doc/release-notes/3.2.0.md bullets.

🤖 Generated with Claude Code

kwart and others added 2 commits July 25, 2026 15:50
Let users pick the interface language explicitly instead of always
following the OS locale. "System default" stays the default, so existing
users see no change.

- UiLocale resolves ui.language (argv -o pre-scan, then advanced config)
  and installs it DISPLAY-only, so number/date formatting and the signed
  output keep following the OS locale. Installed before Constants.RES and
  the commons-cli option descriptions capture strings.
- SupportedLanguages: static tag list + display names, guarded by a test
  against the actual messages*.properties files.
- ResourceProvider.reload(Locale) swaps the shared bundle with English-only
  fallback (no OS-locale fallback), keeping the ~200 call sites untouched.
- Preferences: Language selector as the first row of the General tab,
  persisted as ui.language; empty removes the key. CLI reads the same key
  via -o ui.language=<tag> (e.g. --help in German).
- Fix Norwegian bundle: messages_nb-NO.properties was never matched by
  ResourceBundle; renamed to messages_nb.properties so it applies for both
  nb and nb-NO.
- Tests (SupportedLanguagesTest, UiLocaleTest, PreferencesViewModel
  round-trip, FxTranslationsTest + nb_NO and Preferences.fxml) and docs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@kwart

kwart commented Jul 26, 2026

Copy link
Copy Markdown
Member Author

Code review

Verified locally (branch feat/language-selector @ 1d8663f):

  • mvn -pl engines/api -am test — green; mvn -pl jsignpdf test — green.
  • Manual end-to-end: --help -o ui.language=<de|nb|zh_CN|no|xx> → German / Norwegian / Chinese / Norwegian (legacy no) / English fallback. The Norwegian rename genuinely fixes the dead bundle.
  • ⚠️ FxTranslationsTest ran 1 test, 1 skippedMonocleAssumption skips the whole class on a JavaFX-bundled JDK, so the new testPreferencesLoadsWithAllLocalesAndHasLanguageLabel did not execute locally. It should run on CI's Temurin.

The architecture is right: ResourceProvider.reload() keeps Constants.RES as the single shared instance (no churn across ~200 call sites), the no-fallback Control correctly prevents OS-locale leakage into a chosen language, and DISPLAY-only scoping keeps signed output out of the blast radius.


Findings

1. setDefault(DISPLAY, …) does not reach Locale.getDefault() — JavaFX/Swing built-in strings stay in the OS language — UiLocale.java:52 (medium)

Verified empirically: after Locale.setDefault(Category.DISPLAY, de) on a pt_PT system, Locale.getDefault() still returns pt_PT, and ResourceBundle.getBundle(baseName) (no locale arg) resolves against pt_PT. JavaFX's ControlResources does exactly that:

// com/sun/javafx/scene/control/skin/resources/ControlResources.java:52
return ResourceBundle.getBundle(BASE_NAME).getString(key);

ButtonType.OK / ButtonType.CANCEL resolve their text lazily through it, and we use them widely (MainWindowController.java:1164, ManagePresetsDialog.java:154, ~8 new Alert(...) sites).

Failure scenario: Portuguese-locale desktop, user sets ui.language=de → app text German, Alert buttons "Cancelar". A mixed-language UI in precisely the situation #444 describes.

Two ways out — worth an explicit decision, not silence:

Locale osFormat = Locale.getDefault(Locale.Category.FORMAT);
Locale.setDefault(locale);                              // also sets defaultLocale + DISPLAY
Locale.setDefault(Locale.Category.FORMAT, osFormat);    // formatting stays on the OS locale

This keeps the FORMAT guarantee (signed output unchanged) while making Locale.getDefault()-based lookups follow the choice. Residual risk is String.toLowerCase()/toUpperCase() without an explicit locale — bounded here, since none of the 19 supported languages is tr/az/lt. Otherwise, document the limitation in the release note and the adoc, which currently promise "only interface text changes" without this caveat.

2. English reference bundle loaded without the no-fallback control — SupportedLanguagesTest.java:37 (low)

String english = ResourceBundle.getBundle(RESOURCE_BUNDLE_BASE, Locale.ENGLISH).getString("console.exception");

On a machine whose default locale is nb, the candidate chain is messages_enmessages_nb (default-locale fallback) → messages, so english becomes the Norwegian string and assertTrue(!value.equals(english)) fails — the test asserting the fix breaks exactly for a Norwegian developer. Add the same getNoFallbackControl(FORMAT_PROPERTIES) used two lines below.

3. UiLocale.init in tests permanently mutates global state — UiLocaleTest.java (low)

initInstallsDisplayOnlyFromCliOverride and systemDefaultIgnoresInstalledChoice leave Constants.RES pointing at the German/Czech bundle for the rest of the surefire fork; @After restores only the Locale category defaults. Nothing in engines/api asserts on RES text today (checked — that's why the suite is green), but it's an order-dependent trap for the next test that does. Restoring Constants.RES.reload(Locale.ENGLISH) in @After costs one line.

4. ResourceProvider.reload() hard-codes the base name and inverts a dependency — ResourceProvider.java:51-54 (low)

The instance was constructed with an arbitrary ResourceBundle, but reload always rebuilds from Constants.RESOURCE_BUNDLE_BASE — and it pulls net.sf.jsignpdf.Constants into utils.ResourceProvider, while Constants:101 constructs a ResourceProvider. UiLocale already owns both the base name and NO_FALLBACK; reload(ResourceBundle) (called as Constants.RES.reload(UiLocale.bundle())) keeps that knowledge in one place and drops the cycle.

Also: bundle is non-volatile and now mutated post-construction — safe in practice (init precedes FX/Swing thread start), but volatile is free. And the new import net.sf.jsignpdf.Constants; at line 11 sits between the java.util.* and javax.swing.* groups, against this file's grouping.

5. Non-canonical stored tags aren't represented in the selector (low)

UiLocale.resolve accepts zh_CN, de-AT, nb-NO, no; the ChoiceBox holds only the 19 canonical tags + "". Traced ChoiceBox.valuePropertySingleSelectionModel.select(obj): a non-item value is kept (index -1, setSelectedItem(obj)), and ChoiceBoxSkin.updateLabelText renders getDisplayText(value) — so no silent data loss, but the popup shows nothing checked and the label reads e.g. "Deutsch (Österreich) (German (Austria))". Normalizing the loaded tag to the nearest supported item in loadFrom would tidy it.

Related: ui.language=zh is rejected outright (only zh-CN/zh-TW exist) — mapping bare zhzh-CN alongside the existing nonb mapping would be consistent.

6. Label formatting produces nested parentheses — SupportedLanguages.displayName (cosmetic)

Actual rendered list is mostly excellent — čeština (Czech), norsk bokmål (Norwegian Bokmål), 日本語 (Japanese) — but the region-bearing entries give 中文 (中国) (Chinese (China)) and 中文 (台灣) (Chinese (Taiwan)). Using getDisplayName for both halves is the right call (bare getDisplayLanguage would render zh-CN and zh-TW identically), so this is just presentation; a separator, or dropping the English parenthetical when the self-name already contains parens, would read better.

7. Design doc has drifted from the implementation (doc)

design-doc/3.2-language-selector.md says labels come from getDisplayLanguage(locale) + getDisplayName(ENGLISH) and that the list is "sorted by the native name" — the code uses getDisplayName for both and keeps tag order (en first, then alphabetical by tag). It also lists hlp.uiLanguage and a SignerOptionsFromCmdLineTest case that weren't added. The implementation choices are the better ones; update the doc rather than the code.

8. Test style nits — FxTranslationsTest.java:156,160

New code uses fully-qualified javafx.scene.control.TabPane, java.util.List, java.util.ArrayList in a file that imports every other type; assertEquals("…", true, found) should be assertTrue. Same for net.sf.jsignpdf.Constants.RESOURCE_BUNDLE_BASE in SupportedLanguagesTest.


Non-issues checked

  • jlink/jpackage locale data — all three build scripts use --add-modules ALL-MODULE-PATH, so jdk.localedata ships and native display names render in the installers.
  • Bundle fallback chaingetNoFallbackControl disables only the default-locale fallback, not the parent chain to the English base, so the three new English-only keys resolve correctly in every translated bundle.
  • Entry pointsSigner.main is the sole main class in all three installers; InstallCert (the secondary launcher) uses no RES strings. All non-test ResourceBundle.getBundle sites in app code are now locale-explicit.
  • -o pre-scan — the startsWith("-o") branch also matches other short options beginning with o, but the ui.language= prefix check makes that harmless, and last-match-wins matches commons-cli semantics.
  • No leftover nb-NO / nb_NO references anywhere outside the release note that explains the rename.
  • Security — no meaningful surface; the tag reaches only Locale.forLanguageTag and a log line.

Bottom line

Solid, well-scoped work. Worth addressing before merge: #1 (fix or explicitly document — the one item that affects what a user actually sees) and #2 (one-line fix to a test that can fail for the very audience the Norwegian fix serves). The rest are polish.

Install the choice as the base default plus Category.DISPLAY, with
Category.FORMAT pinned back to the OS locale. setDefault(Category, ...)
leaves Locale.getDefault() alone, and ResourceBundle.getBundle(baseName)
resolves against that - which is how JavaFX loads its own control strings
(ButtonType.OK/CANCEL, text-field context menus). DISPLAY-only therefore
gave a Portuguese-locale user picking German a German app with "Cancelar"
buttons. Number/date formatting still follows the OS locale, so signed
output is unchanged.

Also:

- ResourceProvider.reload() takes a ResourceBundle instead of a Locale, so
  the bundle base name and the fallback policy stay owned by UiLocale and
  utils.ResourceProvider no longer depends on Constants (which constructs a
  ResourceProvider itself). The bundle field becomes volatile.
- UiLocale.canonicalTag() maps a stored tag to the matching selector item,
  so a hand-edited zh_CN / de-AT / no shows up as the zh-CN / de / nb entry
  it actually loads. A bare zh expands to zh-CN rather than falling back to
  English.
- SupportedLanguages.displayName() switches to an en-dash separator when a
  name already brackets its region, avoiding nested parentheses in
  "Chinese (China)".
- SupportedLanguagesTest loads the English reference bundle with the same
  no-fallback control as the Norwegian ones; with the default control the
  test inverted on a machine whose locale is nb.
- UiLocaleTest restores Constants.RES, which init() swaps process-wide, so
  test execution order cannot leak a translated bundle into a later test.
- Translations for the three new Preferences keys.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@kwart
kwart merged commit 138b361 into master Jul 26, 2026
1 check passed
@kwart
kwart deleted the feat/language-selector branch July 26, 2026 20:35
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add UI language selector (override auto-detected locale)

1 participant