moved to MVVM architecture
This commit is contained in:
+3
-4
@@ -1,9 +1,8 @@
|
|||||||
<Application x:Class="CursorLang.App"
|
<Application x:Class="CursorLang.App"
|
||||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
xmlns:local="clr-namespace:CursorLang"
|
ShutdownMode="OnMainWindowClose">
|
||||||
StartupUri="MainWindow.xaml">
|
|
||||||
<Application.Resources>
|
<Application.Resources>
|
||||||
|
|
||||||
</Application.Resources>
|
</Application.Resources>
|
||||||
</Application>
|
</Application>
|
||||||
|
|||||||
+44
-3
@@ -1,10 +1,51 @@
|
|||||||
using System.Windows;
|
using System.Windows;
|
||||||
|
using CursorLang.Services;
|
||||||
|
using CursorLang.ViewModels;
|
||||||
|
using CursorLang.Views;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
|
||||||
namespace CursorLang;
|
namespace CursorLang;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Interaction logic for App.xaml
|
/// Композиционный корень: собирает контейнер и запускает главное окно.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public partial class App : Application
|
public partial class App : Application
|
||||||
{
|
{
|
||||||
}
|
private ServiceProvider? _services;
|
||||||
|
|
||||||
|
protected override void OnStartup(StartupEventArgs e)
|
||||||
|
{
|
||||||
|
base.OnStartup(e);
|
||||||
|
|
||||||
|
var services = new ServiceCollection();
|
||||||
|
ConfigureServices(services);
|
||||||
|
_services = services.BuildServiceProvider();
|
||||||
|
|
||||||
|
MainWindow = _services.GetRequiredService<MainWindow>();
|
||||||
|
MainWindow.Show();
|
||||||
|
|
||||||
|
_services.GetRequiredService<IKeyboardLayoutService>().Start();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void OnExit(ExitEventArgs e)
|
||||||
|
{
|
||||||
|
// Контейнер сам остановит таймеры и закроет окно подсказки
|
||||||
|
_services?.Dispose();
|
||||||
|
base.OnExit(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ConfigureServices(IServiceCollection services)
|
||||||
|
{
|
||||||
|
services.AddSingleton(new KeyboardLayoutOptions());
|
||||||
|
services.AddSingleton(new PopupOptions());
|
||||||
|
|
||||||
|
services.AddSingleton<IKeyboardLayoutService, KeyboardLayoutService>();
|
||||||
|
services.AddSingleton<ILayoutPopupService, LayoutPopupService>();
|
||||||
|
|
||||||
|
services.AddSingleton<LayoutPopupViewModel>();
|
||||||
|
services.AddSingleton<MainViewModel>();
|
||||||
|
|
||||||
|
services.AddSingleton<LayoutPopupWindow>();
|
||||||
|
services.AddSingleton<MainWindow>();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,4 +8,9 @@
|
|||||||
<UseWPF>true</UseWPF>
|
<UseWPF>true</UseWPF>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.2" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.10" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
using System.Runtime.InteropServices;
|
||||||
|
|
||||||
|
namespace CursorLang.Interop;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Win32 API для определения раскладки активного окна.
|
||||||
|
/// </summary>
|
||||||
|
internal static class KeyboardLayoutNative
|
||||||
|
{
|
||||||
|
[DllImport("user32.dll")]
|
||||||
|
internal static extern IntPtr GetForegroundWindow();
|
||||||
|
|
||||||
|
[DllImport("user32.dll")]
|
||||||
|
private static extern uint GetWindowThreadProcessId(IntPtr hWnd, IntPtr lpdwProcessId);
|
||||||
|
|
||||||
|
[DllImport("user32.dll")]
|
||||||
|
private static extern IntPtr GetKeyboardLayout(uint idThread);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Идентификатор локали для окна. Раскладка в Windows привязана к потоку,
|
||||||
|
/// поэтому так её видно у любого приложения, а не только у своего.
|
||||||
|
/// </summary>
|
||||||
|
internal static int GetLocaleIdOf(IntPtr hWnd)
|
||||||
|
{
|
||||||
|
uint threadId = GetWindowThreadProcessId(hWnd, IntPtr.Zero);
|
||||||
|
return (int)GetKeyboardLayout(threadId).ToInt64() & 0xFFFF;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
using System.Runtime.InteropServices;
|
||||||
|
|
||||||
|
namespace CursorLang.Interop;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Win32 API для окна-подсказки: стили, позиционирование у курсора
|
||||||
|
/// и масштаб монитора, на котором курсор находится.
|
||||||
|
/// </summary>
|
||||||
|
internal static class PopupWindowNative
|
||||||
|
{
|
||||||
|
[StructLayout(LayoutKind.Sequential)]
|
||||||
|
internal struct Point
|
||||||
|
{
|
||||||
|
public int X;
|
||||||
|
public int Y;
|
||||||
|
}
|
||||||
|
|
||||||
|
[DllImport("user32.dll")]
|
||||||
|
private static extern bool GetCursorPos(out Point lpPoint);
|
||||||
|
|
||||||
|
[DllImport("user32.dll", SetLastError = true)]
|
||||||
|
private static extern int GetWindowLong(IntPtr hWnd, int nIndex);
|
||||||
|
|
||||||
|
[DllImport("user32.dll")]
|
||||||
|
private static extern int SetWindowLong(IntPtr hWnd, int nIndex, int dwNewLong);
|
||||||
|
|
||||||
|
[DllImport("user32.dll")]
|
||||||
|
private static extern bool SetWindowPos(IntPtr hWnd, IntPtr hWndInsertAfter,
|
||||||
|
int X, int Y, int cx, int cy, uint uFlags);
|
||||||
|
|
||||||
|
[DllImport("user32.dll")]
|
||||||
|
private static extern IntPtr MonitorFromPoint(Point pt, uint dwFlags);
|
||||||
|
|
||||||
|
[DllImport("shcore.dll")]
|
||||||
|
private static extern int GetDpiForMonitor(IntPtr hMonitor, int dpiType, out uint dpiX, out uint dpiY);
|
||||||
|
|
||||||
|
private const int GWL_EXSTYLE = -20;
|
||||||
|
// Окно не забирает фокус у активного приложения
|
||||||
|
private const int WS_EX_NOACTIVATE = 0x08000000;
|
||||||
|
// И не попадает в Alt+Tab
|
||||||
|
private const int WS_EX_TOOLWINDOW = 0x00000080;
|
||||||
|
|
||||||
|
private const uint SWP_NOSIZE = 0x0001;
|
||||||
|
private const uint SWP_NOZORDER = 0x0004;
|
||||||
|
private const uint SWP_NOACTIVATE = 0x0010;
|
||||||
|
|
||||||
|
private const uint MONITOR_DEFAULTTONEAREST = 2;
|
||||||
|
private const int MDT_EFFECTIVE_DPI = 0;
|
||||||
|
|
||||||
|
internal static Point GetCursorPosition()
|
||||||
|
{
|
||||||
|
GetCursorPos(out Point cursor);
|
||||||
|
return cursor;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Подсказка всплывает поверх чужих приложений, поэтому она не должна
|
||||||
|
/// ни активироваться сама, ни отбирать фокус ввода у активного окна.
|
||||||
|
/// </summary>
|
||||||
|
internal static void MakePassive(IntPtr hWnd)
|
||||||
|
{
|
||||||
|
int exStyle = GetWindowLong(hWnd, GWL_EXSTYLE);
|
||||||
|
SetWindowLong(hWnd, GWL_EXSTYLE, exStyle | WS_EX_NOACTIVATE | WS_EX_TOOLWINDOW);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Двигает окно в точку экрана, не меняя размер и порядок окон.
|
||||||
|
/// Координаты — физические пиксели: у мониторов разный масштаб, а
|
||||||
|
/// Window.Left/Top пересчитываются по DPI того монитора, где окно сейчас,
|
||||||
|
/// и на соседнем мониторе дают промах.
|
||||||
|
/// </summary>
|
||||||
|
internal static void MoveTo(IntPtr hWnd, int x, int y)
|
||||||
|
{
|
||||||
|
SetWindowPos(hWnd, IntPtr.Zero, x, y, 0, 0, SWP_NOSIZE | SWP_NOZORDER | SWP_NOACTIVATE);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Масштаб монитора, на котором находится точка (1.0 при 96 DPI).</summary>
|
||||||
|
internal static double GetScaleAt(Point point)
|
||||||
|
{
|
||||||
|
IntPtr monitor = MonitorFromPoint(point, MONITOR_DEFAULTTONEAREST);
|
||||||
|
if (GetDpiForMonitor(monitor, MDT_EFFECTIVE_DPI, out uint dpiX, out _) < 0)
|
||||||
|
{
|
||||||
|
return 1.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return dpiX / 96.0;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,149 +0,0 @@
|
|||||||
using System.Runtime.InteropServices;
|
|
||||||
using System.Windows;
|
|
||||||
using System.Windows.Interop;
|
|
||||||
using System.Windows.Threading;
|
|
||||||
|
|
||||||
namespace CursorLang;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Всплывающая подсказка у курсора с коротким именем раскладки.
|
|
||||||
/// </summary>
|
|
||||||
public partial class LayoutPopupWindow : Window
|
|
||||||
{
|
|
||||||
[StructLayout(LayoutKind.Sequential)]
|
|
||||||
private struct Point
|
|
||||||
{
|
|
||||||
public int X;
|
|
||||||
public int Y;
|
|
||||||
}
|
|
||||||
|
|
||||||
[DllImport("user32.dll")]
|
|
||||||
private static extern bool GetCursorPos(out Point lpPoint);
|
|
||||||
|
|
||||||
[DllImport("user32.dll", SetLastError = true)]
|
|
||||||
private static extern int GetWindowLong(IntPtr hWnd, int nIndex);
|
|
||||||
|
|
||||||
[DllImport("user32.dll")]
|
|
||||||
private static extern int SetWindowLong(IntPtr hWnd, int nIndex, int dwNewLong);
|
|
||||||
|
|
||||||
[DllImport("user32.dll")]
|
|
||||||
private static extern bool SetWindowPos(IntPtr hWnd, IntPtr hWndInsertAfter,
|
|
||||||
int X, int Y, int cx, int cy, uint uFlags);
|
|
||||||
|
|
||||||
[DllImport("user32.dll")]
|
|
||||||
private static extern IntPtr MonitorFromPoint(Point pt, uint dwFlags);
|
|
||||||
|
|
||||||
[DllImport("shcore.dll")]
|
|
||||||
private static extern int GetDpiForMonitor(IntPtr hMonitor, int dpiType, out uint dpiX, out uint dpiY);
|
|
||||||
|
|
||||||
private const int GWL_EXSTYLE = -20;
|
|
||||||
// Окно не забирает фокус у активного приложения
|
|
||||||
private const int WS_EX_NOACTIVATE = 0x08000000;
|
|
||||||
// И не попадает в Alt+Tab
|
|
||||||
private const int WS_EX_TOOLWINDOW = 0x00000080;
|
|
||||||
|
|
||||||
private const uint SWP_NOSIZE = 0x0001;
|
|
||||||
private const uint SWP_NOZORDER = 0x0004;
|
|
||||||
private const uint SWP_NOACTIVATE = 0x0010;
|
|
||||||
|
|
||||||
private const uint MONITOR_DEFAULTTONEAREST = 2;
|
|
||||||
private const int MDT_EFFECTIVE_DPI = 0;
|
|
||||||
|
|
||||||
// Отступ от курсора, чтобы подсказка не оказалась под ним.
|
|
||||||
// Задан в единицах WPF и пересчитывается в пиксели того монитора,
|
|
||||||
// на котором сейчас курсор
|
|
||||||
private const double CursorOffset = 16;
|
|
||||||
|
|
||||||
private readonly DispatcherTimer _hideTimer;
|
|
||||||
private Point _cursor;
|
|
||||||
|
|
||||||
public LayoutPopupWindow()
|
|
||||||
{
|
|
||||||
InitializeComponent();
|
|
||||||
|
|
||||||
_hideTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(500) };
|
|
||||||
_hideTimer.Tick += (_, _) =>
|
|
||||||
{
|
|
||||||
_hideTimer.Stop();
|
|
||||||
Hide();
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Хэндл создан, но окно ещё не отрисовано: здесь и настраиваем стили,
|
|
||||||
// и ставим окно на место, чтобы первый показ прошёл уже на нужном мониторе
|
|
||||||
protected override void OnSourceInitialized(EventArgs e)
|
|
||||||
{
|
|
||||||
base.OnSourceInitialized(e);
|
|
||||||
|
|
||||||
ApplyPassiveWindowStyles();
|
|
||||||
MoveToCursor();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Показывает подсказку у курсора и скрывает её по таймеру.
|
|
||||||
/// </summary>
|
|
||||||
public void ShowAt(string text)
|
|
||||||
{
|
|
||||||
LayoutText.Text = text;
|
|
||||||
ResizeToContent();
|
|
||||||
GetCursorPos(out _cursor);
|
|
||||||
|
|
||||||
// Для уже созданного окна двигаем до показа; при самом первом вызове
|
|
||||||
// хэндла ещё нет, и позиционирование выполнит OnSourceInitialized
|
|
||||||
MoveToCursor();
|
|
||||||
Show();
|
|
||||||
|
|
||||||
// Перезапускаем таймер, чтобы быстрые переключения продлевали показ
|
|
||||||
_hideTimer.Stop();
|
|
||||||
_hideTimer.Start();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Размер считаем сами, а не через SizeToContent: тот вычисляет его при создании
|
|
||||||
// окна, когда содержимое ещё не измерено, и первый показ выходит шире текста.
|
|
||||||
// Рамок у окна нет, поэтому его размер равен размеру содержимого
|
|
||||||
private void ResizeToContent()
|
|
||||||
{
|
|
||||||
var content = (FrameworkElement)Content;
|
|
||||||
content.Measure(new Size(double.PositiveInfinity, double.PositiveInfinity));
|
|
||||||
|
|
||||||
Width = content.DesiredSize.Width;
|
|
||||||
Height = content.DesiredSize.Height;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Подсказка всплывает поверх чужих приложений, поэтому она не должна
|
|
||||||
// ни активироваться сама, ни отбирать фокус ввода у активного окна
|
|
||||||
private void ApplyPassiveWindowStyles()
|
|
||||||
{
|
|
||||||
IntPtr handle = new WindowInteropHelper(this).Handle;
|
|
||||||
int exStyle = GetWindowLong(handle, GWL_EXSTYLE);
|
|
||||||
SetWindowLong(handle, GWL_EXSTYLE, exStyle | WS_EX_NOACTIVATE | WS_EX_TOOLWINDOW);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Позиционируем нативно, в физических пикселях: у мониторов разный масштаб,
|
|
||||||
// а Window.Left/Top пересчитываются по DPI того монитора, где окно сейчас,
|
|
||||||
// и на соседнем мониторе дают промах
|
|
||||||
private void MoveToCursor()
|
|
||||||
{
|
|
||||||
IntPtr handle = new WindowInteropHelper(this).Handle;
|
|
||||||
if (handle == IntPtr.Zero)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
int offset = (int)Math.Round(CursorOffset * GetScaleAt(_cursor));
|
|
||||||
SetWindowPos(handle, IntPtr.Zero, _cursor.X + offset, _cursor.Y + offset, 0, 0,
|
|
||||||
SWP_NOSIZE | SWP_NOZORDER | SWP_NOACTIVATE);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Масштаб монитора, на котором находится точка
|
|
||||||
private static double GetScaleAt(Point point)
|
|
||||||
{
|
|
||||||
IntPtr monitor = MonitorFromPoint(point, MONITOR_DEFAULTTONEAREST);
|
|
||||||
if (GetDpiForMonitor(monitor, MDT_EFFECTIVE_DPI, out uint dpiX, out _) < 0)
|
|
||||||
{
|
|
||||||
return 1.0;
|
|
||||||
}
|
|
||||||
|
|
||||||
return dpiX / 96.0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,182 +0,0 @@
|
|||||||
using System.Windows;
|
|
||||||
// ====
|
|
||||||
using System.Windows.Interop;
|
|
||||||
using System.Windows.Threading;
|
|
||||||
using System.Runtime.InteropServices;
|
|
||||||
|
|
||||||
namespace CursorLang;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Interaction logic for MainWindow.xaml
|
|
||||||
/// </summary>
|
|
||||||
public partial class MainWindow : Window
|
|
||||||
{
|
|
||||||
// Импорт нужных Win32 API
|
|
||||||
[DllImport("user32.dll")]
|
|
||||||
private static extern int RegisterWindowMessage(string lpString);
|
|
||||||
|
|
||||||
[DllImport("user32.dll")]
|
|
||||||
private static extern bool RegisterShellHookWindow(IntPtr hWnd);
|
|
||||||
|
|
||||||
[DllImport("user32.dll")]
|
|
||||||
private static extern bool DeregisterShellHookWindow(IntPtr hWnd);
|
|
||||||
|
|
||||||
[DllImport("user32.dll")]
|
|
||||||
private static extern IntPtr GetForegroundWindow();
|
|
||||||
|
|
||||||
[DllImport("user32.dll")]
|
|
||||||
private static extern uint GetWindowThreadProcessId(IntPtr hWnd, IntPtr lpdwProcessId);
|
|
||||||
|
|
||||||
[DllImport("user32.dll")]
|
|
||||||
private static extern IntPtr GetKeyboardLayout(uint idThread);
|
|
||||||
|
|
||||||
// Константы оболочки
|
|
||||||
private const int HShellLanguage = 8;
|
|
||||||
// Сообщение о смене языка ввода в самом окне
|
|
||||||
private const int WmInputLangChange = 0x0051;
|
|
||||||
private int _shellHookMessageId;
|
|
||||||
private IntPtr _windowHandle;
|
|
||||||
private LayoutPopupWindow? _popup;
|
|
||||||
|
|
||||||
// Опрос активного окна: HShellLanguage в Windows 10/11 не приходит, а событийных
|
|
||||||
// способов узнать о смене раскладки в чужом процессе из managed-кода нет
|
|
||||||
private readonly DispatcherTimer _pollTimer = new() { Interval = TimeSpan.FromMilliseconds(150) };
|
|
||||||
private int _lastLocaleId = -1;
|
|
||||||
private IntPtr _lastForegroundWindow;
|
|
||||||
|
|
||||||
public MainWindow()
|
|
||||||
{
|
|
||||||
InitializeComponent();
|
|
||||||
}
|
|
||||||
|
|
||||||
protected override void OnSourceInitialized(EventArgs e)
|
|
||||||
{
|
|
||||||
base.OnSourceInitialized(e);
|
|
||||||
|
|
||||||
// 1. Получаем хэндл нашего окна (для WinForms используйте this.Handle)
|
|
||||||
_windowHandle = new WindowInteropHelper(this).Handle;
|
|
||||||
|
|
||||||
// 2. Регистрируем уникальное сообщение SHELLHOOK
|
|
||||||
_shellHookMessageId = RegisterWindowMessage("SHELLHOOK");
|
|
||||||
|
|
||||||
// 3. Подписываем наше окно на получение событий оболочки
|
|
||||||
RegisterShellHookWindow(_windowHandle);
|
|
||||||
|
|
||||||
// 4. Добавляем обработчик сообщений окна (WndProc)
|
|
||||||
HwndSource? source = HwndSource.FromHwnd(_windowHandle);
|
|
||||||
if (source != null) source.AddHook(WndProc);
|
|
||||||
|
|
||||||
// 5. Готовим всплывающее окно у курсора
|
|
||||||
_popup = new LayoutPopupWindow();
|
|
||||||
|
|
||||||
// 6. Показываем раскладку, активную на момент запуска (без всплывающего окна)
|
|
||||||
_lastForegroundWindow = GetForegroundWindow();
|
|
||||||
HandleLayout(GetLayoutOf(_lastForegroundWindow), showPopup: false);
|
|
||||||
|
|
||||||
// 7. Запускаем опрос активного окна
|
|
||||||
_pollTimer.Tick += PollForegroundLayout;
|
|
||||||
_pollTimer.Start();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Раскладка потока, которому принадлежит окно
|
|
||||||
private static int GetLayoutOf(IntPtr hWnd)
|
|
||||||
{
|
|
||||||
uint threadId = GetWindowThreadProcessId(hWnd, IntPtr.Zero);
|
|
||||||
return (int)GetKeyboardLayout(threadId).ToInt64() & 0xFFFF;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void PollForegroundLayout(object? sender, EventArgs e)
|
|
||||||
{
|
|
||||||
IntPtr foreground = GetForegroundWindow();
|
|
||||||
if (foreground == IntPtr.Zero)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Смена активного приложения сама по себе не является переключением
|
|
||||||
// раскладки пользователем, поэтому подсказку в этом случае не показываем
|
|
||||||
bool appSwitched = foreground != _lastForegroundWindow;
|
|
||||||
_lastForegroundWindow = foreground;
|
|
||||||
|
|
||||||
HandleLayout(GetLayoutOf(foreground), showPopup: !appSwitched);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static System.Globalization.CultureInfo? GetCulture(int localeId)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
return new System.Globalization.CultureInfo(localeId);
|
|
||||||
}
|
|
||||||
catch (System.Globalization.CultureNotFoundException)
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Обновление лейбла с текущей раскладкой
|
|
||||||
private void UpdateLabel(int localeId)
|
|
||||||
{
|
|
||||||
var culture = GetCulture(localeId);
|
|
||||||
LayoutLabel.Content = culture is null
|
|
||||||
? $"0x{localeId:X4}"
|
|
||||||
: $"{culture.TwoLetterISOLanguageName.ToUpperInvariant()} — {culture.NativeName}";
|
|
||||||
}
|
|
||||||
|
|
||||||
// Единая точка обработки: лейбл в окне и подсказка у курсора.
|
|
||||||
// Повторные сообщения об одной и той же раскладке отсекаются,
|
|
||||||
// так как событие может прийти и от хука, и от таймера
|
|
||||||
private void HandleLayout(int localeId, bool showPopup)
|
|
||||||
{
|
|
||||||
if (localeId == _lastLocaleId)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
_lastLocaleId = localeId;
|
|
||||||
UpdateLabel(localeId);
|
|
||||||
|
|
||||||
if (showPopup)
|
|
||||||
{
|
|
||||||
var culture = GetCulture(localeId);
|
|
||||||
string shortName = culture?.TwoLetterISOLanguageName.ToUpperInvariant() ?? $"0x{localeId:X4}";
|
|
||||||
_popup?.ShowAt(shortName);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Перехватчик сообщений Windows
|
|
||||||
private IntPtr WndProc(IntPtr hwnd, int msg, IntPtr wParam, IntPtr lParam, ref bool handled)
|
|
||||||
{
|
|
||||||
// Если пришло сообщение от Shell
|
|
||||||
if (msg == _shellHookMessageId)
|
|
||||||
{
|
|
||||||
// Проверяем, что это именно событие смены языка
|
|
||||||
if (wParam.ToInt32() == HShellLanguage)
|
|
||||||
{
|
|
||||||
// Раскладка изменилась!
|
|
||||||
// lParam содержит дескриптор клавиатуры (HKL), можно извлечь язык напрямую,
|
|
||||||
// либо вызвать метод из прошлого ответа для активного окна
|
|
||||||
|
|
||||||
int localeId = (int)lParam.ToInt64() & 0xFFFF;
|
|
||||||
|
|
||||||
// Здесь показываем UI возле курсора
|
|
||||||
HandleLayout(localeId, showPopup: true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Смена языка, когда фокус ввода находится в нашем окне:
|
|
||||||
// shell hook в этом случае события не присылает
|
|
||||||
else if (msg == WmInputLangChange)
|
|
||||||
{
|
|
||||||
HandleLayout((int)lParam.ToInt64() & 0xFFFF, showPopup: true);
|
|
||||||
}
|
|
||||||
return IntPtr.Zero;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected override void OnClosed(EventArgs e)
|
|
||||||
{
|
|
||||||
// Обязательно отписываемся при закрытии
|
|
||||||
_pollTimer.Stop();
|
|
||||||
DeregisterShellHookWindow(_windowHandle);
|
|
||||||
_popup?.Close();
|
|
||||||
base.OnClosed(e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
|
||||||
|
namespace CursorLang.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Раскладка клавиатуры в удобном для отображения виде.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="LocaleId">Идентификатор локали (младшее слово HKL).</param>
|
||||||
|
/// <param name="ShortName">Короткое имя для подсказки у курсора, например «RU».</param>
|
||||||
|
/// <param name="DisplayName">Полное имя, например «RU — русский (Россия)».</param>
|
||||||
|
public sealed record KeyboardLayout(int LocaleId, string ShortName, string DisplayName)
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Строит модель по идентификатору локали. Неизвестные локали не являются
|
||||||
|
/// ошибкой: для них показываем сам идентификатор.
|
||||||
|
/// </summary>
|
||||||
|
public static KeyboardLayout FromLocaleId(int localeId)
|
||||||
|
{
|
||||||
|
CultureInfo? culture = TryGetCulture(localeId);
|
||||||
|
if (culture is null)
|
||||||
|
{
|
||||||
|
string fallback = $"0x{localeId:X4}";
|
||||||
|
return new KeyboardLayout(localeId, fallback, fallback);
|
||||||
|
}
|
||||||
|
|
||||||
|
string shortName = culture.TwoLetterISOLanguageName.ToUpperInvariant();
|
||||||
|
return new KeyboardLayout(localeId, shortName, $"{shortName} — {culture.NativeName}");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static CultureInfo? TryGetCulture(int localeId)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return new CultureInfo(localeId);
|
||||||
|
}
|
||||||
|
catch (CultureNotFoundException)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
namespace CursorLang.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Почему изменилась текущая раскладка.
|
||||||
|
/// </summary>
|
||||||
|
public enum LayoutChangeReason
|
||||||
|
{
|
||||||
|
/// <summary>Пользователь переключил раскладку в активном приложении.</summary>
|
||||||
|
UserSwitched,
|
||||||
|
|
||||||
|
/// <summary>Пользователь перешёл в другое приложение, где своя раскладка.</summary>
|
||||||
|
ApplicationSwitched,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Данные события смены раскладки.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class LayoutChangedEventArgs(KeyboardLayout layout, LayoutChangeReason reason) : EventArgs
|
||||||
|
{
|
||||||
|
public KeyboardLayout Layout { get; } = layout;
|
||||||
|
|
||||||
|
public LayoutChangeReason Reason { get; } = reason;
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
using CursorLang.Models;
|
||||||
|
|
||||||
|
namespace CursorLang.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Следит за раскладкой активного окна — в том числе в чужих приложениях.
|
||||||
|
/// </summary>
|
||||||
|
public interface IKeyboardLayoutService
|
||||||
|
{
|
||||||
|
/// <summary>Раскладка активного окна на текущий момент.</summary>
|
||||||
|
KeyboardLayout Current { get; }
|
||||||
|
|
||||||
|
event EventHandler<LayoutChangedEventArgs>? LayoutChanged;
|
||||||
|
|
||||||
|
void Start();
|
||||||
|
|
||||||
|
void Stop();
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
using CursorLang.Models;
|
||||||
|
|
||||||
|
namespace CursorLang.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Показывает подсказку с раскладкой у курсора.
|
||||||
|
/// </summary>
|
||||||
|
public interface ILayoutPopupService
|
||||||
|
{
|
||||||
|
void Show(KeyboardLayout layout);
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
using System.Windows.Threading;
|
||||||
|
using CursorLang.Interop;
|
||||||
|
using CursorLang.Models;
|
||||||
|
|
||||||
|
namespace CursorLang.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Настройки слежения за раскладкой.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class KeyboardLayoutOptions
|
||||||
|
{
|
||||||
|
/// <summary>Как часто проверять раскладку активного окна.</summary>
|
||||||
|
public TimeSpan PollInterval { get; init; } = TimeSpan.FromMilliseconds(150);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Опрашивает активное окно по таймеру.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Опрос выбран не от простоты: событийных способов узнать о смене раскладки
|
||||||
|
/// в чужом процессе из managed-кода нет. HSHELL_LANGUAGE от RegisterShellHookWindow
|
||||||
|
/// в Windows 10/11 не приходит, а уведомления TSF (ITfLanguageProfileNotifySink)
|
||||||
|
/// сообщают только о смене языка внутри своего процесса — оба варианта проверены
|
||||||
|
/// и не сработали. Один тик — три вызова Win32, читающих данные из памяти ядра.
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class KeyboardLayoutService : IKeyboardLayoutService, IDisposable
|
||||||
|
{
|
||||||
|
private readonly DispatcherTimer _pollTimer;
|
||||||
|
private int _lastLocaleId = -1;
|
||||||
|
private IntPtr _lastForegroundWindow;
|
||||||
|
|
||||||
|
public KeyboardLayoutService(KeyboardLayoutOptions options)
|
||||||
|
{
|
||||||
|
_pollTimer = new DispatcherTimer { Interval = options.PollInterval };
|
||||||
|
_pollTimer.Tick += OnTick;
|
||||||
|
}
|
||||||
|
|
||||||
|
public event EventHandler<LayoutChangedEventArgs>? LayoutChanged;
|
||||||
|
|
||||||
|
public KeyboardLayout Current =>
|
||||||
|
KeyboardLayout.FromLocaleId(KeyboardLayoutNative.GetLocaleIdOf(KeyboardLayoutNative.GetForegroundWindow()));
|
||||||
|
|
||||||
|
public void Start()
|
||||||
|
{
|
||||||
|
_lastForegroundWindow = KeyboardLayoutNative.GetForegroundWindow();
|
||||||
|
_lastLocaleId = KeyboardLayoutNative.GetLocaleIdOf(_lastForegroundWindow);
|
||||||
|
_pollTimer.Start();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Stop() => _pollTimer.Stop();
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
_pollTimer.Stop();
|
||||||
|
_pollTimer.Tick -= OnTick;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnTick(object? sender, EventArgs e)
|
||||||
|
{
|
||||||
|
IntPtr foreground = KeyboardLayoutNative.GetForegroundWindow();
|
||||||
|
if (foreground == IntPtr.Zero)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool appSwitched = foreground != _lastForegroundWindow;
|
||||||
|
_lastForegroundWindow = foreground;
|
||||||
|
|
||||||
|
int localeId = KeyboardLayoutNative.GetLocaleIdOf(foreground);
|
||||||
|
if (localeId == _lastLocaleId)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_lastLocaleId = localeId;
|
||||||
|
|
||||||
|
// Переход в другое приложение со своей раскладкой — не то же самое,
|
||||||
|
// что переключение раскладки пользователем, и подписчики вправе
|
||||||
|
// реагировать на эти случаи по-разному
|
||||||
|
LayoutChangeReason reason = appSwitched
|
||||||
|
? LayoutChangeReason.ApplicationSwitched
|
||||||
|
: LayoutChangeReason.UserSwitched;
|
||||||
|
|
||||||
|
LayoutChanged?.Invoke(this, new LayoutChangedEventArgs(KeyboardLayout.FromLocaleId(localeId), reason));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
using System.Windows.Threading;
|
||||||
|
using CursorLang.Models;
|
||||||
|
using CursorLang.ViewModels;
|
||||||
|
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>
|
||||||
|
/// Управляет временем жизни подсказки: окно отвечает только за показ,
|
||||||
|
/// а решение «когда показать и когда убрать» принимается здесь.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class LayoutPopupService : ILayoutPopupService, IDisposable
|
||||||
|
{
|
||||||
|
private readonly LayoutPopupWindow _window;
|
||||||
|
private readonly LayoutPopupViewModel _viewModel;
|
||||||
|
private readonly DispatcherTimer _hideTimer;
|
||||||
|
|
||||||
|
public LayoutPopupService(LayoutPopupWindow window, LayoutPopupViewModel viewModel, PopupOptions options)
|
||||||
|
{
|
||||||
|
_window = window;
|
||||||
|
_viewModel = viewModel;
|
||||||
|
|
||||||
|
_hideTimer = new DispatcherTimer { Interval = options.Duration };
|
||||||
|
_hideTimer.Tick += OnHideTimerTick;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Show(KeyboardLayout layout)
|
||||||
|
{
|
||||||
|
_viewModel.ShortName = layout.ShortName;
|
||||||
|
_window.ShowAtCursor();
|
||||||
|
|
||||||
|
// Перезапускаем таймер, чтобы быстрые переключения продлевали показ
|
||||||
|
_hideTimer.Stop();
|
||||||
|
_hideTimer.Start();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
_hideTimer.Stop();
|
||||||
|
_hideTimer.Tick -= OnHideTimerTick;
|
||||||
|
_window.Close();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnHideTimerTick(object? sender, EventArgs e)
|
||||||
|
{
|
||||||
|
_hideTimer.Stop();
|
||||||
|
_window.Hide();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
|
|
||||||
|
namespace CursorLang.ViewModels;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Содержимое подсказки у курсора.
|
||||||
|
/// </summary>
|
||||||
|
public sealed partial class LayoutPopupViewModel : ObservableObject
|
||||||
|
{
|
||||||
|
[ObservableProperty]
|
||||||
|
private string _shortName = "—";
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
|
using CursorLang.Models;
|
||||||
|
using CursorLang.Services;
|
||||||
|
|
||||||
|
namespace CursorLang.ViewModels;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Показывает текущую раскладку в главном окне и просит показать подсказку
|
||||||
|
/// у курсора, когда пользователь переключил раскладку сам.
|
||||||
|
/// </summary>
|
||||||
|
public sealed partial class MainViewModel : ObservableObject, IDisposable
|
||||||
|
{
|
||||||
|
private readonly IKeyboardLayoutService _layoutService;
|
||||||
|
private readonly ILayoutPopupService _popupService;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private string _currentLayout;
|
||||||
|
|
||||||
|
public MainViewModel(IKeyboardLayoutService layoutService, ILayoutPopupService popupService)
|
||||||
|
{
|
||||||
|
_layoutService = layoutService;
|
||||||
|
_popupService = popupService;
|
||||||
|
|
||||||
|
_currentLayout = layoutService.Current.DisplayName;
|
||||||
|
_layoutService.LayoutChanged += OnLayoutChanged;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose() => _layoutService.LayoutChanged -= OnLayoutChanged;
|
||||||
|
|
||||||
|
private void OnLayoutChanged(object? sender, LayoutChangedEventArgs e)
|
||||||
|
{
|
||||||
|
CurrentLayout = e.Layout.DisplayName;
|
||||||
|
|
||||||
|
// При переходе в другое приложение раскладка меняется без участия
|
||||||
|
// пользователя, и всплывающая подсказка была бы навязчивой
|
||||||
|
if (e.Reason == LayoutChangeReason.UserSwitched)
|
||||||
|
{
|
||||||
|
_popupService.Show(e.Layout);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,11 @@
|
|||||||
<Window x:Class="CursorLang.LayoutPopupWindow"
|
<Window x:Class="CursorLang.Views.LayoutPopupWindow"
|
||||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
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:vm="clr-namespace:CursorLang.ViewModels"
|
||||||
|
mc:Ignorable="d"
|
||||||
|
d:DataContext="{d:DesignInstance Type=vm:LayoutPopupViewModel}"
|
||||||
WindowStyle="None"
|
WindowStyle="None"
|
||||||
ResizeMode="NoResize"
|
ResizeMode="NoResize"
|
||||||
AllowsTransparency="True"
|
AllowsTransparency="True"
|
||||||
@@ -13,10 +18,9 @@
|
|||||||
<Border Background="#E6202020"
|
<Border Background="#E6202020"
|
||||||
CornerRadius="4"
|
CornerRadius="4"
|
||||||
Padding="10,4">
|
Padding="10,4">
|
||||||
<TextBlock x:Name="LayoutText"
|
<TextBlock Foreground="White"
|
||||||
Foreground="White"
|
|
||||||
FontSize="20"
|
FontSize="20"
|
||||||
FontWeight="SemiBold"
|
FontWeight="SemiBold"
|
||||||
Text="—" />
|
Text="{Binding ShortName}" />
|
||||||
</Border>
|
</Border>
|
||||||
</Window>
|
</Window>
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
using System.Windows;
|
||||||
|
using System.Windows.Interop;
|
||||||
|
using CursorLang.Interop;
|
||||||
|
using CursorLang.Services;
|
||||||
|
using CursorLang.ViewModels;
|
||||||
|
|
||||||
|
namespace CursorLang.Views;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Всплывающая подсказка у курсора с коротким именем раскладки.
|
||||||
|
/// Отвечает только за показ: когда её убрать, решает <see cref="LayoutPopupService"/>.
|
||||||
|
/// </summary>
|
||||||
|
public partial class LayoutPopupWindow : Window
|
||||||
|
{
|
||||||
|
private readonly double _cursorOffset;
|
||||||
|
private PopupWindowNative.Point _cursor;
|
||||||
|
|
||||||
|
public LayoutPopupWindow(LayoutPopupViewModel viewModel, PopupOptions options)
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
|
||||||
|
DataContext = viewModel;
|
||||||
|
_cursorOffset = options.CursorOffset;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Показывает подсказку у текущего положения курсора.
|
||||||
|
/// </summary>
|
||||||
|
public void ShowAtCursor()
|
||||||
|
{
|
||||||
|
ResizeToContent();
|
||||||
|
_cursor = PopupWindowNative.GetCursorPosition();
|
||||||
|
|
||||||
|
// Для уже созданного окна двигаем до показа; при самом первом вызове
|
||||||
|
// хэндла ещё нет, и позиционирование выполнит OnSourceInitialized
|
||||||
|
MoveToCursor();
|
||||||
|
Show();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Хэндл создан, но окно ещё не отрисовано: здесь и настраиваем стили,
|
||||||
|
// и ставим окно на место, чтобы первый показ прошёл уже на нужном мониторе
|
||||||
|
protected override void OnSourceInitialized(EventArgs e)
|
||||||
|
{
|
||||||
|
base.OnSourceInitialized(e);
|
||||||
|
|
||||||
|
PopupWindowNative.MakePassive(new WindowInteropHelper(this).Handle);
|
||||||
|
MoveToCursor();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Размер считаем сами, а не через SizeToContent: тот вычисляет его при создании
|
||||||
|
// окна, когда содержимое ещё не измерено, и первый показ выходит шире текста.
|
||||||
|
// Рамок у окна нет, поэтому его размер равен размеру содержимого
|
||||||
|
private void ResizeToContent()
|
||||||
|
{
|
||||||
|
var content = (FrameworkElement)Content;
|
||||||
|
content.Measure(new Size(double.PositiveInfinity, double.PositiveInfinity));
|
||||||
|
|
||||||
|
Width = content.DesiredSize.Width;
|
||||||
|
Height = content.DesiredSize.Height;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void MoveToCursor()
|
||||||
|
{
|
||||||
|
IntPtr handle = new WindowInteropHelper(this).Handle;
|
||||||
|
if (handle == IntPtr.Zero)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Отступ задан в единицах WPF, а двигаем окно в пикселях: пересчитываем
|
||||||
|
// его по масштабу монитора, на котором сейчас курсор
|
||||||
|
int offset = (int)Math.Round(_cursorOffset * PopupWindowNative.GetScaleAt(_cursor));
|
||||||
|
PopupWindowNative.MoveTo(handle, _cursor.X + offset, _cursor.Y + offset);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,15 +1,16 @@
|
|||||||
<Window x:Class="CursorLang.MainWindow"
|
<Window x:Class="CursorLang.Views.MainWindow"
|
||||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||||
|
xmlns:vm="clr-namespace:CursorLang.ViewModels"
|
||||||
mc:Ignorable="d"
|
mc:Ignorable="d"
|
||||||
|
d:DataContext="{d:DesignInstance Type=vm:MainViewModel}"
|
||||||
Title="MainWindow" Height="450" Width="800">
|
Title="MainWindow" Height="450" Width="800">
|
||||||
<Grid>
|
<Grid>
|
||||||
<Label x:Name="LayoutLabel"
|
<Label HorizontalAlignment="Center"
|
||||||
HorizontalAlignment="Center"
|
|
||||||
VerticalAlignment="Center"
|
VerticalAlignment="Center"
|
||||||
FontSize="48"
|
FontSize="48"
|
||||||
Content="—" />
|
Content="{Binding CurrentLayout}" />
|
||||||
</Grid>
|
</Grid>
|
||||||
</Window>
|
</Window>
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
using System.Windows;
|
||||||
|
using CursorLang.ViewModels;
|
||||||
|
|
||||||
|
namespace CursorLang.Views;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Главное окно: показывает текущую раскладку.
|
||||||
|
/// </summary>
|
||||||
|
public partial class MainWindow : Window
|
||||||
|
{
|
||||||
|
public MainWindow(MainViewModel viewModel)
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
DataContext = viewModel;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user