modified PopupLayout
This commit is contained in:
@@ -0,0 +1,21 @@
|
|||||||
|
namespace CursorLang.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The popup window as seen by whoever decides when it is shown.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The window picks its place and size itself, and only three actions are needed from
|
||||||
|
/// it on the outside. The tests check the work of the popup service through the same
|
||||||
|
/// interface: there is no point bringing up a real window to check a timer.
|
||||||
|
/// </remarks>
|
||||||
|
public interface ILayoutPopupWindow
|
||||||
|
{
|
||||||
|
/// <summary>Shows the window at the place set by the settings.</summary>
|
||||||
|
void ShowPopup();
|
||||||
|
|
||||||
|
/// <summary>Takes the window off the screen without destroying it.</summary>
|
||||||
|
void Hide();
|
||||||
|
|
||||||
|
/// <summary>Closes the window for good.</summary>
|
||||||
|
void Close();
|
||||||
|
}
|
||||||
@@ -27,29 +27,54 @@ public sealed class KeyboardLayoutOptions
|
|||||||
public sealed class KeyboardLayoutService : IKeyboardLayoutService, IDisposable
|
public sealed class KeyboardLayoutService : IKeyboardLayoutService, IDisposable
|
||||||
{
|
{
|
||||||
private readonly DispatcherTimer _pollTimer;
|
private readonly DispatcherTimer _pollTimer;
|
||||||
|
private readonly Func<IntPtr> _getForegroundWindow;
|
||||||
|
private readonly Func<int> _getActiveLocaleId;
|
||||||
|
private readonly Action _requestNextLayout;
|
||||||
|
|
||||||
private int _lastLocaleId = -1;
|
private int _lastLocaleId = -1;
|
||||||
private IntPtr _lastForegroundWindow;
|
private IntPtr _lastForegroundWindow;
|
||||||
|
|
||||||
public KeyboardLayoutService(KeyboardLayoutOptions options)
|
public KeyboardLayoutService(KeyboardLayoutOptions options)
|
||||||
|
: this(
|
||||||
|
options,
|
||||||
|
KeyboardLayoutNative.GetForegroundWindow,
|
||||||
|
KeyboardLayoutNative.GetActiveLocaleId,
|
||||||
|
KeyboardLayoutNative.RequestNextLayout)
|
||||||
{
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Takes the sources of system information explicitly: in tests the layout and the
|
||||||
|
/// active window are not provided by Windows.
|
||||||
|
/// </summary>
|
||||||
|
internal KeyboardLayoutService(
|
||||||
|
KeyboardLayoutOptions options,
|
||||||
|
Func<IntPtr> getForegroundWindow,
|
||||||
|
Func<int> getActiveLocaleId,
|
||||||
|
Action requestNextLayout)
|
||||||
|
{
|
||||||
|
_getForegroundWindow = getForegroundWindow;
|
||||||
|
_getActiveLocaleId = getActiveLocaleId;
|
||||||
|
_requestNextLayout = requestNextLayout;
|
||||||
|
|
||||||
_pollTimer = new DispatcherTimer { Interval = options.PollInterval };
|
_pollTimer = new DispatcherTimer { Interval = options.PollInterval };
|
||||||
_pollTimer.Tick += OnTick;
|
_pollTimer.Tick += OnTick;
|
||||||
}
|
}
|
||||||
|
|
||||||
public event EventHandler<LayoutChangedEventArgs>? LayoutChanged;
|
public event EventHandler<LayoutChangedEventArgs>? LayoutChanged;
|
||||||
|
|
||||||
public KeyboardLayout Current => KeyboardLayout.FromLocaleId(KeyboardLayoutNative.GetActiveLocaleId());
|
public KeyboardLayout Current => KeyboardLayout.FromLocaleId(_getActiveLocaleId());
|
||||||
|
|
||||||
public void Start()
|
public void Start()
|
||||||
{
|
{
|
||||||
_lastForegroundWindow = KeyboardLayoutNative.GetForegroundWindow();
|
_lastForegroundWindow = _getForegroundWindow();
|
||||||
_lastLocaleId = KeyboardLayoutNative.GetActiveLocaleId();
|
_lastLocaleId = _getActiveLocaleId();
|
||||||
_pollTimer.Start();
|
_pollTimer.Start();
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Stop() => _pollTimer.Stop();
|
public void Stop() => _pollTimer.Stop();
|
||||||
|
|
||||||
public void SwitchToNext() => KeyboardLayoutNative.RequestNextLayout();
|
public void SwitchToNext() => _requestNextLayout();
|
||||||
|
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
@@ -57,9 +82,14 @@ public sealed class KeyboardLayoutService : IKeyboardLayoutService, IDisposable
|
|||||||
_pollTimer.Tick -= OnTick;
|
_pollTimer.Tick -= OnTick;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OnTick(object? sender, EventArgs e)
|
private void OnTick(object? sender, EventArgs e) => Poll();
|
||||||
|
|
||||||
|
// A single poll step. Called by the timer, and in tests — directly:
|
||||||
|
// there is no point waiting for a tick to check how the reason for a layout
|
||||||
|
// change is decided
|
||||||
|
internal void Poll()
|
||||||
{
|
{
|
||||||
IntPtr foreground = KeyboardLayoutNative.GetForegroundWindow();
|
IntPtr foreground = _getForegroundWindow();
|
||||||
if (foreground == IntPtr.Zero)
|
if (foreground == IntPtr.Zero)
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
@@ -68,7 +98,7 @@ public sealed class KeyboardLayoutService : IKeyboardLayoutService, IDisposable
|
|||||||
bool appSwitched = foreground != _lastForegroundWindow;
|
bool appSwitched = foreground != _lastForegroundWindow;
|
||||||
_lastForegroundWindow = foreground;
|
_lastForegroundWindow = foreground;
|
||||||
|
|
||||||
int localeId = KeyboardLayoutNative.GetActiveLocaleId();
|
int localeId = _getActiveLocaleId();
|
||||||
if (localeId == _lastLocaleId)
|
if (localeId == _lastLocaleId)
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
using System.Windows.Threading;
|
using System.Windows.Threading;
|
||||||
using CursorLang.Models;
|
using CursorLang.Models;
|
||||||
using CursorLang.ViewModels;
|
using CursorLang.ViewModels;
|
||||||
using CursorLang.Views;
|
|
||||||
|
|
||||||
namespace CursorLang.Services;
|
namespace CursorLang.Services;
|
||||||
|
|
||||||
@@ -11,12 +10,12 @@ namespace CursorLang.Services;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class LayoutPopupService : ILayoutPopupService, IDisposable
|
public sealed class LayoutPopupService : ILayoutPopupService, IDisposable
|
||||||
{
|
{
|
||||||
private readonly LayoutPopupWindow _window;
|
private readonly ILayoutPopupWindow _window;
|
||||||
private readonly LayoutPopupViewModel _viewModel;
|
private readonly LayoutPopupViewModel _viewModel;
|
||||||
private readonly AppSettings _settings;
|
private readonly AppSettings _settings;
|
||||||
private readonly DispatcherTimer _hideTimer = new();
|
private readonly DispatcherTimer _hideTimer = new();
|
||||||
|
|
||||||
public LayoutPopupService(LayoutPopupWindow window, LayoutPopupViewModel viewModel, AppSettings settings)
|
public LayoutPopupService(ILayoutPopupWindow window, LayoutPopupViewModel viewModel, AppSettings settings)
|
||||||
{
|
{
|
||||||
_window = window;
|
_window = window;
|
||||||
_viewModel = viewModel;
|
_viewModel = viewModel;
|
||||||
|
|||||||
@@ -103,17 +103,18 @@ public sealed class MainWindowPlacement
|
|||||||
private static PopupWindowNative.Point? CenterOnActiveMonitor(PopupWindowNative.Rect bounds)
|
private static PopupWindowNative.Point? CenterOnActiveMonitor(PopupWindowNative.Rect bounds)
|
||||||
{
|
{
|
||||||
(PopupWindowNative.Rect work, _) = PopupWindowNative.GetActiveMonitorWorkArea();
|
(PopupWindowNative.Rect work, _) = PopupWindowNative.GetActiveMonitorWorkArea();
|
||||||
if (IsEmpty(work))
|
return IsEmpty(work) ? null : Center(bounds, work);
|
||||||
{
|
}
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return new PopupWindowNative.Point
|
/// <summary>
|
||||||
|
/// The point at which a window with the given bounds ends up in the centre of the work area.
|
||||||
|
/// </summary>
|
||||||
|
internal static PopupWindowNative.Point Center(
|
||||||
|
PopupWindowNative.Rect bounds, PopupWindowNative.Rect work) => new()
|
||||||
{
|
{
|
||||||
X = work.Left + (((work.Right - work.Left) - Width(bounds)) / 2),
|
X = work.Left + (((work.Right - work.Left) - Width(bounds)) / 2),
|
||||||
Y = work.Top + (((work.Bottom - work.Top) - Height(bounds)) / 2),
|
Y = work.Top + (((work.Bottom - work.Top) - Height(bounds)) / 2),
|
||||||
};
|
};
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Pulls the window into the work area of the nearest monitor.
|
/// Pulls the window into the work area of the nearest monitor.
|
||||||
@@ -145,6 +146,22 @@ public sealed class MainWindowPlacement
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return Clamp(position, bounds, work);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Pulls the point so that a window with the given bounds fits into the work area
|
||||||
|
/// entirely. A window taller than the work area gets its top edge: the title bar
|
||||||
|
/// is needed more than the lower part of the window.
|
||||||
|
/// </summary>
|
||||||
|
internal static PopupWindowNative.Point Clamp(
|
||||||
|
PopupWindowNative.Point position,
|
||||||
|
PopupWindowNative.Rect bounds,
|
||||||
|
PopupWindowNative.Rect work)
|
||||||
|
{
|
||||||
|
int width = Width(bounds);
|
||||||
|
int height = Height(bounds);
|
||||||
|
|
||||||
return new PopupWindowNative.Point
|
return new PopupWindowNative.Point
|
||||||
{
|
{
|
||||||
X = Math.Clamp(position.X, work.Left, Math.Max(work.Left, work.Right - width)),
|
X = Math.Clamp(position.X, work.Left, Math.Max(work.Left, work.Right - width)),
|
||||||
@@ -156,6 +173,6 @@ public sealed class MainWindowPlacement
|
|||||||
|
|
||||||
private static int Height(PopupWindowNative.Rect rect) => rect.Bottom - rect.Top;
|
private static int Height(PopupWindowNative.Rect rect) => rect.Bottom - rect.Top;
|
||||||
|
|
||||||
private static bool IsEmpty(PopupWindowNative.Rect rect) =>
|
internal static bool IsEmpty(PopupWindowNative.Rect rect) =>
|
||||||
rect.Right <= rect.Left || rect.Bottom <= rect.Top;
|
rect.Right <= rect.Left || rect.Bottom <= rect.Top;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
using CursorLang.Interop;
|
||||||
|
using CursorLang.Models;
|
||||||
|
|
||||||
|
namespace CursorLang.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Computes the screen point to show the popup at.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// There is nothing but arithmetic here: where the cursor is, where the caret is and
|
||||||
|
/// what the monitor bounds are is figured out by the window itself — it has a handle
|
||||||
|
/// of its own for that. The computation is kept apart because it is exactly the place
|
||||||
|
/// where a sign or half a size is easy to get wrong, and this way it can be checked
|
||||||
|
/// without a single window on screen.
|
||||||
|
///
|
||||||
|
/// All the values are in physical pixels: monitors have different scaling, and
|
||||||
|
/// converting to WPF units halfway would mean rounding twice.
|
||||||
|
/// </remarks>
|
||||||
|
internal static class PopupLayout
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// The popup position next to the anchor point — the cursor or the caret.
|
||||||
|
/// The cursor arrives here as a rectangle of zero size.
|
||||||
|
/// </summary>
|
||||||
|
internal static PopupWindowNative.Point NearAnchor(
|
||||||
|
PopupWindowNative.Rect anchor,
|
||||||
|
AnchorSide side,
|
||||||
|
int offset,
|
||||||
|
int width,
|
||||||
|
int height)
|
||||||
|
{
|
||||||
|
int toLeftOf = anchor.Left - offset - width;
|
||||||
|
int toRightOf = anchor.Right + offset;
|
||||||
|
int above = anchor.Top - offset - height;
|
||||||
|
int below = anchor.Bottom + offset;
|
||||||
|
|
||||||
|
// For the "left" and "right" sides the popup lines up with the anchor point
|
||||||
|
int middle = anchor.Top + (((anchor.Bottom - anchor.Top) - height) / 2);
|
||||||
|
|
||||||
|
(int x, int y) = side switch
|
||||||
|
{
|
||||||
|
AnchorSide.TopLeft => (toLeftOf, above),
|
||||||
|
AnchorSide.TopRight => (toRightOf, above),
|
||||||
|
AnchorSide.Left => (toLeftOf, middle),
|
||||||
|
AnchorSide.Right => (toRightOf, middle),
|
||||||
|
AnchorSide.BottomLeft => (toLeftOf, below),
|
||||||
|
_ => (toRightOf, below),
|
||||||
|
};
|
||||||
|
|
||||||
|
return new PopupWindowNative.Point { X = x, Y = y };
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The popup position in the given corner of the monitor work area.
|
||||||
|
/// </summary>
|
||||||
|
internal static PopupWindowNative.Point OnScreen(
|
||||||
|
PopupWindowNative.Rect work,
|
||||||
|
ScreenPosition position,
|
||||||
|
int margin,
|
||||||
|
int width,
|
||||||
|
int height)
|
||||||
|
{
|
||||||
|
int left = work.Left + margin;
|
||||||
|
int right = work.Right - margin - width;
|
||||||
|
int top = work.Top + margin;
|
||||||
|
int bottom = work.Bottom - margin - height;
|
||||||
|
int centerX = work.Left + ((work.Right - work.Left - width) / 2);
|
||||||
|
int centerY = work.Top + ((work.Bottom - work.Top - height) / 2);
|
||||||
|
|
||||||
|
(int x, int y) = position switch
|
||||||
|
{
|
||||||
|
ScreenPosition.TopLeft => (left, top),
|
||||||
|
ScreenPosition.Top => (centerX, top),
|
||||||
|
ScreenPosition.TopRight => (right, top),
|
||||||
|
ScreenPosition.BottomLeft => (left, bottom),
|
||||||
|
ScreenPosition.Bottom => (centerX, bottom),
|
||||||
|
ScreenPosition.BottomRight => (right, bottom),
|
||||||
|
_ => (centerX, centerY),
|
||||||
|
};
|
||||||
|
|
||||||
|
return new PopupWindowNative.Point { X = x, Y = y };
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A rectangle of zero size at a point — the mouse cursor as an anchor.</summary>
|
||||||
|
internal static PopupWindowNative.Rect AsAnchor(PopupWindowNative.Point point) => new()
|
||||||
|
{
|
||||||
|
Left = point.X,
|
||||||
|
Top = point.Y,
|
||||||
|
Right = point.X,
|
||||||
|
Bottom = point.Y,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>WPF units into physical pixels of a monitor with the given scale.</summary>
|
||||||
|
internal static int ToPixels(double wpfUnits, double scale) => (int)Math.Round(wpfUnits * scale);
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ using System.Windows;
|
|||||||
using System.Windows.Interop;
|
using System.Windows.Interop;
|
||||||
using CursorLang.Interop;
|
using CursorLang.Interop;
|
||||||
using CursorLang.Models;
|
using CursorLang.Models;
|
||||||
|
using CursorLang.Services;
|
||||||
using CursorLang.ViewModels;
|
using CursorLang.ViewModels;
|
||||||
|
|
||||||
namespace CursorLang.Views;
|
namespace CursorLang.Views;
|
||||||
@@ -11,7 +12,7 @@ namespace CursorLang.Views;
|
|||||||
/// Responsible only for showing it, its size and its place on screen: when to take it
|
/// Responsible only for showing it, its size and its place on screen: when to take it
|
||||||
/// down is decided by <see cref="Services.LayoutPopupService"/>.
|
/// down is decided by <see cref="Services.LayoutPopupService"/>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public partial class LayoutPopupWindow : Window
|
public partial class LayoutPopupWindow : Window, ILayoutPopupWindow
|
||||||
{
|
{
|
||||||
private readonly AppSettings _settings;
|
private readonly AppSettings _settings;
|
||||||
private Size _contentSize;
|
private Size _contentSize;
|
||||||
@@ -94,14 +95,7 @@ public partial class LayoutPopupWindow : Window
|
|||||||
return caret;
|
return caret;
|
||||||
}
|
}
|
||||||
|
|
||||||
PopupWindowNative.Point cursor = PopupWindowNative.GetCursorPosition();
|
return PopupLayout.AsAnchor(PopupWindowNative.GetCursorPosition());
|
||||||
return new PopupWindowNative.Rect
|
|
||||||
{
|
|
||||||
Left = cursor.X,
|
|
||||||
Top = cursor.Y,
|
|
||||||
Right = cursor.X,
|
|
||||||
Bottom = cursor.Y,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void ApplyBoundsNearAnchor(IntPtr handle, PopupWindowNative.Rect anchor)
|
private void ApplyBoundsNearAnchor(IntPtr handle, PopupWindowNative.Rect anchor)
|
||||||
@@ -112,59 +106,27 @@ public partial class LayoutPopupWindow : Window
|
|||||||
// Every anchor mode has a side and an offset of its own
|
// Every anchor mode has a side and an offset of its own
|
||||||
bool atCaret = _settings.PlacementMode == PopupPlacementMode.AtCaret;
|
bool atCaret = _settings.PlacementMode == PopupPlacementMode.AtCaret;
|
||||||
AnchorSide side = atCaret ? _settings.CaretSide : _settings.CursorSide;
|
AnchorSide side = atCaret ? _settings.CaretSide : _settings.CursorSide;
|
||||||
int offset = ToPixels(atCaret ? _settings.CaretOffset : _settings.CursorOffset, scale);
|
int offset = PopupLayout.ToPixels(atCaret ? _settings.CaretOffset : _settings.CursorOffset, scale);
|
||||||
|
|
||||||
int width = ToPixels(_contentSize.Width, scale);
|
int width = PopupLayout.ToPixels(_contentSize.Width, scale);
|
||||||
int height = ToPixels(_contentSize.Height, scale);
|
int height = PopupLayout.ToPixels(_contentSize.Height, scale);
|
||||||
|
|
||||||
int toLeftOf = anchor.Left - offset - width;
|
PopupWindowNative.Point position = PopupLayout.NearAnchor(anchor, side, offset, width, height);
|
||||||
int toRightOf = anchor.Right + offset;
|
|
||||||
int above = anchor.Top - offset - height;
|
|
||||||
int below = anchor.Bottom + offset;
|
|
||||||
// Для сторон «слева» и «справа» подсказка встаёт вровень с точкой привязки
|
|
||||||
int middle = anchor.Top + (((anchor.Bottom - anchor.Top) - height) / 2);
|
|
||||||
|
|
||||||
(int x, int y) = side switch
|
PopupWindowNative.SetBounds(handle, position.X, position.Y, width, height);
|
||||||
{
|
|
||||||
AnchorSide.TopLeft => (toLeftOf, above),
|
|
||||||
AnchorSide.TopRight => (toRightOf, above),
|
|
||||||
AnchorSide.Left => (toLeftOf, middle),
|
|
||||||
AnchorSide.Right => (toRightOf, middle),
|
|
||||||
AnchorSide.BottomLeft => (toLeftOf, below),
|
|
||||||
_ => (toRightOf, below),
|
|
||||||
};
|
|
||||||
|
|
||||||
PopupWindowNative.SetBounds(handle, x, y, width, height);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void ApplyBoundsOnScreen(IntPtr handle)
|
private void ApplyBoundsOnScreen(IntPtr handle)
|
||||||
{
|
{
|
||||||
(PopupWindowNative.Rect work, double scale) = PopupWindowNative.GetActiveMonitorWorkArea();
|
(PopupWindowNative.Rect work, double scale) = PopupWindowNative.GetActiveMonitorWorkArea();
|
||||||
|
|
||||||
int width = ToPixels(_contentSize.Width, scale);
|
int width = PopupLayout.ToPixels(_contentSize.Width, scale);
|
||||||
int height = ToPixels(_contentSize.Height, scale);
|
int height = PopupLayout.ToPixels(_contentSize.Height, scale);
|
||||||
int margin = ToPixels(_settings.ScreenMargin, scale);
|
int margin = PopupLayout.ToPixels(_settings.ScreenMargin, scale);
|
||||||
|
|
||||||
int left = work.Left + margin;
|
PopupWindowNative.Point position =
|
||||||
int right = work.Right - margin - width;
|
PopupLayout.OnScreen(work, _settings.ScreenPosition, margin, width, height);
|
||||||
int top = work.Top + margin;
|
|
||||||
int bottom = work.Bottom - margin - height;
|
|
||||||
int centerX = work.Left + ((work.Right - work.Left - width) / 2);
|
|
||||||
int centerY = work.Top + ((work.Bottom - work.Top - height) / 2);
|
|
||||||
|
|
||||||
(int x, int y) = _settings.ScreenPosition switch
|
PopupWindowNative.SetBounds(handle, position.X, position.Y, width, height);
|
||||||
{
|
|
||||||
ScreenPosition.TopLeft => (left, top),
|
|
||||||
ScreenPosition.Top => (centerX, top),
|
|
||||||
ScreenPosition.TopRight => (right, top),
|
|
||||||
ScreenPosition.BottomLeft => (left, bottom),
|
|
||||||
ScreenPosition.Bottom => (centerX, bottom),
|
|
||||||
ScreenPosition.BottomRight => (right, bottom),
|
|
||||||
_ => (centerX, centerY),
|
|
||||||
};
|
|
||||||
|
|
||||||
PopupWindowNative.SetBounds(handle, x, y, width, height);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static int ToPixels(double wpfUnits, double scale) => (int)Math.Round(wpfUnits * scale);
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user