Skip to content
Theme

Text and languages

Anywhere a component takes a String it also takes an I18nString, a piece of text that knows its key and can re-resolve itself when the language changes:

private static final I18nString SAVE = new I18nString("editor.save", "Save");
...
toolbar.addItem(new Button(SAVE));

The second argument is both the default text and the fallback for every key no bundle answers, so an application that never registers a translation behaves exactly as it would with plain strings. A missing translation degrades to English rather than showing a key name on screen.

Declare them static final. The resolution is cached per key for the whole process, so a list showing five hundred rows of the same label resolves once per language change rather than five hundred times.

Translations are ordinary .properties files on the classpath, one per language tag:

src/main/resources/i18n/app.properties
src/main/resources/i18n/app_pt-BR.properties
src/main/resources/i18n/app_ja.properties

Register the family once, at startup:

I18n.addBundle(PropertyBundle.family("i18n/app"));

Then switch languages at runtime:

I18n.setLocale(Locale.forLanguageTag("pt-BR"));

The switch re-lays-out every open window on its own, for the same reason a theme switch does: translated text is a different width, so everything has to be measured again.

A string that marks an access letter with & (“&Save”, “&Arquivo”, “ファイル(&F)”) carries the letter in each translation, so the translator picks one that is in the word; Access letters has the rules, and I18nString.withoutAccessLetter() reads such a string as its words for a sentence that quotes it.

The toolkit’s own strings (the colour picker’s labels, the theme names, the built-in component text) already ship translated for a long list of language tags, so switching the locale translates the parts you did not write as well. A language the toolkit has no file for falls back to English, by the same rule your own bundles follow.

A language often has more than one code: jpn and ja are both Japanese, and prs is Persian as written in Afghanistan, fa-AF. The toolkit reads every such code as the one ICU reads it as before it translates, formats or lays anything out, and I18n.canonical(locale) hands your code the same reading, so a font, a resource or a layout you choose by language agrees with it.

A key no bundle has, because of a typo, a rename, or a file that has not caught up, shows its English and says nothing else. To hear about it, subscribe:

I18n.observeUnresolvedKeys(miss ->
System.err.println(miss.locale() + " has no " + miss.key()));

Each key is reported once per language, and again after a bundle is added or removed. A test can collect the reports and fail on any key of its own. English is never a miss, since it is the text the declaration carries, and neither is a plural form the language covers with its own other. With nobody listening, each miss is logged once instead: at INFO for your keys, and at DEBUG for the toolkit’s limn.* keys, a few of which fall back to English on purpose.

The locale does not have to be process-wide. A widget can declare one for itself and everything below it:

codePane.setLocale(Locale.ENGLISH);

Inside that subtree, everything that resolves text resolves in that language — string lookups, message formats, digits, line breaking — while the rest of the window stays in the process locale. The value inherits the way the control size does: a widget’s own declared locale, else its nearest declaring ancestor’s, else the window’s (scene.setLocale(…)), else the process one, and a popup or dialog follows the widget that opened it. setLocale(null) returns a subtree to inheriting.

