Click here to Skip to main content
15,881,089 members
Articles / Programming Languages / C++

Windows Vista-like Task Dialogs

Rate me:
Please Sign up or sign in to vote.
4.77/5 (19 votes)
9 Oct 2006CPOL11 min read 66.6K   695   41   5
A source-compatible custom implementation of the upcoming Task Dialogs API found on Microsoft® Windows Vista™. This implementation makes it possible to use Task Dialogs in your applications designed to work on Windows 2000 and later.

Sample Image - check_box.png

Introduction

As some of you may know, the future version of Windows, codenamed Vista, will include a richer user experience. As part of this, Windows Vista will support a new kind of dialog boxes named Task Dialogs.

Task Dialogs are very similar to plain old message boxes, but they support many more options, and are generally more flexible. With Task Dialogs, we can implement very simple but richer dialog boxes used for prompts, as well as more elaborate wizard-like applications. It is my understanding that many operations in Windows Vista will be exposed through sequences of Task Dialogs.

A Task Dialog has the following general structure:

  • a window title (or caption)
  • a main instruction, displaying an icon and a text
  • a content area, displaying a description and supporting hyperlinks
  • a set of radio buttons
  • a progress bar
  • a set of custom buttons, as well as some common buttons
  • a check box, allowing simple scenarios like "Do not show this dialog again"
  • a footnote area, displaying a small icon and a text and supporting hyperlinks

This article presents my implementation of this new feature, designed to be source-compatible with the upcoming API. Indeed, because this feature will only be available on Windows Vista does not mean that you don't necessarily want to use it in your applications today. So I decided to try and implement this myself.

With this custom implementation, you too can use Task Dialogs in your applications designed to work on Windows 2000 and later platforms.

How to use it in your projects

The download for this article features a dynamic library commctrl_taskdialogs.dll that exposes the functions comprising the Task Dialogs API. It also includes a header file, commctrl_taskdialogs.h, that you include in your applications to access the declarations of types, structures, and functions of the Task Dialogs API. The dynamic library is implemented using ATL and WTL, and thus does not have external dependencies.

On Windows Vista, this API will be exposed by comctl32.dll and is accessible via the commctrl.h header file. Since this implementation is designed to be source-compatible, you should be able to replace the inclusion directive when Vista finally ships.

Image 2

If you read the description of the Task Dialogs API, you will see that it is very easy to implement a simple prompt, like the figure shown above. The following code illustrates how you can display such a dialog box:

C++
    #include <commctrl_taskdialogs.h>
    // on Vista, use #include <commctrl.h> directly

    int button;

    ::TaskDialog(
          ::GetActiveWindow()                // parent window handle
        , ::AfxGetResourceHandle()            // resource handle
        , MAKEINTRESOURCE(IDR_MAINFRAME)        // window title
        , MAKEINTRESOURCE(IDS_TASK_WELCOME_INSTRUCTION)    // main instruction
        , MAKEINTRESOURCE(IDS_TASK_WELCOME_CONTENT)    // text in the content area
        , TDCBF_YES_BUTTON | TDCBF_NO_BUTTON        // common buttons
        , MAKEINTRESOURCE(IDR_MAINFRAME)        // icon
        , &button
        );

    if (button == IDYES) {

        // creates and displays the first task dialog
        // that will take part in the navigation sequence

        ...
    }
)

Simple, isn't it? Despite being very flexible, the Task Dialogs API is exposed with only two functions. That's right.

The TaskDialog function itself is the equivalent of the MessageBox API. It does not do much, besides allowing any combinations of the buttons commonly found on a message box. One notable feature, though, is the possibility for the Task Dialog to load strings and icons from resources, specified by using a resource handle in combination with the MAKEINTRESOURCE macro.

The true power of this API lies in the TaskDialogIndirect function which, as you may guess, takes a TASKDIALOGCONFIG structure plus some other parameters. The members of this structure are organized according to the general structure of a Task Dialog, as seen above. What makes it powerful is the possibility for your application to specify a callback function that will be called whenever something interesting happens in the Task Dialog, such as when a button is pressed, when a radio button or checkbox is selected, when a hyperlink is clicked, etc. As you can see, this function is way more powerful than the MessageBoxIndirect API. Indeed, coupled with its built-in support for a progress bar and callback timer, and the possibility to navigate from one Task Dialog to another, the TaskDialogIndirect function is very much like a mini application framework.

