fix(hotkeys): find the native dialog by enumerating the thread windows

GetLastActivePopup returns the form itself - the common Save dialog is not an owned popup - so the code that raises it never ran. It does live on our thread with the standard #32770 class, which EnumThreadWindows finds regardless of its owner.

Logging confirmed two things worth keeping: WM_HOTKEY is dispatched during the dialog modal loop, and IsWindowEnabled detects the state correctly.
This commit is contained in:
r-zakarya
2026-08-01 19:03:03 +01:00
parent d6701a8994
commit 4a78dc2e90
2 changed files with 30 additions and 2 deletions
+3 -1
View File
@@ -185,7 +185,9 @@ plus bas.
- **Deuxième cause, côté Delphi** : `Ctrl+Shift+Q` et `Ctrl+Shift+A` appellent `RestoreWindowFromTray` **avant** que JS ne puisse refuser — un garde JS arrive trop tard par construction. Restaurer la fenêtre propriétaire d'une modale native laisse le dialogue en rade derrière tout le reste : l'app semble gelée alors qu'elle est simplement *désactivée* par une boîte devenue introuvable (au point qu'il fallait un utilitaire de capture d'écran pour la faire réapparaître).
- **Garde** : `NativeModalActive` teste `IsWindowEnabled` sur notre fenêtre — Windows la désactive tant qu'une modale native lui appartient. Posé en tête des **quatre** points d'entrée hotkey (quick search hotkey + menu tray, new entry, autofill). Il ne se contente pas de refuser : il **ramène le dialogue au premier plan** via `GetLastActivePopup`, ce qui est précisément ce dont l'utilisateur a besoin.
- **ATTENDU après correctif** : pendant l'« Enregistrer sous », n'importe quel hotkey **fait réapparaître le dialogue au premier plan, avec le focus**, et une **notification tray** explique que les raccourcis restent inactifs tant qu'il n'a pas répondu.
- **Piège du focus** : `SetForegroundWindow` est **refusé** à un processus qui n'est pas déjà devant, et le hotkey vient d'une autre application. Il faut attacher notre file d'entrées au thread de la **fenêtre de premier plan** (VS Code). Réutiliser le helper d'autofill ne marche **pas** : il attache au thread de la *cible*, or le dialogue appartient à notre propre thread — l'attache est un no-op et le dialogue remontait sans jamais recevoir le focus.
- **Piège n°1, localiser le dialogue** : `GetLastActivePopup` renvoie **le formulaire lui-même** — la boîte « Enregistrer sous » n'est pas une popup possédée par notre fenêtre. Elle vit en revanche sur notre thread avec la classe standard `#32770`, donc `EnumThreadWindows` la trouve quel que soit son propriétaire. Mesuré : sans ça le bloc de remontée ne s'exécutait jamais, silencieusement.
- **Piège n°2, le focus** : `SetForegroundWindow` est **refusé** à un processus qui n'est pas déjà devant, et le hotkey vient d'une autre application. Il faut attacher notre file d'entrées au thread de la **fenêtre de premier plan** (VS Code). Réutiliser le helper d'autofill ne marche **pas** : il attache au thread de la *cible*, or le dialogue appartient à notre propre thread — l'attache est un no-op et le dialogue remontait sans jamais recevoir le focus.
- **Vérifié au log** : le `WM_HOTKEY` **est** bien distribué pendant la boucle modale du dialogue, et `IsWindowEnabled` détecte correctement l'état. Ces deux points étaient les hypothèses coûteuses à écarter avant de chercher ailleurs.
- **Piège du message** : une **balloon tray**, pas un toast — notre fenêtre est désactivée et derrière le dialogue, rien de rendu dans la page ne serait visible. Elle suit le réglage « alertes tray » : désactivé, tu auras le focus sans le message.
### B14 · dialogues natifs ouverts par un clic programmatique
+27 -1
View File
@@ -201,6 +201,11 @@ implementation
{$R *.fmx}
// Result slot for EnumOwnDialogProc — the enumeration is main-thread only and
// consumed immediately, so a unit variable is enough.
var
GFoundDialogHwnd: HWND = 0;
// Forward — used by WebBrowserInitialized (which sits above the actual
// definition lower in the unit).
function MaskAccessToken(const AUrl: string): string; forward;
@@ -503,6 +508,24 @@ end;
// the user can no longer find. Every hotkey entry point checks this first — and
// rather than just refusing, it surfaces the dialog, which is what the user
// actually needs.
// The common Save/Open dialog is NOT an owned popup of the form —
// GetLastActivePopup returns the form itself — but it does live on our thread
// and carries the standard dialog class. Enumerating the thread's windows finds
// it whoever its owner turns out to be.
function EnumOwnDialogProc(AWnd: HWND; AParam: LPARAM): BOOL; stdcall;
var
LCls: array[0..63] of Char;
begin
Result := True; // keep enumerating
if (AWnd = HWND(AParam)) or (not IsWindowVisible(AWnd)) then Exit;
if GetClassName(AWnd, LCls, Length(LCls)) = 0 then Exit;
if SameText(LCls, '#32770') then
begin
GFoundDialogHwnd := AWnd;
Result := False; // stop
end;
end;
function TMainForm.NativeModalActive: Boolean;
var
LHwnd, LPopup: HWND;
@@ -510,7 +533,10 @@ begin
LHwnd := WindowHandleToPlatform(Self.Handle).Wnd;
Result := (LHwnd <> 0) and (not IsWindowEnabled(LHwnd));
if not Result then Exit;
LPopup := GetLastActivePopup(LHwnd);
GFoundDialogHwnd := 0;
EnumThreadWindows(GetCurrentThreadId, @EnumOwnDialogProc, LPARAM(LHwnd));
LPopup := GFoundDialogHwnd;
if LPopup = 0 then LPopup := GetLastActivePopup(LHwnd); // fallback
if (LPopup <> 0) and (LPopup <> LHwnd) and IsWindow(LPopup) then
begin
ShowWindow(LPopup, SW_SHOW);