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:
+108
-70
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user