The TASKDIALOGCONFIG structure contains a gazillion members, and it may take some time getting used to it. Fortunately, it's rather easy to create some wrappers around it, so you will find wrapper classes for ATL and MFC projects in the download for this article.

Wrapper classes

Because it may be tedious to use the raw TaskDialogIndirect function and maintain all the members of the TASKDIALOGCONFIG structure directly, I have provided some wrapper classes that you can use in your MFC or ATL projects. The use of these wrapper classes is by no means mandatory, however, and I have included a set of macros in the header file to support using the Task Dialogs API from a plain Win32 SDK application.

The wrapper classes make it easy to manage and operate a Task Dialog, and includes support for the callback notifications. It contains a set of virtual functions that are called automatically by the framework, and a set of methods that manage the transmission of messages to the Task Dialog.

For instance, the Task Dialog shown at the beginning of this article is implemented in the sample application by the following code:

C++
class CCheckBoxTaskDialog : public CTaskDialog
{
// construction / destruction
public:

    CCheckBoxTaskDialog()
        : CTaskDialog(
              MAKEINTRESOURCE(IDS_TASK_CHECK_BOX_INSTRUCTION)
            , MAKEINTRESOURCE(IDS_TASK_CHECK_BOX_CONTENT)
            , MAKEINTRESOURCE(IDR_MAINFRAME)
            , 0
            , MAKEINTRESOURCE(IDR_MAINFRAME))
    {

        // only allow hyperlinks on systems that support it
        // i.e. check for the version of the common control library

        if (DllGetVersion(L"comctl32") >= MAKEDLLVERULL(6, 0, 0, 0)) {
            SetFlags(TDF_ENABLE_HYPERLINKS);
            SetVerificationText(MAKEINTRESOURCE(
               IDS_TASK_CHECK_BOX_CHECK_ENABLE_HYPERLINKS));
        }

        SetFlags(TDF_ALLOW_DIALOG_CANCELLATION);
        SetCommonButtons(TDCBF_CLOSE_BUTTON);
        AddButton(IDC_TASK_CHECK_BOX_BUTTON_CONTINUE);

        SetFooter(MAKEINTRESOURCE(IDS_TASK_CHECK_BOX_FOOTER));
        SetFooterIcon(MAKEINTRESOURCE(IDR_MAINFRAME));
    }

// overrides
private:

    BOOL OnButtonClicked(UINT uID)
    {
        if (uID == IDC_TASK_CHECK_BOX_BUTTON_CONTINUE) {
            Navigate(ProgressBarTaskDialog_);
            return TRUE;
        }
        return FALSE;
    }

    void OnVerificationClicked(BOOL bChecked)
    {
        if (bChecked) {
            SetContent(MAKEINTRESOURCE(IDS_TASK_CHECK_BOX_CONTENT_HYPERLINKS));
            SetFooter(MAKEINTRESOURCE(IDS_TASK_CHECK_BOX_FOOTER_HYPERLINKS));
        }

        else {
            SetContent(MAKEINTRESOURCE(IDS_TASK_CHECK_BOX_CONTENT));
            SetFooter(MAKEINTRESOURCE(IDS_TASK_CHECK_BOX_FOOTER));
        }
    }

    void OnHyperLinkClicked(LPCWSTR wszHREF)
    {
        ::ShellExecuteW(GetSafeHwnd(), L"open", wszHREF, L"", L"", SW_SHOWNORMAL);
    }


// data members
private:

    CProgressBarTaskDialog ProgressBarTaskDialog_;
};

For reference, here is the declaration of the CTaskDialog MFC wrapper class. The ATL wrapper class is very similar, and includes the same methods and virtual functions:

C++
#ifndef _INC_AFXTASK
#define _INC_AFXTASK

class CSimpleTaskDialog : public CWnd
{
// construction / destruction
public:

    DECLARE_DYNAMIC(CSimpleTaskDialog)

    CSimpleTaskDialog(
          LPCTSTR instruction = _T("")
        , LPCTSTR content = _T("")
        , LPCTSTR title = _T("")
        , TASKDIALOG_COMMON_BUTTON_FLAGS buttons = TDCBF_OK_BUTTON
        , LPCTSTR icon = 0);

    virtual ~CSimpleTaskDialog();

// attributes
public:

    virtual int GetButton(void) const;

    virtual void SetWindowTitle(LPCTSTR title);
    virtual void SetMainInstruction(LPCTSTR instruction);
    virtual void SetContent(LPCTSTR content);
    virtual void SetIcon(LPCTSTR icon);

// operations
public:

