Skip to content

Commit c1455e4

Browse files
Keep windows on the displays that exist
When an external display is disconnected, Windows moves its windows onto the remaining display without shrinking them. A terminal sized for the larger display then overhangs the laptop panel with its caption buttons out of reach. A saved position can also point at a display that is gone, and the window was restored there as-is. WindowPlacement.Fit takes a window rectangle and the current work areas. When they cover all of the window it returns it unchanged, so a window spanning two connected displays stays put. Otherwise it shrinks the window to fit and moves it inside the work area it overlaps most, or the nearest one when it overlaps none. Mirrored displays count once. MainWindow applies it: - at launch, after restoring a saved position or applying a requested one, before maximizing, so the window maximizes on a display that exists; - when Screens.Changed fires, and again a second later, once Windows has finished moving windows off a removed display; - when the window returns to Normal, since the OS restores the bounds it remembered from before the change. A maximized or full-screen window keeps its state; its saved normal bounds are fitted instead, so un-maximizing and the persisted layout land on a display that exists. Minimized windows are left alone. Windows Terminal only checks, at launch, that the window's top-left corner is on some monitor, and never shrinks a window. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent f0a6f9a commit c1455e4

3 files changed

Lines changed: 269 additions & 0 deletions

File tree

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
using Avalonia;
2+
3+
namespace Devolutions.Terminal.App.Platform;
4+
5+
/// <summary>
6+
/// Keeps a window reachable on the displays that exist now. A display can go
7+
/// away while the app runs or between runs (a laptop undocked); Windows moves a
8+
/// window off a removed display without shrinking it, and a restored position
9+
/// can point at a display that is gone, so a window can end up larger than the
10+
/// screen or outside it, with its caption buttons out of reach.
11+
/// </summary>
12+
public static class WindowPlacement
13+
{
14+
/// <summary>
15+
/// Returns <paramref name="bounds"/> unchanged when the work areas cover all of
16+
/// it, so a window spanning two connected displays stays put. Otherwise returns
17+
/// it shrunk to fit and moved inside the work area it overlaps most, or the
18+
/// nearest one when it overlaps none. All values are physical pixels.
19+
/// </summary>
20+
public static PixelRect Fit(PixelRect bounds, IReadOnlyList<PixelRect> workAreas)
21+
{
22+
ArgumentNullException.ThrowIfNull(workAreas);
23+
if (bounds.Width <= 0 || bounds.Height <= 0)
24+
{
25+
return bounds;
26+
}
27+
28+
// Mirrored displays report the same work area; count it once.
29+
var areas = workAreas.Where(static area => area.Width > 0 && area.Height > 0).Distinct().ToArray();
30+
if (areas.Length == 0)
31+
{
32+
return bounds;
33+
}
34+
35+
long covered = 0;
36+
long bestOverlap = 0;
37+
var target = areas[0];
38+
foreach (var area in areas)
39+
{
40+
var overlap = OverlapArea(bounds, area);
41+
covered += overlap;
42+
if (overlap > bestOverlap)
43+
{
44+
bestOverlap = overlap;
45+
target = area;
46+
}
47+
}
48+
49+
// Work areas do not overlap one another, so full coverage means every pixel
50+
// of the window is on some display.
51+
if (covered >= (long)bounds.Width * bounds.Height)
52+
{
53+
return bounds;
54+
}
55+
56+
if (bestOverlap == 0)
57+
{
58+
target = Nearest(bounds, areas);
59+
}
60+
61+
var width = Math.Min(bounds.Width, target.Width);
62+
var height = Math.Min(bounds.Height, target.Height);
63+
return new PixelRect(
64+
Math.Clamp(bounds.X, target.X, target.Right - width),
65+
Math.Clamp(bounds.Y, target.Y, target.Bottom - height),
66+
width,
67+
height);
68+
}
69+
70+
private static long OverlapArea(PixelRect a, PixelRect b)
71+
{
72+
long width = Math.Min(a.Right, b.Right) - Math.Max(a.X, b.X);
73+
long height = Math.Min(a.Bottom, b.Bottom) - Math.Max(a.Y, b.Y);
74+
return width > 0 && height > 0 ? width * height : 0;
75+
}
76+
77+
// The work area closest to the window's center.
78+
private static PixelRect Nearest(PixelRect bounds, PixelRect[] areas)
79+
{
80+
var centerX = bounds.X + (bounds.Width / 2L);
81+
var centerY = bounds.Y + (bounds.Height / 2L);
82+
var nearest = areas[0];
83+
var nearestDistance = long.MaxValue;
84+
foreach (var area in areas)
85+
{
86+
var dx = centerX - Math.Clamp(centerX, area.X, area.Right);
87+
var dy = centerY - Math.Clamp(centerY, area.Y, area.Bottom);
88+
var distance = (dx * dx) + (dy * dy);
89+
if (distance < nearestDistance)
90+
{
91+
nearestDistance = distance;
92+
nearest = area;
93+
}
94+
}
95+
96+
return nearest;
97+
}
98+
}