The locale matters beyond translations, wherever it is set. Line breaking asks the effective language for its break opportunities, so Thai wraps between its words only where the locale actually says Thai; the same sentence inside an English interface offers nowhere to break at all and is cut where it stops fitting.

  • Shapes a line before it measures or draws it. The glyphs are chosen by the font’s own layout tables instead of one glyph per character, which is the difference between a script rendering and a script rendering correctly: Arabic letters join and take their contextual forms, a Devanagari conjunct is one glyph made of three characters, a Devanagari vowel sign draws before the consonant it follows in the string, Hebrew and Thai marks are placed on the letter they belong to, and Latin picks up the ligatures and kerning its face was built with.
  • Measures what it draws. Ellipsis, wrapping, the caret and hit-testing all come off the same shaped line the renderer paints, so a label that says it fits, fits, and a click lands on the character under the pointer even where the glyphs are not in the order the characters are.
  • Resolves a face per run, not per character. A string that mixes Latin, Greek, Cyrillic, CJK and Arabic is cut into runs of one script and one direction, and each run picks its face once, before any glyph exists. It has to be that way round: a word split in half by a per-character decision would shape as two words. None of it asks you to name a font.
  • Orders mixed text by the Unicode bidirectional algorithm. Total: 42 ريال (SAR) is three runs — left to right, right to left, left to right — and they are ordered on the line without you marking anywhere that the direction changed. A paragraph’s own direction is read from its first strong character.
  • Edits by cluster, and moves by what is on the screen. Arrow keys, selection and backspace move by what a reader would call a character, so combining marks, multi-part emoji and conjuncts are never split in half. Left and Right step to what is next visually; Home, End and the word jumps step through the string, so in right-to-left text Left and Ctrl+Left move opposite ways — which is what Windows and GTK do, and is forced by the fact that a selection has to be one range of the text. A selection crossing a direction boundary is painted as the two or more boxes it actually covers, never as one rectangle over the text between them.
  • Breaks lines with the reading rules of the UI language. Wrapping asks the language for its break opportunities instead of looking for spaces, so Chinese and Japanese wrap where they should and Thai, which writes without spaces at all, breaks between its words. A Label given setMaxLines(2) breaks the same way and ends its second line with the ellipsis, at the end reading takes it to, whichever way the line reads.
  • Supports IME composition. Japanese, Chinese and Korean input methods show their in-progress composition inside the field, underlined, with the candidate window positioned against the caret. On X11 the input method draws the composition itself, in a small window at the caret, and the field receives the text when it is committed.
The same screen in Japanese: the CJK face is resolved for the run, and the application named no font.The same screen in Japanese: the CJK face is resolved for the run, and the application named no font.
The same screen in Japanese: the CJK face is resolved for the run, and the application named no font.

Latin, Greek and Cyrillic come from the default family, and Arabic, Hebrew, Devanagari and Thai from one small Noto face each — those arrive with the backend and parse in the background the first time a character needs one. Chinese, Japanese, Korean and colour emoji come from two faces the application opts into, because together they are 23.6 MB an app that never draws them should not carry: add limn-fonts-all (every fallback at the versions this release was tested with) or name limn-fonts-noto-cjk / limn-fonts-noto-emoji yourself — see Install. Nothing to register and nothing to choose after that line; a code point no face covers is the only thing that draws as an empty box, and the log names the artifact that would fix it.

So the answer to “can I put this language in a Label” is now yes for every script above. What is left to weigh is everything around the text, which is the rest of this page.

An application in Arabic or Hebrew gets correctly shaped text in a mirrored interface, and the mirroring is one line:

scene.setLayoutDirection(LayoutDirection.RTL);

Direction is an inherited axis, resolved per subtree the way the control size and the locale are, and everything that is about reading order follows it: rows lay their children from the right, menus, popups, scrollbars and sliders mirror, a horizontal scroll starts at the reading edge, and the arrow keys keep meaning what is on the screen. Your own layout code follows without changing, because the alignment API was already logical: HAlign.START and MainAlignment.END name the reading edges, so a dialog whose buttons sat at END finds them at the other side of the window with no edit at all.

Some things deliberately do not mirror, because they are not about reading order: a check mark stays a tick, a media transport keeps its tape-deck arrangement and its left-to-right scrub bar, a time axis keeps the newest sample at the right, a pan control’s left end is still the left speaker, and Ctrl+→ still names the key with that arrow printed on it.

Icons are your word, not the toolkit’s. Only the call site knows whether an arrow means “back” (which mirrors), “download” (which does not), or is a shape inside a logo (which must not), so directional icons are marked where you place them:

back.setIcon(TablerArrows.ARROW_LEFT.icon(), Icon.Mirroring.IN_RTL);

Everything else is drawn as authored, so a brand mark can never come back flipped.

Numbers, order and case follow the language

