Skip to main content
Table of Contents

Switching the application language

The localizer is what turns a set of translation files into a live, switchable UI. This guide covers the runtime side of localization: activating a language by locale, reacting when the language changes so you can refresh anything the engine does not update automatically, choosing when localization runs (manually, on idle, or fully automatically), and remembering the user's choice between runs. Reach for these APIs once your translations exist (created with the Localization Editor); if you are still setting the component up, start with Getting started first.

Switching by locale

The core operation is TrySetLocale: it activates the translation for a locale (for example 'nl', 'de', or 'fr-FR') and re-localizes the running application. It returns False when no translation exists for that locale, so you can fall back gracefully instead of leaving the UI half-translated.

Wire OnLanguageChange before the first switch and it fires for every change — including that first one — which is the right place to refresh anything not covered by the automatic localization pass:

Switch language at runtime and react to changesPascalC++
procedure TForm1.FormCreate(Sender: TObject);
begin
  { Wire the notification before the first switch so the handler runs for it too. }
  Localizer.OnLanguageChange := HandleLanguageChange;
end;

procedure TForm1.HandleLanguageChange(Sender: TObject);
begin
  { The active language changed — refresh anything the localizer does not update for you. }
  UpdateStatusBar;
end;

procedure TForm1.SwitchTo(const ALocale: string);
begin
  { Returns False when no translation exists for the requested locale. }
  if not Localizer.TrySetLocale(ALocale) then
    ShowMessage(Format('No translation available for "%s".', [ALocale]));
end;
void __fastcall TForm1::FormCreate(TObject *Sender)
{
  // Wire the notification before the first switch so the handler runs for it too.
  Localizer->OnLanguageChange = HandleLanguageChange;
}

void __fastcall TForm1::HandleLanguageChange(TObject *Sender)
{
  // The active language changed - refresh anything the localizer does not update for you.
  UpdateStatusBar();
}

void __fastcall TForm1::SwitchTo(const System::UnicodeString ALocale)
{
  // Returns false when no translation exists for the requested locale.
  if (!Localizer->TrySetLocale(ALocale))
    ShowMessage(Format("No translation available for \"%s\".", ARRAYOFCONST((ALocale))));
}

The handler signature matches TTMSFNCLangChangeListenerMethod — procedure(Sender: TObject) of object.

Choosing when localization runs

LocalizationStartMode decides when the localizer applies the active language:

  • lsmManual — nothing happens until you call PerformLocalization or TrySetLocale. Use it when you want full control over timing. This is the default.
  • lsmIdleHook — the application is localized automatically when it next becomes idle.
  • lsmAutoHook — the application is localized automatically as forms are created and when it idles, so forms shown after startup come up already translated.

Set it once during setup.

lsmAutoHook/lsmIdleHook apply to VCL and FMX only, and only to dynamically-created forms. The hooks catch forms that are created at runtime (for example the ones you create on demand with Application.CreateForm/TMyForm.Create after startup). Forms created in the project's .dpr file at startup are constructed before the hook is active and are not picked up, and the hooks are not available in TMS WEB Core at all. For .dpr-created forms, for WEB applications, or when you want translation to be independent of the start mode, derive those forms from TTMSFNCLocalizationForm (below) or call PerformLocalization yourself.

Automatic per-form translation

For translation that works in every framework — VCL, FMX, and TMS WEB Core — and does not depend on the start mode, derive your forms from TTMSFNCLocalizationForm instead of the plain framework form class. Each derived form translates itself into the active language when it is created and re-translates on every language change, with no extra wiring. This is the recommended approach for .dpr-created forms and for WEB applications, where the hook-based start modes do not apply.

Automatic per-form translation with TTMSFNCLocalizationFormPascalC++
type
  { Derive your forms from TTMSFNCLocalizationForm instead of TForm. }
  TCustomerForm = class(TTMSFNCLocalizationForm)
    { your controls }
  private
    procedure UpdateWindowCaption;
  protected
    { Optional: react to a language change beyond the automatic translation. }
    procedure LanguageChange(Sender: TObject); override;
  end;

procedure TCustomerForm.LanguageChange(Sender: TObject);
begin
  inherited;
  UpdateWindowCaption;
end;
// Derive your forms from TTMSFNCLocalizationForm instead of TForm.
class TCustomerForm : public TTMSFNCLocalizationForm
{
__published:
  // your controls
private:
  void __fastcall UpdateWindowCaption();
protected:
  // Optional: react to a language change beyond the automatic translation.
  virtual void __fastcall LanguageChange(TObject *Sender);
};

