MFC fails again
MFC, I love you. Really. Your source code is a fantastic example of how not to do it. Don't do it ever.
The problem
OK, so here's the problem - given an MFC dialog (or dialog control), sometimes we have no focus cues when tabbing through UI elements. You press Tab, use cursor keys and yet focus rectangle is not there. Forever. What might the problem be? Let's find out how this works internally.
Focus rect internals
First of all open any system dialog (for example taskbar Properties) using mouse only and ensure there is no focus rectangle on currently focused UI element. Press Tab and watch for it to immediately appear. Whatever you do from now on (excluding cases where the whole window hierarchy is hidden or recreated), focus rect is always on the screen. Now try to open this dialog using keyboard operation (you can open taskbar context menu and select Properties menu item using keyboard keys). Focus rect is shown immediately. What's happening here?
The answer begins it's path in dlgbegin.c
, function InternalCreateDialog
:
HWND InternalCreateDialog( HANDLE hmod, LPDLGTEMPLATE lpdt, DWORD cb, HWND hwndOwner, DLGPROC lpfnDialog, LPARAM lParam, UINT fSCDLGFlags) { ... }
The most interesting part is the following:
/* * UISTATE: if keyboard indicators are on and this is a topmost dialog * set the internal bit. */ if (TEST_KbdCuesPUSIF) { /* * If property page, UISTATE bits were copied from parent when I was created * Top level dialogs act as containers and initialize their state based on * the type of the last input event, after sending UIS_INITIALIZE */ if (!TestwndChild(pwnd)) { SendMessageWorker(pwnd, WM_CHANGEUISTATE, MAKEWPARAM(UIS_INITIALIZE, 0), 0, FALSE); } }
TEST_KbdCuesPUSIF
tests current execution environment for keyboard cues enablement (field check in some global structure), TestwndChild
ensures top level dialog. Then the window gets WM_CHANGEUISTATE
message with two arguments - UIS_INITIALIZE
and zero. Documentation says Top level dialogs act as containers and initialize their state based on the type of the last input event
. What does this mean? The answer is in the default window procedure (dwp.c
):
/***************************************************************************\ * xxxDefWindowProc (API) * * History: * 10-23-90 MikeHar Ported from WaWaWaWindows. * 12-07-90 IanJa CTLCOLOR handling round right way \***************************************************************************/ LRESULT xxxDefWindowProc( PWND pwnd, UINT message, WPARAM wParam, LPARAM lParam) { ... }
Take a look at WM_CHANGEUISTATE
handler:
case WM_CHANGEUISTATE: { WORD wAction = LOWORD(wParam); WORD wFlags = HIWORD(wParam); BOOL bRealChange = FALSE; if (wFlags & ~UISF_VALID || wAction > UIS_LASTVALID || lParam || !TEST_KbdCuesPUSIF) { return 0; } if (wAction == UIS_INITIALIZE) { if (gpsi->bLastRITWasKeyboard) { wAction = UIS_CLEAR; } else { wAction = UIS_SET; } wFlags = UISF_HIDEFOCUS | UISF_HIDEACCEL; wParam = MAKEWPARAM(wAction, wFlags); } UserAssert(wAction == UIS_SET || wAction == UIS_CLEAR); /* * If the state is not going to change, there's nothing to do here */ if (wFlags & UISF_HIDEFOCUS) { bRealChange = (!!TestWF(pwnd, WEFPUIFOCUSHIDDEN)) ^ (wAction == UIS_SET); } if (wFlags & UISF_HIDEACCEL) { bRealChange |= (!!TestWF(pwnd, WEFPUIACCELHIDDEN)) ^ (wAction == UIS_SET); } if (!bRealChange) { break; } /* * Children pass this message up * Top level windows update their children's state and * send down to their imediate children WM_UPDATEUISTATE. */ if (TestwndChild(pwnd)) { ThreadLockAlways(pwnd->spwndParent, &tlpwndParent); lt = xxxSendMessage(pwnd->spwndParent, WM_CHANGEUISTATE, wParam, lParam); ThreadUnlock(&tlpwndParent); return lt; } else { return xxxSendMessage(pwnd, WM_UPDATEUISTATE, wParam, lParam); } } break; case WM_QUERYUISTATE: return (TestWF(pwnd, WEFPUIFOCUSHIDDEN) ? UISF_HIDEFOCUS : 0) | (TestWF(pwnd, WEFPUIACCELHIDDEN) ? UISF_HIDEACCEL : 0); break; case WM_UPDATEUISTATE: { WORD wAction = LOWORD(wParam); WORD wFlags = HIWORD(wParam); if (wFlags & ~UISF_VALID || wAction > UIS_LASTVALID || lParam || !TEST_KbdCuesPUSIF) { return 0; } switch (wAction) { case UIS_INITIALIZE: /* * UISTATE: UIS_INITIALIZE sets the UIState bits * based on the last input type */ if (!gpsi->bLastRITWasKeyboard) { SetWF(pwnd, WEFPUIFOCUSHIDDEN); SetWF(pwnd, WEFPUIACCELHIDDEN); wParam = MAKEWPARAM(UIS_SET, UISF_HIDEACCEL | UISF_HIDEFOCUS); } else { ClrWF(pwnd, WEFPUIFOCUSHIDDEN); ClrWF(pwnd, WEFPUIACCELHIDDEN); wParam = MAKEWPARAM(UIS_CLEAR, UISF_HIDEACCEL | UISF_HIDEFOCUS); } break; case UIS_SET: if (wFlags & UISF_HIDEACCEL) { SetWF(pwnd, WEFPUIACCELHIDDEN); } if (wFlags & UISF_HIDEFOCUS) { SetWF(pwnd, WEFPUIFOCUSHIDDEN); } break; case UIS_CLEAR: if (wFlags & UISF_HIDEACCEL) { ClrWF(pwnd, WEFPUIACCELHIDDEN); } if (wFlags & UISF_HIDEFOCUS) { ClrWF(pwnd, WEFPUIFOCUSHIDDEN); } break; default: break; } /* * Send it down to its immediate children if any */ if (pwnd->spwndChild) { PBWL pbwl; HWND *phwnd; TL tlpwnd; pbwl = BuildHwndList(pwnd->spwndChild, BWL_ENUMLIST, NULL); if (pbwl == NULL) return 0; for (phwnd = pbwl->rghwnd; *phwnd != (HWND)1; phwnd++) { /* * Make sure this hwnd is still around. */ if ((pwnd = RevalidateHwnd(*phwnd)) == NULL) continue; ThreadLockAlways(pwnd, &tlpwnd); xxxSendMessage(pwnd, message, wParam, lParam); ThreadUnlock(&tlpwnd); } FreeHwndList(pbwl); } } break;
We are interested in the following code:
if (wAction == UIS_INITIALIZE) { if (gpsi->bLastRITWasKeyboard) { wAction = UIS_CLEAR; } else { wAction = UIS_SET; } wFlags = UISF_HIDEFOCUS | UISF_HIDEACCEL; wParam = MAKEWPARAM(wAction, wFlags); }
It checks whether last input event was the one from keyboard (bLastRITWasKeyboard
, RIT - raw input thread) and prepares appropriate action - set UISF_HIDEFOCUS
and UISF_HIDEACCEL
flags or clear them. The system tries to understand user experience type (mouse or keyboard) and initializes window state for better interaction. That's why you don't see focus or accelerator cues having opened a dialog using mouse whereas they are both on if you used your keyboard.
There is a little trick to have the system on: move the mouse right after you triggered a dialog using keyboard, or press any key after you triggered a dialog using mouse. The effect speaks for itself.
The rest of the handler simply decides where to send WM_UPDATEUISTATE
message. WM_UPDATEUISTATE
's handler just saves the state to internal OS structure for particular window. As you have already guessed, WM_QUERYUISTATE
reads the state back.
What does this all mean? There is a window state for focus and accelerator cues and a set of messages for it's manipulation. This state is kept in sync by all windows of the same hierarchy. OS initializes the state for best user experience.
Who uses the state
At least common controls, of course. The pattern is simple (listview.c
for example):
case WM_UPDATEUISTATE: { DWORD dwUIStateMask = MAKEWPARAM(0xFFFF, UISF_HIDEFOCUS); // we care only about focus not accel, and redraw only if changed if (CCOnUIState(&(plv->ci), WM_UPDATEUISTATE, wParam & dwUIStateMask, lParam)) { if(plv->iFocus >= 0) { // an item has the focus, invalidate it ListView_InvalidateItem(plv, plv->iFocus, FALSE, RDW_INVALIDATE | RDW_ERASE); } } goto DoDefault; }
It just listens to UI state updates and does appropriate actions (saves the state to local structure, invalidates the window etc.).
Who updates the state
As I've already mentioned, focus rect is shown immediately as you pressed focus navigation key (Tab for example). This is done by ::IsDialogMessage
OS function:
case WM_SYSKEYDOWN: /* * If Alt is down, deal with keyboard cues */ if ((HIWORD(lpMsg->lParam) & SYS_ALTERNATE) && TEST_KbdCuesPUSIF) { if (TestWF(pwnd, WEFPUIFOCUSHIDDEN) || (TestWF(pwnd, WEFPUIACCELHIDDEN))) { SendMessageWorker(pwndDlg, WM_CHANGEUISTATE, MAKEWPARAM(UIS_CLEAR, UISF_HIDEACCEL | UISF_HIDEFOCUS), 0, FALSE); } } break; case WM_KEYDOWN: code = (UINT)SendMessage(lpMsg->hwnd, WM_GETDLGCODE, lpMsg->wParam, (LPARAM)lpMsg); if (code & (DLGC_WANTALLKEYS | DLGC_WANTMESSAGE)) break; switch (lpMsg->wParam) { case VK_TAB: if (code & DLGC_WANTTAB) break; pwnd2 = _GetNextDlgTabItem(pwndDlg, pwnd, (GetKeyState(VK_SHIFT) & 0x8000)); if (TEST_KbdCuesPUSIF) { if (TestWF(pwnd, WEFPUIFOCUSHIDDEN)) { SendMessageWorker(pwndDlg, WM_CHANGEUISTATE, MAKEWPARAM(UIS_CLEAR, UISF_HIDEFOCUS), 0, FALSE); } }
WM_SYSKEYDOWN
and WM_KEYDOWN
analyze keyboard input and trigger UI state updates by sending WM_CHANGEUISTATE
. All child windows then receive state change update, focused window shows up a shiny focus rectangle, labels begin to draw accelerator cues. Simple, huh?
So what the fuck is wrong might be in MFC
As you already know (I hope) MFC focus navigation is implemented via CWnd::PreTranslateMessage
virtual function which eventually calls CWnd::IsDialogMessage
method:
BOOL CWnd::IsDialogMessage(LPMSG lpMsg) { ASSERT(::IsWindow(m_hWnd)); if (m_nFlags & WF_OLECTLCONTAINER) return afxOccManager->IsDialogMessage(this, lpMsg); else return ::IsDialogMessage(m_hWnd, lpMsg); }
In case of ActiveX controls in your dialog template (or any other shit involving OCC state initialization), WF_OLECTLCONTAINER
flag is set and the control passes to COccManager::IsDialogMessage
instead of API ::IsDialogMessage
. Believe me or not, COccManager::IsDialogMessage
analyzes keyboard input and moves the focus. The biggest problem with this method - it doesn't consider UI layout the way ::IsDialogMessage
does. ZOrder enumeration is done using CWnd::GetNextDlgTabItem
(see correct overload with COleControlSiteOrWnd *
parameter), which moves through controls registered in COleControlContainer
- m_pCtrlCont->m_listSitesOrWnds
. As you might have already guessed, this container is populated by MFC during dialog creation (from template of course) and is not updated afterwards. So if you add your controls dynamically (WTL/MFC/whatever) - be ready for focus navigation problems. The solution is to update container with necessary items for each control you create/recreate etc. Plus you have to keep items order to reflect proper ZOrder. What a crap!
Finally, the answer
COccManager::IsDialogMessage
doesn't fucking send WM_CHANGEUISTATE
as ::IsDialogMessage
does. It doesn't fucking do it. Fucking MFC, fuck you. FUCK YOU.