пятница, 14 августа 2009 г.

Как управлять IContextMenu, часть 10 - компоновка расширителей меню - основы

Это перевод How to host an IContextMenu, part 10 - Composite extensions - groundwork. Автор: Реймонд Чен. Предупреждение: этот пост сильно отличается от оригинала, т.к. вырезано много кода, который реализуется самой Delphi.

Вы могли удивляться: почему это интерфейс IContextMenu работает со смещениями идентификаторов меню вместо того, чтобы просто использовать сами идентификаторы напрямую.

Причина: поддержка нечто, что я называю "компоновка" (compositing).

У вас может быть несколько расширений контекстных меню, которые вы бы хотели скомбинировать в одно большое расширение меню. Оболочка (shell) делает это постоянно. Например, контекстное меню, с которым мы всё это время играли, на самом деле представляет собой композицию нескольких совершенно раздельных расширений меню: статические действия (verbs) из реестра, плюс все COM расширения типа "Отправить", "Открыть с помощью", и любые другие, которые были добавлены установленными вами программами (типа анти-вирусов или архиваторов).

Так что перед тем, как мы напишем свою компоновку, нам надо бы найти второе меню для соединения с первым. Вот простой кусок кода, который делает именно это, реализуя две команды: "Top" и "Next", которые ничего особо интересного не делают.

type
TTopContextMenu = class(TInterfacedObject, IInterface, IContextMenu)
private
m_cids: UINT;
function ValidateCommand(idCmd: UINT_PTR; fUnicode: Boolean; out puOffset: UINT): HRESULT;
function Top(const lpici: CMINVOKECOMMANDINFO): HRESULT;
function Next(const lpici: CMINVOKECOMMANDINFO): HRESULT;
protected
// *** IContextMenu ***
function QueryContextMenu(Menu: HMENU; indexMenu, idCmdFirst, idCmdLast, uFlags: UINT): HResult; stdcall;
function InvokeCommand(var lpici: TCMInvokeCommandInfo): HResult; stdcall;
function GetCommandString(idCmd: UINT_PTR; uType: UINT; pwReserved: PUINT; pszName: LPSTR; cchMax: UINT): HResult; stdcall;
end;

Объявление класса не особо интересно. Мы не выполняем свою прорисовку меню, поэтому нам не нужны интерфейсы IContextMenu2 или IContextMenu3.

У нас есть две команды. Вместо использования имён типа 0 и 1, давайте дадим им читабельные имена.

const
TOPCMD_TOP = 0;
TOPCMD_NEXT = 1;
TOPCMD_MAX = 2;

А вот кусок кода, который поможет нам в обслуживании этих команд.

type
TCommandInfo = record
pszNameA: LPCSTR;
pszNameW: LPCWSTR;
pszHelpA: LPCSTR;
pszHelpW: LPCWSTR;
end;

const
c_rgciTop: array[0..1] of TCommandInfo = (
(pszNameA: 'Top'; pszNameW: 'Top';
pszHelpA: 'The top command'; pszHelpW: 'The top command'),
(pszNameA: 'next'; pszNameW: 'next';
pszHelpA: 'The next command'; pszHelpW: 'The next command'));

Удобно, что наши значения TOPCMD_* служат индексами в массиве c_rgciTop.

Ну, Delphi берёт на себя скучную часть: реализацию IInterface/IUnknown (и все ошибки, что вы могли в ней сделать) - поэтому далее у нас начинается интересная часть: реализация IContextMenu.QueryContextMenu. На что требуется обратить внимание в коде ниже:
  • Проверка наличия свободного места между idCmdFirst и idCmdLast сложна из-за того, что idCmdLast включает в себя последнюю точку - именно поэтому у нас появляется +1. Вот ещё одна причина, чтобы предпочитать диапазоны без включения верхних границ.
  • Если установлен флаг CMF_DEFAULTONLY, то мы вообще не добавляем пункты меню, потому что ни один из них не является пунктом меню по-умолчанию.
function TTopContextMenu.QueryContextMenu(Menu: HMENU; indexMenu, idCmdFirst,
idCmdLast, uFlags: UINT): HRESULT;
begin
m_cids := 0;

