Skip to content

Commit 08be68d

Browse files
fix(quicklayout): account for macOS scaling in overlay and move/resize
Window.Position is physical pixels on Windows but logical points on macOS, so the quick-layout overlay sized itself from physical bounds without dividing by the Retina scale factor and rendered ~half-screen, mislocating click/drag placement and Move/Resize on HiDPI displays. Add a WindowPositionScaling seam (identity on Windows, /scaling on macOS) and a shared WindowScreenScaling helper; route every Window.Position read/write in the quick-layout paths through it. The overlay now takes the target window's screen scaling explicitly, dropping the OnOpened/RenderScalingSafe deferral hack. Add debug logging at each coordinate-space boundary and unit tests for the seam.
1 parent 82074fb commit 08be68d

6 files changed

Lines changed: 205 additions & 55 deletions

File tree

src/YASN.App/PlatformServices/AvaloniaQuickWindowLayoutController.cs

Lines changed: 26 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,10 @@ public sealed class AvaloniaQuickWindowLayoutController : IQuickWindowLayoutCont
1717
/// <param name="target">The target screen region.</param>
1818
public void Move(Window window, QuickMoveTarget target)
1919
{
20-
double scaling = GetScaling(window);
21-
WindowRect result = QuickWindowLayout.Move(ToPhysicalRect(window, scaling), GetWorkingArea(window), target);
20+
double scaling = WindowScreenScaling.Get(window);
21+
WindowRect current = ToPhysicalRect(window, scaling);
22+
WindowRect result = QuickWindowLayout.Move(current, GetWorkingArea(window), target);
23+
AppLogger.Debug($"QuickLayout move: target={target} scaling={scaling} current={current} result={result}");
2224
Apply(window, result, scaling);
2325
}
2426

@@ -30,14 +32,16 @@ public void Move(Window window, QuickMoveTarget target)
3032
/// <param name="height">The requested height in logical units.</param>
3133
public void Resize(Window window, double width, double height)
3234
{
33-
double scaling = GetScaling(window);
35+
double scaling = WindowScreenScaling.Get(window);
36+
WindowRect current = ToPhysicalRect(window, scaling);
3437
WindowRect result = QuickWindowLayout.Resize(
35-
ToPhysicalRect(window, scaling),
38+
current,
3639
GetWorkingArea(window),
3740
width * scaling,
3841
height * scaling,
3942
window.MinWidth * scaling,
4043
window.MinHeight * scaling);
44+
AppLogger.Debug($"QuickLayout resize: requestedDip={width}x{height} scaling={scaling} current={current} result={result}");
4145
Apply(window, result, scaling);
4246
}
4347

@@ -49,26 +53,26 @@ public void Resize(Window window, double width, double height)
4953
public void ApplyBounds(Window window, WindowRect bounds)
5054
{
5155
// Left/Top arrive in physical pixels (window position space) and Width/Height in DIP
52-
// (window size space), so each component is applied to its matching coordinate space.
53-
window.Position = new PixelPoint((int)Math.Round(bounds.Left), (int)Math.Round(bounds.Top));
56+
// (window size space). Left/Top is mapped through the platform position seam (identity on
57+
// Windows, divide-by-scaling on macOS); Width/Height are already DIP and applied directly.
58+
double scaling = WindowScreenScaling.Get(window);
59+
int left = (int)Math.Round(WindowPositionScaling.PhysicalToPosition(bounds.Left, scaling, WindowPositionScaling.PositionIsLogical));
60+
int top = (int)Math.Round(WindowPositionScaling.PhysicalToPosition(bounds.Top, scaling, WindowPositionScaling.PositionIsLogical));
61+
window.Position = new PixelPoint(left, top);
5462
window.Width = Math.Max(window.MinWidth, bounds.Width);
5563
window.Height = Math.Max(window.MinHeight, bounds.Height);
64+
AppLogger.Debug($"QuickLayout applyBounds: bounds={bounds} scaling={scaling} pos=({left},{top}) sizeDip={window.Width}x{window.Height}");
5665
}
5766

