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); }