implemeted main feature - first try
This commit is contained in:
@@ -1,6 +1,4 @@
|
||||
using System.Configuration;
|
||||
using System.Data;
|
||||
using System.Windows;
|
||||
using System.Windows;
|
||||
|
||||
namespace CursorLang;
|
||||
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
<Window x:Class="CursorLang.LayoutPopupWindow"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
WindowStyle="None"
|
||||
ResizeMode="NoResize"
|
||||
AllowsTransparency="True"
|
||||
Background="Transparent"
|
||||
ShowInTaskbar="False"
|
||||
ShowActivated="False"
|
||||
Topmost="True"
|
||||
Focusable="False"
|
||||
IsHitTestVisible="False">
|
||||
<Border Background="#E6202020"
|
||||
CornerRadius="4"
|
||||
Padding="10,4">
|
||||
<TextBlock x:Name="LayoutText"
|
||||
Foreground="White"
|
||||
FontSize="20"
|
||||
FontWeight="SemiBold"
|
||||
Text="—" />
|
||||
</Border>
|
||||
</Window>
|
||||
@@ -0,0 +1,149 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,10 @@
|
||||
mc:Ignorable="d"
|
||||
Title="MainWindow" Height="450" Width="800">
|
||||
<Grid>
|
||||
|
||||
<Label x:Name="LayoutLabel"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
FontSize="48"
|
||||
Content="—" />
|
||||
</Grid>
|
||||
</Window>
|
||||
|
||||
+169
-10
@@ -1,13 +1,8 @@
|
||||
using System.Text;
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using System.Windows.Data;
|
||||
using System.Windows.Documents;
|
||||
using System.Windows.Input;
|
||||
using System.Windows.Media;
|
||||
using System.Windows.Media.Imaging;
|
||||
using System.Windows.Navigation;
|
||||
using System.Windows.Shapes;
|
||||
using System.Windows;
|
||||
// ====
|
||||
using System.Windows.Interop;
|
||||
using System.Windows.Threading;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace CursorLang;
|
||||
|
||||
@@ -16,8 +11,172 @@ namespace CursorLang;
|
||||
/// </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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user