    virtual INT_PTR DoModal(CWnd* pParentWnd = CWnd::GetActiveWindow());

// data members
protected:

    TASKDIALOGCONFIG m_config;
    int m_button;

};

class CTaskDialog : public CSimpleTaskDialog
{
// construction / destruction
public:

    DECLARE_DYNAMIC(CTaskDialog)

    CTaskDialog(
              LPCTSTR instruction = _T("")
            , LPCTSTR content = _T("")
            , LPCTSTR title = _T("")
            , TASKDIALOG_COMMON_BUTTON_FLAGS buttons = TDCBF_OK_BUTTON
            , LPCTSTR icon = 0);

// attributes
public:

    int GetRadioButton(void) const;
    BOOL IsVerificationChecked(void) const;

    void SetFlags(TASKDIALOG_FLAGS dwFlags, TASKDIALOG_FLAGS dwExMask);

    void SetCommonButtons(TASKDIALOG_COMMON_BUTTON_FLAGS buttons);

    void SetWindowTitle(LPCTSTR title);
    void SetMainInstruction(LPCTSTR instruction);
    void SetContent(LPCTSTR content);

    void SetIcon(LPCTSTR icon);
    void SetIcon(HICON hIcon);

    void AddButton(UINT nButtonID, LPCTSTR pszButtonText = 0);
    void AddRadioButton(UINT nButtonID, LPCTSTR pszButtonText = 0);

    void SetDefaultButton(UINT uID);
    void SetDefaultRadioButton(UINT uID);

    void SetVerificationText(LPCTSTR verification, BOOL bChecked = FALSE);

    void SetFooterIcon(HICON icon);
    void SetFooterIcon(LPCTSTR pszIcon);

    void SetFooter(LPCTSTR content);

// operations
public:

    INT_PTR DoModal(CWnd* pParentWnd = CWnd::GetActiveWindow());

    void ClickButton(UINT uID);
    void ClickRadioButton(UINT uID);
    void ClickVerification(BOOL bState, BOOL bFocus = FALSE);

    void EnableButton(UINT uID, BOOL bEnabled = TRUE);
    void EnableRadioButton(UINT uID, BOOL bEnabled = TRUE);

    void SetProgressBarMarquee(BOOL bMarquee, UINT nSpeed);
    void SetProgressBarPosition(UINT nPos);
    void SetProgressBarRange(UINT nMinRange, UINT nMaxRange);
    void SetProgressBarState(UINT nState);

    BOOL UpdateElementText(TASKDIALOG_ELEMENTS te, LPCTSTR string);

    void UpdateIcon(TASKDIALOG_ICON_ELEMENTS tie, LPCTSTR icon);
    void UpdateIcon(TASKDIALOG_ICON_ELEMENTS tie, HICON hIcon);

    void Navigate(const TASKDIALOGCONFIG* task_dialog);
    void Navigate(const CTaskDialog& task_dialog);

// overrides
public:

    virtual void OnDialogConstructed(void);
    virtual void OnCreated(void);
    virtual void OnDestroyed(void);
    virtual void OnRadioButtonClicked(UINT uID);
    virtual BOOL OnButtonClicked(UINT uID);
    virtual void OnVerificationClicked(BOOL bChecked);
    virtual void OnHyperLinkClicked(LPCWSTR wszHREF);
    virtual void OnHelp(void);
    virtual BOOL OnTimer(DWORD dwTickCount);
    virtual void OnNavigated(void);

// implementation
private:

    static HRESULT __stdcall TaskDialogCallbackProc(HWND hWnd, 
           UINT uCode, WPARAM wParam, LPARAM lParam, LONG_PTR data);

// data members
private:

    int m_radiobutton;
    BOOL m_verification;

    CArray<TASKDIALOG_BUTTON> m_buttons;
    CArray<TASKDIALOG_BUTTON> m_radioButtons;

};

#endif // _INC_AFXTASK

#include "afxtask.inl"

Points of interest

Writing the accompanying code for this article was very fun and challenging. I will try to present here the various points of interest that occurred during the development:

Initially, I set out to create my Task Dialogs with a dialog template in memory. Unfortunately, I could not make this to work reliably, and it proved to be less flexible than creating the controls dynamically on an empty dialog box. This approach was necessary anyway to support the navigation feature of Task Dialogs, because in that case, the window handle is recycled between the primary Task Dialog and the Task Dialog being navigated to.