void __fastcall TCustomerForm::LanguageChange(TObject *Sender)
{
  TTMSFNCLocalizationForm::LanguageChange(Sender);
  UpdateWindowCaption();
}

Override the protected LanguageChange method (and call inherited) to run custom logic on a language change. Because each form drives its own localization, keep the localizer on lsmManual when you rely on this base class — the start-mode hooks are unnecessary and would duplicate the work.

Where the translation files live

LocalizationFolder is the folder the localizer scans for translation files, and it accepts a prefix token so the path resolves per machine and per user instead of being hard-coded:

Token Expands to
{DEPLOY} The folder files deployed with the application end up in. This is also what an empty LocalizationFolder means.
{APP} The folder holding the executable.
{DOC} The current user's documents folder.
{HOME} The current user's application-support folder.
{PF} The Program Files folder (Windows only).

Token names are not case-sensitive, and a token that is not recognized is left in the path unchanged. After expansion, a path that is still relative is resolved against {DEPLOY}; an absolute path is used as-is.

On Windows {DEPLOY} and {APP} are the same folder. On macOS, iOS and Android the IDE's deployment places added files inside the application bundle, and {DEPLOY} points there (Contents/Resources/StartUp on macOS, the bundle root on iOS, the documents path on Android) while {APP} keeps pointing beside the bundle. Use {DEPLOY}, or leave the value empty, for translations that ship with the application, and one setting works on every target.

Pick a folder the application can write to. The collector saves translation data into this folder, and the usual install locations — {APP} or {DEPLOY} when the application is installed under Program Files, and {PF} itself — are read-only for a standard user, so saving there fails. Use {HOME} or {DOC} for anything written at run time. {DEPLOY} and {APP} are the right choice only for translations that ship with the application and are read but never written.

Give the collector and the localizer the same folder, so the localizer reads what the collector wrote:

Point the collector and localizer at a writable folderPascalC++
procedure TDataModule1.DataModuleCreate(Sender: TObject);
begin
  // The HOME token expands to the current user's application-support folder,
  // which a standard user can write to. The collector saves the translation
  // data, so its folder has to be writable - Program Files is not.
  Collector.LocalizationFolder := '{HOME}\Translations';

  // The localizer only reads, but it has to look where the collector wrote,
  // so give it the same value.
  Localizer.LocalizationFolder := '{HOME}\Translations';

  // The localizer read its design-time folder before OnCreate ran, so read
  // the new folder now.
  Localizer.RescanLanguages;

  // Translations that ship with the application and are never written back
  // can live next to the executable instead:
  //   Localizer.LocalizationFolder := '{APP}\Translations';
end;
void __fastcall TDataModule1::DataModuleCreate(TObject *Sender)
{
  // The HOME token expands to the current user's application-support folder,
  // which a standard user can write to. The collector saves the translation
  // data, so its folder has to be writable - Program Files is not.
  Collector->LocalizationFolder = "{HOME}\\Translations";

  // The localizer only reads, but it has to look where the collector wrote,
  // so give it the same value.
  Localizer->LocalizationFolder = "{HOME}\\Translations";

  // The localizer read its design-time folder before OnCreate ran, so read
  // the new folder now.
  Localizer->RescanLanguages();

  // Translations that ship with the application and are never written back
  // can live next to the executable instead:
  //   Localizer->LocalizationFolder = "{APP}\\Translations";
}

Prefix tokens are expanded in desktop applications only. In a TMS WEB Core application LocalizationFolder acts as a base URL and is used exactly as given.

Running from the IDE

When you run a Windows application from the RAD Studio IDE, the localizer and the collector ignore LocalizationFolder and use the project folder instead: the folder holding the .dproj, where the editor keeps translations.json at design time. Exported language files are read from its runtime language files subfolder. This keeps one copy of the translation data in the project, under source control, instead of a stale copy in every Win32\Debug or Win64\Release output folder.

The application recognizes an IDE launch when its parent process is the IDE and the editor has registered the project on this machine, which happens when you open the editor for the project. Started any other way (from Explorer, by an installer, or on macOS, iOS and Android), it resolves LocalizationFolder as described above. So test a deployment by starting the executable outside the IDE, and ship one of these into the resolved LocalizationFolder:

  • translations.json from the project folder, or
  • the lang.* files from <project folder>untime language files, placed directly in the folder (see Shipping separate language files).

