Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions App.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
<ResourceDictionary.MergedDictionaries>
<ui:ThemesDictionary Theme="Dark"/>
<ui:ControlsDictionary/>
<ResourceDictionary Source="Locales/en-US.xaml"/>
<ResourceDictionary Source="Themes/FluentDark.xaml"/>
</ResourceDictionary.MergedDictionaries>
<helpers:BytesToMbConverter x:Key="BytesToMbConverter"/>
Expand Down
2 changes: 2 additions & 0 deletions App.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -220,10 +220,12 @@ protected override void OnStartup(StartupEventArgs e)
{
var settingsService = this.ServiceProvider.GetRequiredService<IApplicationSettingsService>();
var themeService = this.ServiceProvider.GetRequiredService<IThemeService>();
var localizationService = this.ServiceProvider.GetRequiredService<ILocalizationService>();

Task.Run(async () => await settingsService.LoadSettingsAsync()).GetAwaiter().GetResult();
var settings = settingsService.Settings;
loadedSettings = settings;
localizationService.ApplyLanguage(settings.Language);
effectiveStartMinimized = startMinimized || settings.StartMinimized;
var useDarkTheme = settings.HasUserThemePreference
? settings.UseDarkTheme
Expand Down
529 changes: 529 additions & 0 deletions Locales/en-US.xaml

Large diffs are not rendered by default.

529 changes: 529 additions & 0 deletions Locales/zh-CN.xaml

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions Models/ApplicationSettingsModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,9 @@ public partial class ApplicationSettingsModel : ObservableObject, IModel
[ObservableProperty]
private bool hasUserThemePreference = false;

[ObservableProperty]
private string language = LocalizationService.DefaultLanguage;

// Monitoring Settings
[ObservableProperty]
private int pollingIntervalMs = 5000;
Expand Down Expand Up @@ -249,6 +252,7 @@ public void CopyFrom(ApplicationSettingsModel other)
this.ClearMasksOnClose = other.ClearMasksOnClose;
this.UseDarkTheme = other.UseDarkTheme;
this.HasUserThemePreference = other.HasUserThemePreference;
this.Language = LocalizationService.NormalizeLanguage(other.Language);

// Monitoring Settings
this.PollingIntervalMs = other.PollingIntervalMs;
Expand Down
20 changes: 11 additions & 9 deletions Services/ApplicationSettingsService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -246,16 +246,18 @@ public void ValidateAndFixSettings()
this.settings.MaxNotificationHistoryItems = 1000;
}

// Validate custom icon path
if (this.settings.UseCustomTrayIcon && !string.IsNullOrEmpty(this.settings.CustomTrayIconPath))
{
if (!File.Exists(this.settings.CustomTrayIconPath))
{
// Validate custom icon path
if (this.settings.UseCustomTrayIcon && !string.IsNullOrEmpty(this.settings.CustomTrayIconPath))
{
if (!File.Exists(this.settings.CustomTrayIconPath))
{
this.logger.LogWarning("Custom tray icon file not found: {Path}", this.settings.CustomTrayIconPath);
this.settings.UseCustomTrayIcon = false;
}
}
}
this.settings.UseCustomTrayIcon = false;
}
}

this.settings.Language = LocalizationService.NormalizeLanguage(this.settings.Language);
}