58-
// Avalonia exposes window position in physical pixels but width/height in logical units,
59-
// while screen working areas are physical pixels. Quick-layout math must run in a single
60-
// space, so everything is normalized to physical pixels here using the window scaling.
67+
// Avalonia exposes window width/height in logical units while screen working areas are physical
68+
// pixels, and Window.Position is physical pixels on Windows but logical points on macOS. Quick-
69+
// layout math must run in a single space, so everything is normalized to physical pixels here:
70+
// the position is mapped through the platform seam and the DIP size is multiplied by scaling.
6171
private static WindowRect ToPhysicalRect(Window window, double scaling)
6272
{
63-
return new WindowRect(window.Position.X, window.Position.Y, window.Width * scaling, window.Height * scaling);
64-
}
65-
66-
private static double GetScaling(Window window)
67-
{
68-
Screen? screen = window.Screens.ScreenFromWindow(window) ?? window.Screens.Primary;
69-
double scaling = screen?.Scaling ?? window.RenderScaling;
70-
71-
return scaling <= 0 ? 1.0 : scaling;
73+
double left = WindowPositionScaling.PositionToPhysical(window.Position.X, scaling, WindowPositionScaling.PositionIsLogical);
74+
double top = WindowPositionScaling.PositionToPhysical(window.Position.Y, scaling, WindowPositionScaling.PositionIsLogical);
75+
return new WindowRect(left, top, window.Width * scaling, window.Height * scaling);
7276
}
7377

7478
private static WindowRect GetWorkingArea(Window window)
@@ -81,9 +85,12 @@ private static WindowRect GetWorkingArea(Window window)
8185

8286
private static void Apply(Window window, WindowRect rect, double scaling)
8387
{
84-
window.Position = new PixelPoint((int)Math.Round(rect.Left), (int)Math.Round(rect.Top));
88+
int left = (int)Math.Round(WindowPositionScaling.PhysicalToPosition(rect.Left, scaling, WindowPositionScaling.PositionIsLogical));
89+
int top = (int)Math.Round(WindowPositionScaling.PhysicalToPosition(rect.Top, scaling, WindowPositionScaling.PositionIsLogical));
90+
window.Position = new PixelPoint(left, top);
8591
window.Width = rect.Width / scaling;
8692
window.Height = rect.Height / scaling;
93+
AppLogger.Debug($"QuickLayout apply: rect={rect} scaling={scaling} pos=({left},{top}) sizeDip={window.Width}x{window.Height}");
8794
}
8895
}
8996
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
using Avalonia.Controls;
2+
using Avalonia.Platform;
3+
4+
namespace YASN.PlatformServices
5+
{
6+
/// <summary>
7+
/// Resolves the scale factor (physical pixels per logical point) of the screen a window currently
8+
/// occupies. Shared by the quick-layout controller and the overlay so both use one definition of
9+
/// "the scaling where the note lives" — the overlay can span several monitors, so its own
10+
/// <c>RenderScaling</c> is ambiguous; the target window's screen is the authoritative source.
11+
/// </summary>
12+
public static class WindowScreenScaling
13+
{
14+
/// <summary>
15+
/// Gets the scale factor of the screen the window is on, falling back to the window's render
16+
/// scaling and finally to 1.0 when no positive value is available.
17+
/// </summary>
18+
/// <param name="window">The window whose screen scaling is wanted.</param>
19+
/// <returns>The scale factor, always greater than zero.</returns>
20+
public static double Get(Window window)
21+
{
22+
Screen? screen = window.Screens.ScreenFromWindow(window) ?? window.Screens.Primary;
23+
double scaling = screen?.Scaling ?? window.RenderScaling;
24+
25+
return scaling <= 0 ? 1.0 : scaling;
26+
}
27+
}
28+
}

