added multilang and settings

This commit is contained in:
2026-08-08 21:48:16 +05:00
parent 3d614255d8
commit d146fd442a
18 changed files with 1115 additions and 72 deletions
@@ -0,0 +1,21 @@
using System.ComponentModel;
namespace CursorLang.Services;
/// <summary>Язык интерфейса для выбора в настройках.</summary>
/// <param name="Code">Код культуры: «ru», «en».</param>
/// <param name="DisplayName">Название на самом этом языке.</param>
public sealed record LanguageOption(string Code, string DisplayName);
/// <summary>
/// Даёт строки интерфейса и умеет менять язык без перезапуска.
/// </summary>
public interface ILocalizationService : INotifyPropertyChanged
{
/// <summary>Строка по ключу ресурса. Привязки обновляются при смене языка.</summary>
string this[string key] { get; }
IReadOnlyList<LanguageOption> AvailableLanguages { get; }
string CurrentLanguage { get; set; }
}
@@ -0,0 +1,41 @@
using CursorLang.Models;
namespace CursorLang.Services;
/// <summary>
/// Связывает слежение за раскладкой с показом подсказки.
/// Живёт всё время работы приложения независимо от открытых окон.
/// </summary>
public sealed class LayoutNotificationCoordinator : IDisposable
{
private readonly IKeyboardLayoutService _layoutService;
private readonly ILayoutPopupService _popupService;
public LayoutNotificationCoordinator(IKeyboardLayoutService layoutService, ILayoutPopupService popupService)
{
_layoutService = layoutService;
_popupService = popupService;
}
public void Start()
{
_layoutService.LayoutChanged += OnLayoutChanged;
_layoutService.Start();
}
public void Dispose()
{
_layoutService.LayoutChanged -= OnLayoutChanged;
_layoutService.Stop();
}
private void OnLayoutChanged(object? sender, LayoutChangedEventArgs e)
{
// При переходе в другое приложение раскладка меняется без участия
// пользователя, и всплывающая подсказка была бы навязчивой
if (e.Reason == LayoutChangeReason.UserSwitched)
{
_popupService.Show(e.Layout);
}
}
}
+8 -17
View File
@@ -5,18 +5,6 @@ using CursorLang.Views;
namespace CursorLang.Services;
/// <summary>
/// Настройки подсказки у курсора.
/// </summary>
public sealed class PopupOptions
{
/// <summary>Сколько подсказка держится на экране после последнего переключения.</summary>
public TimeSpan Duration { get; init; } = TimeSpan.FromMilliseconds(500);
/// <summary>Отступ от курсора в единицах WPF, чтобы подсказка не оказалась под ним.</summary>
public double CursorOffset { get; init; } = 16;
}
/// <summary>
/// Управляет временем жизни подсказки: окно отвечает только за показ,
/// а решение «когда показать и когда убрать» принимается здесь.
@@ -25,24 +13,27 @@ public sealed class LayoutPopupService : ILayoutPopupService, IDisposable
{
private readonly LayoutPopupWindow _window;
private readonly LayoutPopupViewModel _viewModel;
private readonly DispatcherTimer _hideTimer;
private readonly AppSettings _settings;
private readonly DispatcherTimer _hideTimer = new();
public LayoutPopupService(LayoutPopupWindow window, LayoutPopupViewModel viewModel, PopupOptions options)
public LayoutPopupService(LayoutPopupWindow window, LayoutPopupViewModel viewModel, AppSettings settings)
{
_window = window;
_viewModel = viewModel;
_settings = settings;
_hideTimer = new DispatcherTimer { Interval = options.Duration };
_hideTimer.Tick += OnHideTimerTick;
}
public void Show(KeyboardLayout layout)
{
_viewModel.ShortName = layout.ShortName;
_window.ShowAtCursor();
_window.ShowPopup();
// Перезапускаем таймер, чтобы быстрые переключения продлевали показ
// Длительность читаем при каждом показе: её меняют в настройках на лету.
// Перезапуск таймера заодно продлевает показ при быстрых переключениях
_hideTimer.Stop();
_hideTimer.Interval = _settings.Duration;
_hideTimer.Start();
}
@@ -0,0 +1,46 @@
using System.Globalization;
using System.Resources;
using System.Windows.Data;
using CommunityToolkit.Mvvm.ComponentModel;
namespace CursorLang.Services;
/// <summary>
/// Берёт строки из ресурсов и при смене языка просит WPF перечитать все привязки.
/// </summary>
public sealed class LocalizationService : ObservableObject, ILocalizationService
{
private static readonly ResourceManager Resources =
new("CursorLang.Resources.Strings", typeof(App).Assembly);
private CultureInfo _culture = CultureInfo.GetCultureInfo("en");
public string this[string key] => Resources.GetString(key, _culture) ?? key;
public IReadOnlyList<LanguageOption> AvailableLanguages { get; } =
[
new LanguageOption("en", "English"),
new LanguageOption("ru", "Русский"),
];
public string CurrentLanguage
{
get => _culture.TwoLetterISOLanguageName;
set
{
if (string.IsNullOrWhiteSpace(value) || value == CurrentLanguage)
{
return;
}
_culture = CultureInfo.GetCultureInfo(value);
CultureInfo.CurrentUICulture = _culture;
OnPropertyChanged(nameof(CurrentLanguage));
// Сообщаем об изменении индексатора: так обновляются все привязки
// вида {Binding Localization[Key]}, то есть весь текст интерфейса
OnPropertyChanged(Binding.IndexerName);
}
}
}
+143
View File
@@ -0,0 +1,143 @@
using System.ComponentModel;
using System.IO;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Windows.Media;
using System.Windows.Threading;
using CursorLang.Models;
namespace CursorLang.Services;
/// <summary>
/// Хранит настройки в %APPDATA%\CursorLang\settings.json.
/// </summary>
public sealed class SettingsService : IDisposable
{
private static readonly JsonSerializerOptions SerializerOptions = new()
{
WriteIndented = true,
Converters = { new ColorJsonConverter(), new JsonStringEnumConverter() },
};
private readonly string _filePath;
private readonly DispatcherTimer _saveTimer;
private AppSettings? _settings;
public SettingsService()
{
string folder = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"CursorLang");
_filePath = Path.Combine(folder, "settings.json");
// Ползунки меняют значения непрерывно, поэтому запись на диск
// откладывается до паузы в изменениях
_saveTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(500) };
_saveTimer.Tick += OnSaveTimerTick;
}
/// <summary>
/// Читает настройки с диска либо отдаёт значения по умолчанию,
/// и дальше сам сохраняет любые изменения.
/// </summary>
public AppSettings Load()
{
_settings = ReadFile() ?? CreateDefault();
_settings.PropertyChanged += OnSettingsChanged;
return _settings;
}
public void Save()
{
if (_settings is null)
{
return;
}
try
{
Directory.CreateDirectory(Path.GetDirectoryName(_filePath)!);
File.WriteAllText(_filePath, JsonSerializer.Serialize(_settings, SerializerOptions));
}
catch (Exception e) when (e is IOException or UnauthorizedAccessException)
{
// Настройки — не тот случай, ради которого стоит ронять приложение
}
}
public void Dispose()
{
_saveTimer.Stop();
_saveTimer.Tick -= OnSaveTimerTick;
if (_settings is not null)
{
_settings.PropertyChanged -= OnSettingsChanged;
Save();
}
}
private AppSettings? ReadFile()
{
try
{
if (!File.Exists(_filePath))
{
return null;
}
return JsonSerializer.Deserialize<AppSettings>(File.ReadAllText(_filePath), SerializerOptions);
}
catch (Exception e) when (e is IOException or UnauthorizedAccessException or JsonException)
{
// Испорченный файл не должен мешать запуску: начнём с умолчаний
return null;
}
}
// Язык по умолчанию берём системный, если он поддерживается
private static AppSettings CreateDefault()
{
string uiLanguage = System.Globalization.CultureInfo.CurrentUICulture.TwoLetterISOLanguageName;
return new AppSettings { Language = uiLanguage == "ru" ? "ru" : "en" };
}
private void OnSettingsChanged(object? sender, PropertyChangedEventArgs e)
{
_saveTimer.Stop();
_saveTimer.Start();
}
private void OnSaveTimerTick(object? sender, EventArgs e)
{
_saveTimer.Stop();
Save();
}
// Color не сериализуется штатно, а хранить его читаемым в файле удобно
private sealed class ColorJsonConverter : JsonConverter<Color>
{
public override Color Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
string? value = reader.GetString();
if (string.IsNullOrWhiteSpace(value))
{
return Colors.Black;
}
try
{
return (Color)ColorConverter.ConvertFromString(value)!;
}
catch (FormatException)
{
return Colors.Black;
}
}
public override void Write(Utf8JsonWriter writer, Color value, JsonSerializerOptions options)
{
writer.WriteStringValue(value.ToString());
}
}
}