Changing the folder in code

The localizer reads its folder when it is loaded, which is before the OnCreate handler of the form or data module it sits on. Set LocalizationFolder in the Object Inspector. If you assign it in code, call the localizer's RescanLanguages afterwards so the localizer reads the new folder, as the getting started snippet does.

Persisting the user's selection

PersistLanguageSelection is True by default: the localizer remembers the last language the user activated and restores it on the next run. Call PerformLocalization at startup to apply that persisted language (rather than TrySetLocale, which forces a specific one):

Persist and restore the user's languagePascalC++
procedure TDataModule1.DataModuleCreate(Sender: TObject);
begin
  { Remember the language the user activates, and restore it on the next run. }
  Localizer.PersistLanguageSelection := True;

  { Re-apply the persisted language at startup (source language if none was saved). }
  Localizer.PerformLocalization;
end;
void __fastcall TDataModule1::DataModuleCreate(TObject *Sender)
{
  // Remember the language the user activates, and restore it on the next run.
  Localizer->PersistLanguageSelection = true;

  // Re-apply the persisted language at startup (source language if none was saved).
  Localizer->PerformLocalization();
}

The selection is stored in translations.json. An application that ships only separate language files has no such file, so store the choice yourself, as shown in that guide's combination example.

Combining start mode, persistence, and file loading

A typical startup combines several of these features: pick an automatic start mode, remember the user's choice, load the translations, and set an initial language. The following runs in a data module's create handler:

Automatic localization with a persisted selectionPascalC++
procedure TDataModule1.DataModuleCreate(Sender: TObject);
begin
  { Localize automatically as forms are created and when the application idles. }
  Localizer.LocalizationStartMode := lsmAutoHook;

  { Remember the chosen language across runs. }
  Localizer.PersistLanguageSelection := True;

  { Load translations from a single file instead of scanning a folder. }
  if TFile.Exists('translations.json') then
    Localizer.LoadFromFile('translations.json');

  { Activate a language now; on later runs the persisted selection is used instead. }
  Localizer.TrySetLocale('de');
end;
void __fastcall TDataModule1::DataModuleCreate(TObject *Sender)
{
  // Localize automatically as forms are created and when the application idles.
  Localizer->LocalizationStartMode = TTMSFNCLocalizationStartMode::lsmAutoHook;

  // Remember the chosen language across runs.
  Localizer->PersistLanguageSelection = true;

  // Load translations from a single file instead of scanning a folder.
  if (TFile::Exists("translations.json"))
    Localizer->LoadFromFile("translations.json");

  // Activate a language now; on later runs the persisted selection is used instead.
  Localizer->TrySetLocale("de");
}

On the first run this activates German; on later runs the persisted selection wins, because PersistLanguageSelection is enabled.

Common pitfalls

  • Wire OnLanguageChange before the first switch. If you assign the handler after calling TrySetLocale, it will not run for that initial change.
  • TrySetLocale can return False. Always check the result; a missing translation leaves the previous language active.
  • lsmManual localizes nothing on its own. With manual mode you must call PerformLocalization (persisted language) or TrySetLocale (specific language) yourself.
  • lsmAutoHook will not catch .dpr-created forms, and is unavailable on WEB. The auto/idle hooks are VCL/FMX-only and only see forms created after startup. Derive main and WEB forms from TTMSFNCLocalizationForm (which works in every framework) or localize them manually.
  • Do not combine TTMSFNCLocalizationForm with a hook start mode. The base form already localizes itself; keep the localizer on lsmManual to avoid duplicate localization passes.
  • Load translations before switching. Point LocalizationFolder at the files, or call LoadFromFile/LoadFromStream, before the first TrySetLocale/PerformLocalization, or there is nothing to apply.
  • A folder assigned in OnCreate is not read automatically. The localizer loads its folder before OnCreate runs; call RescanLanguages after changing LocalizationFolder in code.
  • Running from the IDE is not a deployment test. IDE runs on Windows read the project folder, not LocalizationFolder. Start the executable outside the IDE to check that the deployed files are found.
  • A read-only translation folder fails silently at save time. If the collector cannot write to LocalizationFolder, the edited translations are lost. Resolve the folder with {HOME} or {DOC} rather than {APP} whenever anything writes to it.

See also