diff --git a/WheelWizard.Test/Features/Localization/PluralRulesTests.cs b/WheelWizard.Test/Features/Localization/PluralRulesTests.cs
new file mode 100644
index 000000000..f2632dbc8
--- /dev/null
+++ b/WheelWizard.Test/Features/Localization/PluralRulesTests.cs
@@ -0,0 +1,81 @@
+using System.Globalization;
+using WheelWizard.Localization;
+
+namespace WheelWizard.Test.Features.Localization;
+
+[Collection("SettingsFeature")]
+public class PluralRulesTests
+{
+ [Theory]
+ [InlineData("1", PluralCategory.One)]
+ [InlineData("1.0", PluralCategory.One)]
+ [InlineData("0", PluralCategory.Other)]
+ [InlineData("2", PluralCategory.Other)]
+ [InlineData("1.5", PluralCategory.Other)]
+ [InlineData("-1", PluralCategory.Other)]
+ [InlineData("21", PluralCategory.Other)]
+ public void SelectsSimpleCategory(string count, PluralCategory expected) =>
+ Assert.Equal(expected, PluralRules.Select(decimal.Parse(count, CultureInfo.InvariantCulture)));
+
+ [Fact]
+ public void GlobalTranslationSupportsNamedAndPositionalCountsAndLanguageFallback()
+ {
+ var previous = LocalizationProvider.Current;
+ var service = new EmbeddedYamlLocalizationService(typeof(PluralRulesTests).Assembly);
+ LocalizationProvider.Use(service);
+ try
+ {
+ Assert.Equal("One item for Alex", TranslationFunctions.t("items", count: 1, "Alex"));
+ Assert.Equal("2 items for Alex", TranslationFunctions.t("items", 2, "Alex"));
+ Assert.Equal("Value 3", TranslationFunctions.t("plain", 3));
+ service.SetLanguage("ru");
+ Assert.Equal("ru other", TranslationFunctions.t("items", count: 22));
+ Assert.Equal("English other", TranslationFunctions.t("fallback", count: 22));
+ Assert.Equal("English other", TranslationFunctions.t("en.fallback", count: 0));
+ }
+ finally
+ {
+ LocalizationProvider.Use(previous);
+ }
+ }
+
+ [Theory]
+ [InlineData("pt", "pt other")]
+ [InlineData("pt-BR", "pt other")]
+ [InlineData("pt-PT", "pt other")]
+ [InlineData("PT_pt", "pt other")]
+ public void GlobalTranslationUsesTheSameRuleForEveryRegion(string locale, string expected)
+ {
+ var previous = LocalizationProvider.Current;
+ var service = new EmbeddedYamlLocalizationService(typeof(PluralRulesTests).Assembly);
+ LocalizationProvider.Use(service);
+ try
+ {
+ Assert.Equal(expected, TranslationFunctions.t($"{locale}.items", count: 0));
+ Assert.Equal("English other", TranslationFunctions.t($"{locale}.fallback", count: 0));
+ Assert.Equal("pt one", TranslationFunctions.t($"{locale}.items", count: 1));
+ service.SetLanguage(locale);
+ Assert.Equal(expected, TranslationFunctions.t("items", count: 0));
+ }
+ finally
+ {
+ LocalizationProvider.Use(previous);
+ }
+ }
+
+ [Fact]
+ public void LegacyNumericKeysRemainUnchanged()
+ {
+ var previous = LocalizationProvider.Current;
+ LocalizationProvider.Use(new EmbeddedYamlLocalizationService());
+ try
+ {
+ Assert.Equal("1 day", TranslationFunctions.t_legacy("en.time.days.n", 1));
+ Assert.Equal("2 days", TranslationFunctions.t_legacy("en.time.days.n", 2));
+ }
+ finally
+ {
+ LocalizationProvider.Use(previous);
+ }
+ }
+}
diff --git a/WheelWizard.Test/Features/Settings/SettingsTests.cs b/WheelWizard.Test/Features/Settings/SettingsTests.cs
index 2aa3245bf..fe0e1ae20 100644
--- a/WheelWizard.Test/Features/Settings/SettingsTests.cs
+++ b/WheelWizard.Test/Features/Settings/SettingsTests.cs
@@ -349,8 +349,8 @@ public void TranslationFunction_UsesSpecificNumberVariant_WhenItExists()
{
localizationService.SetLanguage("en");
- Assert.Equal("1 day", TranslationFunctions.t("time.days.n", 1));
- Assert.Equal("2 days", TranslationFunctions.t("time.days.n", 2));
+ Assert.Equal("1 day", TranslationFunctions.t_legacy("time.days.n", 1));
+ Assert.Equal("2 days", TranslationFunctions.t_legacy("time.days.n", 2));
}
finally
{
diff --git a/WheelWizard.Test/Resources/Languages/plural-fixture.yml b/WheelWizard.Test/Resources/Languages/plural-fixture.yml
new file mode 100644
index 000000000..b820e5b1a
--- /dev/null
+++ b/WheelWizard.Test/Resources/Languages/plural-fixture.yml
@@ -0,0 +1,16 @@
+en:
+ items:
+ one: "One item for {$2}"
+ other: "{$1} items for {$2}"
+ fallback:
+ one: "English one"
+ other: "English other"
+ plain: "Value {$1}"
+ru:
+ items:
+ one: "ru one"
+ few: "ru few"
+ many: "ru many"
+ other: "ru other"
+ fallback:
+ one: "ru one"
diff --git a/WheelWizard.Test/Resources/Languages/pt.yml b/WheelWizard.Test/Resources/Languages/pt.yml
new file mode 100644
index 000000000..197276ccf
--- /dev/null
+++ b/WheelWizard.Test/Resources/Languages/pt.yml
@@ -0,0 +1,4 @@
+pt:
+ items:
+ one: "pt one"
+ other: "pt other"
diff --git a/WheelWizard.Test/WheelWizard.Test.csproj b/WheelWizard.Test/WheelWizard.Test.csproj
index 191070897..a7fc24521 100644
--- a/WheelWizard.Test/WheelWizard.Test.csproj
+++ b/WheelWizard.Test/WheelWizard.Test.csproj
@@ -34,4 +34,7 @@
+
+
+
diff --git a/WheelWizard/Features/Localization/EmbeddedYamlLocalizationService.cs b/WheelWizard/Features/Localization/EmbeddedYamlLocalizationService.cs
index d0897b193..92e58b84d 100644
--- a/WheelWizard/Features/Localization/EmbeddedYamlLocalizationService.cs
+++ b/WheelWizard/Features/Localization/EmbeddedYamlLocalizationService.cs
@@ -13,7 +13,7 @@ public sealed class EmbeddedYamlLocalizationService : ILocalizationService
public EmbeddedYamlLocalizationService()
: this(typeof(EmbeddedYamlLocalizationService).Assembly) { }
- internal EmbeddedYamlLocalizationService(Assembly resourceAssembly)
+ public EmbeddedYamlLocalizationService(Assembly resourceAssembly)
{
_translations = LoadTranslations(resourceAssembly);
if (_translations.Count == 0)
@@ -49,6 +49,22 @@ public string Translate(string key)
return TranslateForLanguage(key, CurrentLanguage);
}
+ public string TranslatePlural(string key, decimal count, string? languageCode = null)
+ {
+ var language = NormalizeLanguage(languageCode ?? CurrentLanguage);
+ var category = PluralRules.Select(count).ToString().ToLowerInvariant();
+ foreach (var candidate in new[] { language, DefaultLanguage }.Distinct())
+ {
+ if (
+ TryGetValue(candidate, $"{key}.{category}", out var value)
+ || TryGetValue(candidate, $"{key}.other", out value)
+ || TryGetValue(candidate, key, out value)
+ )
+ return value;
+ }
+ return key;
+ }
+
public string TranslateForLanguage(string key, string languageCode)
{
if (string.IsNullOrWhiteSpace(key))
diff --git a/WheelWizard/Features/Localization/ILocalizationService.cs b/WheelWizard/Features/Localization/ILocalizationService.cs
index 85a49afe9..5c3540aff 100644
--- a/WheelWizard/Features/Localization/ILocalizationService.cs
+++ b/WheelWizard/Features/Localization/ILocalizationService.cs
@@ -7,6 +7,7 @@ public interface ILocalizationService
void SetLanguage(string languageCode);
string Translate(string key);
+ string TranslatePlural(string key, decimal count, string? languageCode = null);
string TranslateForLanguage(string key, string languageCode);
bool TryTranslateForLanguage(string key, string languageCode, out string value);
bool HasLanguage(string languageCode);
diff --git a/WheelWizard/Features/Localization/PluralRules.cs b/WheelWizard/Features/Localization/PluralRules.cs
new file mode 100644
index 000000000..a777eb52f
--- /dev/null
+++ b/WheelWizard/Features/Localization/PluralRules.cs
@@ -0,0 +1,13 @@
+namespace WheelWizard.Localization;
+
+public enum PluralCategory
+{
+ One,
+ Other,
+}
+
+public static class PluralRules
+{
+ // Every language uses the same simple rule, including decimal counts such as 1.0.
+ public static PluralCategory Select(decimal count) => count == 1 ? PluralCategory.One : PluralCategory.Other;
+}
diff --git a/WheelWizard/Features/Localization/TranslationFunctions.cs b/WheelWizard/Features/Localization/TranslationFunctions.cs
index e000a2caf..4b87418fa 100644
--- a/WheelWizard/Features/Localization/TranslationFunctions.cs
+++ b/WheelWizard/Features/Localization/TranslationFunctions.cs
@@ -2,8 +2,44 @@ namespace WheelWizard.Localization;
public static class TranslationFunctions
{
-#pragma warning disable IDE1006 // Naming Styles
public static string t(string key, params object?[] args)
+ {
+ if (args.Length > 0 && TryCount(args[0], out var count))
+ return TranslateCount(key, count, args);
+ var prefixed = TrySplitLanguageKey(key, out var language, out var translationKey);
+ return Format(
+ prefixed ? LocalizationProvider.TranslateForLanguage(translationKey, language) : LocalizationProvider.Translate(translationKey),
+ args
+ );
+ }
+
+ /// Count supplies {$1}; additional arguments supply {$2}, {$3}, etc.
+ public static string t(string key, decimal count, params object?[] args) => TranslateCount(key, count, [count, .. args]);
+
+ private static string TranslateCount(string key, decimal count, object?[] args)
+ {
+ var prefixed = TrySplitLanguageKey(key, out var language, out var translationKey);
+ return Format(LocalizationProvider.Current.TranslatePlural(translationKey, count, prefixed ? language : null), args);
+ }
+
+ private static bool TryCount(object? value, out decimal count)
+ {
+ count = 0;
+ if (value is not (sbyte or byte or short or ushort or int or uint or long or ulong or float or double or decimal))
+ return false;
+ try
+ {
+ count = Convert.ToDecimal(value, System.Globalization.CultureInfo.InvariantCulture);
+ return true;
+ }
+ catch (OverflowException)
+ {
+ return false;
+ }
+ }
+
+#pragma warning disable IDE1006 // Naming Styles
+ public static string t_legacy(string key, params object?[] args)
#pragma warning restore IDE1006 // Naming Styles
{
var hasLanguagePrefix = TrySplitLanguageKey(key, out var languageCode, out var translationKey);
@@ -33,11 +69,11 @@ public static string tTime(TimeSpan timeSpan)
{
var days = timeSpan.Days;
var hours = timeSpan.Hours;
- var dayText = t("time.days.n", days);
+ var dayText = t_legacy("time.days.n", days);
if (hours == 0)
return dayText;
- var hourText = t("time.hours.n", hours);
+ var hourText = t_legacy("time.hours.n", hours);
return $"{dayText} {hourText}";
}
@@ -45,11 +81,11 @@ public static string tTime(TimeSpan timeSpan)
{
var hours = timeSpan.Hours;
var minutes = timeSpan.Minutes;
- var hourText = t("time.hours.n", hours);
+ var hourText = t_legacy("time.hours.n", hours);
if (minutes == 0)
return hourText;
- var minuteText = t("time.minutes.n", minutes);
+ var minuteText = t_legacy("time.minutes.n", minutes);
return $"{hourText} {minuteText}";
}
@@ -57,15 +93,15 @@ public static string tTime(TimeSpan timeSpan)
{
var minutes = timeSpan.Minutes;
var seconds = timeSpan.Seconds;
- var minuteText = t("time.minutes.n", minutes);
+ var minuteText = t_legacy("time.minutes.n", minutes);
if (seconds == 0)
return minuteText;
- var secondText = t("time.seconds.n", seconds);
+ var secondText = t_legacy("time.seconds.n", seconds);
return $"{minuteText} {secondText}";
}
- return t("time.seconds.n", timeSpan.Seconds);
+ return t_legacy("time.seconds.n", timeSpan.Seconds);
}
private static string ResolveNumberVariant(string translationKey, string languageCode, object?[] args)
diff --git a/WheelWizard/Views/Layout.axaml.cs b/WheelWizard/Views/Layout.axaml.cs
index 5c11b8f1a..e558bfe8a 100644
--- a/WheelWizard/Views/Layout.axaml.cs
+++ b/WheelWizard/Views/Layout.axaml.cs
@@ -268,7 +268,7 @@ public void UpdateFriendCount()
{
var friends = GameLicenseService.ActiveCurrentFriends;
FriendsButton.BoxText = $"{friends.Count(friend => friend.IsOnline)}/{friends.Count}";
- FriendsButton.BoxTip = t("hover.friends_online.n", friends.Count(friend => friend.IsOnline));
+ FriendsButton.BoxTip = t_legacy("hover.friends_online.n", friends.Count(friend => friend.IsOnline));
}
public void UpdateSidebarProfile()
@@ -292,7 +292,7 @@ public void UpdatePlayerAndRoomCount(LiveRoomsService sender)
{
var playerCount = sender.PlayerCount;
RoomsButton.BoxText = playerCount.ToString();
- RoomsButton.BoxTip = t("hover.players_online.n", playerCount);
+ RoomsButton.BoxTip = t_legacy("hover.players_online.n", playerCount);
UpdateFriendCount();
}