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
+11 -5
View File
@@ -1,4 +1,5 @@
using System.Windows;
using CursorLang.Models;
using CursorLang.Services;
using CursorLang.ViewModels;
using CursorLang.Views;
@@ -7,7 +8,7 @@ using Microsoft.Extensions.DependencyInjection;
namespace CursorLang;
/// <summary>
/// Композиционный корень: собирает контейнер и запускает главное окно.
/// Композиционный корень: собирает контейнер и запускает окно настроек.
/// </summary>
public partial class App : Application
{
@@ -24,12 +25,13 @@ public partial class App : Application
MainWindow = _services.GetRequiredService<MainWindow>();
MainWindow.Show();
_services.GetRequiredService<IKeyboardLayoutService>().Start();
_services.GetRequiredService<LayoutNotificationCoordinator>().Start();
}
protected override void OnExit(ExitEventArgs e)
{
// Контейнер сам остановит таймеры и закроет окно подсказки
// Контейнер сам остановит таймеры, сохранит настройки
// и закроет окно подсказки
_services?.Dispose();
base.OnExit(e);
}
@@ -37,13 +39,17 @@ public partial class App : Application
private static void ConfigureServices(IServiceCollection services)
{
services.AddSingleton(new KeyboardLayoutOptions());
services.AddSingleton(new PopupOptions());
services.AddSingleton<SettingsService>();
services.AddSingleton(provider => provider.GetRequiredService<SettingsService>().Load());
services.AddSingleton<ILocalizationService, LocalizationService>();
services.AddSingleton<IKeyboardLayoutService, KeyboardLayoutService>();
services.AddSingleton<ILayoutPopupService, LayoutPopupService>();
services.AddSingleton<LayoutNotificationCoordinator>();
services.AddSingleton<LayoutPopupViewModel>();
services.AddSingleton<MainViewModel>();
services.AddSingleton<SettingsViewModel>();
services.AddSingleton<LayoutPopupWindow>();
services.AddSingleton<MainWindow>();
+48 -2
View File
@@ -18,6 +18,9 @@ internal static class PopupWindowNative
[DllImport("user32.dll")]
private static extern bool GetCursorPos(out Point lpPoint);
[DllImport("user32.dll")]
private static extern IntPtr GetForegroundWindow();
[DllImport("user32.dll", SetLastError = true)]
private static extern int GetWindowLong(IntPtr hWnd, int nIndex);
@@ -34,6 +37,30 @@ internal static class PopupWindowNative
[DllImport("shcore.dll")]
private static extern int GetDpiForMonitor(IntPtr hMonitor, int dpiType, out uint dpiX, out uint dpiY);
[DllImport("user32.dll")]
private static extern IntPtr MonitorFromWindow(IntPtr hWnd, uint dwFlags);
[DllImport("user32.dll")]
private static extern bool GetMonitorInfo(IntPtr hMonitor, ref MonitorInfo lpmi);
[StructLayout(LayoutKind.Sequential)]
internal struct Rect
{
public int Left;
public int Top;
public int Right;
public int Bottom;
}
[StructLayout(LayoutKind.Sequential)]
private struct MonitorInfo
{
public int cbSize;
public Rect rcMonitor;
public Rect rcWork;
public uint dwFlags;
}
private const int GWL_EXSTYLE = -20;
// Окно не забирает фокус у активного приложения
private const int WS_EX_NOACTIVATE = 0x08000000;
@@ -75,9 +102,28 @@ internal static class PopupWindowNative
}
/// <summary>Масштаб монитора, на котором находится точка (1.0 при 96 DPI).</summary>
internal static double GetScaleAt(Point point)
internal static double GetScaleAt(Point point) =>
GetScaleOf(MonitorFromPoint(point, MONITOR_DEFAULTTONEAREST));
/// <summary>
/// Рабочая область монитора с активным окном — без панели задач — и его масштаб.
/// Именно на этом мониторе пользователь сейчас работает.
/// </summary>
internal static (Rect WorkArea, double Scale) GetActiveMonitorWorkArea()
{
IntPtr monitor = MonitorFromWindow(GetForegroundWindow(), MONITOR_DEFAULTTONEAREST);
var info = new MonitorInfo { cbSize = Marshal.SizeOf<MonitorInfo>() };
if (!GetMonitorInfo(monitor, ref info))
{
return (new Rect(), 1.0);
}
return (info.rcWork, GetScaleOf(monitor));
}
private static double GetScaleOf(IntPtr monitor)
{
IntPtr monitor = MonitorFromPoint(point, MONITOR_DEFAULTTONEAREST);
if (GetDpiForMonitor(monitor, MDT_EFFECTIVE_DPI, out uint dpiX, out _) < 0)
{
return 1.0;
+50
View File
@@ -0,0 +1,50 @@
using System.Text.Json.Serialization;
using System.Windows.Media;
using CommunityToolkit.Mvvm.ComponentModel;
namespace CursorLang.Models;
/// <summary>
/// Настройки приложения. Все изменения применяются на лету: подсказка и окно
/// настроек привязаны к этим свойствам, а <c>SettingsService</c> сохраняет их на диск.
/// </summary>
public sealed partial class AppSettings : ObservableObject
{
/// <summary>Язык интерфейса в виде кода культуры: «ru», «en».</summary>
[ObservableProperty]
private string _language = "en";
[ObservableProperty]
private PopupPlacementMode _placementMode = PopupPlacementMode.AtCursor;
[ObservableProperty]
private CursorCorner _cursorCorner = CursorCorner.BottomRight;
/// <summary>Отступ от курсора в единицах WPF.</summary>
[ObservableProperty]
private double _cursorOffset = 16;
[ObservableProperty]
private ScreenPosition _screenPosition = ScreenPosition.BottomRight;
/// <summary>Отступ от края монитора в единицах WPF.</summary>
[ObservableProperty]
private double _screenMargin = 24;
[ObservableProperty]
private double _fontSize = 20;
/// <summary>Непрозрачность подсказки: 1.0 — полностью непрозрачная.</summary>
[ObservableProperty]
private double _opacity = 0.9;
/// <summary>Сколько подсказка держится на экране, в миллисекундах.</summary>
[ObservableProperty]
private double _durationMilliseconds = 500;
[ObservableProperty]
private Color _backgroundColor = Color.FromRgb(0x20, 0x20, 0x20);
[JsonIgnore]
public TimeSpan Duration => TimeSpan.FromMilliseconds(DurationMilliseconds);
}
+36
View File
@@ -0,0 +1,36 @@
namespace CursorLang.Models;
/// <summary>
/// Способ выбора места для подсказки.
/// </summary>
public enum PopupPlacementMode
{
/// <summary>Рядом с курсором мыши.</summary>
AtCursor,
/// <summary>В заданной точке монитора с активным окном.</summary>
FixedPoint,
}
/// <summary>
/// С какой стороны от курсора показывать подсказку.
/// </summary>
public enum CursorCorner
{
BottomRight,
BottomLeft,
TopRight,
TopLeft,
}
/// <summary>
/// Место на мониторе для режима <see cref="PopupPlacementMode.FixedPoint"/>.
/// </summary>
public enum ScreenPosition
{
TopLeft,
TopRight,
BottomLeft,
BottomRight,
Center,
}
+145
View File
@@ -0,0 +1,145 @@
<?xml version="1.0" encoding="utf-8"?>
<root>
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
<xsd:import namespace="http://www.w3.org/XML/1998/namespace" />
<xsd:element name="root" msdata:IsDataSet="true">
<xsd:complexType>
<xsd:choice maxOccurs="unbounded">
<xsd:element name="metadata">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" />
</xsd:sequence>
<xsd:attribute name="name" use="required" type="xsd:string" />
<xsd:attribute name="type" type="xsd:string" />
<xsd:attribute name="mimetype" type="xsd:string" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="assembly">
<xsd:complexType>
<xsd:attribute name="alias" type="xsd:string" />
<xsd:attribute name="name" type="xsd:string" />
</xsd:complexType>
</xsd:element>
<xsd:element name="data">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
<xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" msdata:Ordinal="1" />
<xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" />
<xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="resheader">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" />
</xsd:complexType>
</xsd:element>
</xsd:choice>
</xsd:complexType>
</xsd:element>
</xsd:schema>
<resheader name="resmimetype">
<value>text/microsoft-resx</value>
</resheader>
<resheader name="version">
<value>2.0</value>
</resheader>
<resheader name="reader">
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<data name="SettingsTitle" xml:space="preserve">
<value>CursorLang — Settings</value>
</data>
<data name="SectionInterface" xml:space="preserve">
<value>Interface</value>
</data>
<data name="LanguageLabel" xml:space="preserve">
<value>Interface language</value>
</data>
<data name="SectionPlacement" xml:space="preserve">
<value>Placement</value>
</data>
<data name="PlacementModeLabel" xml:space="preserve">
<value>Mode</value>
</data>
<data name="PopupPlacementMode_AtCursor" xml:space="preserve">
<value>Near the cursor</value>
</data>
<data name="PopupPlacementMode_FixedPoint" xml:space="preserve">
<value>Fixed point on screen</value>
</data>
<data name="CursorCornerLabel" xml:space="preserve">
<value>Side of the cursor</value>
</data>
<data name="CursorCorner_BottomRight" xml:space="preserve">
<value>Bottom right</value>
</data>
<data name="CursorCorner_BottomLeft" xml:space="preserve">
<value>Bottom left</value>
</data>
<data name="CursorCorner_TopRight" xml:space="preserve">
<value>Top right</value>
</data>
<data name="CursorCorner_TopLeft" xml:space="preserve">
<value>Top left</value>
</data>
<data name="CursorOffsetLabel" xml:space="preserve">
<value>Offset from cursor</value>
</data>
<data name="ScreenPositionLabel" xml:space="preserve">
<value>Position on screen</value>
</data>
<data name="ScreenPosition_TopLeft" xml:space="preserve">
<value>Top left</value>
</data>
<data name="ScreenPosition_TopRight" xml:space="preserve">
<value>Top right</value>
</data>
<data name="ScreenPosition_BottomLeft" xml:space="preserve">
<value>Bottom left</value>
</data>
<data name="ScreenPosition_BottomRight" xml:space="preserve">
<value>Bottom right</value>
</data>
<data name="ScreenPosition_Center" xml:space="preserve">
<value>Center</value>
</data>
<data name="ScreenMarginLabel" xml:space="preserve">
<value>Margin from screen edge</value>
</data>
<data name="SectionAppearance" xml:space="preserve">
<value>Appearance</value>
</data>
<data name="FontSizeLabel" xml:space="preserve">
<value>Font size</value>
</data>
<data name="OpacityLabel" xml:space="preserve">
<value>Opacity</value>
</data>
<data name="BackgroundColorLabel" xml:space="preserve">
<value>Background color</value>
</data>
<data name="SectionBehavior" xml:space="preserve">
<value>Behavior</value>
</data>
<data name="DurationLabel" xml:space="preserve">
<value>Display time</value>
</data>
<data name="PreviewLabel" xml:space="preserve">
<value>Preview</value>
</data>
<data name="MillisecondsSuffix" xml:space="preserve">
<value>ms</value>
</data>
</root>
+145
View File
@@ -0,0 +1,145 @@
<?xml version="1.0" encoding="utf-8"?>
<root>
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
<xsd:import namespace="http://www.w3.org/XML/1998/namespace" />
<xsd:element name="root" msdata:IsDataSet="true">
<xsd:complexType>
<xsd:choice maxOccurs="unbounded">
<xsd:element name="metadata">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" />
</xsd:sequence>
<xsd:attribute name="name" use="required" type="xsd:string" />
<xsd:attribute name="type" type="xsd:string" />
<xsd:attribute name="mimetype" type="xsd:string" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="assembly">
<xsd:complexType>
<xsd:attribute name="alias" type="xsd:string" />
<xsd:attribute name="name" type="xsd:string" />
</xsd:complexType>
</xsd:element>
<xsd:element name="data">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
<xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" msdata:Ordinal="1" />
<xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" />
<xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="resheader">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" />
</xsd:complexType>
</xsd:element>
</xsd:choice>
</xsd:complexType>
</xsd:element>
</xsd:schema>
<resheader name="resmimetype">
<value>text/microsoft-resx</value>
</resheader>
<resheader name="version">
<value>2.0</value>
</resheader>
<resheader name="reader">
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<data name="SettingsTitle" xml:space="preserve">
<value>CursorLang — Настройки</value>
</data>
<data name="SectionInterface" xml:space="preserve">
<value>Интерфейс</value>
</data>
<data name="LanguageLabel" xml:space="preserve">
<value>Язык интерфейса</value>
</data>
<data name="SectionPlacement" xml:space="preserve">
<value>Расположение</value>
</data>
<data name="PlacementModeLabel" xml:space="preserve">
<value>Режим</value>
</data>
<data name="PopupPlacementMode_AtCursor" xml:space="preserve">
<value>Рядом с курсором</value>
</data>
<data name="PopupPlacementMode_FixedPoint" xml:space="preserve">
<value>В заданной точке экрана</value>
</data>
<data name="CursorCornerLabel" xml:space="preserve">
<value>Сторона от курсора</value>
</data>
<data name="CursorCorner_BottomRight" xml:space="preserve">
<value>Справа снизу</value>
</data>
<data name="CursorCorner_BottomLeft" xml:space="preserve">
<value>Слева снизу</value>
</data>
<data name="CursorCorner_TopRight" xml:space="preserve">
<value>Справа сверху</value>
</data>
<data name="CursorCorner_TopLeft" xml:space="preserve">
<value>Слева сверху</value>
</data>
<data name="CursorOffsetLabel" xml:space="preserve">
<value>Отступ от курсора</value>
</data>
<data name="ScreenPositionLabel" xml:space="preserve">
<value>Позиция на экране</value>
</data>
<data name="ScreenPosition_TopLeft" xml:space="preserve">
<value>Слева сверху</value>
</data>
<data name="ScreenPosition_TopRight" xml:space="preserve">
<value>Справа сверху</value>
</data>
<data name="ScreenPosition_BottomLeft" xml:space="preserve">
<value>Слева снизу</value>
</data>
<data name="ScreenPosition_BottomRight" xml:space="preserve">
<value>Справа снизу</value>
</data>
<data name="ScreenPosition_Center" xml:space="preserve">
<value>По центру</value>
</data>
<data name="ScreenMarginLabel" xml:space="preserve">
<value>Отступ от края экрана</value>
</data>
<data name="SectionAppearance" xml:space="preserve">
<value>Внешний вид</value>
</data>
<data name="FontSizeLabel" xml:space="preserve">
<value>Размер шрифта</value>
</data>
<data name="OpacityLabel" xml:space="preserve">
<value>Прозрачность</value>
</data>
<data name="BackgroundColorLabel" xml:space="preserve">
<value>Цвет фона</value>
</data>
<data name="SectionBehavior" xml:space="preserve">
<value>Поведение</value>
</data>
<data name="DurationLabel" xml:space="preserve">
<value>Время отображения</value>
</data>
<data name="PreviewLabel" xml:space="preserve">
<value>Предпросмотр</value>
</data>
<data name="MillisecondsSuffix" xml:space="preserve">
<value>мс</value>
</data>
</root>
@@ -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; }
}
@@ -1,36 +1,36 @@
using CommunityToolkit.Mvvm.ComponentModel;
using CursorLang.Models;
using CursorLang.Services;
namespace CursorLang.ViewModels;
namespace CursorLang.Services;
/// <summary>
/// Показывает текущую раскладку в главном окне и просит показать подсказку
/// у курсора, когда пользователь переключил раскладку сам.
/// Связывает слежение за раскладкой с показом подсказки.
/// Живёт всё время работы приложения независимо от открытых окон.
/// </summary>
public sealed partial class MainViewModel : ObservableObject, IDisposable
public sealed class LayoutNotificationCoordinator : IDisposable
{
private readonly IKeyboardLayoutService _layoutService;
private readonly ILayoutPopupService _popupService;
[ObservableProperty]
private string _currentLayout;
public MainViewModel(IKeyboardLayoutService layoutService, ILayoutPopupService popupService)
public LayoutNotificationCoordinator(IKeyboardLayoutService layoutService, ILayoutPopupService popupService)
{
_layoutService = layoutService;
_popupService = popupService;
_currentLayout = layoutService.Current.DisplayName;
_layoutService.LayoutChanged += OnLayoutChanged;
}
public void Dispose() => _layoutService.LayoutChanged -= OnLayoutChanged;
public void Start()
{
_layoutService.LayoutChanged += OnLayoutChanged;
_layoutService.Start();
}
public void Dispose()
{
_layoutService.LayoutChanged -= OnLayoutChanged;
_layoutService.Stop();
}
private void OnLayoutChanged(object? sender, LayoutChangedEventArgs e)
{
CurrentLayout = e.Layout.DisplayName;
// При переходе в другое приложение раскладка меняется без участия
// пользователя, и всплывающая подсказка была бы навязчивой
if (e.Reason == LayoutChangeReason.UserSwitched)
+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());
}
}
}
@@ -1,12 +1,18 @@
using CommunityToolkit.Mvvm.ComponentModel;
using CursorLang.Models;
namespace CursorLang.ViewModels;
/// <summary>
/// Содержимое подсказки у курсора.
/// Содержимое подсказки у курсора. Внешний вид берётся прямо из настроек,
/// поэтому их правка применяется без перезапуска.
/// </summary>
public sealed partial class LayoutPopupViewModel : ObservableObject
{
[ObservableProperty]
private string _shortName = "—";
public LayoutPopupViewModel(AppSettings settings) => Settings = settings;
public AppSettings Settings { get; }
}
@@ -0,0 +1,93 @@
using System.ComponentModel;
using System.Windows.Data;
using System.Windows.Media;
using CommunityToolkit.Mvvm.ComponentModel;
using CursorLang.Models;
using CursorLang.Services;
namespace CursorLang.ViewModels;
/// <summary>Вариант выбора в списке: значение и его подпись на текущем языке.</summary>
public sealed record EnumOption<T>(T Value, string Display);
/// <summary>
/// Окно настроек. Значения правятся прямо в <see cref="AppSettings"/>,
/// поэтому подсказка подхватывает их сразу, без кнопки «Применить».
/// </summary>
public sealed partial class SettingsViewModel : ObservableObject, IDisposable
{
private static readonly Color[] Palette =
[
Color.FromRgb(0x20, 0x20, 0x20),
Color.FromRgb(0x00, 0x00, 0x00),
Color.FromRgb(0x1E, 0x3A, 0x8A),
Color.FromRgb(0x0F, 0x76, 0x6E),
Color.FromRgb(0x7C, 0x2D, 0x12),
Color.FromRgb(0x86, 0x19, 0x8F),
Color.FromRgb(0xB9, 0x1C, 0x1C),
Color.FromRgb(0xF5, 0xF5, 0xF5),
];
public SettingsViewModel(AppSettings settings, ILocalizationService localization)
{
Settings = settings;
Localization = localization;
// Язык интерфейса — такая же настройка, как остальные, и хранится там же
Localization.CurrentLanguage = settings.Language;
Settings.PropertyChanged += OnSettingsChanged;
Localization.PropertyChanged += OnLocalizationChanged;
BuildLocalizedOptions();
}
public AppSettings Settings { get; }
public ILocalizationService Localization { get; }
public IReadOnlyList<Color> BackgroundPalette { get; } = Palette;
public IReadOnlyList<EnumOption<PopupPlacementMode>> PlacementModes { get; private set; } = [];
public IReadOnlyList<EnumOption<CursorCorner>> CursorCorners { get; private set; } = [];
public IReadOnlyList<EnumOption<ScreenPosition>> ScreenPositions { get; private set; } = [];
public void Dispose()
{
Settings.PropertyChanged -= OnSettingsChanged;
Localization.PropertyChanged -= OnLocalizationChanged;
}
private void OnSettingsChanged(object? sender, PropertyChangedEventArgs e)
{
if (e.PropertyName == nameof(AppSettings.Language))
{
Localization.CurrentLanguage = Settings.Language;
}
}
// Подписи вариантов приходят из ресурсов, поэтому при смене языка
// списки нужно собрать заново — привязки сами перечитают их
private void OnLocalizationChanged(object? sender, PropertyChangedEventArgs e)
{
if (e.PropertyName == Binding.IndexerName)
{
BuildLocalizedOptions();
}
}
private void BuildLocalizedOptions()
{
PlacementModes = BuildOptions<PopupPlacementMode>(nameof(PopupPlacementMode));
CursorCorners = BuildOptions<CursorCorner>(nameof(CursorCorner));
ScreenPositions = BuildOptions<ScreenPosition>(nameof(ScreenPosition));
OnPropertyChanged(nameof(PlacementModes));
OnPropertyChanged(nameof(CursorCorners));
OnPropertyChanged(nameof(ScreenPositions));
}
private EnumOption<T>[] BuildOptions<T>(string resourcePrefix) where T : struct, Enum =>
[.. Enum.GetValues<T>().Select(value => new EnumOption<T>(value, Localization[$"{resourcePrefix}_{value}"]))];
}
+44
View File
@@ -0,0 +1,44 @@
using System.Globalization;
using System.Windows;
using System.Windows.Data;
using System.Windows.Media;
namespace CursorLang.Views;
/// <summary>
/// Показывает элемент, только если значение совпадает с параметром.
/// Нужен, чтобы настройки курсора и экрана не показывались одновременно.
/// </summary>
public sealed class EnumToVisibilityConverter : IValueConverter
{
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) =>
value?.ToString() == parameter?.ToString() ? Visibility.Visible : Visibility.Collapsed;
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) =>
Binding.DoNothing;
}
/// <summary>
/// Цвет в запись вида «#RRGGBB»: альфа не показывается, потому что
/// за прозрачность отвечает отдельная настройка.
/// </summary>
public sealed class ColorToHexConverter : IValueConverter
{
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) =>
value is Color color ? $"#{color.R:X2}{color.G:X2}{color.B:X2}" : string.Empty;
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) =>
Binding.DoNothing;
}
/// <summary>
/// Цвет в кисть — для образцов палитры в списке.
/// </summary>
public sealed class ColorToBrushConverter : IValueConverter
{
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) =>
value is Color color ? new SolidColorBrush(color) : Brushes.Transparent;
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) =>
value is SolidColorBrush brush ? brush.Color : Binding.DoNothing;
}
+7 -4
View File
@@ -14,13 +14,16 @@
ShowActivated="False"
Topmost="True"
Focusable="False"
IsHitTestVisible="False">
<Border Background="#E6202020"
CornerRadius="4"
IsHitTestVisible="False"
Opacity="{Binding Settings.Opacity}">
<Border CornerRadius="4"
Padding="10,4">
<Border.Background>
<SolidColorBrush Color="{Binding Settings.BackgroundColor}" />
</Border.Background>
<TextBlock Foreground="White"
FontSize="20"
FontWeight="SemiBold"
FontSize="{Binding Settings.FontSize}"
Text="{Binding ShortName}" />
</Border>
</Window>
+53 -17
View File
@@ -1,39 +1,38 @@
using System.Windows;
using System.Windows.Interop;
using CursorLang.Interop;
using CursorLang.Services;
using CursorLang.Models;
using CursorLang.ViewModels;
namespace CursorLang.Views;
/// <summary>
/// Всплывающая подсказка у курсора с коротким именем раскладки.
/// Отвечает только за показ: когда её убрать, решает <see cref="LayoutPopupService"/>.
/// Всплывающая подсказка с коротким именем раскладки.
/// Отвечает только за показ и место на экране: когда её убрать,
/// решает <see cref="Services.LayoutPopupService"/>.
/// </summary>
public partial class LayoutPopupWindow : Window
{
private readonly double _cursorOffset;
private PopupWindowNative.Point _cursor;
private readonly AppSettings _settings;
public LayoutPopupWindow(LayoutPopupViewModel viewModel, PopupOptions options)
public LayoutPopupWindow(LayoutPopupViewModel viewModel, AppSettings settings)
{
InitializeComponent();
DataContext = viewModel;
_cursorOffset = options.CursorOffset;
_settings = settings;
}
/// <summary>
/// Показывает подсказку у текущего положения курсора.
/// Показывает подсказку в месте, заданном настройками.
/// </summary>
public void ShowAtCursor()
public void ShowPopup()
{
ResizeToContent();
_cursor = PopupWindowNative.GetCursorPosition();
// Для уже созданного окна двигаем до показа; при самом первом вызове
// хэндла ещё нет, и позиционирование выполнит OnSourceInitialized
MoveToCursor();
MoveToTargetPosition();
Show();
}
@@ -44,7 +43,7 @@ public partial class LayoutPopupWindow : Window
base.OnSourceInitialized(e);
PopupWindowNative.MakePassive(new WindowInteropHelper(this).Handle);
MoveToCursor();
MoveToTargetPosition();
}
// Размер считаем сами, а не через SizeToContent: тот вычисляет его при создании
@@ -59,7 +58,7 @@ public partial class LayoutPopupWindow : Window
Height = content.DesiredSize.Height;
}
private void MoveToCursor()
private void MoveToTargetPosition()
{
IntPtr handle = new WindowInteropHelper(this).Handle;
if (handle == IntPtr.Zero)
@@ -67,9 +66,46 @@ public partial class LayoutPopupWindow : Window
return;
}
// Отступ задан в единицах WPF, а двигаем окно в пикселях: пересчитываем
// его по масштабу монитора, на котором сейчас курсор
int offset = (int)Math.Round(_cursorOffset * PopupWindowNative.GetScaleAt(_cursor));
PopupWindowNative.MoveTo(handle, _cursor.X + offset, _cursor.Y + offset);
(int x, int y) = _settings.PlacementMode == PopupPlacementMode.AtCursor
? GetCursorPosition()
: GetScreenPosition();
PopupWindowNative.MoveTo(handle, x, y);
}
// Отступы и размеры заданы в единицах WPF, а окно двигаем в пикселях,
// поэтому всё пересчитывается по масштабу нужного монитора
private (int X, int Y) GetCursorPosition()
{
PopupWindowNative.Point cursor = PopupWindowNative.GetCursorPosition();
double scale = PopupWindowNative.GetScaleAt(cursor);
int offset = (int)Math.Round(_settings.CursorOffset * scale);
bool toRight = _settings.CursorCorner is CursorCorner.BottomRight or CursorCorner.TopRight;
bool toBottom = _settings.CursorCorner is CursorCorner.BottomRight or CursorCorner.BottomLeft;
int x = toRight ? cursor.X + offset : cursor.X - offset - ToPixels(Width, scale);
int y = toBottom ? cursor.Y + offset : cursor.Y - offset - ToPixels(Height, scale);
return (x, y);
}
private (int X, int Y) GetScreenPosition()
{
(PopupWindowNative.Rect work, double scale) = PopupWindowNative.GetActiveMonitorWorkArea();
int margin = (int)Math.Round(_settings.ScreenMargin * scale);
int width = ToPixels(Width, scale);
int height = ToPixels(Height, scale);
return _settings.ScreenPosition switch
{
ScreenPosition.TopLeft => (work.Left + margin, work.Top + margin),
ScreenPosition.TopRight => (work.Right - margin - width, work.Top + margin),
ScreenPosition.BottomLeft => (work.Left + margin, work.Bottom - margin - height),
ScreenPosition.BottomRight => (work.Right - margin - width, work.Bottom - margin - height),
_ => (work.Left + ((work.Right - work.Left - width) / 2),
work.Top + ((work.Bottom - work.Top - height) / 2)),
};
}
private static int ToPixels(double wpfUnits, double scale) => (int)Math.Round(wpfUnits * scale);
}
+240 -8
View File
@@ -3,14 +3,246 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:local="clr-namespace:CursorLang.Views"
xmlns:models="clr-namespace:CursorLang.Models"
xmlns:vm="clr-namespace:CursorLang.ViewModels"
mc:Ignorable="d"
d:DataContext="{d:DesignInstance Type=vm:MainViewModel}"
Title="MainWindow" Height="450" Width="800">
<Grid>
<Label HorizontalAlignment="Center"
VerticalAlignment="Center"
FontSize="48"
Content="{Binding CurrentLayout}" />
</Grid>
d:DataContext="{d:DesignInstance Type=vm:SettingsViewModel}"
Title="{Binding Localization[SettingsTitle]}"
Width="560" SizeToContent="Height" MaxHeight="900"
ResizeMode="CanMinimize">
<Window.Resources>
<local:EnumToVisibilityConverter x:Key="EnumToVisibility" />
<local:ColorToBrushConverter x:Key="ColorToBrush" />
<local:ColorToHexConverter x:Key="ColorToHex" />
<Style TargetType="TextBlock" x:Key="FieldLabel">
<Setter Property="VerticalAlignment" Value="Center" />
<Setter Property="Margin" Value="0,0,12,0" />
</Style>
<Style TargetType="TextBlock" x:Key="FieldValue">
<Setter Property="VerticalAlignment" Value="Center" />
<Setter Property="Margin" Value="12,0,0,0" />
<Setter Property="MinWidth" Value="48" />
<Setter Property="TextAlignment" Value="Right" />
<Setter Property="Foreground" Value="#666" />
</Style>
<Style TargetType="GroupBox">
<Setter Property="Padding" Value="12" />
<Setter Property="Margin" Value="0,0,0,12" />
</Style>
<Style TargetType="ComboBox">
<Setter Property="VerticalAlignment" Value="Center" />
</Style>
<Style TargetType="Slider">
<Setter Property="VerticalAlignment" Value="Center" />
<Setter Property="IsSnapToTickEnabled" Value="True" />
</Style>
</Window.Resources>
<ScrollViewer VerticalScrollBarVisibility="Auto" Padding="16">
<StackPanel>
<GroupBox Header="{Binding Localization[SectionInterface]}">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="180" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<TextBlock Style="{StaticResource FieldLabel}"
Text="{Binding Localization[LanguageLabel]}" />
<ComboBox Grid.Column="1"
ItemsSource="{Binding Localization.AvailableLanguages}"
DisplayMemberPath="DisplayName"
SelectedValuePath="Code"
SelectedValue="{Binding Settings.Language}" />
</Grid>
</GroupBox>
<GroupBox Header="{Binding Localization[SectionPlacement]}">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="180" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<TextBlock Style="{StaticResource FieldLabel}"
Text="{Binding Localization[PlacementModeLabel]}" />
<ComboBox Grid.Column="1" Grid.ColumnSpan="2"
ItemsSource="{Binding PlacementModes}"
DisplayMemberPath="Display"
SelectedValuePath="Value"
SelectedValue="{Binding Settings.PlacementMode}" />
<!-- Режим «у курсора» -->
<TextBlock Grid.Row="1" Margin="0,12,12,0"
Style="{StaticResource FieldLabel}"
Text="{Binding Localization[CursorCornerLabel]}"
Visibility="{Binding Settings.PlacementMode,
Converter={StaticResource EnumToVisibility},
ConverterParameter={x:Static models:PopupPlacementMode.AtCursor}}" />
<ComboBox Grid.Row="1" Grid.Column="1" Grid.ColumnSpan="2" Margin="0,12,0,0"
ItemsSource="{Binding CursorCorners}"
DisplayMemberPath="Display"
SelectedValuePath="Value"
SelectedValue="{Binding Settings.CursorCorner}"
Visibility="{Binding Settings.PlacementMode,
Converter={StaticResource EnumToVisibility},
ConverterParameter={x:Static models:PopupPlacementMode.AtCursor}}" />
<TextBlock Grid.Row="2" Margin="0,12,12,0"
Style="{StaticResource FieldLabel}"
Text="{Binding Localization[CursorOffsetLabel]}"
Visibility="{Binding Settings.PlacementMode,
Converter={StaticResource EnumToVisibility},
ConverterParameter={x:Static models:PopupPlacementMode.AtCursor}}" />
<Slider Grid.Row="2" Grid.Column="1" Margin="0,12,0,0"
Minimum="0" Maximum="80" TickFrequency="1"
Value="{Binding Settings.CursorOffset}"
Visibility="{Binding Settings.PlacementMode,
Converter={StaticResource EnumToVisibility},
ConverterParameter={x:Static models:PopupPlacementMode.AtCursor}}" />
<TextBlock Grid.Row="2" Grid.Column="2" Margin="12,12,0,0"
Style="{StaticResource FieldValue}"
Text="{Binding Settings.CursorOffset, StringFormat={}{0:F0}}"
Visibility="{Binding Settings.PlacementMode,
Converter={StaticResource EnumToVisibility},
ConverterParameter={x:Static models:PopupPlacementMode.AtCursor}}" />
<!-- Режим «фиксированная точка» -->
<TextBlock Grid.Row="3" Margin="0,12,12,0"
Style="{StaticResource FieldLabel}"
Text="{Binding Localization[ScreenPositionLabel]}"
Visibility="{Binding Settings.PlacementMode,
Converter={StaticResource EnumToVisibility},
ConverterParameter={x:Static models:PopupPlacementMode.FixedPoint}}" />
<ComboBox Grid.Row="3" Grid.Column="1" Grid.ColumnSpan="2" Margin="0,12,0,0"
ItemsSource="{Binding ScreenPositions}"
DisplayMemberPath="Display"
SelectedValuePath="Value"
SelectedValue="{Binding Settings.ScreenPosition}"
Visibility="{Binding Settings.PlacementMode,
Converter={StaticResource EnumToVisibility},
ConverterParameter={x:Static models:PopupPlacementMode.FixedPoint}}" />
<TextBlock Grid.Row="4" Margin="0,12,12,0"
Style="{StaticResource FieldLabel}"
Text="{Binding Localization[ScreenMarginLabel]}"
Visibility="{Binding Settings.PlacementMode,
Converter={StaticResource EnumToVisibility},
ConverterParameter={x:Static models:PopupPlacementMode.FixedPoint}}" />
<Slider Grid.Row="4" Grid.Column="1" Margin="0,12,0,0"
Minimum="0" Maximum="200" TickFrequency="1"
Value="{Binding Settings.ScreenMargin}"
Visibility="{Binding Settings.PlacementMode,
Converter={StaticResource EnumToVisibility},
ConverterParameter={x:Static models:PopupPlacementMode.FixedPoint}}" />
<TextBlock Grid.Row="4" Grid.Column="2" Margin="12,12,0,0"
Style="{StaticResource FieldValue}"
Text="{Binding Settings.ScreenMargin, StringFormat={}{0:F0}}"
Visibility="{Binding Settings.PlacementMode,
Converter={StaticResource EnumToVisibility},
ConverterParameter={x:Static models:PopupPlacementMode.FixedPoint}}" />
</Grid>
</GroupBox>
<GroupBox Header="{Binding Localization[SectionAppearance]}">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="180" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<TextBlock Style="{StaticResource FieldLabel}"
Text="{Binding Localization[FontSizeLabel]}" />
<Slider Grid.Column="1" Minimum="10" Maximum="72" TickFrequency="1"
Value="{Binding Settings.FontSize}" />
<TextBlock Grid.Column="2" Style="{StaticResource FieldValue}"
Text="{Binding Settings.FontSize, StringFormat={}{0:F0}}" />
<TextBlock Grid.Row="1" Margin="0,12,12,0"
Style="{StaticResource FieldLabel}"
Text="{Binding Localization[OpacityLabel]}" />
<Slider Grid.Row="1" Grid.Column="1" Margin="0,12,0,0"
Minimum="0.1" Maximum="1" TickFrequency="0.05"
Value="{Binding Settings.Opacity}" />
<TextBlock Grid.Row="1" Grid.Column="2" Margin="12,12,0,0"
Style="{StaticResource FieldValue}"
Text="{Binding Settings.Opacity, StringFormat={}{0:P0}}" />
<TextBlock Grid.Row="2" Margin="0,12,12,0"
Style="{StaticResource FieldLabel}"
Text="{Binding Localization[BackgroundColorLabel]}" />
<ComboBox Grid.Row="2" Grid.Column="1" Grid.ColumnSpan="2" Margin="0,12,0,0"
ItemsSource="{Binding BackgroundPalette}"
SelectedItem="{Binding Settings.BackgroundColor}">
<ComboBox.ItemTemplate>
<DataTemplate>
<StackPanel Orientation="Horizontal">
<Border Width="32" Height="16" CornerRadius="2"
BorderBrush="#888" BorderThickness="1"
Background="{Binding Converter={StaticResource ColorToBrush}}" />
<TextBlock Margin="8,0,0,0"
Text="{Binding Converter={StaticResource ColorToHex}}" />
</StackPanel>
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
<TextBlock Grid.Row="3" Margin="0,12,12,0"
Style="{StaticResource FieldLabel}"
Text="{Binding Localization[PreviewLabel]}" />
<Border Grid.Row="3" Grid.Column="1" Grid.ColumnSpan="2"
Margin="0,12,0,0" Padding="16"
Background="#F0F0F0" CornerRadius="4"
HorizontalAlignment="Stretch">
<Border CornerRadius="4" Padding="10,4"
HorizontalAlignment="Center"
Opacity="{Binding Settings.Opacity}"
Background="{Binding Settings.BackgroundColor,
Converter={StaticResource ColorToBrush}}">
<TextBlock Text="RU" Foreground="White" FontWeight="SemiBold"
FontSize="{Binding Settings.FontSize}" />
</Border>
</Border>
</Grid>
</GroupBox>
<GroupBox Header="{Binding Localization[SectionBehavior]}">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="180" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<TextBlock Style="{StaticResource FieldLabel}"
Text="{Binding Localization[DurationLabel]}" />
<Slider Grid.Column="1" Minimum="200" Maximum="5000" TickFrequency="100"
Value="{Binding Settings.DurationMilliseconds}" />
<StackPanel Grid.Column="2" Orientation="Horizontal" VerticalAlignment="Center">
<TextBlock Style="{StaticResource FieldValue}"
Text="{Binding Settings.DurationMilliseconds, StringFormat={}{0:F0}}" />
<TextBlock Margin="4,0,0,0" Foreground="#666"
Text="{Binding Localization[MillisecondsSuffix]}" />
</StackPanel>
</Grid>
</GroupBox>
</StackPanel>
</ScrollViewer>
</Window>
+2 -2
View File
@@ -4,11 +4,11 @@ using CursorLang.ViewModels;
namespace CursorLang.Views;
/// <summary>
/// Главное окно: показывает текущую раскладку.
/// Окно настроек приложения.
/// </summary>
public partial class MainWindow : Window
{
public MainWindow(MainViewModel viewModel)
public MainWindow(SettingsViewModel viewModel)
{
InitializeComponent();
DataContext = viewModel;