Section titled “Numbers, order and case follow the language”

The numbers the toolkit itself renders — a spinner’s value, a chart’s axis and tooltip labels, the media player’s clock — take the locale’s digits at the moment they are formatted. A locale that names its digits decides first: the Unicode extension -u-nu-, which a user’s system settings write for a choice of digits that is not the language’s default, so ar-EG-u-nu-latn writes Latin digits and en-u-nu-arab Arabic-Indic ones; a system the toolkit does not carry is read as no extension. Otherwise which digits a locale takes is CLDR’s answer, checked against CLDR in the build. Arabic as written in Egypt (ar-EG) and the twenty-two other regions that use them gets ٤٢, and a spinner’s editor accepts Arabic-Indic keystrokes there; plain ar, and Arabic everywhere else, writes Latin digits, as native applications do; Persian takes its own extended set, and Hebrew stays Latin, because that is how Hebrew writes numbers. I18n.setNumberingSystem(…) overrides the choice for a deployment that wants the other convention. Your own strings are never rewritten — a part number keeps exactly the characters you authored — and the same seam is public for your own formatting: I18n.localizeDigits(text) on the way out, I18n.toAsciiDigits(text) on the way back in.

A number you format yourself takes the same digits when you format it under I18n.numberLocale() rather than I18n.locale(): the language in effect with the toolkit’s digits named on it, which the platform’s own choice for a bare locale is not (it writes plain ar in Arabic-Indic digits). Dates and times want DateFormats, beside NumberFormats: DateFormats.date(FormatStyle.SHORT), time and dateTime write the language’s own order and words in the toolkit’s digits, where the platform writes every date in ASCII digits, so a date under ar-EG reads in the same digits as the amount beside it. A locale that names a calendar with -u-ca- is written in that calendar, as the date widgets draw it: ja-JP-u-ca-japanese writes the era (“R7/10/25”), th-TH-u-ca-buddhist the Buddhist year, and ar-SA-u-ca-islamic-umalqura the Hijri date. The value is the same LocalDate either way. A date that calendar does not reach, before its first day or era or after its last day (the platform’s Umm al-Qura calendar covers 1882 to 2174, and its Japanese one starts at Meiji 6), is written in the ISO calendar rather than refused, and so is every date of a locale that names no calendar, or one the platform does not carry.

A spinner’s number is written and read the way the language writes it: a spinner stepping by a quarter shows a half as 0,50 in German, Swedish writes a negative value with a true minus sign (−1,5), and no language’s thousands are ever grouped. Typing is more forgiving than the display — a leading +, the hyphen every keyboard types in place of a minus sign, and a comma in English all work — but a point typed where the language groups thousands with points is refused rather than guessed at: 1.234 in German could mean either of two numbers a thousand apart. A number the spinner cannot read is not dropped silently: the field goes back to the value it had and its border flashes the theme’s danger colour, and a screen reader is told the same in words, politely and in the spinner’s language — “1.234” not accepted, still 7. The clock face of a time spinner is HH:MM in every language.

Ordering and case mapping ask the language too. The toolkit shows lists in the order you hand them — order is content — so sorting stays one call on your side, through a collator in the language the user is reading:

names.sort(I18n.collator());

I18n.toUpperCase and I18n.toLowerCase case whole strings the same way, which is what ComboBox type-ahead already does: “stras” finds “Straße”, and a Turkish i finds İstanbul on purpose rather than by accident. When the content is not in the interface’s language — an English interface listing Swedish names must still put ä after z — I18n.setTextLocale(…) declares the language the text is in, and order and case follow it instead.

“12 files loaded” is one sentence in English and three in Russian, where 21 takes the form 1 takes, 22 the form 2 takes, and 11 neither — and six in Arabic. PluralString is the counted sibling of I18nString: one declaration carries the key and the two forms English has, and the form is chosen when the count is known.

