dotnet / wpf

WPF is a .NET Core UI framework for building Windows desktop applications.
MIT License
7.05k stars 1.17k forks source link

FrameworkElement.Language does not use culture user overrides #1946

Open miloush opened 5 years ago

miloush commented 5 years ago

Problem description: Applying FrameworkElement.Language and related Language properties does not take into account current culture settings. There is no way how to supply a specific instance of CultureInfo to the WPF infrastructure.

Minimal repro:

<Window x:Class="TestWpfBugs.StaticProperties.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:s="clr-namespace:System;assembly=mscorlib"
        Language="en-GB">
    <TextBlock DataContext="{x:Static s:DateTime.Now}" Text="{Binding}" />
</Window>

(this assumes English (United Kingdom) to be used for formatting. Use other cultures accordingly.)

Go to Settings > Time and Language > Region > Change data formats and change Short date format to a non-default option, e.g. yyyy-MM-dd. Run the WPF app.

Actual behavior: Current culture settings not respected, the date is shown as dd/MM/yyyy.

Expected behavior: The date to be in the selected format.

Ultimately, the XmlLanguage calls new CultureInfo(name, useUserOverride) when converting the string into culture. The actual behavior passes false for the useUserOverride while the expected behavior would mean passing true instead.

As a result, there isn't any way I am aware of to make the WPF infrastructure use the current culture that user prefers other than annotating all data bindings and template bindings with converters with explicit cultures, which is a considerable performance hit if possible at all.

