revert(winstate): drop the parked restored-size rect, it left a stub window

Widening rcNormalPosition while the window was down did remove the restore
flash, but SetWindowPlacement left a small stub frame in a corner of the screen
whenever the window was maximised — during the minimise animation and equally
once the window was already iconic. A permanent visible artefact is worse than
an ugly animation, so this goes back to the validated behaviour: correct final
state, flash still present.

The flash itself is an FMX bug, reproducible in an empty FMX app. Rationale and
the five rejected approaches are recorded in TEST_SCENARIOS.md I1.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
r-zakarya
2026-07-27 20:51:28 +01:00
parent 17e817f8ab
commit e98acabbbd
2 changed files with 19 additions and 98 deletions
+2 -2
View File
@@ -340,8 +340,8 @@ L'app démarre maximisée (`WindowState = wsMaximized` dans la `.fmx`).
- **ATTENDU** : elle revient **maximisée**, **sans passer par une frame en taille normale**. - **ATTENDU** : elle revient **maximisée**, **sans passer par une frame en taille normale**.
- **Historique** : FMX oublie l'état maximisé quand Windows minimise la fenêtre de l'extérieur, et aucun de nos chemins de restore ne tourne pour un clic sur la barre des tâches. - **Historique** : FMX oublie l'état maximisé quand Windows minimise la fenêtre de l'extérieur, et aucun de nos chemins de restore ne tourne pour un clic sur la barre des tâches.
- **Historique (flash)** : une fois l'état final correct, il restait un scintillement — le cadre apparaissait en taille normale puis grandissait. **C'est un bug FMX**, reproduit sur une application FMX vide : FMX perd le maximisé quand Windows minimise le formulaire de l'extérieur. Windows choisit alors `rcNormalPosition` comme cible **avant** d'animer, donc aucune correction réactive ne peut arriver à temps — trois ont été essayées et rejetées par la mesure : `ForceQueue` (un tour de boucle trop tard), appel inline dans `MINIMIZEEND` (le hook est délivré après le `WM_SIZE`), correction dans le `WM_SIZE` lui-même (l'état devient parfait, le flash reste — preuve que le problème n'était plus l'état). Écarté aussi : poser `WPF_RESTORETOMAXIMIZED`, que `GetWindowPlacement` rapporte déjà à 2 sur une fenêtre maximisée (no-op). - **Historique (flash)** : une fois l'état final correct, il restait un scintillement — le cadre apparaissait en taille normale puis grandissait. **C'est un bug FMX**, reproduit sur une application FMX vide : FMX perd le maximisé quand Windows minimise le formulaire de l'extérieur. Windows choisit alors `rcNormalPosition` comme cible **avant** d'animer, donc aucune correction réactive ne peut arriver à temps — trois ont été essayées et rejetées par la mesure : `ForceQueue` (un tour de boucle trop tard), appel inline dans `MINIMIZEEND` (le hook est délivré après le `WM_SIZE`), correction dans le `WM_SIZE` lui-même (l'état devient parfait, le flash reste — preuve que le problème n'était plus l'état). Écarté aussi : poser `WPF_RESTORETOMAXIMIZED`, que `GetWindowPlacement` rapporte déjà à 2 sur une fenêtre maximisée (no-op).
**Solution** : ne pas combattre la restauration, rendre sa cible inoffensive. `WinStateMinimizeStart` gare le vrai `rcNormalPosition` et le remplace par `rcWork` du moniteur ; FMX restaure toujours « en normal », mais normal vaut désormais plein écran, donc rien ne grandit. `ReleaseNormalRect` rend le rectangle réel une fois la fenêtre re-maximisée (invisible : elle est maximisée à ce moment). **Statut : non corrigé, assumé.** Une cinquième piste a été tentée puis **annulée** : garer `rcNormalPosition` et le remplacer par `rcWork` du moniteur pendant que la fenêtre est en bas, pour que « restaurer en normal » vaille déjà plein écran. Ça supprimait bien le flash, mais tout `SetWindowPlacement` sur une fenêtre en cours de minimisation — ou déjà iconique — laissait **un moignon de fenêtre dans un coin de l'écran**, y compris en différant l'écriture jusqu'à `IsIconic`. Un artefact visible permanent est pire que l'animation. Reverté.
- **Non-régression obligatoire** : après le retour, **dé-maximiser** doit rendre la taille d'origine de l'utilisateur, pas la zone de travail — sinon `ReleaseNormalRect` n'a pas tourné et le rectangle est resté garé. **Ce qui reste** : l'état final est correct (la fenêtre revient bien maximisée), seule l'animation de restauration est laide. Ne pas rouvrir sans une vraie raison — la seule route restante est de sous-classer le `WndProc` de la fenêtre FMX pour corriger le rectangle dans `WM_WINDOWPOSCHANGING`, ce qui insère du code dans la boucle de messages d'une fenêtre qu'on ne contrôle pas, avec le tray, l'autofill et le démarrage `-tray` en aval.
### I2 · normal survit aussi ✅ ### I2 · normal survit aussi ✅
- **ACTION** : dé-maximiser la fenêtre → `Win+D` → revenir. - **ACTION** : dé-maximiser la fenêtre → `Win+D` → revenir.
+10 -89
View File
@@ -19,7 +19,7 @@ uses
System.SysUtils, System.Classes, System.UITypes, System.NetEncoding, System.SysUtils, System.Classes, System.UITypes, System.NetEncoding,
System.StrUtils, System.Generics.Collections, System.IOUtils, System.JSON, System.StrUtils, System.Generics.Collections, System.IOUtils, System.JSON,
System.Net.HttpClient, System.Net.URLClient, System.Net.HttpClient, System.Net.URLClient,
Winapi.Windows, Winapi.ShellAPI, Winapi.MultiMon, Winapi.Windows, Winapi.ShellAPI,
FMX.Forms, FMX.Controls, FMX.Controls.Presentation, FMX.StdCtrls, FMX.Forms, FMX.Controls, FMX.Controls.Presentation, FMX.StdCtrls,
FMX.Memo, FMX.Memo.Types, FMX.ScrollBox, FMX.Edit, FMX.Layouts, FMX.Types, FMX.Memo, FMX.Memo.Types, FMX.ScrollBox, FMX.Edit, FMX.Layouts, FMX.Types,
FMX.Dialogs, FMX.DialogService, FMX.Dialogs, FMX.DialogService,
@@ -87,13 +87,6 @@ type
// True from "minimise starts" until just after the restore settles, so the // True from "minimise starts" until just after the restore settles, so the
// resizes FMX fires while restoring are not recorded as user intent. // resizes FMX fires while restoring are not recorded as user intent.
FIgnoreResizes: Boolean; 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 // Set from activate until the queued correction runs, so the restore's
// own resize is not mistaken for the user resizing the window. // own resize is not mistaken for the user resizing the window.
FRestoring: Boolean; FRestoring: Boolean;
@@ -184,7 +177,6 @@ type
procedure WinStateMinimizeStart; procedure WinStateMinimizeStart;
procedure WinStateMinimizeEnd; procedure WinStateMinimizeEnd;
procedure ApplyWantedWindowState; procedure ApplyWantedWindowState;
procedure ReleaseNormalRect;
end; end;
var var
@@ -1661,70 +1653,21 @@ end;
// The window is going down: from here until the restore settles, every resize // 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 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; procedure TMainForm.WinStateMinimizeStart;
var
LHwnd: HWND;
LPlace: TWindowPlacement;
LMonitor: TMonitorInfo;
begin begin
FIgnoreResizes := True; 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; end;
// Back up: re-apply what the user had. // Back up: re-apply what the user had.
procedure TMainForm.WinStateMinimizeEnd; procedure TMainForm.WinStateMinimizeEnd;
begin begin
// Normally a no-op by now: the correction already ran from the restore's own // Inline, not queued: by the time this hook fires Windows has already put the
// OnResize, which Windows delivers BEFORE this hook. Kept for the paths that // window back at its normal size, and measurement showed nothing touches the
// reach here without a resize. // state afterwards — the extra message-loop turn a ForceQueue costs was the
// whole flicker, one painted normal-sized frame before the correction landed.
ApplyWantedWindowState; ApplyWantedWindowState;
// Net: whatever path we took, the real restored-size rectangle must never // Second pass anyway, in case FMX sizes the window after us on some path.
// stay parked past the end of a restore. // Idempotent: it only acts when the state still disagrees.
ReleaseNormalRect;
TThread.ForceQueue(nil, TThread.ForceQueue(nil,
procedure procedure
begin begin
@@ -1734,36 +1677,22 @@ begin
end); end);
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; procedure TMainForm.ApplyWantedWindowState;
var var
LHwnd: HWND; LHwnd: HWND;
begin begin
if FApplyingWindowState then Exit;
LHwnd := WindowHandleToPlatform(Self.Handle).Wnd; 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 (LHwnd = 0) or IsIconic(LHwnd) then Exit;
if FWantMaximized = IsZoomed(LHwnd) then Exit; if FWantMaximized and (not IsZoomed(LHwnd)) then
FApplyingWindowState := True;
try
if FWantMaximized then
begin begin
LogLine('[winstate] re-maximising after restore'); LogLine('[winstate] re-maximising after restore');
ShowWindow(LHwnd, SW_SHOWMAXIMIZED); 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 end
else else if (not FWantMaximized) and IsZoomed(LHwnd) then
begin begin
LogLine('[winstate] restoring to normal after restore'); LogLine('[winstate] restoring to normal after restore');
ShowWindow(LHwnd, SW_RESTORE); ShowWindow(LHwnd, SW_RESTORE);
end; end;
finally
FApplyingWindowState := False;
end;
end; end;
// Track the state the user settles on. Ignored entirely while a minimise / // Track the state the user settles on. Ignored entirely while a minimise /
@@ -1772,15 +1701,7 @@ procedure TMainForm.FormResizeTrackState(Sender: TObject);
var var
LHwnd: HWND; LHwnd: HWND;
begin begin
// A minimise / restore is in flight, so this resize is never a user gesture — if FIgnoreResizes then Exit;
// 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; LHwnd := WindowHandleToPlatform(Self.Handle).Wnd;
if (LHwnd = 0) or IsIconic(LHwnd) then Exit; if (LHwnd = 0) or IsIconic(LHwnd) then Exit;
if FWantMaximized <> IsZoomed(LHwnd) then if FWantMaximized <> IsZoomed(LHwnd) then