So, creating controls at runtime is more tricky, because you have to pay attention to many more things than you would when designing a dialog box with the resource editor. You have to take care of setting the appropriate font, position the control in the Z-order (for tabstop ordering), and calculate the space it will take on the dialog box. The algorithm I use to position the controls looks like this:

Calculating text extents

First, I calculate the maximum width of the dialog box, accounting for some margins. I made the assumption that the main instruction and the row of buttons should each be displayed on a single line. For starters, I therefore consider the width of the dialog box to be the maximum between these values and the width specified in one member of the TASKDIALOGCONFIG structure. But, I wanted the content area to look good also, so I try to enforce a 16:9 aspect ratio for the text in the content area. I do this by increasing the width of the dialog box until this ratio is reached.

One tricky part was calculating the rectangle occupied by the text in the content area. As far as I know, there is no built-in function to perform these calculations, although there are functions to obtain the text extent on a single line and the number of characters that fit in a specified width. Therefore, I needed to perform the calculations myself, that is splitting each line of text at a word wrapping boundary and accumulating the line heights on the way.

Well, it turned out to be more complicated than that, because I wanted to handle newline characters (\n), but Windows considers that a newline character does not take up any space at all! Here are the functions I used:

C++
/// implements GetTextExtentExPoint with additionnal support for newline-characters
static BOOL WINAPI GetTextExtentExPointExW(HDC hDC, LPCWSTR szText
                    , int cchString, int nMaxExtent
                    , LPINT lpnFit, LPINT alpDx, LPSIZE lpSize)
{

    // first, check whether there is a newline character.
    // if there is one, update the character count

    LPWSTR _szNewLine = ::StrChr(szText, L'\n');
    if (_szNewLine != 0) {
        int cch = 0;
        const WCHAR* _szText = szText;
        while (_szText <= _szNewLine) {
            _szText = ::CharNextW(_szText);
            ++cch;
        }

        cchString = min(cchString, cch);
    }

    // call the real function
    BOOL bResult = ::GetTextExtentExPoint(hDC, szText, cchString, 
                     nMaxExtent, lpnFit, alpDx, lpSize);

    // a single newline-character should take up
    // the same space as one line would

    if (_szNewLine != 0 && lpSize->cy == 0) {
        SIZE size;
        ::GetTextExtentExPoint(hDC, L"|", 1, 65536, 0, 0, &size);
        lpSize->cy = size.cy;
    }

    return bResult;
}

The previous function returns the correct number of characters that fit on a single line, taking into account potential newline characters. At the same time, a line starting with a newline character is considered to take up some space, simulated by a single | character.

Now, we can calculate the text extent of a given text, taking into count where words wrap. I use the following function for this:

C++
/// implements GetTextExtent with additionnal support for word wrapped text
static BOOL WINAPI GetTextExtentExW(HDC hDC, LPCWSTR szText, 
                   int cchString, int nMaxExtent, LPSIZE lpSize)
{
    lpSize->cx = 0;
    lpSize->cy = 0;

    int nFit = 0;
    SIZE extent = { 0, 0 };
    const WCHAR* _szText = szText;

    // calculate the number of characters that fit on a single line
    // taking into account potential newline characters

    if (::GetTextExtentExPointExW(hDC, _szText, cchString, 
                                  nMaxExtent, &nFit, 0, &extent)) {

        // update the horizontal extent of the text

        lpSize->cx = min(nMaxExtent, extent.cx);

        // if the specified text fits on a single line,
        // update the vertical extent of the text

        if (nFit == cchString)
            lpSize->cy = extent.cy;

        // otherwise, break up each individual line and
        // accumulate the vertical dimensions

        else {

            _szText = GetWordWrapBoundary(_szText, &nFit);

            while (true) {

                // update the horizontal and vertical extents of the text

                lpSize->cx = min(nMaxExtent, max(lpSize->cx, extent.cx));
                lpSize->cy += extent.cy;

                if ((cchString -= nFit) == 0)
                    break;

                // perform the calculation for the next line
                // and update the number of characters to consider

                ::GetTextExtentExPointExW(hDC, _szText, cchString, 
                                          nMaxExtent, &nFit, 0, &extent);
                _szText = GetWordWrapBoundary(_szText, &nFit);

            }
        }

        return TRUE;
    }

    return FALSE;
}

