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
|
// which happens however the user comes back. FWantMaximized mirrors the
|
||||||
// user's LAST deliberate choice, so we never fight a normal-size window.
|
// user's LAST deliberate choice, so we never fight a normal-size window.
|
||||||
FWantMaximized: Boolean;
|
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
|
// 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;
|
||||||
@@ -126,7 +134,6 @@ type
|
|||||||
// needing two .fmx variants. Aligned to Client to fill the remaining
|
// needing two .fmx variants. Aligned to Client to fill the remaining
|
||||||
// space between PanelTop (top) and PanelLog/Splitter (bottom).
|
// space between PanelTop (top) and PanelLog/Splitter (bottom).
|
||||||
WebBrowser: TWebBrowserClass;
|
WebBrowser: TWebBrowserClass;
|
||||||
procedure FormActivateRestoreState(Sender: TObject);
|
|
||||||
procedure FormResizeTrackState(Sender: TObject);
|
procedure FormResizeTrackState(Sender: TObject);
|
||||||
procedure AutofillTimerTick(Sender: TObject);
|
procedure AutofillTimerTick(Sender: TObject);
|
||||||
// Shared by cmd://file/save (single-shot) and file/save-commit
|
// Shared by cmd://file/save (single-shot) and file/save-commit
|
||||||
@@ -159,6 +166,11 @@ type
|
|||||||
procedure BridgeQuickSearchHotkey(AKind: TAutofillKind;
|
procedure BridgeQuickSearchHotkey(AKind: TAutofillKind;
|
||||||
ATargetHWND: HWND; const ATitle: string);
|
ATargetHWND: HWND; const ATitle: string);
|
||||||
procedure WebBrowserInitialized(Sender: TObject);
|
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;
|
end;
|
||||||
|
|
||||||
var
|
var
|
||||||
@@ -172,14 +184,37 @@ implementation
|
|||||||
// definition lower in the unit).
|
// definition lower in the unit).
|
||||||
function MaskAccessToken(const AUrl: string): string; forward;
|
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);
|
procedure TMainForm.FormCreate(Sender: TObject);
|
||||||
begin
|
begin
|
||||||
// The .fmx ships WindowState = wsMaximized, so that is the starting intent.
|
// The .fmx ships WindowState = wsMaximized, so that is the starting intent.
|
||||||
// Wired here (not in the .fmx) to keep the designer file untouched.
|
// Wired here (not in the .fmx) to keep the designer file untouched.
|
||||||
FWantMaximized := (WindowState = TWindowState.wsMaximized);
|
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;
|
FRestoring := False;
|
||||||
OnResize := FormResizeTrackState;
|
OnResize := FormResizeTrackState;
|
||||||
OnActivate := FormActivateRestoreState;
|
|
||||||
|
|
||||||
// Create the WebBrowser control programmatically so the {$IFDEF
|
// Create the WebBrowser control programmatically so the {$IFDEF
|
||||||
// USE_EDGE_BROWSER} directive can swap the concrete class without a
|
// USE_EDGE_BROWSER} directive can swap the concrete class without a
|
||||||
@@ -279,6 +314,11 @@ end;
|
|||||||
|
|
||||||
procedure TMainForm.FormDestroy(Sender: TObject);
|
procedure TMainForm.FormDestroy(Sender: TObject);
|
||||||
begin
|
begin
|
||||||
|
if FWinEventHook <> 0 then
|
||||||
|
begin
|
||||||
|
UnhookWinEvent(FWinEventHook);
|
||||||
|
FWinEventHook := 0;
|
||||||
|
end;
|
||||||
if Assigned(FFileSaveChunks) then
|
if Assigned(FFileSaveChunks) then
|
||||||
begin
|
begin
|
||||||
for var LSB in FFileSaveChunks.Values do LSB.Free;
|
for var LSB in FFileSaveChunks.Values do LSB.Free;
|
||||||
@@ -1507,81 +1547,79 @@ begin
|
|||||||
Application.Terminate;
|
Application.Terminate;
|
||||||
end;
|
end;
|
||||||
|
|
||||||
// Remember the state the USER settled on. Reads Win32 (IsZoomed/IsIconic)
|
// ---- Window maximised-state preservation --------------------------------
|
||||||
// rather than FMX's WindowState, which does not reliably follow a native
|
// FMX forgets a window was maximised when Windows minimises it from the
|
||||||
// un-maximise.
|
// 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);
|
procedure TMainForm.FormResizeTrackState(Sender: TObject);
|
||||||
var
|
var
|
||||||
LHwnd: HWND;
|
LHwnd: HWND;
|
||||||
LPlace: TWindowPlacement;
|
|
||||||
LIsRestore: Boolean;
|
|
||||||
begin
|
begin
|
||||||
|
if FIgnoreResizes then Exit;
|
||||||
LHwnd := WindowHandleToPlatform(Self.Handle).Wnd;
|
LHwnd := WindowHandleToPlatform(Self.Handle).Wnd;
|
||||||
if LHwnd = 0 then Exit;
|
if (LHwnd = 0) or IsIconic(LHwnd) then Exit;
|
||||||
if IsIconic(LHwnd) then Exit; // nothing to learn while minimised
|
if FWantMaximized <> IsZoomed(LHwnd) then
|
||||||
|
|
||||||
// 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
|
|
||||||
begin
|
begin
|
||||||
// Not a user gesture — never record it as intent. If it dropped a window
|
FWantMaximized := IsZoomed(LHwnd);
|
||||||
// the user had left maximised, put it back (queued: inline loses the race
|
LogLine('[winstate] user resize: want_max=' +
|
||||||
// against FMX's own sizing).
|
BoolToStr(FWantMaximized, True));
|
||||||
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);
|
|
||||||
end;
|
|
||||||
Exit;
|
|
||||||
end;
|
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;
|
end;
|
||||||
|
|
||||||
procedure TMainForm.AutofillTimerTick(Sender: TObject);
|
procedure TMainForm.AutofillTimerTick(Sender: TObject);
|
||||||
|
|||||||
Reference in New Issue
Block a user