diff --git a/delphi-backend/UMainForm.pas b/delphi-backend/UMainForm.pas index 488d696..ce171b1 100644 --- a/delphi-backend/UMainForm.pas +++ b/delphi-backend/UMainForm.pas @@ -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); - end; - Exit; + FWantMaximized := IsZoomed(LHwnd); + LogLine('[winstate] user resize: want_max=' + + BoolToStr(FWantMaximized, True)); 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);