if idCmdLast - idCmdFirst + 1 >= (TOPCMD_MAX and (not (uFlags and CMF_DEFAULTONLY))) then
begin
InsertMenu(Menu, indexMenu + TOPCMD_TOP, MF_BYPOSITION, idCmdFirst + TOPCMD_TOP, 'Top');
InsertMenu(Menu, indexMenu + TOPCMD_NEXT, MF_BYPOSITION, idCmdFirst + TOPCMD_NEXT, 'Next');
m_cids := TOPCMD_MAX;
end;

Result := MAKE_HRESULT(SEVERITY_SUCCESS, 0, m_cids);
end;

Чтобы реализовать другие методы, нам потребуется несколько вспомогательных функций, производящих сравнение, независимое от языка.

function strcmpiA_invariant(psz1: LPCSTR; psz2: LPCSTR): Integer;
begin
Result := CompareStringA(LOCALE_INVARIANT, NORM_IGNORECASE, psz1, -1, psz2, -1) - CSTR_EQUAL;
end;

function strcmpiW_invariant(psz1: LPCWSTR; psz2: LPCWSTR): Integer;
begin
Result := CompareStringW(LOCALE_INVARIANT, NORM_IGNORECASE, psz1, -1, psz2, -1) - CSTR_EQUAL;
end;

Эти функции похожи на обычные функции сравнения, но они используют инвариантную локаль (invariant locale), поскольку они будут использованы для сравнения канонических строк, а не чего-то, имеющего смысл для пользователя.

Теперь у нас всё готово для написания центральной вспомогательной функции для контекстного меню: нужно сообразить, о какой команде идёт речь.

Комманды могут передаваться в интерфейс IContextMenu (a) по номеру или по имени, и: (b) как ANSI или как Unicode. Суммарно получаем три способа или четыре, смотря как вы предпочитаете считать: "ANSI по номеру" и "Unicode по номеру" - это одно и то же или нет.

function TTopContextMenu.ValidateCommand(idCmd: UINT_PTR; fUnicode: Boolean;
out puOffset: UINT): HRESULT;
var
X: UINT_PTR;
begin
if not IS_INTRESOURCE(idCmd) then
begin
if fUnicode then
begin
idCmd := TOPCMD_MAX;
for X := 0 to TOPCMD_MAX - 1 do
if strcmpiW_invariant(LPCWSTR(X), c_rgciTop[X].pszNameW) = 0 then
Break;
end
else
begin
idCmd := TOPCMD_MAX;
for X := 0 to TOPCMD_MAX - 1 do
begin
if strcmpiA_invariant(LPCSTR(X), c_rgciTop[X].pszNameA) = 0 then
Break;
end;
end
end;

if idCmd < m_cids then
begin
puOffset := idCmd;
Result := S_OK;
Exit;
end;

Result := E_INVALIDARG;
end;

Эта вспомогательная функция принимает параметр "что-то" в форме UINT_PTR и флаг, который указывает, ANSI это "что-то" или же Unicode. Функция сама проверяет является ли "что-то" строкой или же числом. Если это строка, то функция преобразует её в число - с помощью просмотра таблицы команд (с подходящей кодировкой), используя наши вспомогательные функции сравнения. Заметьте, что если строка не найдена, то idCmd будет равно TOPCMD_MAX, что является недопустимым значением (а поэтому будет штатно обработано проверками на ошибки).

После (возможно неудачного) конвертирования в число, результат проверяется на допустимость; если он корректен, то число возвращается из функции для дальнейшей обработки.

С помощью этой вспомогательной функции реализовать остальные методы интерфейса IContextMenu будет намного проще.

function TTopContextMenu.InvokeCommand(var lpici: TCMInvokeCommandInfo): HRESULT;
var
lpicix: PCMInvokeCommandInfoEx;
fUnicode: Boolean;
idCmd: UINT;
begin
lpicix := PCMInvokeCommandInfoEx(@lpici);
fUnicode := (lpici.cbSize >= SizeOf(CMINVOKECOMMANDINFOEX)) and
((lpici.fMask and CMIC_MASK_UNICODE) <> 0);
if fUnicode then
Result := ValidateCommand(UINT_PTR(lpicix^.lpVerbW), fUnicode, idCmd)
else
Result := ValidateCommand(UINT_PTR(lpici.lpVerb), fUnicode, idCmd);
if SUCCEEDED(Result) then
begin
case idCmd of
TOPCMD_TOP: Result := Top(lpici);
TOPCMD_NEXT: Result := Next(lpici);
else
Result := E_INVALIDARG;
end;
end;
end;

