From 09bbd3fe9e1dd44dbec1ef8fb416c39c434103d6 Mon Sep 17 00:00:00 2001 From: r-zakarya <82443831+r-zakarya@users.noreply.github.com> Date: Wed, 26 Aug 2026 15:42:49 +0100 Subject: [PATCH] feat(autofill): lazy mode - une barrette de deux boutons pour remplir a la souris Menu du tray -> "Lazy mode". Deux boutons Full / Password qui flottent au-dessus de tout et font ce que font Ctrl+Shift+L / Ctrl+Shift+P. WS_EX_NOACTIVATE est toute l'astuce : une fenetre ordinaire prendrait le premier plan au clic, le champ que l'utilisateur vient de cliquer perdrait son curseur, et GetForegroundWindow nous designerait nous-memes comme cible. Le clic ne deplace donc jamais le premier plan. Le corps du handler WM_HOTKEY est extrait dans FireAutofillRequest : hotkey et barrette partagent la capture de la cible, le matching de titre et le picker. Un seul chemin, deux gestes. Co-Authored-By: Claude Opus 5 --- CLAUDE.md | 26 +++++ delphi-backend/Source/PM.Bridge.pas | 166 +++++++++++++++++++++++++--- 2 files changed, 175 insertions(+), 17 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 9262929..5a55b1b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -160,6 +160,32 @@ Retour Delphi → JS via `WebBrowser.ExecuteJavaScript('Bridge.onX(...)')`. Combos autofill configurables depuis Settings (synced en DB via `settings_json`). +## Lazy mode (remplir à la souris) + +Menu du tray → **Lazy mode (fill with the mouse)** : une barrette de deux +boutons *Full* / *Password* qui flotte au-dessus de tout et fait exactement ce +que font `Ctrl+Shift+L` / `Ctrl+Shift+P`. + +**`WS_EX_NOACTIVATE` est toute l'astuce.** Une fenêtre ordinaire prendrait le +premier plan au clic — le champ que l'utilisateur vient de cliquer perdrait son +curseur, et `GetForegroundWindow` nous désignerait nous-mêmes comme cible. +NOACTIVATE veut dire que le clic ne déplace jamais le premier plan : le +navigateur reste actif, son champ reste focalisé, et la capture voit exactement +ce que voit le chemin hotkey. + +Détails : +- Fenêtre créée par un **second** `AllocateHWnd` (`FLazyWnd`), séparée de la + fenêtre message-only : `WS_EX_TOPMOST | WS_EX_TOOLWINDOW | WS_EX_NOACTIVATE`, + deux enfants `BUTTON` (IDs 101/102) → `WM_COMMAND` → `FireAutofillRequest`. +- `FireAutofillRequest(AKind)` est le corps **extrait** du handler `WM_HOTKEY` : + hotkey et barrette partagent capture de la cible, matching de titre et picker. + Un seul chemin, deux gestes. +- `WM_NCHITTEST` → `HTCAPTION` : toute la barrette est sa propre barre de titre + (elle finit par se poser sur le champ qu'elle remplit, il faut pouvoir la + pousser). `WM_ERASEBKGND` peint le fond — la classe `TPUtilWindow` + d'`AllocateHWnd` n'a **pas** de brosse de fond. +- Position : coin bas-droit de la *work area* au démarrage, **pas persistée**. + ## Debug mode Fichier `config.txt` à côté de l'exe (tous champs optionnels) : diff --git a/delphi-backend/Source/PM.Bridge.pas b/delphi-backend/Source/PM.Bridge.pas index c707b56..19d639f 100644 --- a/delphi-backend/Source/PM.Bridge.pas +++ b/delphi-backend/Source/PM.Bridge.pas @@ -142,6 +142,21 @@ type // by the host). FQuickSearchHotkeyRegistered: Boolean; FOnQuickSearchHotkey: TAutofillRequestEvent; + // "Lazy mode": a two-button strip floating above every other window, doing + // with the mouse what the autofill hotkeys do with the keyboard. 0 when off. + // + // WS_EX_NOACTIVATE IS THE WHOLE TRICK. A normal window would take the + // foreground on click, and the field the user just clicked in would lose + // its caret before we could type into it - GetForegroundWindow would then + // name US as the target. NOACTIVATE means the click never moves the + // foreground: the browser stays active, its field stays focused, and the + // capture below sees exactly what the hotkey path sees. + FLazyWnd: HWND; + procedure LazyWindowHandler(var AMsg: TMessage); + // Capture the foreground window and hand it to the host. Shared by the + // autofill hotkeys and the lazy strip - same target, same title matching, + // same picker. Only the gesture differs. + procedure FireAutofillRequest(AKind: TAutofillKind); procedure MsgWindowHandler(var AMsg: TMessage); procedure PrepareNid; procedure ShowTrayMenu; @@ -217,6 +232,9 @@ type ARestoreAfter: Boolean = False; AClearFirst: Boolean = True): Boolean; property SecureClipboard: TSecureClipboard read FSecureClipboard; property TrayAdded: Boolean read FTrayAdded; + // Show or hide the lazy strip. Idempotent. + procedure SetLazyBar(AOn: Boolean); + function LazyBarVisible: Boolean; property AutofillRegistered: Boolean read FAutofillRegistered; // Per-slot outcome of the last SetAutofillHotkeys — lets the host name // exactly which combo lost the race with another app's global hotkey @@ -353,6 +371,15 @@ const AF_MOD_CONTROL = $0002; // same value as MOD_CONTROL AF_MOD_SHIFT = $0004; // same value as MOD_SHIFT +// Lazy strip - child button IDs and geometry. Deliberately small: it sits on +// top of whatever the user is reading, so it has to cost as little screen as +// two words allow. +const + LAZY_ID_FULL = 101; + LAZY_ID_PWD = 102; + LAZY_W = 148; + LAZY_H = 40; + // Dynamic WTS function pointers — wtsapi32.dll is not guaranteed on all // Windows SKUs (e.g. minimal Server Core without Session Services), so // we load it at runtime and tolerate absence gracefully. @@ -601,6 +628,7 @@ begin if FIconOwned and (FIconHandle <> 0) then DestroyIcon(FIconHandle); + if FLazyWnd <> 0 then DeallocateHWnd(FLazyWnd); DeallocateHWnd(FMsgWindow); FSecureClipboard.Free; inherited; @@ -879,6 +907,7 @@ const ID_LOCK = 2; ID_QUIT = 3; ID_QUICKSEARCH = 4; + ID_LAZY = 5; var LMenu: HMENU; LPt: TPoint; @@ -890,6 +919,9 @@ begin AppendMenu(LMenu, MF_STRING, ID_OPEN, 'Open'); AppendMenu(LMenu, MF_STRING, ID_QUICKSEARCH, 'Quick search…'); AppendMenu(LMenu, MF_STRING, ID_LOCK, 'Lock vault'); + AppendMenu(LMenu, + MF_STRING or IfThen(LazyBarVisible, MF_CHECKED, MF_UNCHECKED), + ID_LAZY, 'Lazy mode (fill with the mouse)'); AppendMenu(LMenu, MF_SEPARATOR, 0, nil); AppendMenu(LMenu, MF_STRING, ID_QUIT, 'Quit'); @@ -909,6 +941,7 @@ begin case LCmd of ID_OPEN: if Assigned(FOnTrayRestore) then FOnTrayRestore(); ID_QUICKSEARCH: if Assigned(FOnQuickSearchRequest) then FOnQuickSearchRequest(); + ID_LAZY: SetLazyBar(not LazyBarVisible); ID_LOCK: if Assigned(FOnLockRequest) then FOnLockRequest(); ID_QUIT: if Assigned(FOnQuit) then FOnQuit(); end; @@ -951,6 +984,121 @@ begin Result := False; end; +// The target of a fill is whatever window owns the foreground at the moment +// the user asks for it - a shortcut, or a click on the lazy strip, which takes +// no foreground precisely so this stays true. +procedure TPMBridge.FireAutofillRequest(AKind: TAutofillKind); +var + LTarget: HWND; + LTitle: string; + LLen: Integer; +begin + if not Assigned(FOnAutofillRequest) then Exit; + LTarget := GetForegroundWindow; + CaptureForegroundOwner; + // Hiding to the tray does not hand the foreground to anyone else, so ours + // stays foreground while invisible: from there we would capture our own + // window and type the password into it. There is no target to fill. + if IsOwnProcessWindow(LTarget) then + begin + ShowBalloon('Nothing to fill', + 'Click the field you want filled first, then ask for the fill.', True); + Exit; + end; + SetLength(LTitle, 512); + LLen := GetWindowTextW(LTarget, PChar(LTitle), 512); + SetLength(LTitle, LLen); + FOnAutofillRequest(AKind, LTarget, LTitle); +end; + +function TPMBridge.LazyBarVisible: Boolean; +begin + Result := FLazyWnd <> 0; +end; + +procedure TPMBridge.LazyWindowHandler(var AMsg: TMessage); +var + LRect: TRect; +begin + case AMsg.Msg of + // THE STRIP IS ITS OWN TITLE BAR. It has none to grab, and it will end up + // sitting over the very field it fills - so every pixel that is not a + // button drags it out of the way. + WM_NCHITTEST: + begin + AMsg.Result := HTCAPTION; + Exit; + end; + // AllocateHWnd's window class registers no background brush, so nothing + // erases the gaps around the buttons and whatever was behind shows through. + WM_ERASEBKGND: + begin + GetClientRect(FLazyWnd, LRect); + FillRect(HDC(AMsg.WParam), LRect, HBRUSH(COLOR_BTNFACE + 1)); + AMsg.Result := 1; + Exit; + end; + WM_COMMAND: + if HiWord(AMsg.WParam) = BN_CLICKED then + begin + case LoWord(AMsg.WParam) of + LAZY_ID_FULL: FireAutofillRequest(akFull); + LAZY_ID_PWD: FireAutofillRequest(akPasswordOnly); + end; + AMsg.Result := 0; + Exit; + end; + end; + AMsg.Result := DefWindowProc(FLazyWnd, AMsg.Msg, AMsg.WParam, AMsg.LParam); +end; + +procedure TPMBridge.SetLazyBar(AOn: Boolean); + + procedure MakeButton(AId: Integer; const ACaption: string; AX: Integer); + var + LBtn: HWND; + begin + LBtn := CreateWindow('BUTTON', PChar(ACaption), + WS_CHILD or WS_VISIBLE or BS_PUSHBUTTON, AX, 5, 66, 28, + FLazyWnd, HMENU(AId), HInstance, nil); + // Without this the button wears the 1995 system font. + if LBtn <> 0 then + SendMessage(LBtn, WM_SETFONT, WPARAM(GetStockObject(DEFAULT_GUI_FONT)), 1); + end; + +var + LWork: TRect; +begin + if AOn = LazyBarVisible then Exit; + + if not AOn then + begin + DeallocateHWnd(FLazyWnd); // the child buttons go with their parent + FLazyWnd := 0; + Exit; + end; + + FLazyWnd := AllocateHWnd(LazyWindowHandler); + if FLazyWnd = 0 then Exit; + SetWindowLong(FLazyWnd, GWL_EXSTYLE, + GetWindowLong(FLazyWnd, GWL_EXSTYLE) or + WS_EX_NOACTIVATE or WS_EX_TOPMOST or WS_EX_TOOLWINDOW); + SetWindowLong(FLazyWnd, GWL_STYLE, + GetWindowLong(FLazyWnd, GWL_STYLE) or WS_BORDER); + + MakeButton(LAZY_ID_FULL, 'Full', 5); + MakeButton(LAZY_ID_PWD, 'Password', 77); + + // Bottom right of the WORK area, so it does not start life under the + // taskbar. From there the user drags it wherever the login form is. + if not SystemParametersInfo(SPI_GETWORKAREA, 0, @LWork, 0) then + LWork := Rect(0, 0, GetSystemMetrics(SM_CXSCREEN), + GetSystemMetrics(SM_CYSCREEN)); + SetWindowPos(FLazyWnd, HWND_TOPMOST, + LWork.Right - LAZY_W - 24, LWork.Bottom - LAZY_H - 24, LAZY_W, LAZY_H, + SWP_NOACTIVATE or SWP_SHOWWINDOW); +end; + procedure TPMBridge.MsgWindowHandler(var AMsg: TMessage); var LMouseEvent: Word; @@ -1078,23 +1226,7 @@ begin LKind := akTotp else LKind := akFull; - var LTarget := GetForegroundWindow; - CaptureForegroundOwner; - // Hiding to the tray does not hand the foreground to anyone else, so ours - // stays foreground while invisible: from there the hotkey would capture our - // own window and type the password into it. There is no target to fill. - if IsOwnProcessWindow(LTarget) then - begin - ShowBalloon('Nothing to fill', - 'Click the field you want filled, then press the shortcut.', True); - AMsg.Result := DefWindowProc(FMsgWindow, AMsg.Msg, AMsg.WParam, AMsg.LParam); - Exit; - end; - var LTitle: string; - SetLength(LTitle, 512); - var LLen := GetWindowTextW(LTarget, PChar(LTitle), 512); - SetLength(LTitle, LLen); - FOnAutofillRequest(LKind, LTarget, LTitle); + FireAutofillRequest(LKind); end; AMsg.Result := DefWindowProc(FMsgWindow, AMsg.Msg, AMsg.WParam, AMsg.LParam);