This is an automated email from the ASF dual-hosted git repository.

hansva pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/hop.git


The following commit(s) were added to refs/heads/main by this push:
     new 590e589bf2 Issue apache#2814 [Bug]: changing GUI language changes also 
decimal separator (#8159)
590e589bf2 is described below

commit 590e589bf2c22cea818a0d1096aff59999c76de3
Author: Sergio Ramazzina <[email protected]>
AuthorDate: Wed Oct 28 13:03:26 2026 +0100

    Issue apache#2814 [Bug]: changing GUI language changes also decimal 
separator (#8159)
    
    * Issue apache#2814 [Bug]: changing GUI language changes also decimal 
separator
    
    * The interface language was installed as the JVM-wide default locale, so it
      also decided decimal separator, grouping separator, currency and date
      formats. The language now writes only Locale.Category.DISPLAY and the
      regional settings write Locale.Category.FORMAT.
    * Regional settings had no representation of their own. Added 
RegionalSettings
      in core, resolving a single source - OPERATING_SYSTEM (the default), 
CUSTOM
      or LANGUAGE - from RegionalSettingsSource and RegionalSettingsLocale in
      hop-config.json; an unreadable or unknown configuration degrades to
      OPERATING_SYSTEM.
    * Locale.setDefault lived in HopGui.main, so hop-run and hop-server never
      applied it and the same pipeline formatted differently in the editor and 
in
      production. The regional settings are now applied from 
HopEnvironment.init(),
      the bootstrap common to every entry point.
    * Const.DEFAULT_DECIMAL_SEPARATOR and its siblings are static final, 
computed
      at class load before any locale was installed, and ValueMetaBase copied 
them
      into every value, overwriting correctly localised symbols. Added
      getDefaultDecimalSeparator(), getDefaultGroupingSeparator(),
      getDefaultCurrencySymbol() and getDefaultNumberFormat(), which read the 
live
      FORMAT category and cache the symbols against the locale they were built
      from; the constants are kept and deprecated because they are public API.
    * The setting was not reachable from the GUI. Added a Regional settings tab
      carrying the interface language combo moved out of GUI options, two 
mutually
      exclusive checkboxes, a type-to-filter combo over the available locales 
and a
      live preview of the resulting formats.
    * ValueMetaBase and ValueMetaTimestamp compared an explicitly chosen date
      format locale against Locale.getDefault(), so a field locale that 
happened to
      match the interface language was discarded and the date rendered with the
      regional locale instead. The comparison now reads the FORMAT category.
    
    Fixes #2814
    
    * Issue #2814 : FORMAT date locale, log effective regional settings
    
    ValueMetaBase copied Locale.getDefault() into every field, which is the
    interface language once DISPLAY and FORMAT are split. New fields now take
    the FORMAT category so month names follow regional settings, not the GUI
    language.
    
    hop-gui, hop-run and hop-server log the effective language, FORMAT locale
    and timezone at start so a machine-local mismatch is visible.
    
    Docs: field-level symbols still win; default OS source still differs
    across machines unless Override is set; Beam/Spark workers do not load
    hop-config.json.
    
    * Issue #2814 : FORMAT locale for the OS default and collation, lock-free 
format defaults
    
    ---------
    
    Co-authored-by: mattcasters <[email protected]>
    Co-authored-by: Hans Van Akelyen <[email protected]>
---
 .../main/java/org/apache/hop/core/Condition.java   |   2 +-
 core/src/main/java/org/apache/hop/core/Const.java  | 107 ++++-
 .../apache/hop/core/row/value/ValueMetaBase.java   |  23 +-
 .../hop/core/row/value/ValueMetaTimestamp.java     |   8 +-
 .../java/org/apache/hop/i18n/RegionalSettings.java | 225 +++++++++
 .../apache/hop/i18n/RegionalSettingsPreview.java   | 104 ++++
 .../java/org/apache/hop/core/ConstLocaleTest.java  |  69 +++
 .../row/value/ValueMetaDateFormatLocaleTest.java   | 141 ++++++
 .../apache/hop/i18n/RegionalSettingsApplyTest.java | 169 +++++++
 .../hop/i18n/RegionalSettingsPreviewTest.java      |  87 ++++
 .../org/apache/hop/i18n/RegionalSettingsTest.java  | 103 ++++
 .../pages/hop-gui/perspective-configuration.adoc   |  76 ++-
 .../java/org/apache/hop/core/HopEnvironment.java   |   6 +
 .../apache/hop/core/file/TextFileInputField.java   |   5 +-
 .../pipeline/transforms/file/BaseFileField.java    |   5 +-
 .../googlesheets/GoogleSheetsInputField.java       |   5 +-
 .../transforms/csvinput/CsvInputField.java         |   5 +-
 .../main/java/org/apache/hop/ui/hopgui/HopGui.java |   5 +-
 .../configuration/tabs/ConfigGuiOptionsTab.java    |  40 --
 .../tabs/ConfigRegionalSettingsTab.java            | 532 +++++++++++++++++++++
 .../core/dialog/messages/messages_en_US.properties |  16 +
 21 files changed, 1668 insertions(+), 65 deletions(-)

diff --git a/core/src/main/java/org/apache/hop/core/Condition.java 
b/core/src/main/java/org/apache/hop/core/Condition.java
index 0c743ff545..de5a873a5e 100644
--- a/core/src/main/java/org/apache/hop/core/Condition.java
+++ b/core/src/main/java/org/apache/hop/core/Condition.java
@@ -856,7 +856,7 @@ public class Condition implements Cloneable {
       IValueMeta valueMeta = ValueMetaFactory.createValueMeta(name, 
getHopType());
       valueMeta.setLength(length, precision);
       valueMeta.setConversionMask(mask);
-      
valueMeta.setDecimalSymbol(String.valueOf(Const.DEFAULT_DECIMAL_SEPARATOR));
+      
valueMeta.setDecimalSymbol(String.valueOf(Const.getDefaultDecimalSeparator()));
       valueMeta.setGroupingSymbol(null);
       valueMeta.setCurrencySymbol(null);
       return valueMeta;
diff --git a/core/src/main/java/org/apache/hop/core/Const.java 
b/core/src/main/java/org/apache/hop/core/Const.java
index 0aeadf8c8f..a8331426a0 100644
--- a/core/src/main/java/org/apache/hop/core/Const.java
+++ b/core/src/main/java/org/apache/hop/core/Const.java
@@ -300,22 +300,119 @@ public class Const {
   /** The default locale for the hop environment (system defined) */
   public static final Locale DEFAULT_LOCALE = Locale.getDefault();
 
-  /** The default decimal separator . or , */
+  /**
+   * The default decimal separator . or ,
+   *
+   * @deprecated captured at class-load time from {@link #DEFAULT_LOCALE}; use 
{@link
+   *     #getDefaultDecimalSeparator()} to read the active regional settings 
at call time.
+   */
+  @Deprecated(since = "2.20")
   public static final char DEFAULT_DECIMAL_SEPARATOR =
       (new DecimalFormatSymbols(DEFAULT_LOCALE)).getDecimalSeparator();
 
-  /** The default grouping separator , or . */
+  /**
+   * The default grouping separator , or .
+   *
+   * @deprecated captured at class-load time from {@link #DEFAULT_LOCALE}; use 
{@link
+   *     #getDefaultGroupingSeparator()} to read the active regional settings 
at call time.
+   */
+  @Deprecated(since = "2.20")
   public static final char DEFAULT_GROUPING_SEPARATOR =
       (new DecimalFormatSymbols(DEFAULT_LOCALE)).getGroupingSeparator();
 
-  /** The default currency symbol */
+  /**
+   * The default currency symbol
+   *
+   * @deprecated captured at class-load time from {@link #DEFAULT_LOCALE}; use 
{@link
+   *     #getDefaultCurrencySymbol()} to read the active regional settings at 
call time.
+   */
+  @Deprecated(since = "2.20")
   public static final String DEFAULT_CURRENCY_SYMBOL =
       (new DecimalFormatSymbols(DEFAULT_LOCALE)).getCurrencySymbol();
 
-  /** The default number format */
+  /**
+   * The default number format
+   *
+   * @deprecated captured at class-load time from {@link #DEFAULT_LOCALE}; use 
{@link
+   *     #getDefaultNumberFormat()} to read the active regional settings at 
call time.
+   */
+  @Deprecated(since = "2.20")
   public static final String DEFAULT_NUMBER_FORMAT =
       ((DecimalFormat) (NumberFormat.getInstance())).toPattern();
 
+  /** The format defaults of one FORMAT locale. Immutable, so it can be shared 
without locking. */
+  private record FormatDefaults(
+      Locale locale,
+      char decimalSeparator,
+      char groupingSeparator,
+      String currencySymbol,
+      String numberFormat) {
+
+    static FormatDefaults of(Locale locale) {
+      DecimalFormatSymbols symbols = new DecimalFormatSymbols(locale);
+      return new FormatDefaults(
+          locale,
+          symbols.getDecimalSeparator(),
+          symbols.getGroupingSeparator(),
+          symbols.getCurrencySymbol(),
+          ((DecimalFormat) NumberFormat.getInstance(locale)).toPattern());
+    }
+  }
+
+  /**
+   * The format defaults for the FORMAT locale they were built from.
+   *
+   * <p>These accessors are called from the {@code ValueMetaBase} constructor, 
so they sit on a hot
+   * path: building a {@link DecimalFormatSymbols} on every call would be a 
real cost — the same one
+   * {@code ValueMetaBase.getDecimalFormat()} already warns about for {@code 
DecimalFormat}. The
+   * defaults are therefore cached and rebuilt only when the FORMAT locale 
actually changes.
+   *
+   * <p>The snapshot is immutable and published through a volatile field, so 
readers never lock and
+   * never pair one locale's symbols with another's. Two threads that see a 
locale change at the
+   * same time may both build a snapshot; both are correct and the last write 
wins.
+   */
+  private static volatile FormatDefaults formatDefaults;
+
+  private static FormatDefaults getFormatDefaults() {
+    Locale formatLocale = Locale.getDefault(Locale.Category.FORMAT);
+    FormatDefaults defaults = formatDefaults;
+    if (defaults == null || !formatLocale.equals(defaults.locale())) {
+      defaults = FormatDefaults.of(formatLocale);
+      formatDefaults = defaults;
+    }
+    return defaults;
+  }
+
+  /**
+   * The decimal separator of the active regional settings, read at call time.
+   *
+   * <p>Prefer this over {@link #DEFAULT_DECIMAL_SEPARATOR}, which is captured 
when the class is
+   * loaded and therefore predates the regional settings being installed.
+   */
+  public static char getDefaultDecimalSeparator() {
+    return getFormatDefaults().decimalSeparator();
+  }
+
+  /** The grouping separator of the active regional settings, read at call 
time. */
+  public static char getDefaultGroupingSeparator() {
+    return getFormatDefaults().groupingSeparator();
+  }
+
+  /** The currency symbol of the active regional settings, read at call time. 
*/
+  public static String getDefaultCurrencySymbol() {
+    return getFormatDefaults().currencySymbol();
+  }
+
+  /**
+   * The number format pattern of the active regional settings, read at call 
time. In practice the
+   * returned pattern is locale-invariant (locale-specific separators are 
applied later via
+   * DecimalFormatSymbols): on JDK 21 every available locale yields {@code 
#,##0.###} except
+   * en-US-POSIX, so callers do not generally need to re-read it when the 
locale changes.
+   */
+  public static String getDefaultNumberFormat() {
+    return getFormatDefaults().numberFormat();
+  }
+
   /** Default string representing Null String values (empty) */
   public static final String NULL_STRING = "";
 
@@ -3014,7 +3111,7 @@ public class Const {
     if (numberFormats == null) {
       int numberFormatsCount = toInt(BaseMessages.getString(PKG, 
"Const.NumberFormat.Count"), 0);
       numberFormats = new String[numberFormatsCount + 1];
-      numberFormats[0] = DEFAULT_NUMBER_FORMAT;
+      numberFormats[0] = getDefaultNumberFormat();
       for (int i = 1; i <= numberFormatsCount; i++) {
         numberFormats[i] = BaseMessages.getString(PKG, "Const.NumberFormat" + 
i);
       }
diff --git 
a/core/src/main/java/org/apache/hop/core/row/value/ValueMetaBase.java 
b/core/src/main/java/org/apache/hop/core/row/value/ValueMetaBase.java
index eac1795b7d..8daa1301f7 100644
--- a/core/src/main/java/org/apache/hop/core/row/value/ValueMetaBase.java
+++ b/core/src/main/java/org/apache/hop/core/row/value/ValueMetaBase.java
@@ -313,12 +313,17 @@ public class ValueMetaBase implements IValueMeta {
     this.storageType = STORAGE_TYPE_NORMAL;
     this.sortedDescending = false;
     this.outputPaddingEnabled = false;
-    this.decimalSymbol = "" + Const.DEFAULT_DECIMAL_SEPARATOR;
-    this.groupingSymbol = "" + Const.DEFAULT_GROUPING_SEPARATOR;
-    this.currencySymbol = "" + Const.DEFAULT_CURRENCY_SYMBOL;
-    this.dateFormatLocale = Locale.getDefault();
+    this.decimalSymbol = "" + Const.getDefaultDecimalSeparator();
+    this.groupingSymbol = "" + Const.getDefaultGroupingSeparator();
+    this.currencySymbol = "" + Const.getDefaultCurrencySymbol();
+    // FORMAT, not Locale.getDefault(): the latter is the interface language 
once DISPLAY and
+    // FORMAT are split, and a field with no explicit date locale must follow 
the regional
+    // settings rather than the GUI language.
+    this.dateFormatLocale = Locale.getDefault(Locale.Category.FORMAT);
     this.collatorDisabled = true;
-    this.collatorLocale = Locale.getDefault();
+    // FORMAT for the same reason: sort order is a regional setting, and a 
sort must not change
+    // with the GUI language or differ between Hop GUI and hop-run.
+    this.collatorLocale = Locale.getDefault(Locale.Category.FORMAT);
     this.collator = Collator.getInstance(this.collatorLocale);
     this.collatorStrength = 0;
     this.dateFormatTimeZone = TimeZone.getDefault();
@@ -1356,7 +1361,13 @@ public class ValueMetaBase implements IValueMeta {
 
       // Do we have a locale?
       //
-      if (dateFormatLocale == null || 
dateFormatLocale.equals(Locale.getDefault())) {
+      // Compared against the FORMAT category, not against 
Locale.getDefault(): that one carries the
+      // interface language, so a locale deliberately picked on the field 
would be dismissed as "no
+      // locale set" whenever it happened to match the language, and the field 
would silently follow
+      // the regional settings instead of the choice.
+      //
+      if (dateFormatLocale == null
+          || 
dateFormatLocale.equals(Locale.getDefault(Locale.Category.FORMAT))) {
         if (mask != null) {
           dateFormat = new SimpleDateFormat(mask);
         }
diff --git 
a/core/src/main/java/org/apache/hop/core/row/value/ValueMetaTimestamp.java 
b/core/src/main/java/org/apache/hop/core/row/value/ValueMetaTimestamp.java
index 18e7b8a6c5..bd5f556d9b 100644
--- a/core/src/main/java/org/apache/hop/core/row/value/ValueMetaTimestamp.java
+++ b/core/src/main/java/org/apache/hop/core/row/value/ValueMetaTimestamp.java
@@ -666,7 +666,13 @@ public class ValueMetaTimestamp extends ValueMetaDate {
 
       // Do we have a locale?
       //
-      if (dateFormatLocale == null || 
dateFormatLocale.equals(Locale.getDefault())) {
+      // Compared against the FORMAT category, not against 
Locale.getDefault(): that one carries the
+      // interface language, so a locale deliberately picked on the field 
would be dismissed as "no
+      // locale set" whenever it happened to match the language, and the field 
would silently follow
+      // the regional settings instead of the choice.
+      //
+      if (dateFormatLocale == null
+          || 
dateFormatLocale.equals(Locale.getDefault(Locale.Category.FORMAT))) {
         dateFormat = new SimpleTimestampFormat(mask);
       } else {
         dateFormat = new SimpleTimestampFormat(mask, dateFormatLocale);
diff --git a/core/src/main/java/org/apache/hop/i18n/RegionalSettings.java 
b/core/src/main/java/org/apache/hop/i18n/RegionalSettings.java
new file mode 100644
index 0000000000..8ed0d440c1
--- /dev/null
+++ b/core/src/main/java/org/apache/hop/i18n/RegionalSettings.java
@@ -0,0 +1,225 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.i18n;
+
+import java.util.Arrays;
+import java.util.Locale;
+import java.util.TimeZone;
+import org.apache.hop.core.config.HopConfig;
+import org.apache.hop.core.logging.ILogChannel;
+import org.apache.hop.core.logging.LogChannel;
+import org.apache.hop.core.util.EnvUtil;
+import org.apache.hop.core.util.Utils;
+
+/**
+ * Holds the regional settings (decimal and grouping separators, currency, 
date formats) as a
+ * concern separate from the interface language, which stays under {@link 
LanguageChoice}.
+ *
+ * <p>The effective locale is installed in the JVM as {@link 
Locale.Category#FORMAT}, while {@link
+ * Locale#getDefault()} — the locale {@code ResourceBundle} resolves messages 
with — keeps carrying
+ * the interface language.
+ */
+public class RegionalSettings {
+
+  /** Where the regional settings come from. */
+  public enum Source {
+    /**
+     * Follow the selected interface language, so that changing the language 
changes the formats
+     * with it. This is a deliberate choice a user makes, not the source an 
unconfigured
+     * installation falls back to.
+     */
+    LANGUAGE,
+    /** Inherit them from the operating system Hop is running on. */
+    OPERATING_SYSTEM,
+    /** Use an explicitly selected locale. */
+    CUSTOM
+  }
+
+  public static final String STRING_REGIONAL_SETTINGS_SOURCE = 
"RegionalSettingsSource";
+  public static final String STRING_REGIONAL_SETTINGS_LOCALE = 
"RegionalSettingsLocale";
+
+  /**
+   * The locale the JVM started with, captured before anything can overwrite 
it. The first {@code
+   * Locale.setDefault(language)} destroys this value and it cannot be 
recovered afterwards, so
+   * {@link Source#OPERATING_SYSTEM} would have nothing to read without this 
field.
+   *
+   * <p>It is the FORMAT category, not {@link Locale#getDefault()}: on Windows 
the JVM starts with
+   * the display language there and the regional format (user.language.format) 
in FORMAT, so an
+   * English Windows with Belgian regional settings would otherwise lose its 
{@code ,} decimal.
+   */
+  private static final Locale OPERATING_SYSTEM_LOCALE = 
captureOperatingSystemLocale();
+
+  static Locale captureOperatingSystemLocale() {
+    return Locale.getDefault(Locale.Category.FORMAT);
+  }
+
+  private static RegionalSettings instance;
+
+  private Source source;
+  private Locale customLocale;
+
+  private RegionalSettings() {
+    reload();
+  }
+
+  public static synchronized RegionalSettings getInstance() {
+    if (instance == null) {
+      instance = new RegionalSettings();
+    }
+    return instance;
+  }
+
+  /**
+   * Re-reads the configuration, degrading to {@link Source#OPERATING_SYSTEM} 
on anything unusable.
+   */
+  public void reload() {
+    String sourceValue =
+        HopConfig.readOptionString(STRING_REGIONAL_SETTINGS_SOURCE, 
Source.OPERATING_SYSTEM.name());
+    try {
+      source = Source.valueOf(sourceValue);
+    } catch (IllegalArgumentException e) {
+      LogChannel.GENERAL.logBasic(
+          "Unknown value '"
+              + sourceValue
+              + "' for option "
+              + STRING_REGIONAL_SETTINGS_SOURCE
+              + ", deriving regional settings from the operating system 
instead.");
+      source = Source.OPERATING_SYSTEM;
+    }
+
+    String localeValue = 
HopConfig.readOptionString(STRING_REGIONAL_SETTINGS_LOCALE, null);
+    customLocale = Utils.isEmpty(localeValue) ? null : 
EnvUtil.createLocale(localeValue);
+
+    if (source == Source.CUSTOM && !isUsable(customLocale)) {
+      LogChannel.GENERAL.logBasic(
+          "Regional settings locale '"
+              + localeValue
+              + "' is not available in this JVM, deriving regional settings 
from the operating"
+              + " system instead.");
+      source = Source.OPERATING_SYSTEM;
+    }
+  }
+
+  /** Persists the current source and custom locale. */
+  public void save() {
+    HopConfig.getInstance().saveOption(STRING_REGIONAL_SETTINGS_SOURCE, 
source.name());
+    HopConfig.getInstance()
+        .saveOption(
+            STRING_REGIONAL_SETTINGS_LOCALE, customLocale == null ? null : 
customLocale.toString());
+  }
+
+  /** The locale actually used to format numbers, currencies and dates. */
+  public Locale getEffectiveLocale() {
+    return switch (source) {
+      case OPERATING_SYSTEM -> OPERATING_SYSTEM_LOCALE;
+      case CUSTOM -> customLocale;
+      case LANGUAGE -> LanguageChoice.getInstance().getDefaultLocale();
+    };
+  }
+
+  /**
+   * Applies the regional settings for a headless run (hop-run, hop-server, 
REST), so those runs
+   * honour the configuration of the machine they run on.
+   *
+   * <p>Distributed Beam and Spark workers are not covered by this method: 
they never load a {@code
+   * hop-config.json} in the first place, so they fall back to the default 
source and format with
+   * their own operating system locale regardless of what this method would 
apply.
+   */
+  public void applyHeadless() {
+    // Under the default source this writes OPERATING_SYSTEM_LOCALE, which was 
captured from the
+    // JVM's own initial FORMAT default — precisely what a headless run 
already carries, including
+    // when it was set with -Duser.language or -Duser.language.format. Writing 
it back is therefore
+    // a no-op.
+    Locale formatLocale = getEffectiveLocale();
+    if (formatLocale == null) {
+      LogChannel.GENERAL.logBasic(
+          "No usable regional settings locale is configured; leaving the 
format settings alone.");
+      return;
+    }
+    Locale.setDefault(Locale.Category.FORMAT, formatLocale);
+    logEffective(LogChannel.GENERAL, "installation:" + source.name());
+  }
+
+  /**
+   * Applies the interface language and then the regional settings, in that 
order.
+   *
+   * <p>The order is mandatory: {@code Locale.setDefault(Locale)} writes all 
three categories, so
+   * setting the language after the regional settings would wipe the FORMAT 
category. For the same
+   * reason the FORMAT category is always written back, even when the regional 
settings are derived
+   * from the language and the two carry the same value.
+   */
+  public void applyGui() {
+    Locale.setDefault(LanguageChoice.getInstance().getDefaultLocale());
+    Locale formatLocale = getEffectiveLocale();
+    if (formatLocale == null) {
+      LogChannel.GENERAL.logBasic(
+          "No usable regional settings locale is configured; leaving the 
format settings alone.");
+      return;
+    }
+    Locale.setDefault(Locale.Category.FORMAT, formatLocale);
+    logEffective(LogChannel.GENERAL, "installation:" + source.name());
+  }
+
+  /**
+   * Writes the language, FORMAT locale and default timezone currently in 
effect. hop-gui, hop-run
+   * and hop-server all log this so a machine-local mismatch is visible 
without inspecting
+   * configuration files.
+   *
+   * @param log channel to write to; {@link LogChannel#GENERAL} when none is 
available yet
+   * @param sourceDescription where the FORMAT locale came from, for example 
{@code
+   *     installation:CUSTOM}
+   */
+  public static void logEffective(ILogChannel log, String sourceDescription) {
+    if (log == null) {
+      return;
+    }
+    log.logBasic(
+        "Regional settings: language="
+            + Locale.getDefault()
+            + " format="
+            + Locale.getDefault(Locale.Category.FORMAT)
+            + " timezone="
+            + TimeZone.getDefault().getID()
+            + " source="
+            + sourceDescription);
+  }
+
+  private static boolean isUsable(Locale locale) {
+    return locale != null && 
Arrays.asList(Locale.getAvailableLocales()).contains(locale);
+  }
+
+  public Source getSource() {
+    return source;
+  }
+
+  public void setSource(Source source) {
+    this.source = source;
+  }
+
+  public Locale getCustomLocale() {
+    return customLocale;
+  }
+
+  public void setCustomLocale(Locale customLocale) {
+    this.customLocale = customLocale;
+  }
+
+  public Locale getOperatingSystemLocale() {
+    return OPERATING_SYSTEM_LOCALE;
+  }
+}
diff --git 
a/core/src/main/java/org/apache/hop/i18n/RegionalSettingsPreview.java 
b/core/src/main/java/org/apache/hop/i18n/RegionalSettingsPreview.java
new file mode 100644
index 0000000000..77d7923e55
--- /dev/null
+++ b/core/src/main/java/org/apache/hop/i18n/RegionalSettingsPreview.java
@@ -0,0 +1,104 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.i18n;
+
+import java.text.DateFormat;
+import java.text.NumberFormat;
+import java.util.Date;
+import java.util.Locale;
+
+/**
+ * The sample values shown in the regional settings preview, computed for an 
arbitrary locale
+ * without touching the JVM default. Deliberately free of any UI dependency so 
it can be tested
+ * headlessly.
+ */
+public class RegionalSettingsPreview {
+
+  /** A fixed sample number, chosen to exercise both the grouping and the 
decimal separator. */
+  private static final double SAMPLE_NUMBER = 10000.23d;
+
+  private static final double SAMPLE_CURRENCY = 1234.56d;
+  private static final double SAMPLE_PERCENT = 0.85d;
+
+  private final String shortDate;
+  private final String longDate;
+  private final String shortTime;
+  private final String longTime;
+  private final String number;
+  private final String negativeNumber;
+  private final String currency;
+  private final String percent;
+
+  private RegionalSettingsPreview(Locale locale, Date now) {
+    this.shortDate = DateFormat.getDateInstance(DateFormat.SHORT, 
locale).format(now);
+    this.longDate = DateFormat.getDateInstance(DateFormat.LONG, 
locale).format(now);
+    this.shortTime = DateFormat.getTimeInstance(DateFormat.SHORT, 
locale).format(now);
+    this.longTime = DateFormat.getTimeInstance(DateFormat.MEDIUM, 
locale).format(now);
+    this.number = NumberFormat.getNumberInstance(locale).format(SAMPLE_NUMBER);
+    this.negativeNumber = 
NumberFormat.getNumberInstance(locale).format(-SAMPLE_NUMBER);
+    this.currency = 
NumberFormat.getCurrencyInstance(locale).format(SAMPLE_CURRENCY);
+    this.percent = 
NumberFormat.getPercentInstance(locale).format(SAMPLE_PERCENT);
+  }
+
+  /** Builds the preview values for the given locale, using the current date 
and time. */
+  public static RegionalSettingsPreview of(Locale locale) {
+    return new RegionalSettingsPreview(locale, new Date());
+  }
+
+  /**
+   * Builds the preview values for a fixed instant. Package-private on 
purpose: comparing how two
+   * locales render the same instant is only meaningful if it really is the 
same instant, and some
+   * calendar dates render identically in two locales that normally differ — 
the Italian and US
+   * short formats coincide on 10/10, 11/11 and 12/12, for instance.
+   */
+  static RegionalSettingsPreview of(Locale locale, Date instant) {
+    return new RegionalSettingsPreview(locale, instant);
+  }
+
+  public String getShortDate() {
+    return shortDate;
+  }
+
+  public String getLongDate() {
+    return longDate;
+  }
+
+  public String getShortTime() {
+    return shortTime;
+  }
+
+  public String getLongTime() {
+    return longTime;
+  }
+
+  public String getNumber() {
+    return number;
+  }
+
+  public String getNegativeNumber() {
+    return negativeNumber;
+  }
+
+  public String getCurrency() {
+    return currency;
+  }
+
+  public String getPercent() {
+    return percent;
+  }
+}
diff --git a/core/src/test/java/org/apache/hop/core/ConstLocaleTest.java 
b/core/src/test/java/org/apache/hop/core/ConstLocaleTest.java
new file mode 100644
index 0000000000..fae20292a4
--- /dev/null
+++ b/core/src/test/java/org/apache/hop/core/ConstLocaleTest.java
@@ -0,0 +1,69 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *       http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.core;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import java.util.Locale;
+import org.apache.hop.junit.rules.RestoreHopEnvironmentExtension;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+
+@ExtendWith(RestoreHopEnvironmentExtension.class)
+class ConstLocaleTest {
+
+  @Test
+  void separatorsFollowTheFormatCategoryAtCallTime() {
+    Locale.setDefault(Locale.Category.FORMAT, Locale.US);
+    assertEquals('.', Const.getDefaultDecimalSeparator());
+    assertEquals(',', Const.getDefaultGroupingSeparator());
+
+    Locale.setDefault(Locale.Category.FORMAT, Locale.ITALY);
+    assertEquals(',', Const.getDefaultDecimalSeparator());
+    assertEquals('.', Const.getDefaultGroupingSeparator());
+  }
+
+  @Test
+  void currencySymbolFollowsTheFormatCategoryAtCallTime() {
+    Locale.setDefault(Locale.Category.FORMAT, Locale.US);
+    assertEquals("$", Const.getDefaultCurrencySymbol());
+
+    Locale.setDefault(Locale.Category.FORMAT, Locale.ITALY);
+    assertEquals("€", Const.getDefaultCurrencySymbol());
+  }
+
+  /**
+   * The symbols are cached because these accessors sit on the ValueMetaBase 
construction path.
+   * Repeated reads without a locale change must stay stable, and a locale 
change must still be
+   * picked up - that second half is what makes the cache safe rather than 
merely fast.
+   */
+  @Test
+  void cachedSymbolsAreStableAcrossRepeatedReadsAndStillFollowALocaleChange() {
+    Locale.setDefault(Locale.Category.FORMAT, Locale.ITALY);
+    char first = Const.getDefaultDecimalSeparator();
+    char second = Const.getDefaultDecimalSeparator();
+    assertEquals(first, second);
+    assertEquals(',', first);
+
+    Locale.setDefault(Locale.Category.FORMAT, Locale.US);
+    assertEquals('.', Const.getDefaultDecimalSeparator());
+
+    Locale.setDefault(Locale.Category.FORMAT, Locale.ITALY);
+    assertEquals(',', Const.getDefaultDecimalSeparator());
+  }
+}
diff --git 
a/core/src/test/java/org/apache/hop/core/row/value/ValueMetaDateFormatLocaleTest.java
 
b/core/src/test/java/org/apache/hop/core/row/value/ValueMetaDateFormatLocaleTest.java
new file mode 100644
index 0000000000..f1aecfc838
--- /dev/null
+++ 
b/core/src/test/java/org/apache/hop/core/row/value/ValueMetaDateFormatLocaleTest.java
@@ -0,0 +1,141 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *       http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.core.row.value;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import java.util.Date;
+import java.util.Locale;
+import org.apache.hop.junit.rules.RestoreHopEnvironmentExtension;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+
+/**
+ * The interface language and the regional settings are separate since the 
FORMAT category was
+ * introduced, so a locale set explicitly on a field has to be compared 
against the regional
+ * settings rather than against the language.
+ */
+@ExtendWith(RestoreHopEnvironmentExtension.class)
+class ValueMetaDateFormatLocaleTest {
+
+  /** 1 January 2025, so the month name differs per locale. */
+  private static final Date DATE = new Date(1735689600000L);
+
+  private static final String MASK = "MMMM yyyy";
+
+  private static void languageAndRegionalSettings(Locale language, Locale 
regional) {
+    // Order matters: setDefault(Locale) writes all three categories, so the 
FORMAT one goes last.
+    Locale.setDefault(language);
+    Locale.setDefault(Locale.Category.FORMAT, regional);
+  }
+
+  /**
+   * The regression this test exists for: the locale picked on the field 
happens to be the same as
+   * the interface language. Comparing it against the language would classify 
it as "no locale
+   * chosen" and fall back to the regional settings, silently discarding the 
user's choice.
+   */
+  @Test
+  void explicitDateLocaleIsHonouredEvenWhenItEqualsTheInterfaceLanguage() {
+    languageAndRegionalSettings(Locale.ITALY, Locale.US);
+
+    ValueMetaDate valueMeta = new ValueMetaDate("d");
+    valueMeta.setConversionMask(MASK);
+    valueMeta.setDateFormatLocale(Locale.ITALY);
+
+    assertEquals("gennaio 2025", valueMeta.getDateFormat().format(DATE));
+  }
+
+  /** The same choice, when it does not collide with the language, always 
worked. */
+  @Test
+  void explicitDateLocaleIsHonouredWhenItDiffersFromTheInterfaceLanguage() {
+    languageAndRegionalSettings(Locale.ITALY, Locale.US);
+
+    ValueMetaDate valueMeta = new ValueMetaDate("d");
+    valueMeta.setConversionMask(MASK);
+    valueMeta.setDateFormatLocale(Locale.FRANCE);
+
+    assertEquals("janvier 2025", valueMeta.getDateFormat().format(DATE));
+  }
+
+  /** With no explicit choice the field follows the regional settings, not the 
language. */
+  @Test
+  void withoutAnExplicitDateLocaleTheRegionalSettingsWin() {
+    languageAndRegionalSettings(Locale.US, Locale.ITALY);
+
+    ValueMetaDate valueMeta = new ValueMetaDate("d");
+    valueMeta.setConversionMask(MASK);
+    valueMeta.setDateFormatLocale(null);
+
+    assertEquals("gennaio 2025", valueMeta.getDateFormat().format(DATE));
+  }
+
+  /**
+   * A newly constructed field must pick FORMAT as its date locale. Using 
DISPLAY (the interface
+   * language) here would format month names in English while numbers followed 
Italian separators.
+   */
+  @Test
+  void constructorDateLocaleFollowsTheRegionalSettingsNotTheLanguage() {
+    languageAndRegionalSettings(Locale.US, Locale.ITALY);
+
+    ValueMetaDate valueMeta = new ValueMetaDate("d");
+    valueMeta.setConversionMask(MASK);
+
+    assertEquals("gennaio 2025", valueMeta.getDateFormat().format(DATE));
+  }
+
+  @Test
+  void constructorCollatorLocaleFollowsTheRegionalSettingsNotTheLanguage() {
+    languageAndRegionalSettings(Locale.US, Locale.ITALY);
+
+    ValueMetaString valueMeta = new ValueMetaString("s");
+
+    assertEquals(Locale.ITALY, valueMeta.getCollatorLocale());
+  }
+
+  @Test
+  void 
timestampExplicitDateLocaleIsHonouredEvenWhenItEqualsTheInterfaceLanguage() {
+    languageAndRegionalSettings(Locale.ITALY, Locale.US);
+
+    ValueMetaTimestamp valueMeta = new ValueMetaTimestamp("t");
+    valueMeta.setConversionMask(MASK);
+    valueMeta.setDateFormatLocale(Locale.ITALY);
+
+    assertEquals("gennaio 2025", valueMeta.getDateFormat().format(DATE));
+  }
+
+  @Test
+  void timestampWithoutAnExplicitDateLocaleFollowsTheRegionalSettings() {
+    languageAndRegionalSettings(Locale.US, Locale.ITALY);
+
+    ValueMetaTimestamp valueMeta = new ValueMetaTimestamp("t");
+    valueMeta.setConversionMask(MASK);
+    valueMeta.setDateFormatLocale(null);
+
+    assertEquals("gennaio 2025", valueMeta.getDateFormat().format(DATE));
+  }
+
+  @Test
+  void 
timestampConstructorDateLocaleFollowsTheRegionalSettingsNotTheLanguage() {
+    languageAndRegionalSettings(Locale.US, Locale.ITALY);
+
+    ValueMetaTimestamp valueMeta = new ValueMetaTimestamp("t");
+    valueMeta.setConversionMask(MASK);
+
+    assertEquals("gennaio 2025", valueMeta.getDateFormat().format(DATE));
+  }
+}
diff --git 
a/core/src/test/java/org/apache/hop/i18n/RegionalSettingsApplyTest.java 
b/core/src/test/java/org/apache/hop/i18n/RegionalSettingsApplyTest.java
new file mode 100644
index 0000000000..856a908324
--- /dev/null
+++ b/core/src/test/java/org/apache/hop/i18n/RegionalSettingsApplyTest.java
@@ -0,0 +1,169 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.i18n;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.text.DecimalFormatSymbols;
+import java.util.Locale;
+import org.apache.hop.core.config.HopConfig;
+import org.apache.hop.core.exception.HopValueException;
+import org.apache.hop.core.row.IValueMeta;
+import org.apache.hop.core.row.value.ValueMetaNumber;
+import org.apache.hop.junit.rules.RestoreHopEnvironmentExtension;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+
+@ExtendWith(RestoreHopEnvironmentExtension.class)
+class RegionalSettingsApplyTest {
+
+  // RestoreHopEnvironmentExtension restores locales and LanguageChoice but 
knows nothing about
+  // the RegionalSettings singleton, so reset it explicitly to avoid leaking 
state into whatever
+  // test class runs next in the same JVM fork.
+  @AfterAll
+  static void resetRegionalSettingsSingleton() {
+    RegionalSettings.getInstance().reload();
+  }
+
+  @BeforeEach
+  void resetConfiguration() {
+    
HopConfig.getInstance().saveOption(RegionalSettings.STRING_REGIONAL_SETTINGS_SOURCE,
 null);
+    
HopConfig.getInstance().saveOption(RegionalSettings.STRING_REGIONAL_SETTINGS_LOCALE,
 null);
+    
LanguageChoice.getInstance().setDefaultLocale(Locale.forLanguageTag("en-US"));
+    RegionalSettings.getInstance().reload();
+  }
+
+  /**
+   * With no regional configuration at all, a headless run puts the operating 
system locale on the
+   * FORMAT category and leaves the interface language alone.
+   *
+   * <p>This is not a behaviour change: the operating system locale is the 
JVM's own initial
+   * default, which is exactly what a headless run already carries, so writing 
it back changes
+   * nothing that an existing installation observes.
+   */
+  @Test
+  void headlessAppliesTheOperatingSystemLocaleWhenNothingIsConfigured() {
+    Locale.setDefault(Locale.GERMANY);
+    Locale.setDefault(Locale.Category.FORMAT, Locale.GERMANY);
+    RegionalSettings settings = RegionalSettings.getInstance();
+
+    settings.applyHeadless();
+
+    assertEquals(Locale.GERMANY, Locale.getDefault());
+    assertEquals(settings.getOperatingSystemLocale(), 
Locale.getDefault(Locale.Category.FORMAT));
+  }
+
+  /**
+   * Windows starts the JVM with the display language as the general locale 
and the regional format
+   * in FORMAT. The operating system locale must be read from FORMAT, or a 
headless run would
+   * overwrite the regional format with the display language.
+   */
+  @Test
+  void operatingSystemLocaleIsReadFromTheFormatCategory() {
+    Locale.setDefault(Locale.US);
+    Locale.setDefault(Locale.Category.FORMAT, Locale.forLanguageTag("nl-BE"));
+
+    assertEquals(Locale.forLanguageTag("nl-BE"), 
RegionalSettings.captureOperatingSystemLocale());
+  }
+
+  @Test
+  void headlessAppliesTheCustomLocaleToTheFormatCategoryOnly() {
+    Locale.setDefault(Locale.GERMANY);
+    RegionalSettings settings = RegionalSettings.getInstance();
+    settings.setSource(RegionalSettings.Source.CUSTOM);
+    settings.setCustomLocale(Locale.ITALY);
+
+    settings.applyHeadless();
+
+    assertEquals(Locale.GERMANY, Locale.getDefault());
+    assertEquals(Locale.ITALY, Locale.getDefault(Locale.Category.FORMAT));
+  }
+
+  /**
+   * With no regional configuration the GUI keeps the interface language while 
deriving the formats
+   * from the operating system, exactly as a headless run does.
+   */
+  @Test
+  void guiDerivesFormatsFromTheOperatingSystemByDefault() {
+    Locale.setDefault(Locale.GERMANY);
+    RegionalSettings settings = RegionalSettings.getInstance();
+    Locale osLocale = settings.getOperatingSystemLocale();
+    // Pick a language that cannot coincide with this machine's locale, or the 
assertion below
+    // would hold whichever of the two the code actually used.
+    Locale language = Locale.ITALY.equals(osLocale) ? Locale.US : Locale.ITALY;
+    LanguageChoice.getInstance().setDefaultLocale(language);
+
+    settings.applyGui();
+
+    assertEquals(language, Locale.getDefault());
+    assertEquals(osLocale, Locale.getDefault(Locale.Category.FORMAT));
+    assertNotEquals(language, Locale.getDefault(Locale.Category.FORMAT));
+  }
+
+  /**
+   * With the language chosen as the source, every category follows the 
interface language, so the
+   * GUI, hop-run and hop-server all agree.
+   */
+  @Test
+  void guiDerivesFormatsFromTheLanguageWhenThatSourceIsChosen() {
+    Locale.setDefault(Locale.GERMANY);
+    RegionalSettings settings = RegionalSettings.getInstance();
+    Locale osLocale = settings.getOperatingSystemLocale();
+    // Pick a language that cannot coincide with this machine's locale, or the 
assertion below
+    // would hold whichever of the two the code actually used.
+    Locale language = Locale.ITALY.equals(osLocale) ? Locale.US : Locale.ITALY;
+    LanguageChoice.getInstance().setDefaultLocale(language);
+    settings.setSource(RegionalSettings.Source.LANGUAGE);
+
+    settings.applyGui();
+
+    assertEquals(language, Locale.getDefault());
+    assertEquals(language, Locale.getDefault(Locale.Category.FORMAT));
+    assertNotEquals(osLocale, Locale.getDefault(Locale.Category.FORMAT));
+  }
+
+  /**
+   * The feature this issue asks for: an English interface with Italian 
regional settings. The
+   * language governs the messages, the regional locale governs the numbers.
+   */
+  @Test
+  void guiKeepsTheLanguageWhileOverridingTheRegionalSettings() throws 
HopValueException {
+    
LanguageChoice.getInstance().setDefaultLocale(Locale.forLanguageTag("en-US"));
+    RegionalSettings settings = RegionalSettings.getInstance();
+    settings.setSource(RegionalSettings.Source.CUSTOM);
+    settings.setCustomLocale(Locale.ITALY);
+
+    settings.applyGui();
+
+    // The interface language, which is what ResourceBundle resolves messages 
with.
+    assertEquals(Locale.forLanguageTag("en-US"), Locale.getDefault());
+    // The regional settings.
+    assertEquals(Locale.ITALY, Locale.getDefault(Locale.Category.FORMAT));
+    assertEquals(',', new DecimalFormatSymbols().getDecimalSeparator());
+
+    // And the engine follows: the exact literal depends on the default 
conversion mask, so we
+    // assert on the separator that actually reaches the converted value.
+    IValueMeta valueMeta = new ValueMetaNumber("n");
+    String converted = valueMeta.getString(10000.23d);
+    assertTrue(converted.contains(","), "Expected an Italian decimal 
separator, got: " + converted);
+  }
+}
diff --git 
a/core/src/test/java/org/apache/hop/i18n/RegionalSettingsPreviewTest.java 
b/core/src/test/java/org/apache/hop/i18n/RegionalSettingsPreviewTest.java
new file mode 100644
index 0000000000..f2028d0a71
--- /dev/null
+++ b/core/src/test/java/org/apache/hop/i18n/RegionalSettingsPreviewTest.java
@@ -0,0 +1,87 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.i18n;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.Calendar;
+import java.util.Date;
+import java.util.Locale;
+import org.junit.jupiter.api.Test;
+
+class RegionalSettingsPreviewTest {
+
+  @Test
+  void formatsNumbersWithTheGivenLocaleRegardlessOfTheJvmDefault() {
+    RegionalSettingsPreview italian = RegionalSettingsPreview.of(Locale.ITALY);
+    RegionalSettingsPreview american = RegionalSettingsPreview.of(Locale.US);
+
+    assertEquals("10.000,23", italian.getNumber());
+    assertEquals("10,000.23", american.getNumber());
+  }
+
+  @Test
+  void formatsNegativeNumbersAndPercentages() {
+    assertEquals("-10.000,23", 
RegionalSettingsPreview.of(Locale.ITALY).getNegativeNumber());
+    assertEquals("-10,000.23", 
RegionalSettingsPreview.of(Locale.US).getNegativeNumber());
+    // The sample percentage has no fractional part, so it reads the same in 
both of the locales
+    // above; assert the exact expected value rather than merely that a 
percent sign is present.
+    assertEquals("85%", RegionalSettingsPreview.of(Locale.ITALY).getPercent());
+  }
+
+  @Test
+  void currencyUsesTheLocaleCurrencySymbol() {
+    assertTrue(
+        RegionalSettingsPreview.of(Locale.US).getCurrency().contains("$"),
+        RegionalSettingsPreview.of(Locale.US).getCurrency());
+    assertTrue(
+        RegionalSettingsPreview.of(Locale.ITALY).getCurrency().contains("€"),
+        RegionalSettingsPreview.of(Locale.ITALY).getCurrency());
+  }
+
+  /**
+   * Dates and times must follow the locale that was passed in. Asserting 
merely that the strings
+   * are non-empty would pass against an implementation that quietly used the 
JVM default locale,
+   * which is the one mistake this class exists to avoid, so two locales 
rendering the same instant
+   * must disagree instead.
+   *
+   * <p>The instant is fixed rather than "now" for two reasons. The Italian 
short format is {@code
+   * dd/MM/yy} and the US one {@code M/d/yy}, so they render identically 
whenever day and month are
+   * equal and both are at least ten — 10/10, 11/11 and 12/12 — and the test 
would fail on those
+   * three days a year against perfectly correct code. A fixed instant also 
removes the (tiny)
+   * chance of the two preview objects straddling a day boundary and appearing 
to differ for the
+   * wrong reason.
+   */
+  @Test
+  void dateAndTimeFollowTheGivenLocale() {
+    Calendar calendar = Calendar.getInstance();
+    calendar.clear();
+    calendar.set(2026, Calendar.JANUARY, 15, 14, 30, 45);
+    Date instant = calendar.getTime();
+
+    RegionalSettingsPreview italian = RegionalSettingsPreview.of(Locale.ITALY, 
instant);
+    RegionalSettingsPreview american = RegionalSettingsPreview.of(Locale.US, 
instant);
+
+    assertNotEquals(italian.getShortDate(), american.getShortDate());
+    assertNotEquals(italian.getLongDate(), american.getLongDate());
+    assertNotEquals(italian.getShortTime(), american.getShortTime());
+    assertNotEquals(italian.getLongTime(), american.getLongTime());
+  }
+}
diff --git a/core/src/test/java/org/apache/hop/i18n/RegionalSettingsTest.java 
b/core/src/test/java/org/apache/hop/i18n/RegionalSettingsTest.java
new file mode 100644
index 0000000000..b834f522bb
--- /dev/null
+++ b/core/src/test/java/org/apache/hop/i18n/RegionalSettingsTest.java
@@ -0,0 +1,103 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.i18n;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+
+import java.util.Locale;
+import org.apache.hop.core.config.HopConfig;
+import org.apache.hop.junit.rules.RestoreHopEnvironmentExtension;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+
+@ExtendWith(RestoreHopEnvironmentExtension.class)
+class RegionalSettingsTest {
+
+  @BeforeEach
+  void clearConfiguration() {
+    
HopConfig.getInstance().saveOption(RegionalSettings.STRING_REGIONAL_SETTINGS_SOURCE,
 null);
+    
HopConfig.getInstance().saveOption(RegionalSettings.STRING_REGIONAL_SETTINGS_LOCALE,
 null);
+    
LanguageChoice.getInstance().setDefaultLocale(Locale.forLanguageTag("en-US"));
+    RegionalSettings.getInstance().reload();
+  }
+
+  @Test
+  void defaultsToTheOperatingSystemWhenNothingIsConfigured() {
+    RegionalSettings settings = RegionalSettings.getInstance();
+
+    assertEquals(RegionalSettings.Source.OPERATING_SYSTEM, 
settings.getSource());
+    assertEquals(settings.getOperatingSystemLocale(), 
settings.getEffectiveLocale());
+  }
+
+  @Test
+  void effectiveLocaleFollowsTheOperatingSystemWhenSelected() {
+    RegionalSettings settings = RegionalSettings.getInstance();
+    settings.setSource(RegionalSettings.Source.OPERATING_SYSTEM);
+
+    assertNotNull(settings.getOperatingSystemLocale());
+    assertEquals(settings.getOperatingSystemLocale(), 
settings.getEffectiveLocale());
+  }
+
+  @Test
+  void effectiveLocaleIsTheCustomOneWhenOverridden() {
+    RegionalSettings settings = RegionalSettings.getInstance();
+    settings.setSource(RegionalSettings.Source.CUSTOM);
+    settings.setCustomLocale(Locale.ITALY);
+
+    assertEquals(Locale.ITALY, settings.getEffectiveLocale());
+  }
+
+  @Test
+  void unknownSourceValueFallsBackToTheOperatingSystem() {
+    HopConfig.getInstance()
+        .saveOption(RegionalSettings.STRING_REGIONAL_SETTINGS_SOURCE, 
"NOT_A_SOURCE");
+    RegionalSettings.getInstance().reload();
+
+    assertEquals(
+        RegionalSettings.Source.OPERATING_SYSTEM, 
RegionalSettings.getInstance().getSource());
+  }
+
+  @Test
+  void customSourceWithUnusableLocaleFallsBackToTheOperatingSystem() {
+    HopConfig.getInstance()
+        .saveOption(
+            RegionalSettings.STRING_REGIONAL_SETTINGS_SOURCE,
+            RegionalSettings.Source.CUSTOM.name());
+    
HopConfig.getInstance().saveOption(RegionalSettings.STRING_REGIONAL_SETTINGS_LOCALE,
 null);
+    RegionalSettings.getInstance().reload();
+
+    RegionalSettings settings = RegionalSettings.getInstance();
+    assertEquals(RegionalSettings.Source.OPERATING_SYSTEM, 
settings.getSource());
+    assertEquals(settings.getOperatingSystemLocale(), 
settings.getEffectiveLocale());
+  }
+
+  @Test
+  void saveAndReloadRoundTripsTheConfiguration() {
+    RegionalSettings settings = RegionalSettings.getInstance();
+    settings.setSource(RegionalSettings.Source.CUSTOM);
+    settings.setCustomLocale(Locale.ITALY);
+    settings.save();
+
+    settings.reload();
+
+    assertEquals(RegionalSettings.Source.CUSTOM, settings.getSource());
+    assertEquals(Locale.ITALY, settings.getCustomLocale());
+  }
+}
diff --git 
a/docs/hop-user-manual/modules/ROOT/pages/hop-gui/perspective-configuration.adoc
 
b/docs/hop-user-manual/modules/ROOT/pages/hop-gui/perspective-configuration.adoc
index 38d8cb6c0f..00eeb65626 100644
--- 
a/docs/hop-user-manual/modules/ROOT/pages/hop-gui/perspective-configuration.adoc
+++ 
b/docs/hop-user-manual/modules/ROOT/pages/hop-gui/perspective-configuration.adoc
@@ -36,6 +36,7 @@ The perspective lists its sections in a tree on the left. 
Selecting one shows it
 
 * **General**: how Apache Hop and Hop GUI behave - confirmations, tooltips and 
dialog positions.
 * **Look &amp; Feel**: how Hop GUI is drawn on your desktop or in your browser.
+* **Regional settings**: the interface language and, separately, the number, 
currency and date formats used by Hop GUI, `hop-run` and `hop-server`.
 * **Plugins**: one entry per plugin that contributes options, including 
xref:hop-gui/perspective-search.adoc[Search], the Explorer perspective and 
xref:hop-gui/perspective-ai-advisor.adoc[AI Assistant]. Every option is listed 
in <<_plugin_options_reference,Plugin options reference>> below.
 * **System variables**: set and inspect the Apache Hop global variables.
 * **Security**: how a session is authenticated, split over General, Basic, 
External and OAuth sub-tabs. Until authentication is turned on the session is 
unrestricted and anyone can change these settings, which the tab says at the 
top.
@@ -137,11 +138,6 @@ Some of these settings only take effect after Hop GUI is 
restarted; the dialog s
 |===
 |Option|Description|Default
 
-|Preferred language
-a|The language Hop GUI is shown in.
-
-TIP: Check the 
https://hop.apache.org/community/contribution-guides/translation-contribution-guide/[Translation
 Contribution Guide] to translate Apache Hop into your own language.
-|English (US)
 
 |Hide the menu bar|Hide the menu bar. With it hidden, the menu is reachable 
from the Apache Hop icon in the upper left corner of Hop GUI.|yes
 
@@ -303,6 +299,76 @@ Which columns the pipeline metrics grid shows while a 
pipeline runs. Hiding a co
 |Show Data volume out|Show the *Data volume out* column, bytes written to the 
output stream. Needs `HOP_METRIC_DATA_VOLUME` as above.|yes
 |===
 
+=== Regional settings
+
+The `Regional settings` configuration tab controls two things that used to be 
tied together: the language of the Hop GUI interface, and the number, currency 
and date formats used throughout Hop.
+
+[%header, width=90%, cols="2,5,1"]
+|===
+|Option|Description|Default
+|Preferred Language
+a|the language used for the Hop GUI interface. This does not affect number, 
currency or date formats; those are controlled separately below.
+
+TIP: Check the 
https://hop.apache.org/community/contribution-guides/translation-contribution-guide/[Translation
 Contribution Guide] to translate Apache Hop to your native language.|English 
(US)
+
+|Use operating system regional settings
+a|use the number, currency and date formats of the operating system Hop is 
running on, regardless of the preferred language. This is the default.
+|yes
+
+|Override regional settings
+a|use an explicitly chosen locale for number, currency and date formats, 
regardless of the preferred language. The dropdown lists every locale available 
on the platform, not only the languages Hop is translated into, and is only 
enabled when this option is ticked.
+|no
+|===
+
+`Use operating system regional settings` and `Override regional settings` are 
mutually exclusive: ticking one disables the other. `Use operating system 
regional settings` is ticked on a fresh installation, so number, currency and 
date formats come from the machine Hop runs on unless you say otherwise. 
Unticking both makes the regional settings follow the preferred language, which 
is the way to have the interface language and the formats move together.
+
+Two preview panels below the options show sample dates, times, numbers and 
currency for the current selection, updated live as options change, before 
anything is saved.
+
+Whichever source is in effect, operating system or an explicit override, 
applies both to Hop GUI and to headless runs: `hop-run` and `hop-server` read 
the same `hop-config.json` and honor it the same way the GUI does.
+
+At start, hop-gui, `hop-run` and `hop-server` log the effective settings, for 
example `Regional settings: language=en_US format=it_IT timezone=Europe/Rome 
source=installation:CUSTOM`. Use that line to confirm the process you are 
looking at is formatting the way you think it is.
+
+NOTE: Because this setting applies to the whole running process, in Hop Web it 
belongs to the installation rather than to each connected user. This is already 
true of the preferred language today, so it is not a new limitation introduced 
by this feature. Saving this tab applies the preferred language to the whole 
running process as well, so in Hop Web it affects every connected session until 
the server restarts.
+
+==== How regional settings are resolved
+
+The preferred language and the regional settings are two independent settings, 
and keeping them apart is the point of this tab. The preferred language governs 
the interface: menus, dialogs, button captions and messages. The regional 
settings govern the formats data is rendered with: the decimal separator, the 
grouping separator, the currency symbol, and the date and time patterns. An 
English interface with Italian numbers is a valid and supported combination, 
and so is the reverse.
+
+The table below shows which locale ends up driving the formats, for each 
source and for each side of Hop.
+
+[%header, width=90%, cols="2,2,2"]
+|===
+|Source |Hop GUI |`hop-run` and `hop-server`
+
+|`Use operating system regional settings` (the default)
+|operating system
+|operating system
+
+|`Override regional settings`
+|the chosen locale
+|the chosen locale
+
+|Neither option ticked
+|preferred language
+|preferred language
+|===
+
+Previously the resolution was not consistent. In Hop GUI the preferred 
language decided the formats, except on fields carrying explicit decimal, 
grouping or currency symbols, which followed the operating system; `hop-run` 
and `hop-server` always used the operating system, so the editor and production 
could disagree on the same pipeline. The formats now come from the operating 
system everywhere unless an explicit choice is made, and the preferred language 
never affects them.
+
+On an existing installation whose configuration is left untouched, nothing 
changes for `hop-run` and `hop-server`. In Hop GUI, fields that carry explicit 
decimal, grouping or currency symbols are unchanged as well; fields left blank, 
and dates with no explicit mask, now follow the operating system where they 
previously followed the preferred language.
+
+The reason to untick both options is reproducibility against the preferred 
language: it makes Hop GUI, `hop-run` and `hop-server` agree on that language, 
so a number converted to a string reads the same in the editor and in 
production, and any hash computed over such a string stays stable between them.
+
+Resolution order, most specific first:
+
+. Field-level decimal, grouping, currency, date locale and date timezone on a 
transform field. These always win.
+. This installation tab (operating system, an explicit override, or the 
preferred language).
+. The JVM / operating system default.
+
+The default source is the operating system. Hop GUI, `hop-run` and 
`hop-server` then agree *on the same machine*, but a laptop on `nl_BE` and a 
hop-server on `en_US` still format blank fields differently. *Override regional 
settings* (and copying that `hop-config.json`) is the way to pin a locale for 
an installation. Transform field symbols remain the way to handle a file that 
does not match that locale.
+
+Distributed Beam and Spark workers never load `hop-config.json`. They format 
with their own JVM locale and timezone unless the pipeline sets field-level 
symbols.
+
 === Plugins
 
 The plugins tab contains configuration options that are provided by the 
various plugins in Apache Hop.
diff --git a/engine/src/main/java/org/apache/hop/core/HopEnvironment.java 
b/engine/src/main/java/org/apache/hop/core/HopEnvironment.java
index 50f7db499f..9498a3d730 100644
--- a/engine/src/main/java/org/apache/hop/core/HopEnvironment.java
+++ b/engine/src/main/java/org/apache/hop/core/HopEnvironment.java
@@ -49,6 +49,7 @@ import org.apache.hop.datastream.plugin.DataStreamPluginType;
 import org.apache.hop.execution.plugin.ExecutionInfoLocationPluginType;
 import org.apache.hop.execution.sampler.ExecutionDataSamplerPluginType;
 import org.apache.hop.hop.plugin.HopCommandPluginType;
+import org.apache.hop.i18n.RegionalSettings;
 import org.apache.hop.imp.ImportPluginType;
 import org.apache.hop.lineage.hub.LineageHub;
 import org.apache.hop.lineage.plugin.LineageSinkPluginType;
@@ -140,6 +141,11 @@ public class HopEnvironment {
           HopClientEnvironment.init();
         }
 
+        // Install the regional settings (decimal separators, currency, date 
formats) before any
+        // conversion can happen. With the default configuration this is a 
no-op.
+        //
+        RegionalSettings.getInstance().applyHeadless();
+
         // Register the native types and the plugins for the various plugin 
types...
         //
         pluginTypes.forEach(PluginRegistry::addPluginType);
diff --git 
a/engine/src/main/java/org/apache/hop/core/file/TextFileInputField.java 
b/engine/src/main/java/org/apache/hop/core/file/TextFileInputField.java
index 8e85c22a4d..a8c17b3c78 100644
--- a/engine/src/main/java/org/apache/hop/core/file/TextFileInputField.java
+++ b/engine/src/main/java/org/apache/hop/core/file/TextFileInputField.java
@@ -96,11 +96,14 @@ public class TextFileInputField implements 
ITextFileInputField {
         "yyyyMMdd", "ddMMyyyy", "d-M-yyyy", "d/M/yyyy", "d-M-yy", "d/M/yy",
       };
 
+  // These are DecimalFormat pattern strings, not locale-specific rendered 
values: the actual
+  // decimal/grouping separators are substituted at format time from 
DecimalFormatSymbols, so
+  // the patterns themselves are locale-invariant and safe to keep in a static 
initializer.
   private static final String[] numberFormats =
       new String[] {
         "",
         "#",
-        Const.DEFAULT_NUMBER_FORMAT,
+        Const.getDefaultNumberFormat(),
         "0.00",
         "0000000000000",
         "###,###,###.#######",
diff --git 
a/engine/src/main/java/org/apache/hop/pipeline/transforms/file/BaseFileField.java
 
b/engine/src/main/java/org/apache/hop/pipeline/transforms/file/BaseFileField.java
index ce941f67d0..f894c845df 100644
--- 
a/engine/src/main/java/org/apache/hop/pipeline/transforms/file/BaseFileField.java
+++ 
b/engine/src/main/java/org/apache/hop/pipeline/transforms/file/BaseFileField.java
@@ -143,11 +143,14 @@ public class BaseFileField implements ITextFileInputField 
{
         "d/M/yy",
       };
 
+  // These are DecimalFormat pattern strings, not locale-specific rendered 
values: the actual
+  // decimal/grouping separators are substituted at format time from 
DecimalFormatSymbols, so
+  // the patterns themselves are locale-invariant and safe to keep in a static 
initializer.
   protected static final String[] numberFormats =
       new String[] {
         "",
         "#",
-        Const.DEFAULT_NUMBER_FORMAT,
+        Const.getDefaultNumberFormat(),
         "0.00",
         "0000000000000",
         "###,###,###.#######",
diff --git 
a/plugins/tech/google/src/main/java/org/apache/hop/pipeline/transforms/googlesheets/GoogleSheetsInputField.java
 
b/plugins/tech/google/src/main/java/org/apache/hop/pipeline/transforms/googlesheets/GoogleSheetsInputField.java
index cc61fef571..b274747416 100644
--- 
a/plugins/tech/google/src/main/java/org/apache/hop/pipeline/transforms/googlesheets/GoogleSheetsInputField.java
+++ 
b/plugins/tech/google/src/main/java/org/apache/hop/pipeline/transforms/googlesheets/GoogleSheetsInputField.java
@@ -84,11 +84,14 @@ public class GoogleSheetsInputField /*implements Cloneable, 
ITextFileInputField*
         "yyyyMMdd", "ddMMyyyy", "d-M-yyyy", "d/M/yyyy", "d-M-yy", "d/M/yy",
       };
 
+  // These are DecimalFormat pattern strings, not locale-specific rendered 
values: the actual
+  // decimal/grouping separators are substituted at format time from 
DecimalFormatSymbols, so
+  // the patterns themselves are locale-invariant and safe to keep in a static 
initializer.
   private static final String[] number_formats =
       new String[] {
         "",
         "#",
-        Const.DEFAULT_NUMBER_FORMAT,
+        Const.getDefaultNumberFormat(),
         "0.00",
         "0000000000000",
         "###,###,###.#######",
diff --git 
a/plugins/transforms/textfile/src/main/java/org/apache/hop/pipeline/transforms/csvinput/CsvInputField.java
 
b/plugins/transforms/textfile/src/main/java/org/apache/hop/pipeline/transforms/csvinput/CsvInputField.java
index 14b3a8ae8d..490fe837ad 100644
--- 
a/plugins/transforms/textfile/src/main/java/org/apache/hop/pipeline/transforms/csvinput/CsvInputField.java
+++ 
b/plugins/transforms/textfile/src/main/java/org/apache/hop/pipeline/transforms/csvinput/CsvInputField.java
@@ -82,11 +82,14 @@ public class CsvInputField implements ITextFileInputField {
         "yyyyMMdd", "ddMMyyyy", "d-M-yyyy", "d/M/yyyy", "d-M-yy", "d/M/yy",
       };
 
+  // These are DecimalFormat pattern strings, not locale-specific rendered 
values: the actual
+  // decimal/grouping separators are substituted at format time from 
DecimalFormatSymbols, so
+  // the patterns themselves are locale-invariant and safe to keep in a static 
initializer.
   private static final String[] numberFormats =
       new String[] {
         "",
         "#",
-        Const.DEFAULT_NUMBER_FORMAT,
+        Const.getDefaultNumberFormat(),
         "0.00",
         "0000000000000",
         "###,###,###.#######",
diff --git a/ui/src/main/java/org/apache/hop/ui/hopgui/HopGui.java 
b/ui/src/main/java/org/apache/hop/ui/hopgui/HopGui.java
index eb6ca942d1..cf8b84e7dd 100644
--- a/ui/src/main/java/org/apache/hop/ui/hopgui/HopGui.java
+++ b/ui/src/main/java/org/apache/hop/ui/hopgui/HopGui.java
@@ -28,7 +28,6 @@ import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Comparator;
 import java.util.List;
-import java.util.Locale;
 import java.util.Map;
 import java.util.Set;
 import java.util.UUID;
@@ -87,7 +86,7 @@ import org.apache.hop.core.vfs.HopVfs;
 import org.apache.hop.core.vfs.HopVfsNamespace;
 import org.apache.hop.core.vfs.HopVfsNamespaces;
 import org.apache.hop.i18n.BaseMessages;
-import org.apache.hop.i18n.LanguageChoice;
+import org.apache.hop.i18n.RegionalSettings;
 import org.apache.hop.metadata.api.IHasHopMetadataProvider;
 import org.apache.hop.metadata.api.IHopMetadataProvider;
 import org.apache.hop.metadata.serializer.multi.MultiMetadataProvider;
@@ -676,7 +675,7 @@ public class HopGui
       if (!HopLogStore.isInitialized()) {
         HopLogStore.init();
       }
-      Locale.setDefault(LanguageChoice.getInstance().getDefaultLocale());
+      RegionalSettings.getInstance().applyGui();
 
       HopGui hopGui = HopGui.getInstance();
       hopGui.getCommandLineArguments().addAll(Arrays.asList(arguments));
diff --git 
a/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/configuration/tabs/ConfigGuiOptionsTab.java
 
b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/configuration/tabs/ConfigGuiOptionsTab.java
index 8ddb686a72..ea106ad54e 100644
--- 
a/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/configuration/tabs/ConfigGuiOptionsTab.java
+++ 
b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/configuration/tabs/ConfigGuiOptionsTab.java
@@ -27,12 +27,9 @@ import org.apache.hop.core.config.HopConfig;
 import org.apache.hop.core.gui.plugin.GuiPlugin;
 import org.apache.hop.core.gui.plugin.tab.GuiTab;
 import org.apache.hop.core.gui.plugin.toolbar.GuiToolbarElement;
-import org.apache.hop.core.util.EnvUtil;
 import org.apache.hop.history.AuditManager;
 import org.apache.hop.history.AuditState;
 import org.apache.hop.i18n.BaseMessages;
-import org.apache.hop.i18n.GlobalMessages;
-import org.apache.hop.i18n.LanguageChoice;
 import org.apache.hop.ui.core.PropsUi;
 import org.apache.hop.ui.core.dialog.BaseDialog;
 import org.apache.hop.ui.core.dialog.ErrorDialog;
@@ -136,7 +133,6 @@ public class ConfigGuiOptionsTab {
   private Button wMetricsPanelShowDataVolume;
   private Button wMetricsPanelShowDataVolumeIn;
   private Button wMetricsPanelShowDataVolumeOut;
-  private Combo wDefaultLocale;
 
   private Combo wAutoLayoutDirection;
   private Text wAutoLayoutLayerSpacing;
@@ -291,15 +287,6 @@ public class ConfigGuiOptionsTab {
       // Reload global zoom
       String globalZoomFactor = Integer.toString((int) 
(props.getGlobalZoomFactor() * 100)) + '%';
       wGlobalZoom.setText(globalZoomFactor);
-
-      // Reload default locale
-      int idxDefault =
-          Const.indexOfString(
-              LanguageChoice.getInstance().getDefaultLocale().toString(),
-              GlobalMessages.localeCodes);
-      if (idxDefault >= 0) {
-        wDefaultLocale.select(idxDefault);
-      }
     } finally {
       // Always reset the flag
       isReloading = false;
@@ -360,24 +347,6 @@ public class ConfigGuiOptionsTab {
     lookScrolledComposite = sLookComp;
     lastControl = expandToolbar;
 
-    // Preferred language - at the top
-    Control[] defaultLocaleControls =
-        createComboField(
-            wLookComp,
-            "EnterOptionsDialog.DefaultLocale.Label",
-            null,
-            GlobalMessages.localeDescr,
-            lastControl,
-            margin);
-    wDefaultLocale = (Combo) defaultLocaleControls[1];
-    int idxDefault =
-        Const.indexOfString(
-            LanguageChoice.getInstance().getDefaultLocale().toString(), 
GlobalMessages.localeCodes);
-    if (idxDefault >= 0) {
-      wDefaultLocale.select(idxDefault);
-    }
-    lastControl = wDefaultLocale;
-
     // Hide menu bar - at the top
     wHideMenuBar =
         createCheckbox(
@@ -1442,15 +1411,6 @@ public class ConfigGuiOptionsTab {
     props.setMetricsPanelShowDataVolumeOut(
         dataVolumeVarEnabled && wMetricsPanelShowDataVolumeOut.getSelection());
 
-    int defaultLocaleIndex = wDefaultLocale.getSelectionIndex();
-    if (defaultLocaleIndex < 0 || defaultLocaleIndex >= 
GlobalMessages.localeCodes.length) {
-      // Code hardening, when the combo-box ever gets in a strange state,
-      // use the first language as default (should be English)
-      defaultLocaleIndex = 0;
-    }
-    String defaultLocale = GlobalMessages.localeCodes[defaultLocaleIndex];
-    
LanguageChoice.getInstance().setDefaultLocale(EnvUtil.createLocale(defaultLocale));
-
     if (EnvironmentUtils.getInstance().isWeb()) {
       // Hop Web: store theme in audit (per-user); followSystem = follow 
OS/browser dark mode
       boolean followSystem = wWebFollowSystemTheme != null && 
wWebFollowSystemTheme.getSelection();
diff --git 
a/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/configuration/tabs/ConfigRegionalSettingsTab.java
 
b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/configuration/tabs/ConfigRegionalSettingsTab.java
new file mode 100644
index 0000000000..5eb575568b
--- /dev/null
+++ 
b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/configuration/tabs/ConfigRegionalSettingsTab.java
@@ -0,0 +1,532 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+package org.apache.hop.ui.hopgui.perspective.configuration.tabs;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Comparator;
+import java.util.List;
+import java.util.Locale;
+import org.apache.hop.core.Const;
+import org.apache.hop.core.gui.plugin.GuiPlugin;
+import org.apache.hop.core.gui.plugin.tab.GuiTab;
+import org.apache.hop.core.util.EnvUtil;
+import org.apache.hop.i18n.BaseMessages;
+import org.apache.hop.i18n.GlobalMessages;
+import org.apache.hop.i18n.LanguageChoice;
+import org.apache.hop.i18n.RegionalSettings;
+import org.apache.hop.i18n.RegionalSettingsPreview;
+import org.apache.hop.ui.core.PropsUi;
+import org.apache.hop.ui.core.dialog.BaseDialog;
+import org.apache.hop.ui.core.gui.GuiResource;
+import org.apache.hop.ui.core.widget.ComboFilterPopup;
+import 
org.apache.hop.ui.hopgui.perspective.configuration.ConfigurationPerspective;
+import org.eclipse.swt.SWT;
+import org.eclipse.swt.custom.CTabFolder;
+import org.eclipse.swt.custom.CTabItem;
+import org.eclipse.swt.custom.ScrolledComposite;
+import org.eclipse.swt.layout.FormAttachment;
+import org.eclipse.swt.layout.FormData;
+import org.eclipse.swt.layout.FormLayout;
+import org.eclipse.swt.widgets.Button;
+import org.eclipse.swt.widgets.Combo;
+import org.eclipse.swt.widgets.Composite;
+import org.eclipse.swt.widgets.Control;
+import org.eclipse.swt.widgets.Group;
+import org.eclipse.swt.widgets.Label;
+
+/**
+ * Lets the user pick the interface language and, independently of it, the 
locale that decides the
+ * decimal separator, the grouping separator, the currency and the date and 
time formats.
+ */
+@GuiPlugin
+public class ConfigRegionalSettingsTab {
+  private static final Class<?> PKG = BaseDialog.class;
+
+  private Combo wDefaultLocale;
+  private Button wUseOperatingSystem;
+  private Button wOverride;
+  private Combo wLocale;
+
+  private Label wShortDate;
+  private Label wLongDate;
+  private Label wShortTime;
+  private Label wLongTime;
+  private Label wNumber;
+  private Label wNegativeNumber;
+  private Label wCurrency;
+  private Label wPercent;
+
+  private List<Locale> localeList;
+
+  /** Widgets fire their listeners while they are being populated; don't save 
on those events. */
+  private boolean isInitializing;
+
+  public ConfigRegionalSettingsTab() {
+    // This instance is created in the GuiPlugin system by calling this 
constructor, after which it
+    // calls the addConfigRegionalSettingsTab() method.
+  }
+
+  @GuiTab(
+      id = "10120-config-perspective-regional-settings-tab",
+      parentId = ConfigurationPerspective.CONFIG_PERSPECTIVE_TABS,
+      description = "Regional settings")
+  public void addConfigRegionalSettingsTab(CTabFolder wTabFolder) {
+    isInitializing = true;
+
+    int margin = PropsUi.getMargin();
+    RegionalSettings settings = RegionalSettings.getInstance();
+    // Mutable: a saved locale that the offered list filters out is appended 
to it.
+    localeList = new ArrayList<>(buildLocaleList());
+
+    CTabItem wRegionalTab = new CTabItem(wTabFolder, SWT.NONE);
+    wRegionalTab.setFont(GuiResource.getInstance().getFontDefault());
+    wRegionalTab.setText(BaseMessages.getString(PKG, 
"ConfigRegionalSettingsTab.Title"));
+    wRegionalTab.setImage(GuiResource.getInstance().getImageOptions());
+
+    ScrolledComposite sRegionalComp =
+        new ScrolledComposite(wTabFolder, SWT.V_SCROLL | SWT.H_SCROLL);
+    sRegionalComp.setLayout(new FormLayout());
+
+    Composite wRegionalComp = new Composite(sRegionalComp, SWT.NONE);
+    PropsUi.setLook(wRegionalComp);
+    FormLayout regionalLayout = new FormLayout();
+    regionalLayout.marginWidth = PropsUi.getFormMargin();
+    regionalLayout.marginHeight = PropsUi.getFormMargin();
+    wRegionalComp.setLayout(regionalLayout);
+
+    Control lastControl = null;
+
+    // Preferred language: this is the interface language only, it no longer 
decides the formats.
+    //
+    wDefaultLocale =
+        createComboField(
+            wRegionalComp,
+            "EnterOptionsDialog.DefaultLocale.Label",
+            GlobalMessages.localeDescr,
+            false,
+            lastControl,
+            margin);
+    int languageIndex =
+        Const.indexOfString(
+            LanguageChoice.getInstance().getDefaultLocale().toString(), 
GlobalMessages.localeCodes);
+    if (languageIndex >= 0) {
+      wDefaultLocale.select(languageIndex);
+    }
+    lastControl = wDefaultLocale;
+
+    // Use the operating system regional settings.
+    //
+    wUseOperatingSystem =
+        createCheckbox(
+            wRegionalComp,
+            "ConfigRegionalSettingsTab.UseOperatingSystem.Label",
+            "ConfigRegionalSettingsTab.UseOperatingSystem.Tooltip",
+            settings.getSource() == RegionalSettings.Source.OPERATING_SYSTEM,
+            lastControl,
+            margin);
+    wUseOperatingSystem.addListener(
+        SWT.Selection,
+        e -> {
+          if (wUseOperatingSystem.getSelection()) {
+            wOverride.setSelection(false);
+          }
+          onSourceChanged();
+        });
+    lastControl = wUseOperatingSystem;
+
+    // Override the regional settings with an explicitly picked locale.
+    //
+    wOverride =
+        createCheckbox(
+            wRegionalComp,
+            "ConfigRegionalSettingsTab.Override.Label",
+            "ConfigRegionalSettingsTab.Override.Tooltip",
+            settings.getSource() == RegionalSettings.Source.CUSTOM,
+            lastControl,
+            margin);
+    wOverride.addListener(
+        SWT.Selection,
+        e -> {
+          if (wOverride.getSelection()) {
+            wUseOperatingSystem.setSelection(false);
+            // Without a selection the override would be saved as a plain 
language choice, leaving
+            // the ticked checkbox and the configuration disagreeing.
+            ensureLocaleSelected();
+          }
+          onSourceChanged();
+        });
+    lastControl = wOverride;
+
+    // Editable, unlike the language combo: the filter popup narrows several 
hundred entries by
+    // typing, and it needs a combo it can write into.
+    wLocale =
+        createComboField(
+            wRegionalComp,
+            "ConfigRegionalSettingsTab.Locale.Label",
+            
localeList.stream().map(Locale::getDisplayName).toArray(String[]::new),
+            true,
+            lastControl,
+            margin);
+    ComboFilterPopup.attach(wLocale, () -> Arrays.asList(wLocale.getItems()), 
null);
+    selectCustomLocale(settings.getCustomLocale());
+    wLocale.setEnabled(wOverride.getSelection());
+    wLocale.addListener(
+        SWT.Modify,
+        e -> {
+          // Every keystroke lands here now. Half-typed text names no locale, 
so wait until the
+          // combo shows one of the offered entries.
+          if (selectedLocaleIndex() < 0) {
+            return;
+          }
+          refreshPreview();
+          save();
+        });
+    lastControl = wLocale;
+
+    // The preview of what the settings above produce, before anything is 
saved.
+    //
+    Group wDateTimeGroup =
+        createPreviewGroup(
+            wRegionalComp, "ConfigRegionalSettingsTab.DateTimeGroup.Label", 
lastControl, margin);
+    wShortDate =
+        createPreviewRow(wDateTimeGroup, 
"ConfigRegionalSettingsTab.ShortDate.Label", null, margin);
+    wLongDate =
+        createPreviewRow(
+            wDateTimeGroup, "ConfigRegionalSettingsTab.LongDate.Label", 
wShortDate, margin);
+    wShortTime =
+        createPreviewRow(
+            wDateTimeGroup, "ConfigRegionalSettingsTab.ShortTime.Label", 
wLongDate, margin);
+    wLongTime =
+        createPreviewRow(
+            wDateTimeGroup, "ConfigRegionalSettingsTab.LongTime.Label", 
wShortTime, margin);
+    lastControl = wDateTimeGroup;
+
+    Group wNumbersGroup =
+        createPreviewGroup(
+            wRegionalComp, "ConfigRegionalSettingsTab.NumbersGroup.Label", 
lastControl, margin);
+    wNumber =
+        createPreviewRow(wNumbersGroup, 
"ConfigRegionalSettingsTab.Number.Label", null, margin);
+    wNegativeNumber =
+        createPreviewRow(
+            wNumbersGroup, "ConfigRegionalSettingsTab.NegativeNumber.Label", 
wNumber, margin);
+    wCurrency =
+        createPreviewRow(
+            wNumbersGroup, "ConfigRegionalSettingsTab.Currency.Label", 
wNegativeNumber, margin);
+    wPercent =
+        createPreviewRow(
+            wNumbersGroup, "ConfigRegionalSettingsTab.Percent.Label", 
wCurrency, margin);
+
+    // Registered last: the preview it triggers reads every other widget on 
this tab.
+    //
+    wDefaultLocale.addListener(
+        SWT.Modify,
+        e -> {
+          refreshPreview();
+          save();
+        });
+
+    refreshPreview();
+
+    wRegionalComp.layout();
+    wRegionalComp.pack();
+    sRegionalComp.setContent(wRegionalComp);
+    sRegionalComp.setExpandHorizontal(true);
+    sRegionalComp.setExpandVertical(true);
+    sRegionalComp.setMinWidth(wRegionalComp.getBounds().width);
+    sRegionalComp.setMinHeight(wRegionalComp.getBounds().height);
+
+    wRegionalTab.setControl(sRegionalComp);
+
+    isInitializing = false;
+  }
+
+  /**
+   * The locales offered for the override. Deliberately not {@link 
GlobalMessages#localeCodes},
+   * which only lists the languages Hop is translated into: the regional 
settings are independent of
+   * the interface language, so any locale the JVM knows about is a valid 
choice. Locales without a
+   * country carry no formats worth picking, and variants and scripts would 
only clutter the list
+   * with near-duplicates.
+   */
+  private List<Locale> buildLocaleList() {
+    return Arrays.stream(Locale.getAvailableLocales())
+        .filter(l -> !l.getCountry().isEmpty())
+        .filter(l -> l.getVariant().isEmpty() && l.getScript().isEmpty())
+        .sorted(Comparator.comparing(Locale::getDisplayName))
+        .toList();
+  }
+
+  private void selectCustomLocale(Locale customLocale) {
+    if (customLocale == null) {
+      return;
+    }
+    if (!localeList.contains(customLocale)) {
+      // A saved locale can carry a variant or a script, which the offered 
list filters out. Keep it
+      // rather than dropping the user's choice on the floor.
+      localeList.add(customLocale);
+      wLocale.add(customLocale.getDisplayName());
+    }
+    selectLocaleAt(localeList.indexOf(customLocale));
+  }
+
+  /**
+   * The index in {@link #localeList} of the locale the combo currently shows, 
or -1 when it shows
+   * none. Resolved through the displayed text rather than {@link 
Combo#getSelectionIndex()}: the
+   * filter popup applies a choice with {@code setText()}, which leaves the 
selection index behind
+   * on whatever was picked before. Reading the text covers both ways of 
choosing, and reports "no
+   * locale" while a query is being typed.
+   */
+  private int selectedLocaleIndex() {
+    return wLocale.indexOf(wLocale.getText());
+  }
+
+  /**
+   * Show the locale at {@code index}. The text is written explicitly on top 
of the selection: on an
+   * editable combo {@code select()} alone is not guaranteed to update the 
text field, and the text
+   * is what {@link #selectedLocaleIndex()} reads back.
+   */
+  private void selectLocaleAt(int index) {
+    wLocale.select(index);
+    wLocale.setText(wLocale.getItem(index));
+  }
+
+  private void ensureLocaleSelected() {
+    if (selectedLocaleIndex() >= 0) {
+      return;
+    }
+    // Start from the locale that is in effect right now, so ticking the 
override keeps the formats
+    // the user is already seeing instead of silently switching them to 
another locale.
+    Locale current = RegionalSettings.getInstance().getEffectiveLocale();
+    int index = current == null ? -1 : localeList.indexOf(current);
+    selectLocaleAt(index >= 0 ? index : 0);
+  }
+
+  /**
+   * Reloads the widgets from the configuration, so a change made outside this 
tab doesn't leave it
+   * showing stale values. Invoked reflectively when the configuration 
perspective is activated.
+   */
+  public void reloadValues() {
+    if (wDefaultLocale == null || wDefaultLocale.isDisposed()) {
+      // The tab was never built or is already disposed.
+      return;
+    }
+
+    isInitializing = true;
+    try {
+      RegionalSettings settings = RegionalSettings.getInstance();
+
+      int languageIndex =
+          Const.indexOfString(
+              LanguageChoice.getInstance().getDefaultLocale().toString(),
+              GlobalMessages.localeCodes);
+      if (languageIndex >= 0) {
+        wDefaultLocale.select(languageIndex);
+      }
+
+      wUseOperatingSystem.setSelection(
+          settings.getSource() == RegionalSettings.Source.OPERATING_SYSTEM);
+      wOverride.setSelection(settings.getSource() == 
RegionalSettings.Source.CUSTOM);
+      selectCustomLocale(settings.getCustomLocale());
+      wLocale.setEnabled(wOverride.getSelection());
+
+      refreshPreview();
+    } finally {
+      isInitializing = false;
+    }
+  }
+
+  private void onSourceChanged() {
+    wLocale.setEnabled(wOverride.getSelection());
+    refreshPreview();
+    save();
+  }
+
+  /**
+   * The locale the current state of the widgets would produce, so the preview 
can show the effect
+   * of a choice before it is saved.
+   */
+  private Locale currentEffectiveLocale() {
+    if (wUseOperatingSystem.getSelection()) {
+      return RegionalSettings.getInstance().getOperatingSystemLocale();
+    }
+    if (wOverride.getSelection() && selectedLocaleIndex() >= 0) {
+      return localeList.get(selectedLocaleIndex());
+    }
+    int index = wDefaultLocale.getSelectionIndex();
+    if (index < 0 || index >= GlobalMessages.localeCodes.length) {
+      index = 0;
+    }
+    return EnvUtil.createLocale(GlobalMessages.localeCodes[index]);
+  }
+
+  private void refreshPreview() {
+    RegionalSettingsPreview preview = 
RegionalSettingsPreview.of(currentEffectiveLocale());
+    wShortDate.setText(preview.getShortDate());
+    wLongDate.setText(preview.getLongDate());
+    wShortTime.setText(preview.getShortTime());
+    wLongTime.setText(preview.getLongTime());
+    wNumber.setText(preview.getNumber());
+    wNegativeNumber.setText(preview.getNegativeNumber());
+    wCurrency.setText(preview.getCurrency());
+    wPercent.setText(preview.getPercent());
+  }
+
+  private void save() {
+    if (isInitializing) {
+      return;
+    }
+
+    int index = wDefaultLocale.getSelectionIndex();
+    if (index < 0 || index >= GlobalMessages.localeCodes.length) {
+      // Code hardening, when the combo-box ever gets in a strange state,
+      // use the first language as default (should be English)
+      index = 0;
+    }
+    LanguageChoice.getInstance()
+        
.setDefaultLocale(EnvUtil.createLocale(GlobalMessages.localeCodes[index]));
+
+    RegionalSettings settings = RegionalSettings.getInstance();
+    if (wUseOperatingSystem.getSelection()) {
+      settings.setSource(RegionalSettings.Source.OPERATING_SYSTEM);
+    } else if (wOverride.getSelection() && selectedLocaleIndex() >= 0) {
+      settings.setSource(RegionalSettings.Source.CUSTOM);
+      settings.setCustomLocale(localeList.get(selectedLocaleIndex()));
+    } else {
+      settings.setSource(RegionalSettings.Source.LANGUAGE);
+    }
+    settings.save();
+    settings.applyGui();
+  }
+
+  private Combo createComboField(
+      Composite parent,
+      String labelKey,
+      String[] items,
+      boolean editable,
+      Control lastControl,
+      int margin) {
+    Label label = new Label(parent, SWT.LEFT);
+    PropsUi.setLook(label);
+    label.setText(BaseMessages.getString(PKG, labelKey));
+
+    FormData fdLabel = new FormData();
+    fdLabel.left = new FormAttachment(0, 0);
+    fdLabel.right = new FormAttachment(100, 0);
+    if (lastControl != null) {
+      fdLabel.top = new FormAttachment(lastControl, margin);
+    } else {
+      fdLabel.top = new FormAttachment(0, margin);
+    }
+    label.setLayoutData(fdLabel);
+
+    Combo combo =
+        new Combo(
+            parent, SWT.SINGLE | SWT.LEFT | SWT.BORDER | (editable ? SWT.NONE 
: SWT.READ_ONLY));
+    PropsUi.setLook(combo);
+    combo.setItems(items);
+
+    FormData fdCombo = new FormData();
+    fdCombo.left = new FormAttachment(0, 0);
+    fdCombo.right = new FormAttachment(100, 0);
+    fdCombo.top = new FormAttachment(label, margin / 2);
+    combo.setLayoutData(fdCombo);
+
+    return combo;
+  }
+
+  private Button createCheckbox(
+      Composite parent,
+      String labelKey,
+      String tooltipKey,
+      boolean selected,
+      Control lastControl,
+      int margin) {
+    Button checkbox = new Button(parent, SWT.CHECK);
+    PropsUi.setLook(checkbox);
+    checkbox.setText(BaseMessages.getString(PKG, labelKey));
+    if (tooltipKey != null) {
+      checkbox.setToolTipText(BaseMessages.getString(PKG, tooltipKey));
+    }
+    checkbox.setSelection(selected);
+
+    FormData fdCheckbox = new FormData();
+    fdCheckbox.left = new FormAttachment(0, 0);
+    fdCheckbox.right = new FormAttachment(100, 0);
+    if (lastControl != null) {
+      fdCheckbox.top = new FormAttachment(lastControl, margin);
+    } else {
+      fdCheckbox.top = new FormAttachment(0, margin);
+    }
+    checkbox.setLayoutData(fdCheckbox);
+
+    return checkbox;
+  }
+
+  private Group createPreviewGroup(
+      Composite parent, String labelKey, Control lastControl, int margin) {
+    Group group = new Group(parent, SWT.SHADOW_NONE);
+    PropsUi.setLook(group);
+    group.setText(BaseMessages.getString(PKG, labelKey));
+
+    FormLayout groupLayout = new FormLayout();
+    groupLayout.marginWidth = PropsUi.getFormMargin();
+    groupLayout.marginHeight = PropsUi.getFormMargin();
+    group.setLayout(groupLayout);
+
+    FormData fdGroup = new FormData();
+    fdGroup.left = new FormAttachment(0, 0);
+    fdGroup.right = new FormAttachment(100, 0);
+    if (lastControl != null) {
+      fdGroup.top = new FormAttachment(lastControl, 2 * margin);
+    } else {
+      fdGroup.top = new FormAttachment(0, margin);
+    }
+    group.setLayoutData(fdGroup);
+
+    return group;
+  }
+
+  /** Adds a caption and its value to a preview group and returns the label 
carrying the value. */
+  private Label createPreviewRow(Group group, String labelKey, Control 
lastControl, int margin) {
+    Label caption = new Label(group, SWT.RIGHT);
+    PropsUi.setLook(caption);
+    caption.setText(BaseMessages.getString(PKG, labelKey));
+
+    FormData fdCaption = new FormData();
+    fdCaption.left = new FormAttachment(0, 0);
+    fdCaption.right = new FormAttachment(PropsUi.getInstance().getMiddlePct(), 
-margin);
+    if (lastControl != null) {
+      fdCaption.top = new FormAttachment(lastControl, margin);
+    } else {
+      fdCaption.top = new FormAttachment(0, margin);
+    }
+    caption.setLayoutData(fdCaption);
+
+    Label value = new Label(group, SWT.LEFT);
+    PropsUi.setLook(value);
+
+    FormData fdValue = new FormData();
+    fdValue.left = new FormAttachment(PropsUi.getInstance().getMiddlePct(), 0);
+    fdValue.right = new FormAttachment(100, 0);
+    fdValue.top = new FormAttachment(caption, 0, SWT.CENTER);
+    value.setLayoutData(fdValue);
+
+    return value;
+  }
+}
diff --git 
a/ui/src/main/resources/org/apache/hop/ui/core/dialog/messages/messages_en_US.properties
 
b/ui/src/main/resources/org/apache/hop/ui/core/dialog/messages/messages_en_US.properties
index f0c1197396..106430f850 100644
--- 
a/ui/src/main/resources/org/apache/hop/ui/core/dialog/messages/messages_en_US.properties
+++ 
b/ui/src/main/resources/org/apache/hop/ui/core/dialog/messages/messages_en_US.properties
@@ -447,3 +447,19 @@ TransformFieldsDialog.TableCol.StorageType=Storage
 TransformFieldsDialog.TableCol.TrimType=Trim
 TransformFieldsDialog.TableCol.Type=Type
 TransformFieldsDialog.Title=Transform fields and their origin
+ConfigRegionalSettingsTab.Title=Regional settings
+ConfigRegionalSettingsTab.UseOperatingSystem.Label=Use operating system 
regional settings
+ConfigRegionalSettingsTab.UseOperatingSystem.Tooltip=Inherit decimal 
separator, grouping separator, currency and date formats from the operating 
system Hop runs on.\nThis is the default: unless a choice below overrides it, 
those formats come from the machine Hop runs on, in the Hop GUI and in hop-run 
and hop-server alike, whatever the interface language is.
+ConfigRegionalSettingsTab.Override.Label=Override regional settings
+ConfigRegionalSettingsTab.Override.Tooltip=Pick the locale used for decimal 
separator, grouping separator, currency and date formats, independently of the 
interface language.
+ConfigRegionalSettingsTab.Locale.Label=Locale:
+ConfigRegionalSettingsTab.DateTimeGroup.Label=Date and time
+ConfigRegionalSettingsTab.NumbersGroup.Label=Numbers and currency
+ConfigRegionalSettingsTab.ShortDate.Label=Short date:
+ConfigRegionalSettingsTab.LongDate.Label=Long date:
+ConfigRegionalSettingsTab.ShortTime.Label=Short time:
+ConfigRegionalSettingsTab.LongTime.Label=Long time:
+ConfigRegionalSettingsTab.Number.Label=Number:
+ConfigRegionalSettingsTab.NegativeNumber.Label=Negative:
+ConfigRegionalSettingsTab.Currency.Label=Currency:
+ConfigRegionalSettingsTab.Percent.Label=Percent:

Reply via email to