В этом коде вопрос "Тут три случая или четыре?" разрешается в пользу "четыре". У нас есть две формы записи CMINVOKECOMMANDINFO: базовая (только ANSI) и расширенная (которая добавляет поддержку Unicode).

Если у нас запись CMINVOKECOMMANDINFOEX и установлен флаг CMIC_MASK_UNICODE, то тогда вместо ANSI-полей должны использоваться Unicode-поля записи CMINVOKECOMMANDINFOEX.

Это означает, что у нас действительно 4 сценария:

  • ANSI строка в поле lpVerb.
  • Число в поле lpVerb.
  • Unicode строка в поле lpVerbW.
  • Число в поле lpVerbW.

После проверки ANSI у нас параметр или же Unicode, мы просим ValidateCommand выполнить проверку команды (verb) и сконвертировать её в число (TOPCMD_*), которое мы будем использовать в case-операторе для вызова действий.

Отсутствие реализации вызова команды по имени - это чрезвычано частый недосмотр в реализациях обработчиком контекстных меню. Из-за этого люди не смогут вызывать ваши команды (verb) программно.

"Почему меня должно волновать, что люди могут вызывать мои действия программно?"

Потому что если вы этого не сделаете, то люди не смогут писать программы, наподобие тех, что бы пишем в этой серии! Например, предположим, что вы написали расширение оболочки, позволяющее людям сделать что-то с файлом. Если вы не дадите доступа к этой команде, то станет невозможным написать программу, которая, скажем, ищет все файлы, модифицированные за последние сутки, и выполняет над ними это действие.

(Меня всегда забавляло, что люди жалуются, что Проводник не предоставляет достаточно программных возможностей по кастомизации, но в то же время они не предоставляют хотя бы аналогичный уровень сервиса в своих собственных программах).

Ой, постойте, я думаю, что нам надо реализовать наши операции. В конце концов, они не делают ничего выдающегося.

function TTopContextMenu.Top(const lpici: CMINVOKECOMMANDINFO): HRESULT;
begin
MessageBox(lpici.hwnd, 'Top', 'Title', MB_OK);
Result := S_OK;
end;

function TTopContextMenu.Next(const lpici: CMINVOKECOMMANDINFO): HRESULT;
begin
MessageBox(lpici.hwnd, 'Next', 'Title', MB_OK);
Result := S_OK;
end;

Оставшийся метод - это IContextMenu.GetCommandString, который, вероятно, наибольшее количество людей реализуют неправильно, потому что последствия кривых реализаций не видны сразу самому реализующему. Косяки вылезают только у людей, которые пытаются использовать контекстное меню программно.

function TTopContextMenu.GetCommandString(idCmd: UINT_PTR; uType: UINT;
pwReserved: PUINT; pszName: LPSTR; cchMax: UINT): HRESULT;
var
id: UINT;
begin
Result := ValidateCommand(idCmd, (uType and GCS_UNICODE) <> 0, id);
if FAILED(Result) then
begin
if (uType = GCS_VALIDATEA) or (uType = GCS_VALIDATEW) then
Result := S_FALSE;
Exit;
end;

case uType of
GCS_VERBA:
begin
lstrcpynA(pszName, c_rgciTop[id].pszNameA, cchMax);
Result := S_OK;
Exit;
end;

GCS_VERBW:
begin
lstrcpynW(LPWSTR(pszName), c_rgciTop[id].pszNameW, cchMax);
Result := S_OK;
Exit;
end;

GCS_HELPTEXTA:
begin
lstrcpynA(pszName, c_rgciTop[id].pszHelpA, cchMax);
Result := S_OK;
Exit;
end;