‎src/Devolutions.Terminal.App/Views/MainWindow.axaml.cs‎

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,20 @@ public MainWindow(
188188
{
189189
UpdateFullscreenChrome();
190190
}
191+
192+
// Restoring from maximized brings back the bounds the OS remembered,
193+
// which may no longer fit the displays.
194+
if (args.Property == WindowStateProperty && WindowState == WindowState.Normal)
195+
{
196+
Dispatcher.UIThread.Post(FitNormalBoundsToScreens, DispatcherPriority.Background);
197+
}
198+
};
199+
Screens.Changed += (_, _) =>
200+
{
201+
// Windows moves windows off a removed display shortly after reporting
202+
// the change; fit again once that has settled.
203+
Dispatcher.UIThread.Post(FitNormalBoundsToScreens, DispatcherPriority.Background);
204+
DispatcherTimer.RunOnce(FitNormalBoundsToScreens, TimeSpan.FromSeconds(1));
191205
};
192206
AddHandler(KeyDownEvent, OnWindowKeyDown, RoutingStrategies.Tunnel);
193207
AddHandler(TextInputEvent, OnWindowTextInput, RoutingStrategies.Tunnel);
@@ -344,6 +358,12 @@ private void ApplyLaunchOptions(TerminalWindowActivation activation)
344358
Height = Math.Max(240, (rows * cell.Height) + 16 + titleBarHeight);
345359
}
346360

361+
// Without a requested position the OS places the window on a display.
362+
if (activation.PositionX is not null && activation.PositionY is not null)
363+
{
364+
FitNormalBoundsToScreens();
365+
}
366+
347367
if (activation.LaunchMode.HasFlag(TerminalWindowLaunchMode.Fullscreen))
348368
{
349369
WindowState = WindowState.FullScreen;
@@ -3935,6 +3955,60 @@ private void MainWindow_OnSizeChanged(object? sender, SizeChangedEventArgs e)
39353955
RebuildTabs();
39363956
}
39373957

3958+
/// <summary>
3959+
/// Fits the normal-state bounds to the displays that exist now (see
3960+
/// <see cref="WindowPlacement.Fit"/>). A normal window moves and shrinks at
3961+
/// once; a maximized or full-screen one keeps its state, and the saved normal
3962+
/// bounds it would restore to are fitted instead. Minimized windows sit at an
3963+
/// off-screen placeholder position and are left alone.
3964+
/// </summary>
3965+
private void FitNormalBoundsToScreens()
3966+
{
3967+
if (WindowState == WindowState.Minimized)
3968+
{
3969+
return;
3970+
}
3971+
3972+
var workAreas = Screens.All.Select(static screen => screen.WorkingArea).ToArray();
3973+
var normal = WindowState == WindowState.Normal;
3974+
if (workAreas.Length == 0 || (!normal && _normalPosition is null))
3975+
{
3976+
return;
3977+
}
3978+
3979+
var position = normal ? Position : _normalPosition!.Value;
3980+
var width = normal ? (double.IsNaN(Width) ? Bounds.Width : Width) : _normalSize.Width;
3981+
var height = normal ? (double.IsNaN(Height) ? Bounds.Height : Height) : _normalSize.Height;
3982+
var scale = ScalingAt(position);
3983+
var bounds = new PixelRect(
3984+
position.X,
3985+
position.Y,
3986+
Math.Max(1, (int)Math.Round(width * scale)),
3987+
Math.Max(1, (int)Math.Round(height * scale)));
3988+
var fitted = WindowPlacement.Fit(bounds, workAreas);
3989+
if (fitted == bounds)
3990+
{
3991+
return;
3992+
}
3993+
3994+
var fittedScale = ScalingAt(fitted.Position);
3995+
_normalPosition = fitted.Position;
3996+
_normalSize = new WindowSizeState
3997+
{
3998+
Width = fitted.Width / fittedScale,
3999+
Height = fitted.Height / fittedScale,
4000+
};
4001+
if (normal)
4002+
{
4003+
Position = fitted.Position;
4004+
Width = _normalSize.Width;
4005+
Height = _normalSize.Height;
4006+
}
4007+
4008+
double ScalingAt(PixelPoint point) =>
4009+
Screens.ScreenFromPoint(point)?.Scaling ?? Screens.Primary?.Scaling ?? 1;
4010+
}
4011+
39384012
private void CaptureNormalWindowBounds()
39394013
{
39404014
if (WindowState != WindowState.Normal)
@@ -4253,6 +4327,14 @@ private void ApplyPersistedWindowState(WindowLayoutState? state)
42534327
Height = _normalSize.Height;
42544328
}
42554329