The previous function makes use of a small utility function, GetWordWrapBoundary, that calculates the index of the last character in a word wrapping boundary. That function is used in order to avoid using the [] subscript operator and making any assumption about whether a character has a fixed size or not. I don't know if it's relevant to UNICODE strings featuring complex scripts, but I better be safe than sorry:

C++
static LPCWSTR WINAPI GetWordWrapBoundary(LPCWSTR szText, int* pAt)
{
    // first go the the last specified character

    const WCHAR* _szAt = szText;
    for (int nAt = *pAt; nAt > 0; nAt--) {
        _szAt = ::CharNextW(_szAt);
        if (*_szAt == L'\0')
            break;
    }

    if (*_szAt == L'\0')
        return _szAt;

    // then go back until we find a space or a newline character,
    // updating the specified index as we go
    const WCHAR* _szText = _szAt;

    while (*_szText != L' ' && *_szText != L'\n' && _szText != szText) {
        _szText = ::CharPrevW(szText, _szText);
        --*pAt;
    }

    // finally, if we found a space or a newline character,
    // go forward one character

    if (_szText != _szAt) {
        _szText = ::CharNextW(_szText);
        ++*pAt;
    }

    return _szText;
}

Based upon the dialog width, I can now position the controls one at a time from top to bottom. First, the main icon and main instruction static controls are positioned, then the content static control, the radio buttons, and the progress bar. At this stage, I know the maximum height of the content area, so I place the white rectangle frame and etched horizontal line at the correct locations. Then the row of buttons, the checkbox, and footnote area controls can all be positioned at the bottom of the dialog box.

Cancelling the Task Dialog

Another interesting feature is the handling of the Task Dialog TDF_ALLOW_DIALOG_CANCELLATION flag. If this flag is specified, a Task Dialog can be closed using the close button in the upper right corner, or using the ESC key or ALT+F4 key combination. If this flag is not specified, however, the Task Dialog cannot be closed this way. Additionally, if there is a common "Cancel" button on the Task Dialog, the setting of this flag is implied.

A standard dialog box with a system menu behaves this way:

  • clicking on the close button sends a WM_SYSMESSAGE message with code SC_CLOSE to the dialog box
  • hitting the ALT+F4 key combination performs the same effect, i.e., a WM_SYSMESSAGE message with SC_CLOSE
  • hitting the ESC key simulates a click on the "Cancel", i.e., a WM_COMMAND message with BN_CLICKED and ID IDCANCEL

Those observations lead to an interesting implementation. First, we need to enable or disable the system close menu based upon the setting of the TDF_ALLOW_DIALOG_CANCELLATION flag. The following code takes care of this part:

C++
void CTaskDialog::EnableSystemClose(BOOL bEnabled)
{
    // to enable the system close command, we restore
    // the dialog box' system menu (bEnabled == TRUE)

    // otherwize, we create a copy of the system menu
    // so that we can later modify it (bEnabled == FALSE)

    HMENU hMenu = ::GetSystemMenu(m_hWnd, bEnabled);

    if (bEnabled)
        return ;

    if (hMenu != 0) {

        // first, lookup the index of the SC_CLOSE command

        int count = ::GetMenuItemCount(hMenu);
        for (int index = 0; index < count; index++) {
            DWORD dwID = ::GetMenuItemID(hMenu, index);
            if (::GetMenuItemID(hMenu, index) == SC_CLOSE)
                break;
        }

        if (index < count) {

            // remove the SC_CLOSE command

            ::RemoveMenu(hMenu, index, MF_BYPOSITION);

            // if the previous command is a separator
            // remove it as well to obtain a nice menu

            {
                MENUITEMINFO mnuItemInfo = { sizeof(MENUITEMINFO), MIIM_FTYPE };
                ::GetMenuItemInfo(hMenu, index - 1, TRUE, &mnuItemInfo);
                if (mnuItemInfo.fType == MFT_SEPARATOR)
                    ::RemoveMenu(hMenu, index - 1, MF_BYPOSITION);
            }
        }
    }
}

Disabling the system close command is not enough. As we said, the Dialog Manager will handle the ALT+F4 key combination and send this command to our dialog box. A simple way to do this is to filter out the system close command and delegate to the handler for OnCancel():

C++
void CTaskDialog::OnSysCommand(UINT nCode, CPoint /* pt */)
{
    SetMsgHandled(FALSE);
    if ((nCode & 0xFFF0) == SC_CLOSE) {
        SetMsgHandled(TRUE);
        if (HasFlag(TDF_ALLOW_DIALOG_CANCELLATION))
            OnCancel(BN_CLICKED, IDCANCEL, m_hWnd);
    }
}