GCS_HELPTEXTW:
begin
lstrcpynW(LPWSTR(pszName), c_rgciTop[id].pszHelpW, cchMax);
Result := S_OK;
Exit;
end;

GCS_VALIDATEA,
GCS_VALIDATEW:
begin
Result := S_OK; // Всё, что они хотели - это просто проверка
Exit;
end;
end;

Result := E_NOTIMPL;
end;

Тут мы снова используем метод ValidateCommand для выполнения всей сложной работы по проверке команды, переданной в параметре idCmd, с подсказкой по интерпретации в флаге GCS_UNICODE параметра uType.

Если команда не допустима, мы просто передаём код ошибки выше, а исключением случаев с GCS_VALIDATE, для которых документация указывает, что мы должны возвращать S_FALSE, для указания того, что команда неверна.

Если же команда верна, то мы возвращаем запрашиваемую информацию, что реализуется простым оператором case.

Окей, теперь, когда у нас есть это полноценное контекстное меню, мы можем немножко поиграться с ним. Возьмите программу из части 6 или части 9 и сделайте такие изменения в методе OnContextMenu:

procedure TForm1.FormContextPopup(Sender: TObject; MousePos: TPoint;
var Handled: Boolean);
var
pcm: IContextMenu;
Menu: HMENU;
Info: TCMInvokeCommandInfoEx;
Pt: TPoint;
iCmd: Integer;
begin
Handled := True;

Pt := MousePos;
if (Pt.X = -1) and (Pt.Y = -1) then
begin
Pt.X := 0;
Pt.Y := 0;
end;
Windows.ClientToScreen(Handle, Pt);

pcm := TTopContextMenu.Create;
try
Menu := CreatePopupMenu;
if Menu <> 0 then
...

Теперь мы получаем наше контекстное меню не вызовом функции GetUIObjectOfFile, а просто создавая реализующий его объект TTopContextMenu. Поскольку наш объект реализует IContextMenu, то весь оставшийся код остаётся без изменений.

Когда вы запустите программу, обратите внимание, что работают даже подсказки к пунктам меню.

Ах, вот сила использования интерфейсов вместо объектов: мы можете полностью изменить реализацию, выбросив на свалку старый объект, а весь код даже ничего и не заменит, до тех пор, пока сам интерфейс не будет изменён.

Окей, сегодня был длинный день, и мы просто делали подготовку - писали код, который понадобится завтра. Никаких прорывов, никаких "ага!" - просто печать кода. Прочитай описание метода, пойми, что надо сделать, и сделай это.

В следующий раз, мы рассмотрим соединение нескольких контекстных меню вместе, используя это контекстное меню в качестве одного компонента.

5 комментариев:

  1. Странно, но у меня подчеркиваются:
    MAKE_HRESULT
    IS_INTRESOURCE
    strcmpiA_invariant.
    Не хватает какого-то модуля?

    ОтветитьУдалить
  2. Как обычно - в заголовочниках JEDI. Конкретно - в JwaWinError. Ну а strcmpiA_invariant объявлена прямо тут ;)

    ОтветитьУдалить
  3. Спасибо, с strcmpiA_invariant недоглядел.

    ОтветитьУдалить
  4. Плюс подключил
    JwaWinUser для IS_INTRESOURCE
    JwaWinNT для LOCALE_INVARIANT

    ОтветитьУдалить
  5. IS_INTRESOURCE и LOCALE_INVARIANT есть и в Windows.pas (ну, по крайней мере в Delphi 2009, где я писал этот пример).

    ОтветитьУдалить

Можно использовать некоторые HTML-теги, например:

<b>Жирный</b>
<i>Курсив</i>
<a href="http://www.example.com/">Ссылка</a>

Вам необязательно регистрироваться для комментирования - для этого просто выберите из списка "Анонимный" (для анонимного комментария) или "Имя/URL" (для указания вашего имени и ссылки на сайт). Все прочие варианты потребуют от вас входа в вашу учётку (поддерживается OpenID).

Пожалуйста, по возможности используйте "Имя/URL" вместо "Анонимный". URL можно просто не указывать.

Ваше сообщение может быть помечено как спам спам-фильтром - не волнуйтесь, оно появится после проверки администратором.