4330+
// A saved position may belong to a display that is gone; fit before
4331+
// maximizing, so the window maximizes on a display that exists. Without
4332+
// one the OS places the window on a display.
4333+
if (_normalPosition is not null)
4334+
{
4335+
FitNormalBoundsToScreens();
4336+
}
4337+
42564338
switch (state?.LaunchMode)
42574339
{
42584340
case LaunchMode.Maximized:
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
using Avalonia;
2+
using Devolutions.Terminal.App.Platform;
3+
using Xunit;
4+
5+
namespace Devolutions.Terminal.App.Tests;
6+
7+
public sealed class WindowPlacementTests
8+
{
9+
// A 1920x1080 laptop panel with a 48-pixel taskbar, and a 2560x1440 external
10+
// display to its right.
11+
private static readonly PixelRect Laptop = new(0, 0, 1920, 1032);
12+
private static readonly PixelRect External = new(1920, 0, 2560, 1392);
13+
14+
[Fact]
15+
public void LeavesWindowInsideOneDisplayAlone()
16+
{
17+
var bounds = new PixelRect(100, 80, 1200, 800);
18+
Assert.Equal(bounds, WindowPlacement.Fit(bounds, [Laptop, External]));
19+
}
20+
21+
[Fact]
22+
public void LeavesWindowSpanningConnectedDisplaysAlone()
23+
{
24+
var bounds = new PixelRect(1500, 100, 1000, 800);
25+
Assert.Equal(bounds, WindowPlacement.Fit(bounds, [Laptop, External]));
26+
}
27+
28+
[Fact]
29+
public void ShrinksWindowLargerThanTheRemainingDisplay()
30+
{
31+
// Sized for the external display, then moved onto the laptop panel by
32+
// Windows when the external one was disconnected.
33+
var bounds = new PixelRect(100, 50, 2400, 1300);
34+
Assert.Equal(Laptop, WindowPlacement.Fit(bounds, [Laptop]));
35+
}
36+
37+
[Fact]
38+
public void MovesWindowPartlyOffTheEdgeBackInside()
39+
{
40+
var bounds = new PixelRect(1500, 600, 800, 600);
41+
Assert.Equal(new PixelRect(1120, 432, 800, 600), WindowPlacement.Fit(bounds, [Laptop]));
42+
}
43+
44+
[Fact]
45+
public void MovesWindowOnARemovedDisplayToTheNearestOne()
46+
{
47+
// Restored at a position on the external display, which is gone.
48+
var bounds = new PixelRect(2500, 200, 1200, 800);
49+
Assert.Equal(new PixelRect(720, 200, 1200, 800), WindowPlacement.Fit(bounds, [Laptop]));
50+
51+
// With a display left of the laptop too, the laptop is still the nearest.
52+
var left = new PixelRect(-2560, 0, 2560, 1392);
53+
Assert.Equal(new PixelRect(720, 200, 1200, 800), WindowPlacement.Fit(bounds, [left, Laptop]));
54+
}
55+
56+
[Fact]
57+
public void FitsIntoTheDisplayItOverlapsMostWhenPartIsOnNoDisplay()
58+
{
59+
// The external display is taller, so the bottom of a window spanning both
60+
// hangs below the laptop panel, where there is no display.
61+
var bounds = new PixelRect(1700, 600, 1000, 700);
62+
Assert.Equal(new PixelRect(1920, 600, 1000, 700), WindowPlacement.Fit(bounds, [Laptop, External]));
63+
}
64+
65+
[Fact]
66+
public void HandlesDisplaysLeftOfAndAbovePrimary()
67+
{
68+
var left = new PixelRect(-2560, -360, 2560, 1392);
69+
var bounds = new PixelRect(-3000, -500, 1000, 700);
70+
Assert.Equal(new PixelRect(-2560, -360, 1000, 700), WindowPlacement.Fit(bounds, [left, Laptop]));
71+
}
72+
73+
[Fact]
74+
public void CountsMirroredDisplaysOnce()
75+
{
76+
// Two displays mirroring the same area would otherwise cover the off-screen
77+
// half of the window twice over.
78+
var bounds = new PixelRect(1400, 100, 1000, 700);
79+
Assert.Equal(new PixelRect(920, 100, 1000, 700), WindowPlacement.Fit(bounds, [Laptop, Laptop]));
80+
}
81+
82+
[Fact]
83+
public void LeavesBoundsAloneWithoutDisplayInformation()
84+
{
85+
var bounds = new PixelRect(5000, 5000, 800, 600);
86+
Assert.Equal(bounds, WindowPlacement.Fit(bounds, []));
87+
Assert.Equal(bounds, WindowPlacement.Fit(bounds, [new PixelRect(0, 0, 0, 0)]));
88+
}
89+
}

0 commit comments

Comments
 (0)