はじめに
本稿では、Win32 APIベースのプログラムとMFCプログラムのどちらでも使える、コンパクトで効率的なクラスを紹介します。
ハイパーリンクコントロールを自作するにあたり、既存のハイパーリンクコントロールをネットでいろいろ探してみましたが、私の求める条件を十分に満たすものは1つもありませんでした。余分な機能が詰め込まれている、バグがある、シンプルだけれども1つか2つ必要な機能が足りない、という具合でした。その中で最も優れていたのが、Neal Stublenのコードでした。とりわけ解決方法のエレガンスさには感銘を受けました。このコードはWin32 APIプログラムでもMFCプログラムでも使えるのです。ただ、残念なことに、バグが1つあるのと、私が求める機能が3つ欠けていました。そこで、Neal Stublenのコードを基にして独自のハイパーリンクコントロールを作成することにしました。本稿では、ハイパーリンクに関してMicrosoftが提供しているオプションについて述べたうえで、Nealが実装した機能と私が追加した機能、それにNealのコードに対するバグ修正とその他いくつかの改善点について説明します。
Microsoftが提供するオプション
本稿を初めて発表したとき、数名の読者から、その機能ならWTLが提供している、という指摘をいただきました。今振り返ってみると、それでもなお自分でクラスを書いたのは正しい判断だったと思います。WTLが私の解決策よりも優れている点は、内容が充実していてより多くの機能を備えていることです。しかし、WTLを利用する人の目的は小さくて高速な実行ファイルを作成することのはずですから、もし彼らが必要とする機能が私のクラスにすべて含まれているならば、WTLのクラスよりも私のクラスを使った方がメリットがあると思います。実際、私のクラスと比べた場合、WTLの実装には次のような難点があります。
- コードの量が多い。
- WTLのメッセージディスパッチングスキームを使っている。このスキームは、MFCのものより高速でコンパクトかもしれないが、私のクラスで使用している純粋なWindowsプロシージャほど効率的ではない。
- WTLにはツールチップがあるが、このツールチップをカスタマイズしてステータスバーにURLテキストを送信するといった機能を実現することは難しいと思われる(私のクラスでは簡単に実現可能)。
- WTLのハイパーリンククラスを使うためには、プロジェクトにWTLが含まれていなければならない。一方、私のクラスはWin32 API C言語プロジェクトやMFC、さらにWTLでも使える。
私は、WTLを使ってプログラムを書いたことはありませんが、仮にその経験があったとしても、きっと本稿の自作クラスを利用するでしょう。Microsoftは、ハイパーリンクコントロールを求める人々のために、もう1つ選択肢を用意しています。「ComCtrl32.dll」のバージョン6にハイパーリンクコントロールを追加したのです。しかし、このバージョンはWindows XPでしか利用できません。他のWindowsオペレーティングシステムでのみ実行可能なアプリケーションでは、新たに用意されたこの一般的なハイパーリンクコントロールライブラリに頼ることはできませんし、そもそも「ComCtl32.dll」のバージョン6でしか使用できない機能を使うべきではありません。潜在的ユーザ層をWindows XPユーザに限定できる場合は問題ないかもしれませんが、そうでない場合の方が多いはずです。
私が作成したクラスの機能
まずは、静的コントロールをハイパーリンクにするために必要な変更のうち、すでにNealが解決していたものを紹介しましょう。
- ハイパーリンクテキストがクリックされたら、そのテキストで指定された場所を参照するブラウザウィンドウを開く。
- カーソルがハイパーリンクの上にきたら、標準の矢印カーソルを指差しカーソルに変更する。
- カーソルがハイパーリンクの上にきたら、そのテキストをアンダーライン表示にする。
- ハイパーリンクコントロールのテキストを黒以外の色で表示する。
以下は、私が追加した機能です。
- リンク先が訪問済みであるハイパーリンクコントロールの表示色を変える。
- キーボードからハイパーリンクコントロールにアクセスできるようにする。
- フォーカスまたはカーソルがハイパーリンクコントロール上にあるときにプログラムによって何らかの動作を行えるように、ある種のフックを導入する。
これらの新機能をどのように実装したかを説明する前に、Nealのコードに加えた大きな構造的な変更について述べておきたいと思います。具体的には、NealのコードをCHyperLinkという1つのクラスにしました。このクラスの定義を次に示します。
class CHyperLink
{
public:
CHyperLink(void);
virtual ~CHyperLink(void);
BOOL ConvertStaticToHyperlink(HWND hwndCtl, LPCTSTR strURL);
BOOL ConvertStaticToHyperlink(HWND hwndParent,
UINT uiCtlId, LPCTSTR strURL);
BOOL setURL( LPCTSTR strURL);
LPCTSTR getURL(void) const { return m_strURL; }
protected:
/*
* Override if you want to perform some action when
* the link has the focus or when the cursor is over
* the link such as displaying the URL somewhere.
*/
virtual void OnSelect(void) {}
virtual void OnDeselect(void) {}
LPTSTR m_strURL; // hyperlink URL
private:
// Hyperlink colors
static COLORREF g_crLinkColor, g_crVisitedColor;
static HCURSOR g_hLinkCursor; // Cursor for hyperlink
static HFONT g_UnderlineFont; // Font for underline display
static int g_counter; // Global resources user
// counter
BOOL m_bOverControl; // cursor over control?
BOOL m_bVisited; // Has it been visited?
HFONT m_StdFont; // Standard font
WNDPROC m_pfnOrigCtlProc;
void createUnderlineFont(void);
static void createLinkCursor(void);
void createGlobalResources(void)
{
createUnderlineFont();
createLinkCursor();
}
static void destroyGlobalResources(void)
{
/*
* No need to call DestroyCursor() for cursors
* acquired through LoadCursor().
*/
g_hLinkCursor = NULL;
DeleteObject(g_UnderlineFont);
g_UnderlineFont = NULL;
}
void Navigate(void);
static void DrawFocusRect(HWND hwnd);
static LRESULT CALLBACK _HyperlinkParentProc(HWND hwnd,
UINT message, WPARAM wParam, LPARAM lParam);
static LRESULT CALLBACK _HyperlinkProc(HWND hwnd,
UINT message, WPARAM wParam, LPARAM lParam);
};
この変更を行った理由は次のとおりです。
- ハイパーリンクを選択または選択解除したときの動作をカスタマイズできるようにするため。このクラスから新しいクラスを派生させれば、動作を自由にカスタマイズできます。
- ウィンドウプロシージャにおける
GetProp()の呼び出し回数を減らすため。コードを1つのクラスにまとめたことにより、GetProp()の呼び出しを1回行うだけで、必要な変数をすべて含んだオブジェクトのポインタを取得できます。
理由1に挙げたカスタマイズを行うには、OnSelect()およびOnDeselect()関数をオーバーライドします。後ほど、デモアプリケーションを紹介するときに実例を交えて説明します。
このクラスでは、別の点でも改善が行われています。メンバの一部がstatic宣言されていることに気づいたでしょうか。これによって、複数のハイパーリンクコントロールで同じリソース(ハンドカーソルやアンダーライン付きフォントなど)を共有することができます。ConvertStaticToHyperlink()関数には次のブロックを追加してあります。
if( g_counter++ == 0 )
{
createGlobalResources();
}
また、コントロールウィンドウプロシージャ内のWM_DESTROYメッセージハンドラには次のコードを追加してあります。
if( --CHyperLink::g_counter <= 0 )
{
destroyGlobalResources();
}
最初のConvertStaticToHyperlink()の呼び出し時にグローバルリソースの割り当てが行われ、最後のハイパーリンクの破棄時に共有リソースも一緒に解放されます。このアプローチの利点は、メモリの利用を効率化できることと、ハンドカーソルの読み込みが一度で済むことです。WM_SETCURSORの新しいコードは、次のようになります。
case WM_SETCURSOR:
{
SetCursor(CHyperLink::g_hLinkCursor);
return TRUE;
}
それでは、新しい機能の説明に戻りましょう。最も簡単なのは、訪問済みのハイパーリンクの表示色を変更するという機能です。これはWM_CTLCOLORSTATICハンドラをほんの少し変更するだけで実現できます。必要なのは、リンクが訪問済みかどうかを表すブール型変数をチェックすることだけです(この変数はリンク訪問時にtrueに設定されます)。関連部分のコードを以下に示します。
inline void CHyperLink::Navigate(void)
{
SHELLEXECUTEINFO sei;
::ZeroMemory(&sei,sizeof(SHELLEXECUTEINFO));
sei.cbSize = sizeof( SHELLEXECUTEINFO ); // Set Size
sei.lpVerb = TEXT( "open" ); // Set Verb
sei.lpFile = m_strURL; // Set Target
// To Open
sei.nShow = SW_SHOWNORMAL; // Show Normal
WINXDISPLAY(ShellExecuteEx(&sei));
m_bVisited = TRUE;
}
case WM_CTLCOLORSTATIC:
{
HDC hdc = (HDC) wParam;
HWND hwndCtl = (HWND) lParam;
CHyperLink *pHyperLink =
(CHyperLink *)GetProp(hwndCtl, PROP_OBJECT_PTR);
if(pHyperLink)
{
LRESULT lr = CallWindowProc(pfnOrigProc,
hwnd, message, wParam, lParam);
if (!pHyperLink->m_bVisited)
{
// This is the most common case for static
// branch prediction optimization
SetTextColor(hdc, CHyperLink::g_crLinkColor);
}
else
{
SetTextColor(hdc, CHyperLink::g_crVisitedColor);
}
return lr;
}
break;
}
キーボード操作をサポートするには、次のメッセージを処理しなければなりません。
WM_KEYUP
WM_SETFOCUS
WM_KILLFOCUS
このハイパーリンクコントロールは、スペースキーの押下に反応します。WM_SETFOCUSとWM_KILLFOCUSによって、フォーカスを示す矩形が描かれます。この矩形は親ウィンドウに対して描かれます。この理由は、第一に、そうしないと矩形の各辺がハイパーリンクテキストに近くなりすぎて文字が読みにくくなるからです。第二の理由は、このサンプルではWM_CTLCOLOR_STATICハンドラから透明ブラシを返すことでハイパーリンクコントロールを透明にしているからです。親ウィンドウがハイパーリンクコントロールの背景を消すという処理にすると、フォーカス矩形の扱いが面倒になります。親ウィンドウに対してフォーカス矩形を描くことにより、こうしたささいな問題を解決することができます。
ここでもう1つ説明しておきたいのは、公開されているハイパーリンクコントロールの多くはWM_LBUTTONDOWNを使っているのに、なぜWM_KEYUPとWM_LBUTTONUPを使うことにしたのか、という点です。この理由は簡単です。Internet Explorer(IE)のハイパーリンクや従来のWindowsコントロールと動作を一貫させるためです。実は私もそうだったのですが、きっとほとんどの皆さんは、このささいな点には注意を払わなかったと思います。それではIEで試してみましょう。ハイパーリンクをクリックし、ボタンを押し下げたままにしてください。マウスボタンを放すまではリンク先にジャンプしません。ダイアログ内のプッシュボタンも同様です。また、プッシュボタンにフォーカスを合わせてスペースキーを押しても、キーを押し下げているかぎりは何の動作も起こりません。ところで、私はキーボード入力のサポート方法の調査中に、MSDNでPaul DiLasciaのすばらしい記事を読みました。彼は、WM_GETDLGCODEメッセージハンドラとWM_CHARメッセージハンドラを組み合わせて使用し、WM_GETDLGCODEからDLGC_WANTCHARSを返すことで、コントロールがWM_CHARメッセージの受け取りを求めていることをダイアログボックスマネージャに知らせていました。しかし、私はこのやり方には賛成しません。理由は次のとおりです。
- 簡潔性:私の方法では1つのハンドラ(
WM_KEYUP)で済むのに対し、2つのハンドラ(WM_GETDLGCODEとWM_CHAR)を必要とします。
- 正確性:従来のコントロールのような、スペースキーを放したときにハイパーリンクがアクティブになるという動作を実現できません。また、
WM_CHARを使用した場合、キーが押されたままだとコントロールが何度もメッセージを受け取るという問題があります。
- 最後に、これらの主張を裏付けるように、Petzoldの著書『Programming Windows』では、コントロールのサブクラス化に
WM_KEYUPが使われています。
いずれにせよ、関連部分のコードは次のようになります。
inline void CHyperLink::DrawFocusRect(HWND hwnd)
{
HWND hwndParent = GetParent(hwnd);
if( hwndParent )
{
// calculate where to draw focus rectangle, in screen
// coords
RECT rc;
GetWindowRect(hwnd, &rc);
INFLATERECT(&rc,1,1); // add one pixel all around
// convert to parent
// window client coords
::ScreenToClient(hwndParent, (LPPOINT)&rc);
::ScreenToClient(hwndParent, ((LPPOINT)&rc)+1);
HDC dcParent = GetDC(hwndParent); // parent window’s DC
::DrawFocusRect(dcParent, &rc); // draw it!
ReleaseDC(hwndParent,dcParent);
}
}
case WM_KEYUP:
{
if( wParam != VK_SPACE )
{
break;
}
}
// Fall through
case WM_LBUTTONUP:
{
pHyperLink->Navigate();
return 0;
}
case WM_SETFOCUS: // Fall through
case WM_KILLFOCUS:
{
if( message == WM_SETFOCUS )
{
pHyperLink->OnSelect();
}
else // WM_KILLFOCUS
{
pHyperLink->OnDeselect();
}
CHyperLink::DrawFocusRect(hwnd);
return 0;
}
Navigate()とDrawFocusRect()を両方ともインライン関数にしていることに気がついたでしょうか。これらの関数は、どちらもハイパーリンクのウィンドウプロシージャから呼び出されます。インライン関数になっているのは、可読性を最大限に高めると同時に、不必要な関数呼び出しを避けてウィンドウプロシージャを最適化するためです。
今度は、バグ修正に取り組みましょう。このコントロールでは、ReleaseCapture()を呼び出す以外の方法でもマウスキャプチャを解放できます。たとえば、リンクをクリックして、カーソルをそのリンクの上に置いたままにしておきます。Webブラウザのウィンドウがポップアップすると、マウスキャプチャは失われます。このとき、実際にはマウスキャプチャが失われているのに、コントロールのほうではそれを認識していないという矛盾した状態になってしまうのです。このバグを修正する秘訣は、コントロールが明示的に解放しなくてもマウスキャプチャが解放される場合があるという点を理解すること、そしてWM_CAPTURECHANGEDメッセージを適切に処理することです。コードは次のようになります。
case WM_MOUSEMOVE:
{
if ( pHyperLink->m_bOverControl )
{
// This is the most common case for static branch
// prediction optimization
RECT rect;
GetClientRect(hwnd,&rect);
POINT pt = { LOWORD(lParam), HIWORD(lParam) };
if (!PTINRECT(&rect,pt))
{
ReleaseCapture();
}
}
else
{
pHyperLink->m_bOverControl = TRUE;
SendMessage(hwnd, WM_SETFONT,
(WPARAM)CHyperLink::g_UnderlineFont, FALSE);
InvalidateRect(hwnd, NULL, FALSE);
pHyperLink->OnSelect();
SetCapture(hwnd);
}
return 0;
}
case WM_CAPTURECHANGED:
{
pHyperLink->m_bOverControl = FALSE;
pHyperLink->OnDeselect();
SendMessage(hwnd, WM_SETFONT,
(WPARAM)pHyperLink->m_StdFont, FALSE);
InvalidateRect(hwnd, NULL, FALSE);
return 0;
}
ウィンドウプロシージャの話題を終えるにあたって、もう1つ大事な点に触れておきます。処理されたメッセージは、静的コントロールのプロシージャには戻されません(静的コントロールには必要ないからです)。基本的にはこれでうまく動作しますが、その静的コントロールがすでにサブクラス化されている場合は問題が起こり得るので注意が必要です。たとえば、静的コントロールが、マウスメッセージを処理する必要のあるツールチップコントロールによってサブクラス化されているとします。この場合、ツールチップコントロールは期待どおりには動作しません。ツールチップコントロールをCHyperLinkクラスと連携させて使う方法については、デモプログラムのところで説明します。
最後に、GetProp()の呼び出しを高速化するために、文字列ではなく「アトム」を使います。簡単なグローバルオブジェクトを使ってアトムの格納を行うことで、CHyperLinkが一度も使用されないうちにアトムの初期化が行われ、プログラムの実行期間の全体にわたってアトムが存在することになります。また、システム全体を通して一意な名前を作成するために、意味のある文字列にGUIDを追加しています。
/*
* typedefs
*/
class CGlobalAtom
{
public:
CGlobalAtom(void)
{ atom = GlobalAddAtom(TEXT("_Hyperlink_Object_Pointer_")
TEXT("{AFEED740-CC6D-47c5-831D-9848FD916EEF}")); }
~CGlobalAtom(void)
{ DeleteAtom(atom); }
ATOM atom;
};
/*
* Local variables
*/
static CGlobalAtom ga;
#define PROP_OBJECT_PTR ((LPCTSTR)(DWORD)ga.atom)
#define PROP_ORIGINAL_PROC ((LPCTSTR)(DWORD)ga.atom)
デモプログラム
ここで紹介するデモは、MFC AppWizardで生成した簡単なアプリケーションです。私が作成したCHyperLinkクラスの使い方を示すために、バージョン情報ダイアログのクラスに変更を加えています。
まず、ダイアログエディタにいくつかの静的コントロールを追加します。TABSTOPスタイルを選択し、各コントロールにユニークなIDを与えます。また、CHyperLinkクラスから新しいクラス(CDemoLink)を派生させ、OnSelect()およびOnDeselect()メソッドをオーバーライドします。ここでは、フレームウィンドウのSetMessageText()関数を呼び出すことで、URLテキストをステータスバーに送ります。
class CDemoLink : public CHyperLink
{
protected:
virtual void OnSelect(void)
{ ((CFrameWnd *)AfxGetMainWnd())->SetMessageText(m_strURL); }
virtual void OnDeselect(void)
{ ((CFrameWnd *)AfxGetMainWnd())->
SetMessageText(AFX_IDS_IDLEMESSAGE); }
};
次に、このダイアログクラスにCDemoLink型のメンバ変数を追加し、WM_INITDIALOGハンドラで次の処理を行います。
void CAboutDlg::setURL(CHyperLink &ctr, int id)
{
TCHAR buffer[128];
int nLen = ::LoadString(AfxGetResourceHandle(),
id, buffer, 128);
if( !nLen )
{
lstrcpy( buffer, __TEXT(""));
}
ctr.ConvertStaticToHyperlink(GetSafeHwnd(),id,buffer);
}
BOOL CAboutDlg::OnInitDialog()
{
CDialog::OnInitDialog();
// TODO: Add extra initialization here
setURL(m_DemoLink,IDC_HOMEPAGE);
setURL(m_DemoMail,IDC_EMAIL);
return TRUE; // return TRUE unless you set the focus
// to a control
// EXCEPTION: OCX Property Pages should
// return FALSE
}
このデモプログラムは、CHyperLinkとツールチップコントロールを連動させる方法も示しています。そのためには、MFCのCToolTipCtrlクラスから新しいクラス(CSubclassToolTipCtrl)を派生させる必要があります。まずは、この新たな派生クラスを利用するコードを示したうえで、この新しいクラスのねらいを説明します。
BOOL CAboutDlgWithToolTipURL::OnInitDialog()
{
CAboutDlg::OnInitDialog();
// TODO: Add extra initialization here
m_ctlTT.Create(this);
setURL(m_DemoLink,IDC_HOMEPAGE);
setURL(m_DemoMail,IDC_EMAIL);
/*
* It is OK to add a Window tool to the tool tip
* control with the CHyperLink dynamically allocated
* URL string because the windows are destroyed with
* WM_DESTROY before the CHyperLink destructor where
* the URL string is freed.
*/
m_ctlTT.AddWindowTool(GetDlgItem(IDC_HOMEPAGE)
->GetSafeHwnd(),
(LPTSTR)m_DemoLink.getURL());
m_ctlTT.AddWindowTool(GetDlgItem(IDC_EMAIL)->GetSafeHwnd(),
(LPTSTR)m_DemoMail.getURL());
return TRUE; // return TRUE unless you set the focus
// to a control
// EXCEPTION: OCX Property Pages should
// return FALSE
}
m_ctlTTはCSubclassToolTipCtrl型のオブジェクトであり、AddWindowTool()は新しい関数です。MFCのCToolTipCtrlクラスをそのまま使わなかったのは、ツールのメッセージを受け取る必要があったからです。ここでは低レベルのツールチップAPIを使用してメッセージを受け取るようにしています。MFCのCToolTipCtrlクラスで同じことをしようと思ったら、CToolTipCtrl::RelayEvent()を介してツールのメッセージを中継していく必要があります。RelayEvent()の使い方は比較的簡単ですが、今回のCHyperLinkの設計目標の1つはユーザーにMFCの使用を強制しないことなので、あまり好ましい方法ではありません。この低レベルAPIは、TTM_RELAYEVENTメッセージと共に使うこともできますが、ツールチップを使っていないハイパーリンクのすべてに不必要なオーバーヘッドを課すことになってしまいます。この問題の解決策は『Programming Windows with MFC』という本の中にありました。ツールチップコントロールの、ツールウィンドウをサブクラス化できるという機能を利用すればよいのです。
CSubclassToolTipCtrlクラスの定義を次に示します。
class CSubclassToolTipCtrl : public CToolTipCtrl
{
// Construction
public:
CSubclassToolTipCtrl();
// Operations
public:
/*********************************************************
*
* Name : AddWindowTool
*
* Purpose : Add a window tool by using the Tooltip
* subclass feature
*
* Parameters:
* hWin (HWND) Tool window
* pszText (LPCTSTR) Tip text (can also be
* a string resource ID).
*
* Return value : Returns TRUE if successful,
* or FALSE otherwise.
*
*********************************************************/
BOOL AddWindowTool( HWND hWin, LPTSTR pszText );
// Implementation
public:
virtual ~CSubclassToolTipCtrl();
};
/*
* Function CSubclassToolTipCtrl::AddWindowTool
*/
BOOL CSubclassToolTipCtrl::AddWindowTool(
HWND hWin, LPTSTR pszText )
{
TOOLINFO ti;
::ZeroMemory(&ti, sizeof(TOOLINFO));
ti.cbSize = sizeof(TOOLINFO);
ti.uFlags = TTF_IDISHWND
| TTF_SUBCLASS;
ti.hwnd = ::GetParent(hWin);
ti.uId = (UINT)hWin;
ti.hinst = AfxGetInstanceHandle();
ti.lpszText = pszText;
return (BOOL)SendMessage(TTM_ADDTOOL,0,(LPARAM)&ti);
}
ここでは、『Programming Windows with MFC』の解決策にちょっとした改善を加えてあります。TOOLINFO構造体を、使う前に0に初期化したのです。使用前に構造体を初期化しておくのは、問題を未然に防ぐための予防措置です。初期化しないままこの構造体定義に新しいフィールドを追加すると、未初期化フィールドにランダムな値が入るため、厄介で見つかりにくいバグになるおそれがあります。構造体をリセットすることで、この問題からプログラムを守ることができます。
まとめ
説明は以上です。本稿が皆さんの参考になれば幸いです。本稿がお役に立ったようでしたら、少しだけ時間をとって評価をしていただけると助かります。評価は、本稿の一番下の部分から行えます。
参考文献
改訂履歴
2005年12月3日
- WTLおよび「comctlr32.dll」バージョン6による代替案を追加。
CHyperLinkをUNICODEに準拠するよう変更。
CHyperLinkとツールチップを連動させる方法の例を追加。
2005年11月17日