September 25, 2005

Про меню, собственную отрисовку и PocketPC

Итак, потребовалось мне сделать меню в MFC с собственной отрисовкой пунктов со значками как в Win2k/XP, да еще чтобы оно выпадало по клику на кнопку в виде картинки в CCeCommandBar-е. Задача казалось бы тривиальная... Ан нет, не все тут так просто.

В первую очередь необходимо сделать наследника от CMenu, реализовав в нем функции отрисовки. Тут все по MSDN-у:

	virtual void DrawItem(LPDRAWITEMSTRUCT lpDrawItemStruct);
	virtual void MeasureItem(LPMEASUREITEMSTRUCT lpMIS);

переопределили, проверили через CMenu::TrackPopupMenu() и успокоились, обозвав новый класс CMenuEx. Теперь нужно "вставить" его в CCeCommandBar диалога.
Вообще, для вставки туда меню сущевствует лишь один-единственный метод:

CMenu* CCeCommandBar::InsertMenuBar(LPCTSTR lpszMenuName, int nButton = CMDBAR_END);

Этот самый метод грузит из ресурсов HMENU и "запихивает" его, точнее его главные пункты, в комманд-бар внизу экрана. Так-как возвращает он CMenu, то это уже почти хорошо: можем мучать меню, как хотим. В том числе, добавлять и удалять owner-draw пункты. Но, отрисовка-то идет именно в классе CMenuEx, а его еще нужно выставить. В принципе, можно было сделать просто:

	// ...
	CMenu* pMenu 	= pCeCommandBar->InsertMenuBar(...);
	HMENU hMenu 	= pMenu->m_hMenu;
	pMenu->Detach();
	CMenuEx* pMenuEx = new CMenuEx();
	pMenuEx->Attach(hMenu);
	// Здесь уже изменяем CMenuEx как хотим

Таким образом мы бы переаттачили хендл меню к новому классу, но было бы это жуть как криво, я так делать даже не пробовал. Во-первых, потому-что CCeCommandBar::GetMenuBar() нам будет поле этого возвращать указатель на не-валидное меню, во-вторых нам бы пришлось хранить в ресурсах липовое меню, а в-третьих, работать просто так это все-равно не совсем будет =) (почему - обьясню дальше). Но пока это был единственный способ, который делать не хотелось. Да и кроме того, не была решена еще одна проблемма - пункт меню на комманд-баре был текстовым, а мне нужна была картинка.
Я надеюсь, что все знают (наивный =), что все, что есть на комманд-баре ни что иное, как обыкновенные кнопки, которые можно изменять и удалять. И чтобы не мучиться, делаем наследника от CCeCommandBar, где изменяем нужную нам кнопку с текстом меню на новую, с картинкой:

BOOL CCeCommandBarEx::SetButtonMenu(int nButton, int nBitmap)
{
	CToolBarCtrl* pToolBar = &GetToolBarCtrl();
	pToolBar->DeleteButton(nButton);  // Убиваем старую кнопку

	// Получаем информацию о бывшей кнопке
	TBBUTTON btn;
	memset(&btn, 0, sizeof(btn));
	pToolBar->GetButton(nButton, &btn);
	
	// Убираем текст и ставим картинку
	btn.iString		= -1;
	btn.iBitmap		= nBitmap;

	BOOL nResult = pToolBar->InsertButton(nButton, &btn);
	SetSizes(CSize(23, 21), CSize(16, 15));
	return nResult;
}

Работать будет стабильно. Но, это хорошо в случае, когда нужно сделать только замену текста в контрол-баре на картинку. У меня-же меню генерилось в CMenuEx динамически, поэтому менять просто нечего было. Ладно, лезем в CCeCommandBar::InsertMenuBar:

CMenu* CCeCommandBar::InsertMenuBar(LPCTSTR lpszMenuName, int nButton /*= CMDBAR_END*/)
{
	// ...
	HINSTANCE hInst = AfxFindResourceHandle(lpszMenuName, RT_MENU);
	HMENU hMenu = ::LoadMenu(hInst, lpszMenuName);

	TBBUTTON tbButton; 
	memset(&tbButton, 0, sizeof(TBBUTTON));
	tbButton.iBitmap = I_IMAGENONE;
	tbButton.fsState = TBSTATE_ENABLED;
	tbButton.fsStyle = TBSTYLE_DROPDOWN | TBSTYLE_NO_DROPDOWN_ARROW | TBSTYLE_AUTOSIZE;
	// используется TBSTYLE_NO_DROPDOWN_ARROW, которого нету в доккументации MSDN

	MENUITEMINFO mii;
	TCHAR szMenuItem[256];
	memset(&mii, 0, sizeof(MENUITEMINFO));
	mii.fMask = MIIM_TYPE;
	mii.fType = MFT_STRING;
	mii.dwTypeData = szMenuItem;
	
	int nIndex = 0;
	while(TRUE)
	{
		tbButton.dwData = (DWORD)::GetSubMenu(hMenu, nIndex); // Хендл попап-меню
		if(tbButton.dwData == NULL)
			break;
		
		// ...

		tbButton.iString = (int)szMenuItem;
		tbButton.idCommand = m_wNextMenuID++; // Интересно...

		// Вот здесь добавляется кнопка с текстом меню:
		VERIFY(DefWindowProc(TB_INSERTBUTTON, nButton, (LPARAM)&tbButton)); 
		
		// ...
	}

	m_nCount = GetNumButtons();

	CMenu* pMenu = new CMenu;
	if(pMenu != NULL)
	{
		pMenu->Attach(hMenu);
		m_pMenuArray.Add(pMenu);
	}

	// ...	

	return pMenu;
}

