fix(window): observe the minimise itself via a WinEvent hook

Third discriminator measured and rejected: the log shows the restore's resize
arrives with fg=True (22:44:08), so foreground ownership no more separates it
from a user gesture than the placement flags did.

FMX simply exposes nothing usable — no resize while iconic, and the restore's
resize is indistinguishable from the user's own. So stop inferring and observe
the transition at the OS level: SetWinEventHook on EVENT_SYSTEM_MINIMIZESTART
/ MINIMIZEEND, scoped to our process, WINEVENT_OUTOFCONTEXT (no injection,
callback on our thread).

Minimise start raises a guard that makes the resize handler ignore everything
until the restore settles; minimise end re-applies the state the user actually
left, in both directions. Hook failure is non-fatal — only the preservation is
lost. Unhooked in FormDestroy.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
r-zakarya
2026-07-25 22:47:52 +01:00
parent ca54c39c75
commit 7a19bdc13b
+108 -70
View File
@@ -79,6 +79,14 @@ type
// which happens however the user comes back. FWantMaximized mirrors the
// user's LAST deliberate choice, so we never fight a normal-size window.
FWantMaximized: Boolean;
// Handle of the WinEvent hook that reports minimise start/end for our own
// window. FMX gives no usable signal (no resize while iconic, and the
// restore's resize is indistinguishable from a user gesture by placement
// flags or foreground ownership — both were measured and rejected).
FWinEventHook: THandle;
// 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;
// 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;
@@ -126,7 +134,6 @@ type
// needing two .fmx variants. Aligned to Client to fill the remaining
// space between PanelTop (top) and PanelLog/Splitter (bottom).
WebBrowser: TWebBrowserClass;
procedure FormActivateRestoreState(Sender: TObject);
procedure FormResizeTrackState(Sender: TObject);
procedure AutofillTimerTick(Sender: TObject);
// Shared by cmd://file/save (single-shot) and file/save-commit
@@ -159,6 +166,11 @@ type
procedure BridgeQuickSearchHotkey(AKind: TAutofillKind;
ATargetHWND: HWND; const ATitle: string);
procedure WebBrowserInitialized(Sender: TObject);
public
// Called from the WinEvent hook. WINEVENT_OUTOFCONTEXT delivers through
// our own message queue, so these already run on the main thread.
procedure WinStateMinimizeStart;
procedure WinStateMinimizeEnd;
end;
var
@@ -172,14 +184,37 @@ implementation
// definition lower in the unit).
function MaskAccessToken(const AUrl: string): string; forward;
// ---- WinEvent hook plumbing (see the maximised-state block further down) --
const
EVENT_SYSTEM_MINIMIZESTART = $0016;
EVENT_SYSTEM_MINIMIZEEND = $0017;
WINEVENT_OUTOFCONTEXT = $0000;
OBJID_WINDOW = 0;
function SetWinEventHook(eventMin, eventMax: DWORD; hmodWinEventProc: HMODULE;
lpfnWinEventProc: Pointer; idProcess, idThread, dwFlags: DWORD): THandle;
stdcall; external 'user32.dll' name 'SetWinEventHook';
function UnhookWinEvent(hWinEventHook: THandle): BOOL;
stdcall; external 'user32.dll' name 'UnhookWinEvent';
// Defined lower down, next to the window-state logic it serves.
procedure WinStateEventProc(hWinEventHook: THandle; AEvent: DWORD; AWnd: HWND;
idObject, idChild: LongInt; idEventThread, dwmsEventTime: DWORD); stdcall; forward;
procedure TMainForm.FormCreate(Sender: TObject);
begin
// The .fmx ships WindowState = wsMaximized, so that is the starting intent.
// Wired here (not in the .fmx) to keep the designer file untouched.
FWantMaximized := (WindowState = TWindowState.wsMaximized);
FIgnoreResizes := False;
// Scoped to our own process; WINEVENT_OUTOFCONTEXT means no DLL injection
// and the callback arrives on this thread. Failure is non-fatal — we just
// lose the maximised-state preservation.
FWinEventHook := SetWinEventHook(
EVENT_SYSTEM_MINIMIZESTART, EVENT_SYSTEM_MINIMIZEEND, 0,
@WinStateEventProc, GetCurrentProcessId, 0, WINEVENT_OUTOFCONTEXT);
FRestoring := False;
OnResize := FormResizeTrackState;
OnActivate := FormActivateRestoreState;
// Create the WebBrowser control programmatically so the {$IFDEF
// USE_EDGE_BROWSER} directive can swap the concrete class without a
@@ -279,6 +314,11 @@ end;
procedure TMainForm.FormDestroy(Sender: TObject);
begin
if FWinEventHook <> 0 then
begin
UnhookWinEvent(FWinEventHook);
FWinEventHook := 0;
end;
if Assigned(FFileSaveChunks) then
begin
for var LSB in FFileSaveChunks.Values do LSB.Free;
@@ -1507,81 +1547,79 @@ begin
Application.Terminate;
end;
// Remember the state the USER settled on. Reads Win32 (IsZoomed/IsIconic)
// rather than FMX's WindowState, which does not reliably follow a native
// un-maximise.
// ---- Window maximised-state preservation --------------------------------
// FMX forgets a window was maximised when Windows minimises it from the
// outside (Win+D / Show desktop / taskbar): it comes back "normal". None of
// our own restore paths run for a taskbar restore, and three cheaper signals
// were measured and rejected against real traces:
// * FMX WindowState — does not follow a native un-maximise
// * WPF_RESTORETOMAXIMIZED — set for ANY maximised window, not just a restore
// * foreground ownership — already ours during the restore's resize
// So observe the minimise itself, at the OS level, with a WinEvent hook.
procedure WinStateEventProc(hWinEventHook: THandle; AEvent: DWORD; AWnd: HWND;
idObject, idChild: LongInt; idEventThread, dwmsEventTime: DWORD); stdcall;
begin
if (idObject <> OBJID_WINDOW) or (AWnd = 0) or (MainForm = nil) then Exit;
if AWnd <> WindowHandleToPlatform(MainForm.Handle).Wnd then Exit;
if AEvent = EVENT_SYSTEM_MINIMIZESTART then MainForm.WinStateMinimizeStart
else if AEvent = EVENT_SYSTEM_MINIMIZEEND then MainForm.WinStateMinimizeEnd;
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.
procedure TMainForm.WinStateMinimizeStart;
begin
FIgnoreResizes := True;
LogLine('[winstate] minimise start (want_max=' +
BoolToStr(FWantMaximized, True) + ')');
end;
// Back up: re-apply what the user had. Queued so it lands after FMX has
// finished sizing the window — inline loses that race.
procedure TMainForm.WinStateMinimizeEnd;
begin
LogLine('[winstate] minimise end (want_max=' +
BoolToStr(FWantMaximized, True) + ')');
TThread.ForceQueue(nil,
procedure
var
LHwnd: HWND;
begin
LHwnd := WindowHandleToPlatform(Self.Handle).Wnd;
if (LHwnd <> 0) and (not IsIconic(LHwnd)) then
begin
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);
end;
end;
// Release the guard only after our own ShowWindow's resize has passed.
TThread.ForceQueue(nil, procedure begin FIgnoreResizes := False; end);
end);
end;
// Track the state the user settles on. Ignored entirely while a minimise /
// restore is in flight — that is the whole point of the hook above.
procedure TMainForm.FormResizeTrackState(Sender: TObject);
var
LHwnd: HWND;
LPlace: TWindowPlacement;
LIsRestore: Boolean;
begin
if FIgnoreResizes then Exit;
LHwnd := WindowHandleToPlatform(Self.Handle).Wnd;
if LHwnd = 0 then Exit;
if IsIconic(LHwnd) then Exit; // nothing to learn while minimised
// Windows offers no usable "this resize is a restore" signal: the log showed
// WPF_RESTORETOMAXIMIZED (flags=2) is set for ANY maximised window, and
// showCmd is already 1/3 by then — testing those classified the user's own
// Maximize click as a restore and dropped the intent.
//
// What does separate them: a user gesture happens while the window is the
// FOREGROUND window. FMX's re-layout during a restore (and during startup)
// runs before the window owns the foreground.
LIsRestore := GetForegroundWindow <> LHwnd;
FillChar(LPlace, SizeOf(LPlace), 0); // diagnostics only
LPlace.length := SizeOf(LPlace);
GetWindowPlacement(LHwnd, @LPlace);
LogLine(Format('[winstate] resize zoomed=%s showCmd=%d flags=%d fg=%s want=%s',
[BoolToStr(IsZoomed(LHwnd), True), LPlace.showCmd, LPlace.flags,
BoolToStr(not LIsRestore, True), BoolToStr(FWantMaximized, True)]));
if LIsRestore then
if (LHwnd = 0) or IsIconic(LHwnd) then Exit;
if FWantMaximized <> IsZoomed(LHwnd) then
begin
// Not a user gesture — never record it as intent. If it dropped a window
// the user had left maximised, put it back (queued: inline loses the race
// against FMX's own sizing).
if FWantMaximized and (not IsZoomed(LHwnd)) and (not FRestoring) then
begin
FRestoring := True;
TThread.ForceQueue(nil,
procedure
var LH: HWND;
begin
FRestoring := False;
LH := WindowHandleToPlatform(Self.Handle).Wnd;
if (LH = 0) or IsIconic(LH) or IsZoomed(LH) then Exit;
LogLine('[winstate] re-maximising after restore');
ShowWindow(LH, SW_SHOWMAXIMIZED);
end);
FWantMaximized := IsZoomed(LHwnd);
LogLine('[winstate] user resize: want_max=' +
BoolToStr(FWantMaximized, True));
end;
Exit;
end;
if FRestoring then Exit; // our own ShowWindow's resize
FWantMaximized := IsZoomed(LHwnd); // genuine user gesture
end;
// Safety net only: if the window is back, visible and not maximised while the
// user's last deliberate state WAS maximised, put it right. Guarded so the
// constant activate churn (WebView2 focus) can't act on its own.
procedure TMainForm.FormActivateRestoreState(Sender: TObject);
var
LHwnd: HWND;
begin
if FRestoring or (not FWantMaximized) then Exit;
LHwnd := WindowHandleToPlatform(Self.Handle).Wnd;
if (LHwnd = 0) or IsIconic(LHwnd) or IsZoomed(LHwnd) then Exit;
FRestoring := True;
LogLine('[winstate] activate: re-maximising (want=True, zoomed=False)');
TThread.ForceQueue(nil,
procedure
var LH: HWND;
begin
FRestoring := False;
LH := WindowHandleToPlatform(Self.Handle).Wnd;
if (LH = 0) or IsIconic(LH) or IsZoomed(LH) then Exit;
ShowWindow(LH, SW_SHOWMAXIMIZED);
end);
end;
procedure TMainForm.AutofillTimerTick(Sender: TObject);