Clearly changing the current behavior would not only be a compatibility break but also prevent people from using non-overridden cultures, but there might be other solutions, such as (in decreasing number of scenarios it would allow):

  1. letting developers to override language with an instance CultureInfo rather than just a name string, possible with an extra property of type CultureInfo that would be used if set (either on XmlLanguage or FE, otherwise fallback to Language.
  2. introducing new property e.g. UseLanguageOverride on XmlLanguage supporting returning user overridden cultures, possibly but not necessarily with an extra property on FE too.
  3. introducing special XML language strings meaning "current culture" and "current UI culture".

Any thoughts?

weltkante commented 5 years ago

The problem is that WPF expects the XmlLanguage name to be equivalent to the CultureInfo in some places, so the name must be unique and must not cause mismatches between the true CultureInfo and a customized one.

I'm using this as a workaround:


public static void InitializeCurrentLanguageForWPF()
{
    // Create a made-up IETF language tag "more specific" than the culture we are based on.
    // This allows all standard logic regarding IETF language tag hierarchy to still make sense and we are
    // compatible with the fact that we may have overridden language specific defaults with Windows OS settings.
    var culture = CultureInfo.CurrentCulture;
    var language = XmlLanguage.GetLanguage(culture.IetfLanguageTag + "-current");
    var type = typeof(XmlLanguage);
    const BindingFlags kField = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly;
    type.GetField("_equivalentCulture", kField).SetValue(language, culture);
    type.GetField("_compatibleCulture", kField).SetValue(language, culture);
    if (culture.IsNeutralCulture)
        culture = CultureInfo.CreateSpecificCulture(culture.Name);
    type.GetField("_specificCulture", kField).SetValue(language, culture);
    FrameworkElement.LanguageProperty.OverrideMetadata(typeof(FrameworkElement), new FrameworkPropertyMetadata(language));
}

To make something like this possible without reflection we just need to be able to define a custom XmlLanguage based on a CultureInfo. Since the IETF language tag is not optional the only way to make this working is by adding a custom suffix. Even though this is not an "official" IETF language tag it should be as close as you can get compatibility wise. (You don't want to use an official IETF language tag with a custom CultureInfo because that can cause bugs when roundtripping, using a custom tag at least makes it clear what the cause is when anything goes wrong, and "correctly written" code can still generalize the custom tag properly to actually existing languages.)

mrlacey commented 4 years ago

There's another workaround. On app startup, you can set FrameworkElement.LanguageProperty.OverrideMetadata(typeof(FrameworkElement), new FrameworkPropertyMetadata(XmlLanguage.GetLanguage(CultureInfo.CurrentCulture.Name)));

The bigger issue is that the default language for FrameworkElement is "en-us" when it should be CultureInfo.CurrentCulture.

The current behavior shows a naive approach to localization as it assumes "en-us" is a suitable global default when it isn't. It also means that anyone who doesn't want their app to use formatting or other localization details for "English (United States)" has to do extra work. The default for an app should be to use the system settings. If an app wants to hard-code that only a single, specific culture should be used everywhere, this should only need to be set in a single place.

weltkante commented 4 years ago

There's another workaround. On app startup, you can set [default Language based on CurrentUICulture]

Considering that the culture is used for formatting in bindings I don't think using CurrentUICulture is correct.

mrlacey commented 4 years ago

There's another workaround. On app startup, you can set [default Language based on CurrentUICulture]

Considering that the culture is used for formatting in bindings I don't think using CurrentUICulture is correct.

yes, my mistake. (will correct above)

weltkante commented 4 years ago

Unfortunately when you switch to CurrentCulture your workaround no longer works. The CurrentCulture is not identifyable by its name as it contains customizations the user made in the system controls panel. Once you roundtrip back to CultureInfo from the name you'll lose those user settings, meaning bindings still don't match what non-WPF components produce when they are asked to format with CurrentCulture (e.g. in ToString calls). See my above workaround to define a custom name and keep user specific format settings.

I think the whole WPF localization infrastructure was a design mistake. It looks like originally it was intended to localize XAML resources so you'd be supposed to use CurrentUICulture (which does rountrip based on its name), but then they made the mistake of using the same culture for formatting, which does not work out at all.

Considering this issue is about the XAML respecting user customizations in the CultureInfo I think my workaround is the only one which reliably works. WPF will pick up the culture from the XmlLanguage object so if you didn't store it there via reflection you have no chance to work around the problem, CurrentCulture is not obtainable by name I believe.

Dunge commented 3 years ago

@weltkante I'm trying to use your workaround without success, do you have any idea why? Maybe some changes in .NET5?

Unlike OP, I don't want to follow configured Windows regional options, I just don't like the new DateTime representation under ICU for my local culture and want to force it back to hh:mm:ss.

            var culture = CultureInfo.CurrentCulture;
            if (culture.Name == "fr-CA")
            {
                culture.DateTimeFormat.FullDateTimePattern = "dddd d MMMM yyyy HH:mm:ss";
                culture.DateTimeFormat.LongTimePattern = "HH:mm:ss";
                culture.DateTimeFormat.ShortTimePattern = "HH:mm";
                culture.DateTimeFormat.TimeSeparator = ":";
            }

            // Create a made-up IETF language tag "more specific" than the culture we are based on.
            // This allows all standard logic regarding IETF language tag hierarchy to still make sense and we are
            // compatible with the fact that we may have overridden language specific defaults with Windows OS settings.
            var language = XmlLanguage.GetLanguage(culture.IetfLanguageTag + "-current");
            var type = typeof(XmlLanguage);
            const BindingFlags kField = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly;
            type.GetField("_equivalentCulture", kField).SetValue(language, culture);
            type.GetField("_compatibleCulture", kField).SetValue(language, culture);
            if (culture.IsNeutralCulture)
                culture = CultureInfo.CreateSpecificCulture(culture.Name);
            type.GetField("_specificCulture", kField).SetValue(language, culture);
            FrameworkElement.LanguageProperty.OverrideMetadata(typeof(FrameworkElement), new FrameworkPropertyMetadata(language));

            foreach (Window childWindow in Application.Current.Windows)
            {
                childWindow.Dispatcher.Thread.CurrentCulture = culture;
                childWindow.Dispatcher.Thread.CurrentUICulture = culture;
                childWindow.Language = language;
            }

            Thread.CurrentThread.CurrentCulture = culture;
            Thread.CurrentThread.CurrentUICulture = culture;
            CultureInfo.DefaultThreadCurrentCulture = culture;
            CultureInfo.DefaultThreadCurrentUICulture = culture;

The language object methods do return the requested CultureInfo object with the DatetimeFormat properties changed. After this, current and all new threads report the wanted proper format for DateTime.Now.ToString(), but existing and old WPF window still prints the default format. I also tried doing ait t the application start before WPF initialization without success.

edit: Sorry, I had a custom converter at some point re-switching the culture.

weltkante commented 3 years ago

but existing and old WPF window still prints the default format

[edit] never mind, I see you are already trying to "update" existing windows by iterating over Application.Current.Windows. I never did this myself and I can reproduce that changing the language property after window creation doesn't really refresh anything, which kinda is expected since WPF is designed around static localization.

Anyways, the approach I've posted works for me in .NET 5 when called from the Application_Startup event. You want to call this early. Note that there it is not possible to execute my snippet twice (you only can set a DependencyProperty default value once) so there is really no point delaying this initialization until other windows are already created.

prlcutting commented 1 month ago

I just ran into the same problem in .NET 8. It really puzzled me, until I eventually stumbled across this GitHub issue. The standard behavior violates the Principle of Least Astonishment. Fortunately, the workaround described by @weltkante worked for me. Thank you for posting that solution!

However, having to resort to reflection to set private backing fields in WPF code feels more than a little dirty. It would be great if WPF could just "do the right thing" out of the box, and somehow preserve backwards compatibility. Even if there was some switch/setting we had to enable to get supported behavior, I'd be happy.

This issue has now been open for 5 years. Any update on if/when this will get prioritized for a fix please?

miloush commented 1 month ago

My update is that I feel less strongly about compatibility here. Without any public API change there are two options:

1) Always use original culture (current behavior) 2) Always use user-overridden culture

If 1. goes wrong, there is nothing a user can do, application developer has to fix it (and there isn't a supported way to do that). If 2. goes wrong, the user has ability to workaround it by changing their settings.

The risks are further minimized by several non-trivial requirements that have to happen together:

And for those applications, it can only break things for users for which things are already broken.