diff --git a/CursorLang/Services/ILayoutPopupWindow.cs b/CursorLang/Services/ILayoutPopupWindow.cs
new file mode 100644
index 0000000..cbee1dd
--- /dev/null
+++ b/CursorLang/Services/ILayoutPopupWindow.cs
@@ -0,0 +1,21 @@
+namespace CursorLang.Services;
+
+///
+/// The popup window as seen by whoever decides when it is shown.
+///
+///
+/// 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.
+///
+public interface ILayoutPopupWindow
+{
+ /// Shows the window at the place set by the settings.
+ void ShowPopup();
+
+ /// Takes the window off the screen without destroying it.
+ void Hide();
+
+ /// Closes the window for good.
+ void Close();
+}
diff --git a/CursorLang/Services/KeyboardLayoutService.cs b/CursorLang/Services/KeyboardLayoutService.cs
index d23015a..0120bb6 100644
--- a/CursorLang/Services/KeyboardLayoutService.cs
+++ b/CursorLang/Services/KeyboardLayoutService.cs
@@ -27,29 +27,54 @@ public sealed class KeyboardLayoutOptions
public sealed class KeyboardLayoutService : IKeyboardLayoutService, IDisposable
{
private readonly DispatcherTimer _pollTimer;
+ private readonly Func _getForegroundWindow;
+ private readonly Func _getActiveLocaleId;
+ private readonly Action _requestNextLayout;
+
private int _lastLocaleId = -1;
private IntPtr _lastForegroundWindow;
public KeyboardLayoutService(KeyboardLayoutOptions options)
+ : this(
+ options,
+ KeyboardLayoutNative.GetForegroundWindow,
+ KeyboardLayoutNative.GetActiveLocaleId,
+ KeyboardLayoutNative.RequestNextLayout)
{
+ }
+
+ ///
+ /// Takes the sources of system information explicitly: in tests the layout and the
+ /// active window are not provided by Windows.
+ ///
+ internal KeyboardLayoutService(
+ KeyboardLayoutOptions options,
+ Func getForegroundWindow,
+ Func getActiveLocaleId,
+ Action requestNextLayout)
+ {
+ _getForegroundWindow = getForegroundWindow;
+ _getActiveLocaleId = getActiveLocaleId;
+ _requestNextLayout = requestNextLayout;
+
_pollTimer = new DispatcherTimer { Interval = options.PollInterval };
_pollTimer.Tick += OnTick;
}
public event EventHandler? LayoutChanged;
- public KeyboardLayout Current => KeyboardLayout.FromLocaleId(KeyboardLayoutNative.GetActiveLocaleId());
+ public KeyboardLayout Current => KeyboardLayout.FromLocaleId(_getActiveLocaleId());
public void Start()
{
- _lastForegroundWindow = KeyboardLayoutNative.GetForegroundWindow();
- _lastLocaleId = KeyboardLayoutNative.GetActiveLocaleId();
+ _lastForegroundWindow = _getForegroundWindow();
+ _lastLocaleId = _getActiveLocaleId();
_pollTimer.Start();
}
public void Stop() => _pollTimer.Stop();
- public void SwitchToNext() => KeyboardLayoutNative.RequestNextLayout();
+ public void SwitchToNext() => _requestNextLayout();
public void Dispose()
{
@@ -57,9 +82,14 @@ public sealed class KeyboardLayoutService : IKeyboardLayoutService, IDisposable
_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)
{
return;
@@ -68,7 +98,7 @@ public sealed class KeyboardLayoutService : IKeyboardLayoutService, IDisposable
bool appSwitched = foreground != _lastForegroundWindow;
_lastForegroundWindow = foreground;
- int localeId = KeyboardLayoutNative.GetActiveLocaleId();
+ int localeId = _getActiveLocaleId();
if (localeId == _lastLocaleId)
{
return;
diff --git a/CursorLang/Services/LayoutPopupService.cs b/CursorLang/Services/LayoutPopupService.cs
index e2deda3..48fe7dc 100644
--- a/CursorLang/Services/LayoutPopupService.cs
+++ b/CursorLang/Services/LayoutPopupService.cs
@@ -1,7 +1,6 @@
using System.Windows.Threading;
using CursorLang.Models;
using CursorLang.ViewModels;
-using CursorLang.Views;
namespace CursorLang.Services;
@@ -11,12 +10,12 @@ namespace CursorLang.Services;
///
public sealed class LayoutPopupService : ILayoutPopupService, IDisposable
{
- private readonly LayoutPopupWindow _window;
+ private readonly ILayoutPopupWindow _window;
private readonly LayoutPopupViewModel _viewModel;
private readonly AppSettings _settings;
private readonly DispatcherTimer _hideTimer = new();
- public LayoutPopupService(LayoutPopupWindow window, LayoutPopupViewModel viewModel, AppSettings settings)
+ public LayoutPopupService(ILayoutPopupWindow window, LayoutPopupViewModel viewModel, AppSettings settings)
{
_window = window;
_viewModel = viewModel;
diff --git a/CursorLang/Services/MainWindowPlacement.cs b/CursorLang/Services/MainWindowPlacement.cs
index c4306c8..196d20a 100644
--- a/CursorLang/Services/MainWindowPlacement.cs
+++ b/CursorLang/Services/MainWindowPlacement.cs
@@ -103,17 +103,18 @@ public sealed class MainWindowPlacement
private static PopupWindowNative.Point? CenterOnActiveMonitor(PopupWindowNative.Rect bounds)
{
(PopupWindowNative.Rect work, _) = PopupWindowNative.GetActiveMonitorWorkArea();
- if (IsEmpty(work))
- {
- return null;
- }
+ return IsEmpty(work) ? null : Center(bounds, work);
+ }
- return new PopupWindowNative.Point
+ ///
+ /// The point at which a window with the given bounds ends up in the centre of the work area.
+ ///
+ internal static PopupWindowNative.Point Center(
+ PopupWindowNative.Rect bounds, PopupWindowNative.Rect work) => new()
{
X = work.Left + (((work.Right - work.Left) - Width(bounds)) / 2),
Y = work.Top + (((work.Bottom - work.Top) - Height(bounds)) / 2),
};
- }
///
/// Pulls the window into the work area of the nearest monitor.
@@ -145,6 +146,22 @@ public sealed class MainWindowPlacement
return null;
}
+ return Clamp(position, bounds, work);
+ }
+
+ ///
+ /// 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.
+ ///
+ 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
{
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 bool IsEmpty(PopupWindowNative.Rect rect) =>
+ internal static bool IsEmpty(PopupWindowNative.Rect rect) =>
rect.Right <= rect.Left || rect.Bottom <= rect.Top;
}
diff --git a/CursorLang/Services/PopupLayout.cs b/CursorLang/Services/PopupLayout.cs
new file mode 100644
index 0000000..ca5efac
--- /dev/null
+++ b/CursorLang/Services/PopupLayout.cs
@@ -0,0 +1,95 @@
+using CursorLang.Interop;
+using CursorLang.Models;
+
+namespace CursorLang.Services;
+
+///
+/// Computes the screen point to show the popup at.
+///
+///
+/// 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.
+///
+internal static class PopupLayout
+{
+ ///
+ /// The popup position next to the anchor point — the cursor or the caret.
+ /// The cursor arrives here as a rectangle of zero size.
+ ///
+ 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 };
+ }
+
+ ///
+ /// The popup position in the given corner of the monitor work area.
+ ///
+ 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 };
+ }
+
+ /// A rectangle of zero size at a point — the mouse cursor as an anchor.
+ internal static PopupWindowNative.Rect AsAnchor(PopupWindowNative.Point point) => new()
+ {
+ Left = point.X,
+ Top = point.Y,
+ Right = point.X,
+ Bottom = point.Y,
+ };
+
+ /// WPF units into physical pixels of a monitor with the given scale.
+ internal static int ToPixels(double wpfUnits, double scale) => (int)Math.Round(wpfUnits * scale);
+}
diff --git a/CursorLang/Views/LayoutPopupWindow.xaml.cs b/CursorLang/Views/LayoutPopupWindow.xaml.cs
index fc1e4d2..2cd76ea 100644
--- a/CursorLang/Views/LayoutPopupWindow.xaml.cs
+++ b/CursorLang/Views/LayoutPopupWindow.xaml.cs
@@ -2,6 +2,7 @@ using System.Windows;
using System.Windows.Interop;
using CursorLang.Interop;
using CursorLang.Models;
+using CursorLang.Services;
using CursorLang.ViewModels;
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
/// down is decided by .
///
-public partial class LayoutPopupWindow : Window
+public partial class LayoutPopupWindow : Window, ILayoutPopupWindow
{
private readonly AppSettings _settings;
private Size _contentSize;
@@ -94,14 +95,7 @@ public partial class LayoutPopupWindow : Window
return caret;
}
- PopupWindowNative.Point cursor = PopupWindowNative.GetCursorPosition();
- return new PopupWindowNative.Rect
- {
- Left = cursor.X,
- Top = cursor.Y,
- Right = cursor.X,
- Bottom = cursor.Y,
- };
+ return PopupLayout.AsAnchor(PopupWindowNative.GetCursorPosition());
}
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
bool atCaret = _settings.PlacementMode == PopupPlacementMode.AtCaret;
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 height = ToPixels(_contentSize.Height, scale);
+ int width = PopupLayout.ToPixels(_contentSize.Width, scale);
+ int height = PopupLayout.ToPixels(_contentSize.Height, scale);
- int toLeftOf = anchor.Left - offset - width;
- 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);
+ PopupWindowNative.Point position = PopupLayout.NearAnchor(anchor, side, offset, width, height);
- (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),
- };
-
- PopupWindowNative.SetBounds(handle, x, y, width, height);
+ PopupWindowNative.SetBounds(handle, position.X, position.Y, width, height);
}
private void ApplyBoundsOnScreen(IntPtr handle)
{
(PopupWindowNative.Rect work, double scale) = PopupWindowNative.GetActiveMonitorWorkArea();
- int width = ToPixels(_contentSize.Width, scale);
- int height = ToPixels(_contentSize.Height, scale);
- int margin = ToPixels(_settings.ScreenMargin, scale);
+ int width = PopupLayout.ToPixels(_contentSize.Width, scale);
+ int height = PopupLayout.ToPixels(_contentSize.Height, scale);
+ int margin = PopupLayout.ToPixels(_settings.ScreenMargin, scale);
- 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);
+ PopupWindowNative.Point position =
+ PopupLayout.OnScreen(work, _settings.ScreenPosition, margin, width, height);
- (int x, int y) = _settings.ScreenPosition 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),
- };
-
- PopupWindowNative.SetBounds(handle, x, y, width, height);
+ PopupWindowNative.SetBounds(handle, position.X, position.Y, width, height);
}
-
- private static int ToPixels(double wpfUnits, double scale) => (int)Math.Round(wpfUnits * scale);
}