fix(winstate): park the restored-size rect so the FMX restore has nothing to grow

FMX drops the maximised state when Windows minimises the form externally —
reproducible in an empty FMX app. Windows then picks rcNormalPosition as the
restore target before it animates, so every reactive correction measured (queued,
inline in MINIMIZEEND, inside the restore's own WM_SIZE) landed after the frame
was already growing: the state ended correct and the flash remained.

Widen rcNormalPosition to the monitor work area while the window is down instead.
FMX still restores to normal, except normal is now full-screen, so nothing grows;
ReleaseNormalRect writes the real rectangle back once the window is maximised
again, keeping the user's own restored size intact.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
r-zakarya
2026-07-27 20:13:52 +01:00
parent b52cdc2d0b
commit 17e817f8ab
2 changed files with 99 additions and 18 deletions
+96 -17
View File
@@ -19,7 +19,7 @@ uses
System.SysUtils, System.Classes, System.UITypes, System.NetEncoding,
System.StrUtils, System.Generics.Collections, System.IOUtils, System.JSON,
System.Net.HttpClient, System.Net.URLClient,
Winapi.Windows, Winapi.ShellAPI,
Winapi.Windows, Winapi.ShellAPI, Winapi.MultiMon,
FMX.Forms, FMX.Controls, FMX.Controls.Presentation, FMX.StdCtrls,
FMX.Memo, FMX.Memo.Types, FMX.ScrollBox, FMX.Edit, FMX.Layouts, FMX.Types,
FMX.Dialogs, FMX.DialogService,
@@ -87,6 +87,13 @@ type
// True from "minimise starts" until just after the restore settles, so the
// resizes FMX fires while restoring are not recorded as user intent.
FIgnoreResizes: Boolean;
// Re-entrance guard: ApplyWantedWindowState runs from OnResize and its own
// ShowWindow fires another OnResize.
FApplyingWindowState: Boolean;
// The window's real restored-size rectangle, parked while we temporarily
// widen it to the monitor's work area (see WinStateMinimizeStart).
FSavedNormalRect: TRect;
FNormalRectHijacked: Boolean;
// Set from activate until the queued correction runs, so the restore's
// own resize is not mistaken for the user resizing the window.
FRestoring: Boolean;
@@ -177,6 +184,7 @@ type
procedure WinStateMinimizeStart;
procedure WinStateMinimizeEnd;
procedure ApplyWantedWindowState;
procedure ReleaseNormalRect;
end;
var
@@ -1653,21 +1661,70 @@ end;
// The window is going down: from here until the restore settles, every resize
// FMX fires is part of that transition, never a user gesture.
// FMX loses the maximised state whenever Windows minimises the form from the
// outside — reproducible in an empty FMX app, so there is nothing to fix on our
// side of it. The window comes back at its RESTORED size and DWM animates the
// frame growing towards that rectangle, which is the flash; every correction we
// can run (OnResize, MINIMIZEEND) lands after that decision is made.
//
// So instead of fighting the restore, make the rectangle it restores to
// harmless: park the real one and widen it to the monitor's work area while the
// window is down. FMX still restores "to normal", except normal is now
// full-screen — nothing visibly grows — and the OnResize correction promotes it
// to a true maximised window right after. ApplyWantedWindowState puts the real
// rectangle back, so un-maximising later still lands on the user's own size.
procedure TMainForm.WinStateMinimizeStart;
var
LHwnd: HWND;
LPlace: TWindowPlacement;
LMonitor: TMonitorInfo;
begin
FIgnoreResizes := True;
if FNormalRectHijacked or (not FWantMaximized) then Exit;
LHwnd := WindowHandleToPlatform(Self.Handle).Wnd;
if LHwnd = 0 then Exit;
LPlace := Default(TWindowPlacement);
LPlace.length := SizeOf(LPlace);
if not GetWindowPlacement(LHwnd, @LPlace) then Exit;
LMonitor := Default(TMonitorInfo);
LMonitor.cbSize := SizeOf(LMonitor);
if not GetMonitorInfo(MonitorFromWindow(LHwnd, MONITOR_DEFAULTTONEAREST), @LMonitor) then Exit;
FSavedNormalRect := LPlace.rcNormalPosition;
FNormalRectHijacked := True;
LPlace.rcNormalPosition := LMonitor.rcWork;
// The window is maximised right now, so touching only rcNormalPosition moves
// nothing on screen.
SetWindowPlacement(LHwnd, @LPlace);
end;
// Undo the hijack above. Safe to call whenever: it is a no-op unless the real
// rectangle is currently parked.
procedure TMainForm.ReleaseNormalRect;
var
LHwnd: HWND;
LPlace: TWindowPlacement;
begin
if not FNormalRectHijacked then Exit;
FNormalRectHijacked := False;
LHwnd := WindowHandleToPlatform(Self.Handle).Wnd;
if LHwnd = 0 then Exit;
LPlace := Default(TWindowPlacement);
LPlace.length := SizeOf(LPlace);
if not GetWindowPlacement(LHwnd, @LPlace) then Exit;
LPlace.rcNormalPosition := FSavedNormalRect;
SetWindowPlacement(LHwnd, @LPlace);
end;
// Back up: re-apply what the user had.
procedure TMainForm.WinStateMinimizeEnd;
begin
// Inline, not queued: by the time this hook fires Windows has already put the
// window back at its normal size, and measurement showed nothing touches the
// state afterwards — the extra message-loop turn a ForceQueue costs was the
// whole flicker, one painted normal-sized frame before the correction landed.
// Normally a no-op by now: the correction already ran from the restore's own
// OnResize, which Windows delivers BEFORE this hook. Kept for the paths that
// reach here without a resize.
ApplyWantedWindowState;
// Second pass anyway, in case FMX sizes the window after us on some path.
// Idempotent: it only acts when the state still disagrees.
// Net: whatever path we took, the real restored-size rectangle must never
// stay parked past the end of a restore.
ReleaseNormalRect;
TThread.ForceQueue(nil,
procedure
begin
@@ -1677,21 +1734,35 @@ begin
end);
end;
// Re-entrant by nature: it is called from OnResize and its own ShowWindow fires
// another OnResize. FApplyingWindowState keeps that one pass deep.
procedure TMainForm.ApplyWantedWindowState;
var
LHwnd: HWND;
begin
if FApplyingWindowState then Exit;
LHwnd := WindowHandleToPlatform(Self.Handle).Wnd;
// Iconic means the window is genuinely down (minimise-to-tray, autofill):
// nothing to correct, and forcing a state here would fight the hide.
if (LHwnd = 0) or IsIconic(LHwnd) then Exit;
if FWantMaximized and (not IsZoomed(LHwnd)) then
begin
LogLine('[winstate] re-maximising after restore');
ShowWindow(LHwnd, SW_SHOWMAXIMIZED);
end
else if (not FWantMaximized) and IsZoomed(LHwnd) then
begin
LogLine('[winstate] restoring to normal after restore');
ShowWindow(LHwnd, SW_RESTORE);
if FWantMaximized = IsZoomed(LHwnd) then Exit;
FApplyingWindowState := True;
try
if FWantMaximized then
begin
LogLine('[winstate] re-maximising after restore');
ShowWindow(LHwnd, SW_SHOWMAXIMIZED);
// Maximised again, so writing rcNormalPosition back is invisible here —
// and it has to happen before the user can un-maximise.
ReleaseNormalRect;
end
else
begin
LogLine('[winstate] restoring to normal after restore');
ShowWindow(LHwnd, SW_RESTORE);
end;
finally
FApplyingWindowState := False;
end;
end;
@@ -1701,7 +1772,15 @@ procedure TMainForm.FormResizeTrackState(Sender: TObject);
var
LHwnd: HWND;
begin
if FIgnoreResizes then Exit;
// A minimise / restore is in flight, so this resize is never a user gesture —
// but it IS where Windows applies the normal-sized restore, one message before
// MINIMIZEEND is delivered. Correcting from the hook therefore always paints a
// normal frame first; this is the earliest point we get.
if FIgnoreResizes then
begin
ApplyWantedWindowState;
Exit;
end;
LHwnd := WindowHandleToPlatform(Self.Handle).Wnd;
if (LHwnd = 0) or IsIconic(LHwnd) then Exit;
if FWantMaximized <> IsZoomed(LHwnd) then