We need to filter the ESC key also, because it is translated by the Dialog Manager to a click on a "Cancel" button. In our function, we delegate to a handler for the buttons, because the callback function can prevent the Task Dialog from being actually closed:

C++
LRESULT CTaskDialog::OnCancel(UINT /* nCode */, int /* nID */, HWND /* hWnd */)
{
    if ((::GetKeyState(VK_ESCAPE) & 0x80000) == 0 || 
                       HasFlag(TDF_ALLOW_DIALOG_CANCELLATION))
        return OnButtonClicked(IDCANCEL);
    return 0L;
}

One final twist is the possibility for the callback function to enable or disable specific buttons on the Task Dialog, including the "Cancel button. When the "Cancel" button is disabled, we do not want to allow dialog cancellation. The following snippet is used to handle this case:

C++
void CTaskDialog::OnEnableButton(UINT uID, BOOL bEnabled)
{
    if (!::IsWindow(GetDlgItem(uID)))
        return ;

    ::EnableWindow(GetDlgItem(uID), bEnabled);

    if (uID == IDCANCEL) {
        if (bEnabled)
            config_.dwFlags |= TDF_ALLOW_DIALOG_CANCELLATION;
        else
            config_.dwFlags &= ~TDF_ALLOW_DIALOG_CANCELLATION;
        EnableSystemClose(HasFlag(TDF_ALLOW_DIALOG_CANCELLATION));
    }
}

Notice that we actually change the setting of the TDF_ALLOW_DIALOG_CANCELLATION flag. But this is OK, because this only happens if there is a "Cancel" button on the Task Dialog, in which case, as we said, the setting of the flag is implied anyway.

Limitations

In real life, Task Dialogs support an expansion area useful to host more details that are only displayed upon request from the user. My implementation does not include this feature.

Again, Task Dialogs will be available on Windows Vista, and will make use of the upgraded common controls, such as the Command Links. I did not emulate this behavior in my custom implementation.

It seems that on Windows Vista, the Task Dialogs API will only be available in UNICODE. My implementation is available both as ANSI and UNICODE functions. There is one limitation though, in the handling of the TDN_HYPERLINK_CLICKED notification. Hyperlinks are implemented in Windows by the SysLink common control that is only available in UNICODE. Therefore, this notification will always return a UNICODE string, even for ANSI Task Dialogs.

Known issues

There is a problem displaying a small standard icon in the footnote area. Although I use the ::LoadImage API with the appropriate size for a small icon, standard icons are always loaded with their default size of 32x32 pixels on my system (Windows XP). Custom icons work all right, though.

I use a custom function to move the focus to a specific control in the Task Dialog at various moments. I should have used the standard WM_NEXTDLGCTL message for this, but it did not appear to work for this project. Perhaps this is related to the fact that the controls on the dialog box are created dynamically. Anyway, could not make this work otherwise.

Credits

Even though I mainly used the preliminary description of the Task Dialogs API in MSDN, I also would like to mention the articles that I found useful along the way:

First, I'm a regular reader of Raymond Chen's blog, The Old New Thing. This article was the one that started it all...

I made extensive use of this article by Kenny Kerr, as part of his Windows Vista for Developers article series, both for the description of the API and the implementation of the ATL C++ wrapper class.

Conclusion

This article presents a custom implementation of the upcoming Windows Vista Task Dialogs API, and provides wrapper classes for ATL/WTL and MFC projects.

This implementation was exciting to write and challenging in many ways, and I hope that you enjoyed reading it as much as I enjoyed creating this project.

I hope that someone finds this useful.

License

This article, along with any associated source code and files, is licensed under The Code Project Open License (CPOL)


Written By
Web Developer
France France
Coming soon...

Comments and Discussions

 
GeneralWord Wrap Pin
c2j218-Oct-06 0:07
c2j218-Oct-06 0:07 
GeneralRe: Word Wrap Pin
Maxime Labelle7-Nov-06 20:15
Maxime Labelle7-Nov-06 20:15 

General General    News News    Suggestion Suggestion    Question Question    Bug Bug    Answer Answer    Joke Joke    Praise Praise    Rant Rant    Admin Admin   

Use Ctrl+Left/Right to switch messages, Ctrl+Up/Down to switch threads, Ctrl+Shift+Left/Right to switch pages.