Первое, что тут борсилось в глаза, это стиль TBSTYLE_NO_DROPDOWN_ARROW, которого нету в доккументации. Ладно, теперь будет: =)

#define TBSTYLE_NO_DROPDOWN_ARROW 0x0080

Но, одно лишь выставление этого стиля + TBSTYLE_DROPDOWN мне _почти_ ничего не дало, а именно, кнопка стала выглядеть нажатой во время выпадения меню (которое делалось в WM_NOTIFY через CMenuEx::TrackPopupMenu(), как у обычной TBSTYLE_DROPDOWN кнопки). Но все-таки не так, как нормальный пункт меню (оставался вверху пробел). Идем дальше.
Следующее, что бросилось мне в глаза - это строка

tbButton.dwData = (DWORD)::GetSubMenu(hMenu, nIndex);

Тут хендл меню выставляется в качестве dwData кнопки. Не долго думая, я прошарил по исходникам MFC в поисках виндовой процедуры, которая будет обрисовывать и отрабатывать такую кнопку, но ничего подобного я не нашел. А значит, показ меню и обработку такой чудо-кнопки выполняет не MFC, а WinCE API! Реализовываем добавление меню к уже сущевствующей кнопке:

BOOL CCeCommandBarEx::SetButtonMenu(int nButton, CMenu* pMenu)
{
	CToolBarCtrl* pToolBar = &GetToolBarCtrl();
	TBBUTTON btn;
	memset(&btn, 0, sizeof(btn));
	pToolBar->GetButton(nButton, &btn);
	pToolBar->DeleteButton(nButton);  // Убиваем старую кнопку

	// Для стиля TBSTYLE_NO_DROPDOWN_ARROW | TBSTYLE_DROPDOWN член dwData выставляется
	// как хендл нашего pop-up:
	btn.fsStyle	= TBSTYLE_DROPDOWN | TBSTYLE_NO_DROPDOWN_ARROW | TBSTYLE_AUTOSIZE;
	btn.dwData	= (DWORD)pMenu->m_hMenu;
	btn.idCommand = 0; // Это влом объяснять, почему так.

	return pToolBar->InsertButton(nButton, &btn);
}

Ура, работает! =) Но, не совсем. При добавлении в меню пунктов с собственной отрисовкой, все падает по ASSERT-у. Тут до меня дошло, что кто-то из окон должен принимать WM_MEASUREITEM и WM_DRAWITEM сообщения. В WinAPI это деоает параметр hWnd функции ::TrackPopupMenu(). Но, в MFC это дело как-то обрабатывается в CMenu! Причем, само CMenu даже от CCmdTarget наследником не является, не говоря уж про CWnd... Что-то мне подсказывало, что тут не все так просто. За сим мнением я полез в CMenu::TrackPopupMenu().
И что-же он делает? Он выставляет окно, которое будет получать все сообщения PopUp меню, HMENU отрисовываемого меню в специально отведненные для этого члены класса _AFX_THREAD_STATE. А после показа меню через WinCE API ::TrackPopupMenu(...) сбрасывает это окно на бывшее.

	// ...
	_AFX_THREAD_STATE* pThreadState = AfxGetThreadState();
	pThreadState->m_hTrackingWindow = pWnd->m_hWnd;
	pThreadState->m_hTrackingMenu = m_hMenu;
	BOOL bOK = ::WCE_FCTN(TrackPopupMenu)(m_hMenu, nFlags, x, y, 0,
			pThreadState->m_hTrackingWindow, lpRect);
	// ...

Ладненько, одним CMenu значит тут все не обошлось. Лезем в CWnd.
CWnd в свою очередь имеет собственную реализацию двух методов, которые отвечают за отрисовку: это OnDrawItem и OnMeasureItem. Метод OnDrawItem реализован достаточно просто.

