Окна на чистом WinAPI. Или просто о сложном
Disclaimer
Казалось бы, что WinAPI уходит в прошлое. Давно уже существует огромное количество кросс-платформенных фреймфорков, Windows не только на десктопах, да и сами Microsoft в свой магазин не жалуют приложения, которые используют этого монстра. Помимо этого статей о том, как создать окошки на WinAPI, не только здесь, но и по всему интернету, исчисляется тысячами по уровню от дошколят и выше. Весь этот процесс разобран уже даже не по атомам, а по субатомным частицам. Что может быть проще и понятнее? А тут я еще…
Но не все так просто, как кажется.
Почему о WinAPI сейчас?
В один прекрасный момент, изучая потроха одной из игр в весьма неплохом эмуляторе NES, я подумал: Вроде неплохой такой эмуль, а в отладчике нет такой простой вещи, как навигация по кнопкам клавиатуры, которая есть в любом нормальном отладчике.
Здесь я не зря дал ссылку на репозиторий, т.к. видно, что ребята столкнулись с проблемой, о которой речь пойдет ниже, но так и не решили ее.
О чем это я? А вот об этом кусочке кода:
case WM_KEYDOWN:
MessageBox(hwndDlg,"Die!","I'm dead!",MB_YESNO|MB_ICONINFORMATION);
break;
Таким образом, авторы хотели добавить поддержку клавиатуры, но суровая реальность недр архитектуры диалоговых окон в Windows жестко пресекла такую самодеятельность. Те, кто пользовался эмулятором и отладчиком в нем, хоть раз видели это сообщение?
В чем же проблема?
Ответ такой: так делать нельзя!
И, возвращаясь, к изначальному вопросу о WinAPI: очень много популярных, и не очень, проектов продолжают его использовать и в настоящее время, т.к. лучше, чем на чистом API многие вещи не сделать (тут можно бесконечно приводить аналогии вроде сравнения высокоуровневых языков и ассемблера, но сейчас не об этом). Да и мало ли почему? Просто используют и все тут.
О проблеме
Диалоговые окна упрощают работу с GUI, одновременно лишая нас возможности сделать что-то самостоятельно. Например, сообщения WM_KEYDOWN/WM_KEYUP, приходящие в оконную процедуру, «съедаются» в недрах DefDlgProc, беря на себя такие вещи, как: Навигация по Tab, обработка клавиш Esc, Enter, и т.д. Кроме того, диалоги не нужно создавать вручную: проще, ведь, набросать кнопок, списков, в редакторе ресурсов, вызвать в WinMain CreateDialog/DialogBox и все готово.
Обойти такие мелкие неприятности просто. Есть, как минимум, два вполне легальных способа:
- Создать свой собственный класс через RegisterClassEx и в процедуре обработки класса схватывать WM_KEYDOWN, перенаправлять в процедуру обработки самого диалога. Да-да! Можно создавать диалоги со своим собственным классом, и встроенный в VS редактор даже позволяет задавать имя класса для диалога. Вот только кто об этом знает и этим пользуется?
Минус очевиден: Нужно регистрировать еще один класс, иметь на 1 CALLBACK процедуру больше, суть которой будет всего-навсего в трансляции пары сообщений. Кроме того, мы не будем знать куда их транслировать, и придется городить костыли. - Использовать встроенный механизм акселераторов. И нам даже не придется менять код диалоговой процедуры! Ну, разве что, добавить одну строчку в switch/case, но об этом ниже.
Tutorials?
Не побоюсь сказать, что все туториалы по созданию окон через WinAPI начинаются с такого незамысловатого кода, обозначая его, как «цикл обработки сообщений» (опущу детали по подготовке класса окна и прочую обвязку):
while (GetMessage(&msg, nullptr, 0, 0))
{
TranslateMessage(&msg);
DispatchMessage(&msg);
}
Здесь действительно все просто:
- GetMessage () выхватывает очередное сообщение из очереди, и ключевой момент: блокирует поток, если в очереди пусто.
- TranslateMessage () из WM_KEYDOWN/WM_KEYUP формирует сообщения WM_CHAR/WM_SYSCHAR (они нужны, если кто-то хочет сделать свой редактор текста).
- DispatchMessage () отправляет сообщение в оконную процедуру (если таковая существует).
Начнем с того, что этот код использовать опасно, и вот почему. Обратите внимание на сноску:
Because the return value can be nonzero, zero, or -1, avoid code like this:while (GetMessage( lpMsg, hWnd, 0, 0)) ...
И ниже приводится пример правильного цикла.
Стоит сказать, что в шаблонах VS для Win32 приложений, написан именно такой неправильный цикл. И это очень печально. Ведь мало кто будет вникать в то, что сделали сами авторы, ведь это априори правильно. И неправильный код множится вместе с багами, которые очень сложно отловить.
После этого фрагмента кода, как правило, следует рассказ про акселераторы, и добавляется пара новых строчек (учитывая замечание в MSDN, предлагаю сразу писать правильный цикл):
HACCEL hAccel = LoadAccelerators(hInstance, MAKEINTRESOURCE(IDR_ACCELERATOR));
BOOL bRet = 0;
while ( bRet = GetMessage(&msg, nullptr, 0, 0) )
{
if ( -1 == bRet ) break;
if ( !TranslateAccelerator(msg.hwnd, hAccel, &msg) )
{
TranslateMessage(&msg);
DispatchMessage(&msg);
}
}
Этот вариант я видел чаще всего. И он (та-дам) снова неправильный!
Сперва о том, что изменилось (потом о проблемах этого кода):
В первой строчке из ресурсов загружается таблица клавиш, при нажатии на которые, будет формироваться сообщение WM_COMMAND с соответствующим id команды.
Собственно TranslateAccelerator этим и занимается: если видит WM_KEYDOWN и код клавиши, которые есть в этом списке, то (опять же ключевой момент) будет формировать сообщение WM_COMMAND (MAKEWPARAM (id, 1)) и отправлять в соответствующую для дескриптора окна, указанного в первом аргументе, процедуру обработки.
Из последней фразы, думаю, стало понятно, в чем проблема предыдущего кода.
Поясню: GetMessage выхватывает сообщения для ВСЕХ объектов типа «окно» (в число которых входят и дочерние: кнопки, списки и прочее), а TranslateAccelerator будет отправлять сформированную WM_COMMAND куда? Правильно: обратно в кнопку/список и т.д. Но мы обрабатываем WM_COMMAND в своей процедуре, а значит нам интересно ее получать в ней же.
Ясно, что TranslateAccelerator надо вызывать для нашего созданного окна:
HWND hMainWnd = CreateWindow(...);
HACCEL hAccel = LoadAccelerators(hInstance, MAKEINTRESOURCE(IDR_ACCELERATOR));
BOOL bRet = 0;
while (bRet = GetMessage(&msg, nullptr, 0, 0))
{
if ( -1 == bRet ) break;
if ( !TranslateAccelerator(hMainWnd, hAccel, &msg) )
{
TranslateMessage(&msg);
DispatchMessage(&msg);
}
}
И вроде все хорошо и замечательно теперь: мы разобрали все детально и все должно работать идеально.
И снова нет. :-) Это будет работать правильно, пока у нас ровно одно окно — наше. Как только появится немодальное новое окно (диалог), все клавиши, которые будут в нем нажаты оттранслируются в WM_COMMAND и отправляться куда? И опять же правильно: в наше главное окно.
На этом этапе предлагаю не городить костылей по решению этой тупиковой ситуации, а предлагаю рассмотреть вещи, которые уже реже (или почти не встречаются) в туториалах.
IsDialogMessage
По названию этой функции можно подумать, что она зачем-то определяет: относится данное сообщение диалогу или нет. Но, во-первых, зачем нам это знать? А во-вторых, что с этой информацией нам делать дальше?
На самом деле, делает она чуть больше, чем следует из названия. А именно:
- Осуществляет навигацию по дочерним контролам кнопками Tab/Shift+Tab/вверх/вниз/вправо/влево. Плюс еще кое-что, но этого нам достаточно
- По нажатии на ESC формирует WM_COMMAND (IDCANCEL)
- По нажатии на Enter формирует WM_COMMAND (IDOK) или нажатие на текущую кнопку по умолчанию
- Переключает кнопки по умолчанию (рамочка у таких кнопок чуть ярче остальных)
- Ну и еще разные штуки, которые облегчают пользователю работу с диалогом
Что она нам дает? Во-первых, нам не надо думать о навигации внутри окна. Нам и так все сделают. Кстати, навигацию по Tab можно сделать, добавив стиль WS_EX_CONTROLPARENT нашему основному окну, но это топорно и не так функционально.
Во-вторых, она нам облегчит жизнь по всем остальным пунктам, перечисленным в списке (и даже немного больше).
Вообще, она используется где-то в недрах Windows для обеспечения работы модальных диалоговых окон, а программистам дана, чтобы вызывать ее для немодальных диалогов. Однако мы ее можем использовать где угодно:
Although the IsDialogMessage function is intended for modeless dialog boxes, you can use it with any window that contains controls, enabling the windows to provide the same keyboard selection as is used in a dialog box.
Т.е. теперь, если мы оформим цикл так:
HWND hMainWnd = CreateWindow(...);
HACCEL hAccel = LoadAccelerators(hInstance, MAKEINTRESOURCE(IDR_ACCELERATOR));
BOOL bRet = 0;
while (bRet = GetMessage(&msg, nullptr, 0, 0))
{
if ( -1 == bRet ) break;
if ( !TranslateAccelerator(hMainWnd, hAccel, &msg) )
{
if ( !IsDialogMessage(hMainWnd, &msg) )
{
TranslateMessage(&msg);
DispatchMessage(&msg);
}
}
}
То наше окошко будет иметь навигацию, как в родном диалоге Windows. Но теперь мы получили два недостатка:
- Этот код также будет хорошо работать только с одним (немодальным) окном;
- Получив все достоинства диалоговой навигации, мы лишаемся прелестей в виде сообщений WM_KEYDOWN/WM_KEYUP (только для самого окна, а не для дочерних контролов);
И вот на этом этапе вообще все туториалы заканчиваются и начинаются вопросы: How to handle keyboard events in a winapi standard dialog?
Это первая ссылка в гугле, но поверьте: тысячи их. Про предлагаемые решений (лучшее из которых — это создать свой класс диалогов, о чем я писал выше, до subclassing и RegisterHotKey. Где-то я даже видел «лучший» из способов: использовать Windows Hooks).
Пора поговорить о том, чего нет в туториалах и ответах.
Как правило (как правило! Если кому-то захочется большего, то можно регистрировать свой класс для диалогов и работать так. И, если же, кому-то это интересно, я могу дополнить этим статью) WM_KEYDOWN хотят тогда, когда хотят обработать нажатие на клавишу, которая выполнит функцию в независимости от выбранного контрола в окне — т.е. некая общая функция для всего данного конкретного диалога. А раз так, то почему бы не воспользоваться богатыми возможностями, которые нам сама WinAPI и предлагает: TranslateAccelerator.
Везде используют ровно одну таблицу акселераторов, и только для главного окна. Ну действительно: цикл GetMessage-loop один, значит и таблица одна. Куда еще их девать?
На самом деле, циклы GetMessage-loop могут быть вложенными. Давайте еще раз посмотрим описание PostQuitMessage:
The PostQuitMessage function posts a WM_QUIT message to the thread’s message queue and returns immediately; the function simply indicates to the system that the thread is requesting to quit at some time in the future.
И GetMessage:
If the function retrieves the WM_QUIT message, the return value is zero.
Таким образом, выход из GetMessage-loop осуществится, если мы вызовем PostQuitMessage в процедуре окна. Что это значит?
Мы можем для каждого немодального окна в нашей программе создавать свой собственный подобный цикл. В данном случае DialogBoxParam нам не подходит, т.к. оно крутит свой собственный цикл и повлиять мы на него не можем. Однако если создадим диалог через CreateDialogBoxParam или окно через CreateWindow, то можно закрутить еще один цикл. При этом в каждом таком окне и диалоге мы должны вызывать PostQuitMessage:
HWND hMainWnd = CreateWindow(...);
HACCEL hAccel = LoadAccelerators(hInstance, MAKEINTRESOURCE(IDR_ACCELERATOR));
BOOL bRet = 0;
while (bRet = GetMessage(&msg, nullptr, 0, 0))
{
if ( -1 == bRet ) break;
if ( !TranslateAccelerator(hMainWnd, hAccel, &msg) )
{
if ( !IsDialogMessage(hMainWnd, &msg) )
{
TranslateMessage(&msg);
DispatchMessage(&msg);
}
}
}
// ....
LRESULT CALLBACK WndProc(HWND hwnd, UINT umsg, WPARAM wparam, LPARAM lparam)
{
switch( umsg )
{
case WM_MYMESSAGE:
{
HWND hDlg = CreateDialog(hInstance, MAKEINTRESOURCE(IDD_MYDIALOG), hwnd, MyDialogBoxProc);
HACCEL hAccel = LoadAccelerators(hInstance, MAKEINTRESOURCE(IDR_ACCELERATOR_FOR_MY_DIALOG));
BOOL bRet = 0, fSavedEnabledState = IsWindowEnabled(hwnd);
EnableWindow(hwnd, FALSE); // disable parent window, as dialog window is modal
while (bRet = GetMessage(&msg, nullptr, 0, 0))
{
if ( -1 == bRet ) break;
if ( !TranslateAccelerator(hDlg, hAccel, &msg) )
{
if ( !IsDialogMessage(hDlg, &msg) )
{
TranslateMessage(&msg);
DispatchMessage(&msg);
}
}
}
EnableWindow(hwnd, fSavedEnabledState); // enable parent window. Dialog was closed
break;
}
}
}
INT_PTR CALLBACK MyDlgProc(HWND hwnd, UINT umsg, WPARAM wparam, LPARAM lparam)
{
switch(umsg)
{
case WM_CLOSE:
{
// EndDialog( hwnd, 0 ); -- DONT DO THAT!
// EndDialog is valid ONLY for Modal Dialogs, created with DialogBox(Param)
DestroyWindow( hwnd );
break;
}
case WM_DESTROY:
{
PostQuitMessage( 0 );
break;
}
// ....
}
return 0;
}
Обратите внимание: теперь для каждого нового окна в нашей программе мы можем добавить в обработку собственную таблицу акселераторов. WM_QUIT будет выхватывать GetMessage из цикла для диалога, а внешний цикл его даже не увидит. Почему так происходит?
Дело в том, что внешний цикл «встал» на вызове DispatchMessage, который вызвал нашу процедуру, которая крутит свой собственный внутренний цикл GetMessage с таким же DispatchMessage. Классический вложенный вызов (в данном случае DispatchMessage). Посему внешний цикл не получит WM_QUIT и не завершится на этом этапе. Все будет работать стройно.
Но и тут есть свои недостатки:
Каждый такой цикл будет обрабатывать сообщения только для «своего» окна. Про другие-то мы здесь не знаем. А значит, если где-то объявится еще один цикл, то все остальные окна не будут получать нужной обработки своих сообщений парой TranslateAccelerator/IsDialogMessage.
Что ж, пора учесть все эти замечание и написать наконец правильную обработку всех сообщений от всех окон нашей программы. Хочу заметить, что ниже рассматривается случай для одного потока. Т.к. каждый поток имеет свою очередь сообщений, то для каждого потока придется создавать свои структуры. Делается это весьма тривиальными изменениями в коде.
Делаем красиво
Т.к. правильная постановка задачи является половиной решения, то сперва надо эту самую задачу правильно же и поставить.
Во-первых, было бы логично, что только активное окно принимает сообщения. Т.е. для неактивного окна мы не будем транслировать акселераторы и передавать сообщения в IsDialogMessage.
Во-вторых, если для окна не задана таблица акселераторов, то транслировать нечего, будем просто отдавать сообщение в IsDialogMessage.
Создадим простой std: map, который будет мапить дескриптор окна в дескриптор таблицы акселераторов. Вот так:
std::map l_mAccelTable;
И по мере создания окон будем в него добавлять новые окна с дескриптором на свою любимую таблицу (или нуль, если такая обработка не требуется).
Вот так:
BOOL AddAccelerators(HWND hWnd, HACCEL hAccel)
{
if ( IsWindow( hWnd ) )
{
l_mAccelTable[ hWnd ] = hAccel;
return TRUE;
}
return FALSE;
}
BOOL AddAccelerators(HWND hWnd, LPCTSTR accel)
{
return AddAccelerators( hWnd, LoadAccelerators( hInstance, accel ) );
}
BOOL AddAccelerators(HWND hWnd, int accel)
{
return AddAccelerators( hWnd, MAKEINTRESOURCE( accel ) );
}
BOOL AddAccelerators(HWND hWnd)
{
return AddAccelerators( hWnd, HACCEL( NULL ) );
}
Ну и после закрытия окна удалять. Вот так:
void DelAccel(HWND hWnd)
{
std::map::iterator me = l_mAccelTable.find( hWnd );
if ( me != l_mAccelTable.end() )
{
if ( me->second )
{
DestroyAcceleratorTable( me->second );
}
l_mAccelTable.erase( me );
}
}
Теперь, как создаем новый диалог/окно, вызываем AddAccelerators (hNewDialog, IDR_MY_ACCEL_TABLE). Как закрываем: DelAccel (hNewDialog).
Список окон с нужными дескрипторами у нас есть. Немного модифицируем наш основной цикл обработки сообщений:
// ...
HWND hMainWnd = CreateWindow(...);
AddAccelerators(hMainWnd, IDR_ACCELERATOR);
BOOL bRet = 0;
while (bRet = GetMessage(&msg, nullptr, 0, 0))
{
if ( -1 == bRet ) break;
if ( !HandleAccelArray( GetActiveWindow(), msg ) )
{
TranslateMessage(&msg);
DispatchMessage(&msg);
}
}
// ...
Значительно лучше! Что же там в HandleAccelArray и зачем там GetActiveWindow ()?
Немного теории:
Есть две функции, возвращающих дескриптор активного окна GetForegroundWindow и GetActiveWindow. Отличие первой от второй вполне доходчиво описано в описании второй:
The return value is the handle to the active window attached to the calling thread’s message queue. Otherwise, the return value is NULL.
Если первая будет возвращать дескриптор любого окна в системе, то последняя только того, которое использует очередь сообщений нашего потока. Т.к. нас интересуют окна только нашего потока (а значит те, которые будут попадать в нашу очередь сообщений), то и возьмем последнюю.
Так вот HandleAccelArray, руководствуясь переданным ей дескриптором на активное окно, ищет это самое окно в нашей мапе, и если оно там есть, отдает это сообщение на трансляцию в TranslateAccelerator, а затем (если первый не увидел нужного) в IsDialogMessage. Если и последняя не обработала сообщение, то возвращаем FALSE, чтобы пройти по стандартной процедуре TranslateMessage/DispatchMessage.
Выглядит так:
BOOL HandleAccelWindow(std::map::const_iterator mh, MSG & msg)
{
const HWND & hWnd = mh->first;
const HACCEL & hAccel = mh->second;
if ( !TranslateAccelerator( hWnd, hAccel, &msg ) )
{
// message not for TranslateAccelerator. Try it with IsDialogMessage
if ( !IsDialogMessage( hWnd, &msg ) )
{
// so, do default stuff
return FALSE;
}
}
// ok, message translated. Say to message-loop, to get next message
return TRUE;
}
BOOL HandleAccelArray( HWND hActive, MSG & msg )
{
if ( !hActive )
return FALSE; // no active window. Nothing to do
std::map::const_iterator mh = l_mAccelTable.find( hActive );
if ( mh != l_mAccelTable.end() )
{
// Got it! Try to translate this message for the active window
return HandleAccelWindow( mh, msg );
}
return FALSE;
}
Теперь каждое дочернее окно вправе добавить себе любимую таблицу акселераторов и спокойно ловить и обрабатывать WM_COMMAND с нужным кодом.
А что там еще об одной строчке в коде обработчика WM_COMMAND?
Описание в TranslateAccelerator гласит:
To differentiate the message that this function sends from messages sent by menus or controls, the high-order word of the wParam parameter of the WM_COMMAND or WM_SYSCOMMAND message contains the value 1.
Обычно код обработки WM_COMMAND выглядит так:
switch( HIWORD( wParam ) )
{
case BN_CLICKED: // command from buttons/menus
{
switch( LOWORD( wParam ) )
{
case IDC_BUTTON1: DoButton1Stuff(); break;
case IDC_BUTTON2: DoButton2Stuff(); break;
// ...
}
break;
}
}
Теперь можно написать так:
switch( HIWORD( wParam ) )
{
case 1: // accelerator
case BN_CLICKED: // command from buttons/menus
{
switch( LOWORD( wParam ) )
{
case IDC_BUTTON1: DoButton1Stuff(); break;
case IDC_BUTTON2: DoButton2Stuff(); break;
// ...
}
break;
}
}
И теперь, возвращаясь к тому же fceux, добавив всего одну строчку в код обработки команд от кнопок, мы получим желаемое: управлять дебагером с клавиатуры. Достаточно добавить небольшую обертку вокруг главного цикла сообщений и новую таблицу акселераторов с нужными соответствиями VK_KEY => IDC_DEBUGGER_BUTTON.
PS: Мало кто знает, но можно создавать свою собственную таблицу акселераторов, а теперь и применять ее прямо налету. :-)
PPS: Т.к. DialogBox/DialogBoxParam крутит собственный цикл, то от при вызове диалога через них акселераторы работать не будут и наш цикл (или циклы) будет «простаивать».
PPPS: После вызова HandleAccelWindow мап l_mAccelTable может измениться, т.к. TranslateAccelerator или IsDialogMessage вызывают DispatchMessage, а там может встретиться AddAccelerators или DelAccel в наших обработчиках! Поэтому лучше его после этой функции не трогать.
Пощупать код можно здесь. За основу был взят код, генерируемый из стандартного шаблона MS VS 2017.