src/YASN.App/Views/FloatingNoteWindow.axaml.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -642,7 +642,8 @@ private async void HandleQuickLayoutClick(object? sender, RoutedEventArgs e)
642642
/// </summary>
643643
public async Task ShowQuickLayoutOverlay()
644644
{
645-
QuickLayoutOverlayWindow overlay = new QuickLayoutOverlayWindow(Width, Height);
645+
double scaling = WindowScreenScaling.Get(this);
646+
QuickLayoutOverlayWindow overlay = new QuickLayoutOverlayWindow(Width, Height, scaling);
646647
WindowRect? bounds = await overlay.ShowDialog<WindowRect?>(this).ConfigureAwait(true);
647648
if (bounds is not null)
648649
{

src/YASN.App/Views/QuickLayoutOverlayWindow.axaml.cs

Lines changed: 24 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ public sealed partial class QuickLayoutOverlayWindow : Window
2323
private readonly Rectangle selectionRectangle;
2424
private readonly double currentWidthDip;
2525
private readonly double currentHeightDip;
26-
private readonly PixelRect virtualBounds;
26+
private readonly double scaling;
2727
private readonly PixelPoint virtualOrigin;
2828

2929
private Point? dragStart;
@@ -32,19 +32,25 @@ public sealed partial class QuickLayoutOverlayWindow : Window
3232
/// Initializes the overlay for the XAML designer.
3333
/// </summary>
3434
public QuickLayoutOverlayWindow()
35-
: this(640, 400)
35+
: this(640, 400, 1.0)
3636
{
3737
}
3838

3939
/// <summary>
40-
/// Initializes the overlay for a target window of the given current size.
40+
/// Initializes the overlay for a target window of the given current size and screen scaling.
4141
/// </summary>
4242
/// <param name="currentWidthDip">The target window width, in DIP, used for a click reposition.</param>
4343
/// <param name="currentHeightDip">The target window height, in DIP, used for a click reposition.</param>
44-
public QuickLayoutOverlayWindow(double currentWidthDip, double currentHeightDip)
44+
/// <param name="scaling">
45+
/// The scale factor of the screen the target window is on (physical pixels per DIP). Passed in
46+
/// rather than read from the overlay's own <see cref="TopLevel.RenderScaling"/>, which is
47+
/// ambiguous for a window spanning monitors and is not yet valid before the window is shown.
48+
/// </param>
49+
public QuickLayoutOverlayWindow(double currentWidthDip, double currentHeightDip, double scaling)
4550
{
4651
this.currentWidthDip = currentWidthDip;
4752
this.currentHeightDip = currentHeightDip;
53+
this.scaling = scaling <= 0 ? 1.0 : scaling;
4854
InitializeComponent();
4955

5056
overlayCanvas = this.FindControl<Canvas>("OverlayCanvas")
@@ -53,34 +59,26 @@ public QuickLayoutOverlayWindow(double currentWidthDip, double currentHeightDip)
5359
?? throw new InvalidOperationException("SelectionRectangle was not found.");
5460

5561
PixelRect bounds = GetVirtualDesktopBounds();
56-
virtualBounds = bounds;
5762
virtualOrigin = bounds.Position;
58-
Position = bounds.Position;
5963

60-
// Size is deferred to OnOpened: RenderScaling is only valid once the window is shown.
61-
// Computing it here reads an uninitialized scale, which on macOS Retina (where the
62-
// primary screen reports Scaling 1.0 despite a 2x render scale) sizes the overlay 2x too
63-
// large and breaks the click/drag -> position/size mapping.
64+
// The union origin is physical pixels; Window.Position is physical on Windows but logical
65+
// points on macOS, so map it through the platform seam. Size is DIP on every platform, so
66+
// divide the physical span by scaling. Both use the target window's screen scaling, which
67+
// is valid here (no need to defer to OnOpened).
68+
Position = new PixelPoint(
69+
(int)Math.Round(WindowPositionScaling.PhysicalToPosition(bounds.X, this.scaling, WindowPositionScaling.PositionIsLogical)),
70+
(int)Math.Round(WindowPositionScaling.PhysicalToPosition(bounds.Y, this.scaling, WindowPositionScaling.PositionIsLogical)));
71+
Width = bounds.Width / this.scaling;
72+
Height = bounds.Height / this.scaling;
73+
74+
AppLogger.Debug($"QuickLayout overlay: positionLogical={WindowPositionScaling.PositionIsLogical} scaling={this.scaling} union={bounds} pos={Position} sizeDip={Width}x{Height}");
6475

6576
overlayCanvas.PointerPressed += HandlePointerPressed;
6677
overlayCanvas.PointerMoved += HandlePointerMoved;
6778
overlayCanvas.PointerReleased += HandlePointerReleased;
6879
KeyDown += HandleKeyDown;
6980
}
7081

71-
/// <summary>
72-
/// Sizes the overlay to span the virtual desktop once the window is shown and
73-
/// <see cref="TopLevel.RenderScaling"/> reports the real scale factor.
74-
/// </summary>
75-
protected override void OnOpened(EventArgs e)
76-
{
77-
base.OnOpened(e);
78-
79-
double scaling = RenderScalingSafe();
80-
Width = virtualBounds.Width / scaling;
81-
Height = virtualBounds.Height / scaling;
82-
}
83-
8482
private void InitializeComponent()
8583
{
8684
AvaloniaXamlLoader.Load(this);
@@ -103,17 +101,6 @@ private PixelRect GetVirtualDesktopBounds()
103101
return union;
104102
}
105103

106-
private double RenderScalingSafe()
107-
{
108-
double scaling = RenderScaling;
109-
if (scaling > 0)
110-
{
111-
return scaling;
112-
}
113-
114-
return Screens.Primary?.Scaling is { } primary and > 0 ? primary : 1.0;
115-
}
116-
117104
private void HandlePointerPressed(object? sender, PointerPressedEventArgs e)
118105
{
119106
PointerPoint point = e.GetCurrentPoint(overlayCanvas);
@@ -166,13 +153,15 @@ private void HandlePointerReleased(object? sender, PointerReleasedEventArgs e)
166153
WindowRect result = QuickLayoutSelection.Resolve(
167154
start,
168155
end,
169-
RenderScalingSafe(),
156+
scaling,
170157
virtualOrigin.X,
171158
virtualOrigin.Y,
172159
currentWidthDip,
173160
currentHeightDip,
174161
MinSelectionDip);
175162

163+
AppLogger.Debug($"QuickLayout release: start={start} end={end} scaling={scaling} origin=({virtualOrigin.X},{virtualOrigin.Y}) result={result}");
164+
176165
Close(result);
177166
}
178167

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
namespace YASN.WindowLayout
2+
{
3+
/// <summary>
4+
/// Converts between screen physical-pixel space and Avalonia's <c>Window.Position</c> space.
5+
/// The two spaces differ by platform: on Windows <c>Window.Position</c> is physical pixels, so the
6+
/// conversion is the identity; on macOS Avalonia expresses <c>Window.Position</c> in logical points
7+
/// (it divides by the desktop scaling factor — see AvaloniaUI/Avalonia#11333), so physical pixels
8+
/// must be divided by the scale factor going out and multiplied coming back. Quick-layout geometry
9+
/// runs entirely in physical-pixel space (where <c>Screen.Bounds</c>/<c>WorkingArea</c> live); this
10+
/// seam is applied only where a value crosses into or out of <c>Window.Position</c>.
11+
/// </summary>
12+
public static class WindowPositionScaling
13+
{
14+
/// <summary>
15+
/// Whether the current OS expresses <c>Window.Position</c> in logical points (macOS) rather than
16+
/// physical pixels (Windows). Read once from the OS; pass it explicitly to the conversion
17+
/// methods so they stay pure and unit-testable for both platforms.
18+
/// </summary>
19+
public static bool PositionIsLogical { get; } = OperatingSystem.IsMacOS();
20+
21+
/// <summary>
22+
/// Converts a physical-pixel coordinate into a <c>Window.Position</c> value for the current
23+
/// platform.
24+
/// </summary>
25+
/// <param name="physical">The coordinate in physical pixels.</param>
26+
/// <param name="scaling">The screen scale factor (physical pixels per logical point).</param>
27+
/// <param name="positionIsLogical">Whether position space is logical points (macOS).</param>
28+
/// <returns>The coordinate in <c>Window.Position</c> space.</returns>
29+
public static double PhysicalToPosition(double physical, double scaling, bool positionIsLogical)
30+
{
31+
if (!positionIsLogical)
32+
{
33+
return physical;
34+
}
35+
36+
double safeScaling = scaling <= 0 ? 1.0 : scaling;
37+
return physical / safeScaling;
38+
}
39+
40+
/// <summary>
41+
/// Converts a <c>Window.Position</c> value into a physical-pixel coordinate for the current
42+
/// platform.
43+
/// </summary>
44+
/// <param name="position">The coordinate in <c>Window.Position</c> space.</param>
45+
/// <param name="scaling">The screen scale factor (physical pixels per logical point).</param>
46+
/// <param name="positionIsLogical">Whether position space is logical points (macOS).</param>
47+
/// <returns>The coordinate in physical pixels.</returns>
48+
public static double PositionToPhysical(double position, double scaling, bool positionIsLogical)
49+
{
50+
if (!positionIsLogical)
51+
{
52+
return position;
53+
}
54+
55+
double safeScaling = scaling <= 0 ? 1.0 : scaling;
56+
return position * safeScaling;
57+
}
58+
}
59+
}
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
using YASN.WindowLayout;
2+
3+
namespace YASN.Migration.Tests
4+
{
5+
/// <summary>
6+
/// Verifies the platform seam mapping physical pixels to and from Avalonia's window-position space.
7+
/// The mapping is the source of correct window placement on macOS, where <c>Window.Position</c> is
8+
/// logical points, versus Windows, where it is physical pixels — so these tests pin the per-platform
9+
/// behaviour the overlay and quick-layout controller depend on.
10+
/// </summary>
11+
public sealed class WindowPositionScalingTests
12+
{
13+
/// <summary>
14+
/// On Windows, position space is physical pixels, so the conversion is the identity regardless
15+
/// of DPI. Both 1.0 and 2.0 scaling must pass through unchanged, proving DPI never alters the
16+
/// Windows path (the original, working behaviour must not regress).
17+
/// </summary>
18+
[Theory]
19+
[InlineData(1.0)]
20+
[InlineData(2.0)]
21+
public void WindowsPositionSpaceIsIdentity(double scaling)
22+
{
23+
Assert.Equal(400, WindowPositionScaling.PhysicalToPosition(400, scaling, positionIsLogical: false), 3);
24+
Assert.Equal(400, WindowPositionScaling.PositionToPhysical(400, scaling, positionIsLogical: false), 3);
25+
}
26+
27+
/// <summary>
28+
/// On macOS at 2x, position space is logical points: physical pixels are halved going out and
29+
/// doubled coming back. This is exactly the conversion that keeps the overlay full-screen and
30+
/// the note placed where the user clicked on Retina.
31+
/// </summary>
32+
[Fact]
33+
public void MacPositionSpaceConvertsByScaling()
34+
{
35+
Assert.Equal(200, WindowPositionScaling.PhysicalToPosition(400, 2.0, positionIsLogical: true), 3);
36+
Assert.Equal(400, WindowPositionScaling.PositionToPhysical(200, 2.0, positionIsLogical: true), 3);
37+
}
38+
39+
/// <summary>
40+
/// The two directions must be exact inverses at fractional scaling, or a reposition would drift
41+
/// every time it round-trips through the seam (read position -> physical -> write position).
42+
/// </summary>
43+
[Fact]
44+
public void MacRoundTripIsLossless()
45+
{
46+
const double original = 933;
47+
double physical = WindowPositionScaling.PositionToPhysical(original, 1.5, positionIsLogical: true);
48+
double back = WindowPositionScaling.PhysicalToPosition(physical, 1.5, positionIsLogical: true);
49+
50+
Assert.Equal(original, back, 6);
51+
}
52+
53+
/// <summary>
54+
/// A non-positive scaling must fall back to identity rather than divide by zero, so a window
55+
/// reporting an invalid scale still places sanely instead of throwing or producing infinity.
56+
/// </summary>
57+
[Theory]
58+
[InlineData(0.0)]
59+
[InlineData(-1.0)]
60+
public void NonPositiveScalingFallsBackToIdentity(double scaling)
61+
{
62+
Assert.Equal(300, WindowPositionScaling.PhysicalToPosition(300, scaling, positionIsLogical: true), 3);
63+
Assert.Equal(300, WindowPositionScaling.PositionToPhysical(300, scaling, positionIsLogical: true), 3);
64+
}
65+
}
66+
}

0 commit comments

Comments
 (0)