void CWnd::OnDrawItem(int /*nIDCtl*/, LPDRAWITEMSTRUCT lpDrawItemStruct)
{
	if (lpDrawItemStruct->CtlType == ODT_MENU)
	{
		CMenu* pMenu = CMenu::FromHandlePermanent(
			(HMENU)lpDrawItemStruct->hwndItem);

		// Здесь pMenu уже указывает на наш экземпляр CMenuEx
		if (pMenu != NULL)
		{
			pMenu->DrawItem(lpDrawItemStruct);
			return; // eat it
		}
	}
	else
	{
		// reflect notification to child window control
		if (ReflectLastMsg(lpDrawItemStruct->hwndItem))
			return;     // eat it
	}
	// not handled - do default
	Default();
}

Так-как наше меню subclassed на CMenuEx, то CMenu::FromHandlePermanent() вернет из карты указателей экземпляр именно нашего объекта, в котором и будет вызван метод DrawItem(). Тут проблемм возникнуть не должно, благо handle меню передается через структуру информации отрисовки. Зато они возникнут на OnMeasureItem:

void CWnd::OnMeasureItem(int /*nIDCtl*/, LPMEASUREITEMSTRUCT lpMeasureItemStruct)
{
	if (lpMeasureItemStruct->CtlType == ODT_MENU)
	{
		ASSERT(lpMeasureItemStruct->CtlID == 0);
		CMenu* pMenu;

		_AFX_THREAD_STATE* pThreadState = _afxThreadState.GetData();
		if (pThreadState->m_hTrackingWindow == m_hWnd)
		{
			// Сюда мы не попадаем, т.к. m_hTrackingWindow == NULL во время вызова
			// из CCeCommandBar
			pMenu = CMenu::FromHandle(pThreadState->m_hTrackingMenu);
		}
		else
		{
			// А приаттаченого меню у нашего окна нет, поэтому GetMenu() вернет NULL
			pMenu = GetMenu();
		}

		if (pMenu != NULL)
			pMenu->MeasureItem(lpMeasureItemStruct);

		// ...
	}
	// not handled - do default
	Default();
}

Так-как наше окно не является m_hTrackingWindow окном, которое на практике вообще не выставлено во время вызова из CCeCommandBar (m_hTrackingWindow == NULL) ибо вызов TrackMenuBar() из CCeCommandBar идет не через CMenu, то
указателя на экземпляр класса, в котором вызывать MeasureItem() мы так и не получим. Более того, мы вообще не можем узнать по умолчанию, какое именно меню мы пытаемся нарисовать. Через MEASUREITEMSTRUCT мы можем получить только itemID конкретного пункта меню, и itemData этого пункта. (почему разработчикам MFC было впадлу включить HANDLE вызвавшего сообщение WM_MEASUREITEM контрола, как это сделано у DRAWITEMSTRUCT через hwndItem - для меня остается загадкой...)
Так-как действовать больше неоткуда, будем действовать через itemData. А именно, каждому пункту меню через dwItemData будем передавать структуру, содержащую указатель на экземпляр CMenu и остальные данные, специфичные для конкретной реализации потомка CMenu (у нас ведь может быть много разных типов owner-draw меню).

struct MenuItemData
{
	CMenu*	pMenu;	// Указатель на наше меню, должен быть первым членом в структуре
	// .. остальные данные
};

После выставления у всех итемов меню UserData в MenuItemData*, остается лишь переопределить обработку OnMeasureItem у класса CCeCommandBar (в Remote Spy++ отловил, что именно его окно получает WM_MEASUREITEM и WM_DRAWITEM сообщения):

void CCeCommandBarEx::OnMeasureItem(int /*nIDCtl*/, LPMEASUREITEMSTRUCT lpMeasureItemStruct)
{
	if (lpMeasureItemStruct->CtlType == ODT_MENU)
	{
		ASSERT(lpMeasureItemStruct->CtlID == 0);

		DWORD* pItemDataStruct = (DWORD*)lpMeasureItemStruct->itemData;
		CMenu* pMenu = (CMenu*)pItemDataStruct[0];		// Accessing first data member

		pMenu = _AfxFindPopupMenuFromID(pMenu, lpMeasureItemStruct->itemID);
		if (pMenu != NULL)
			pMenu->MeasureItem(lpMeasureItemStruct);
		return;	// ready
	}
	else
	{
		CWnd* pChild = GetDescendantWindow(lpMeasureItemStruct->CtlID, TRUE);
		if (pChild != NULL && pChild->SendChildNotifyLastMsg())
			return;     // eaten by child
	}
	// not handled - do default
	Default();
}

И все. Ура, заработало! =)

--
(с) 2005 JohnCapfull.