diff --git a/TEST_SCENARIOS.md b/TEST_SCENARIOS.md index 6f2ae71..2f4b464 100644 --- a/TEST_SCENARIOS.md +++ b/TEST_SCENARIOS.md @@ -339,7 +339,9 @@ L'app démarre maximisée (`WindowState = wsMaximized` dans la `.fmx`). - **ACTION** : fenêtre **maximisée** → `Win+D` (ou *Show desktop*) → revenir à l'app (barre des tâches, tray ou Alt+Tab). - **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 (flash)** : une fois l'état correct, il restait un scintillement — la fenêtre revenait normale puis se maximisait. La correction était postée via `ForceQueue` ; le log a montré que l'état ne changeait **pas** entre le hook et le tour de boucle suivant, donc la file ne protégeait d'aucune course et n'ajoutait que la frame visible. Elle s'exécute maintenant **en ligne** dans `MINIMIZEEND` (`ApplyWantedWindowState`), la passe différée restant en filet idempotent. Piste écartée au passage : poser `WPF_RESTORETOMAXIMIZED` — `GetWindowPlacement` rapporte déjà ce flag sur toute fenêtre maximisée, donc le stamp est un 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). +- **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é. ### I2 · normal survit aussi ✅ - **ACTION** : dé-maximiser la fenêtre → `Win+D` → revenir. diff --git a/delphi-backend/UMainForm.pas b/delphi-backend/UMainForm.pas index fed16c0..c598f51 100644 --- a/delphi-backend/UMainForm.pas +++ b/delphi-backend/UMainForm.pas @@ -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