/** One declaration, the two forms English has; the other languages' forms live in the catalog. */
private static final PluralString LOADED = PluralString.of(
"demo.guide.loaded", "{0}: {1} file loaded", "{0}: {1} files loaded");
/**
* @param folder the folder whose load finished
* @param count how many files it holds
* @return the sentence in the interface's language, its number written the way that language
* writes numbers
*/
public static String loaded(String folder, long count) {
return LOADED.format(count, folder); // the count is the LAST argument: {1} here
}

The count is the last argument, after whatever you passed, so a translator writes the sentence in the order the language wants it and your own arguments keep their numbers. It is inserted already written the way the interface’s language writes numbers — 1,000,000 in English, 1.000.000 in Portuguese, ١٬٠٠٠٬٠٠٠ in Arabic — the same formatting NumberFormats gives a table’s numeric column.

In the catalog a counted key gains one suffix per form the language uses: demo.guide.loaded.one and .other for English, .one, .few and .many for Russian, six for Arabic. A language declares exactly the forms it has (PluralRules.integerCategories), a form a catalog leaves out falls back to that language’s other and then to the English declared in code, and the toolkit’s own catalogs are checked against CLDR so a language never carries a form it does not use. The tree’s “folder, 12 items” announcement is one of these.

A sentence that follows a change of language

Section titled “A sentence that follows a change of language”

format(…), on an I18nString or a PluralString, answers a String: the sentence in the language of that moment. A label given it keeps that text when the user switches language. Give the label the sentence itself instead: withArguments(…) on an I18nString, and forCount(count, …) on a PluralString, answer an I18nString that is formatted again in whatever language is in effect when it is read.

status.setText(SAVED.withArguments(document.name(), time)); // "Saved {0} at {1}"
count.setText(FILES.forCount(files.size())); // "{0} file" / "{0} files"

An argument that is itself an I18nString is read in the new language too, and the count’s form and digits follow the language as well. What follows is the language, not the arguments: they are values taken when you make the text, and it is formatted once per language and kept. When the count or the name changes, make a new text and set it again, as the second line does each time the files change. Use format(…) when you need the text of the moment as a String: for a log line, a file name, or a message to another program.

A custom widget that calls canvas.drawText(text, x, y, font, ink) gets shaped text: the canvas hands the string to the installed ruler and draws what comes back, so every script above is correct without your doing anything. What it does not get is the geometry — where a caret goes, which index is under an x — it pays a cache lookup on every frame that draws it, and a string with no strong character of its own (a price, a count, a clock face) falls back to left to right instead of reading the way the interface around it reads. The shaped form fixes all three, and the value is meant to be held rather than rebuilt every frame, because shaping is the expensive half of drawing text:

ShapedText.Direction base = ShapedText.Direction.of(text, neutralBase());
if (shaped == null || !shaped.matches(text, font, base, textRuler())) {
shaped = shapeText(text, font);
}
canvas.drawText(shaped, x, baseline, ink);

shapeText shapes the line the way your widget reads: the first-strong rule still decides for any string that can decide for itself, and the widget’s resolved direction answers for the rest. matches compares the four things that can invalidate the held value — the text, the font, the direction, and the ruler’s own state — so a theme change, a control-size step, a direction change or a font arriving in the background all re-shape, and nothing else does. The value answers everything else you would otherwise measure a prefix for: where a caret goes, which index is under an x, which boxes cover a selection, where to cut for an ellipsis.

To stand a line in the middle of a box the way the toolkit’s own controls do, take the baseline from the ruler, textRuler().centredBaseline(font, boxHeight), and return the same number from baselineOffset() so a row aligned on baselines puts yours on the line with theirs. It centres the capitals rather than the whole line box: the room a face reserves above its tallest letter and below its deepest is never the same, so a line box centred exactly still reads high or low.

  • Vertical writing. Mongolian and the vertical form of CJK need a second axis through the whole layout system, not a direction flag.
  • Justification and hyphenation. Lines are broken, not fitted: a wrapped paragraph is ragged at the end of its lines.