public async Task ExportSettingsAsync(string filePath)
{
Expand Down
44 changes: 44 additions & 0 deletions Services/ILocalizationService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
/*
* ThreadPilot - Advanced Windows Process and Power Plan Manager
* Copyright (C) 2025 Prime Build
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, version 3 only.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
namespace ThreadPilot.Services
{
/// <summary>
/// Service for managing application localization and display language.
/// </summary>
public interface ILocalizationService
{
/// <summary>
/// Gets the current display language.
/// </summary>
string CurrentLanguage { get; }

/// <summary>
/// Event fired when the active language changes.
/// </summary>
event EventHandler<string>? LanguageChanged;

/// <summary>
/// Applies the specified display language.
/// </summary>
void ApplyLanguage(string? language);

/// <summary>
/// Gets the localized string for the specified key.
/// </summary>
string GetString(string key);
}
}
269 changes: 269 additions & 0 deletions Services/LocalizationService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,269 @@
/*
* ThreadPilot - Advanced Windows Process and Power Plan Manager
* Copyright (C) 2025 Prime Build
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, version 3 only.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
namespace ThreadPilot.Services
{
using System;
using System.Collections.Generic;
using System.Windows;
using Microsoft.Extensions.Logging;

/// <summary>
/// Service for managing application localization and display language.
/// </summary>
public class LocalizationService : ILocalizationService
{
public const string DefaultLanguage = "en-US";
public const string SimplifiedChineseLanguage = "zh-CN";

private const string EnUsDictionaryPath = "Locales/en-US.xaml";
private const string ZhCnDictionaryPath = "Locales/zh-CN.xaml";

private readonly ILogger<LocalizationService> logger;
private readonly IReadOnlyDictionary<string, string>? englishStrings;
private readonly IReadOnlyDictionary<string, string>? chineseStrings;
private ResourceDictionary? activeLocaleDictionary;
private ResourceDictionary? englishFallbackDictionary;
private Uri? activeLocaleUri;

public string CurrentLanguage { get; private set; } = DefaultLanguage;

public event EventHandler<string>? LanguageChanged;

public LocalizationService(ILogger<LocalizationService> logger)
: this(logger, englishStrings: null, chineseStrings: null)
{
}

public LocalizationService(
ILogger<LocalizationService> logger,
IReadOnlyDictionary<string, string>? englishStrings,
IReadOnlyDictionary<string, string>? chineseStrings)
{
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
this.englishStrings = englishStrings;
this.chineseStrings = chineseStrings;
}

public static string NormalizeLanguage(string? language)
{
if (string.Equals(language, SimplifiedChineseLanguage, StringComparison.OrdinalIgnoreCase))
{
return SimplifiedChineseLanguage;
}

return DefaultLanguage;
}

public void ApplyLanguage(string? language)
{
var normalizedLanguage = NormalizeLanguage(language);
var targetUri = new Uri(GetDictionaryPath(normalizedLanguage), UriKind.Relative);

this.CurrentLanguage = normalizedLanguage;

var appResources = System.Windows.Application.Current?.Resources;
if (appResources == null)
{
this.activeLocaleUri = targetUri;
this.LanguageChanged?.Invoke(this, normalizedLanguage);
return;
}

try
{
this.ApplyLanguageDictionary(appResources, targetUri);
this.activeLocaleUri = targetUri;
this.logger.LogInformation("Applied display language {Language}", normalizedLanguage);
this.LanguageChanged?.Invoke(this, normalizedLanguage);
}
catch (Exception ex)
{
this.logger.LogError(ex, "Failed to apply language {Language}", normalizedLanguage);
}
}

public string GetString(string key)
{
if (string.IsNullOrWhiteSpace(key))
{
return string.Empty;
}

if (this.TryGetStringFromOverrides(this.CurrentLanguage, key, out var localized))
{
return localized;
}

if (this.TryGetStringFromApplicationResources(key, out localized))
{
return localized;
}

if (this.activeLocaleDictionary != null && TryGetString(this.activeLocaleDictionary, key, out localized))
{
return localized;
}

if (this.CurrentLanguage != DefaultLanguage &&
this.TryGetStringFromOverrides(DefaultLanguage, key, out localized))
{
return localized;
}

if (this.CurrentLanguage != DefaultLanguage &&
this.TryGetStringFromEnglishFallbackDictionary(key, out localized))
{
return localized;
}

return key;
}

private void ApplyLanguageDictionary(ResourceDictionary appResources, Uri targetUri)
{
ResourceDictionary? matchingDictionary = null;
for (var i = appResources.MergedDictionaries.Count - 1; i >= 0; i--)
{
var dictionary = appResources.MergedDictionaries[i];
var source = dictionary.Source?.OriginalString;
if (IsLocaleDictionary(source))
{
if (matchingDictionary == null &&
string.Equals(source, targetUri.OriginalString, StringComparison.OrdinalIgnoreCase))
{
matchingDictionary = dictionary;
continue;
}

appResources.MergedDictionaries.RemoveAt(i);
}
}

if (matchingDictionary != null)
{
appResources.MergedDictionaries.Remove(matchingDictionary);
appResources.MergedDictionaries.Insert(0, matchingDictionary);
this.activeLocaleDictionary = matchingDictionary;
}
else
{
var nextDictionary = new ResourceDictionary { Source = targetUri };
appResources.MergedDictionaries.Insert(0, nextDictionary);
this.activeLocaleDictionary = nextDictionary;
}
}

private static string GetDictionaryPath(string language)
{
return language == SimplifiedChineseLanguage ? ZhCnDictionaryPath : EnUsDictionaryPath;
}

private static bool IsLocaleDictionary(string? source)
{
return !string.IsNullOrWhiteSpace(source) &&
(source.EndsWith(EnUsDictionaryPath, StringComparison.OrdinalIgnoreCase) ||
source.EndsWith(ZhCnDictionaryPath, StringComparison.OrdinalIgnoreCase));
}

private static bool TryGetString(ResourceDictionary dictionary, string key, out string value)
{
if (dictionary.Contains(key) && dictionary[key] is string text && !string.IsNullOrEmpty(text))
{
value = text;
return true;
}

value = string.Empty;
return false;
}

private bool TryGetStringFromOverrides(string language, string key, out string value)
{
var source = language == SimplifiedChineseLanguage ? this.chineseStrings : this.englishStrings;
if (source != null && source.TryGetValue(key, out var text) && !string.IsNullOrEmpty(text))
{
value = text;
return true;
}

value = string.Empty;
return false;
}

private bool TryGetStringFromApplicationResources(string key, out string value)
{
value = string.Empty;
var app = System.Windows.Application.Current;
if (app == null)
{
return false;
}

try
{
if (app.Dispatcher.CheckAccess())
{
return TryGetApplicationResourceValue(app, key, out value);
}

var found = false;
var dispatcherValue = string.Empty;
app.Dispatcher.Invoke(() =>
{
found = TryGetApplicationResourceValue(app, key, out dispatcherValue);
});
value = dispatcherValue;
return found;
}
catch (Exception ex)
{
this.logger.LogDebug(ex, "Failed to read localized resource {Key}", key);
return false;
}
}

private static bool TryGetApplicationResourceValue(System.Windows.Application app, string key, out string value)
{
if (app.Resources.Contains(key) && app.Resources[key] is string text && !string.IsNullOrEmpty(text))
{
value = text;
return true;
}

value = string.Empty;
return false;
}

private bool TryGetStringFromEnglishFallbackDictionary(string key, out string value)
{
value = string.Empty;
try
{
this.englishFallbackDictionary ??= new ResourceDictionary
{
Source = new Uri(EnUsDictionaryPath, UriKind.Relative),
};
return TryGetString(this.englishFallbackDictionary, key, out value);
}
catch (Exception ex)
{
this.logger.LogDebug(ex, "Failed to load English fallback localization dictionary");
return false;
}
}
}
}
Loading
Loading