Show tooltip on invalid input in edit control

4.4k views Asked by At

I have subclassed edit control to accept only floating numbers. I would like to pop a tooltip when user makes an invalid input. The behavior I target is like the one edit control with ES_NUMBER has :

enter image description here

So far I was able to implement tracking tooltip and display it when user makes invalid input.

However, the tooltip is misplaced. I have tried to use ScreenToClient and ClientToScreen to fix this but have failed.

Here are the instructions for creating SCCE :

1) Create default Win32 project in Visual Studio.

2) Add the following includes in your stdafx.h, just under #include <windows.h> :

#include <windowsx.h>
#include <commctrl.h>

#pragma comment( lib, "comctl32.lib")

#pragma comment(linker, \
    "\"/manifestdependency:type='Win32' "\
    "name='Microsoft.Windows.Common-Controls' "\
    "version='6.0.0.0' "\
    "processorArchitecture='*' "\
    "publicKeyToken='6595b64144ccf1df' "\
    "language='*'\"")

3) Add these global variables:

HWND g_hwndTT;
TOOLINFO g_ti;

4) Here is a simple subclass procedure for edit controls ( just for testing purposes ) :

LRESULT CALLBACK EditSubProc ( HWND hwnd, UINT message, 
    WPARAM wParam, LPARAM lParam, 
    UINT_PTR uIdSubclass, DWORD_PTR dwRefData )
{
    switch (message)
    {
    case WM_CHAR:
        {
            POINT pt;
            if( ! isdigit( wParam ) )  // if not a number pop a tooltip!
            {
                if (GetCaretPos(&pt))  // here comes the problem
                {
                    // coordinates are not good, so tooltip is misplaced
                    ClientToScreen( hwnd, &pt );


                    /************************** EDIT #1 ****************************/
                    /******* If I delete this line x-coordinate is OK *************/
                    /*** y-coordinate should be little lower, but it is still OK **/
                    /**************************************************************/

                    ScreenToClient( GetParent(hwnd), &pt );

                    /************************* Edit #2 ****************************/

                    // this adjusts the y-coordinate, see the second edit
                    RECT rcClientRect;
                    Edit_GetRect( hwnd, &rcClientRect );
                    pt.y = rcClientRect.bottom;

                    /**************************************************************/

                    SendMessage(g_hwndTT, TTM_TRACKACTIVATE, 
                        TRUE, (LPARAM)&g_ti);
                    SendMessage(g_hwndTT, TTM_TRACKPOSITION, 
                        0, MAKELPARAM(pt.x, pt.y));
                }
                return FALSE;
            }
            else
            {
                SendMessage(g_hwndTT, TTM_TRACKACTIVATE, 
                    FALSE, (LPARAM)&g_ti);
                return ::DefSubclassProc( hwnd, message, wParam, lParam );
            }
        }
        break;
    case WM_NCDESTROY:
        ::RemoveWindowSubclass( hwnd, EditSubProc, 0 );
        return DefSubclassProc( hwnd, message, wParam, lParam);
        break;
    }
    return DefSubclassProc( hwnd, message, wParam, lParam);
} 

5) Add the following WM_CREATE handler :

case WM_CREATE:
    {
        HWND hEdit = CreateWindowEx( 0, L"EDIT", L"edit", WS_CHILD | WS_VISIBLE |
            WS_BORDER | ES_CENTER, 150, 150, 100, 30, hWnd, (HMENU)1000, hInst, 0 );

        // try with tooltip
        g_hwndTT = CreateWindow(TOOLTIPS_CLASS, NULL,
            WS_POPUP | TTS_ALWAYSTIP | TTS_BALLOON,
            0, 0, 0, 0, hWnd, NULL, hInst, NULL);

        if( !g_hwndTT )
            MessageBeep(0);  // just to signal error somehow

        g_ti.cbSize = sizeof(TOOLINFO);
        g_ti.uFlags = TTF_TRACK | TTF_ABSOLUTE;
        g_ti.hwnd = hWnd;
        g_ti.hinst = hInst;
        g_ti.lpszText = TEXT("Hi there");

        if( ! SendMessage(g_hwndTT, TTM_ADDTOOL, 0, (LPARAM)&g_ti) )
            MessageBeep(0);  // just to have some error signal

        // subclass edit control
        SetWindowSubclass( hEdit, EditSubProc, 0, 0 );
    }
    return 0L;  

6) Initialize common controls in MyRegisterClass ( before return statement ) :

// initialize common controls
INITCOMMONCONTROLSEX iccex;
iccex.dwSize = sizeof(INITCOMMONCONTROLSEX);
iccex.dwICC = ICC_BAR_CLASSES | ICC_WIN95_CLASSES | 
    ICC_TAB_CLASSES | ICC_TREEVIEW_CLASSES | ICC_STANDARD_CLASSES ;

if( !InitCommonControlsEx(&iccex) ) 
    MessageBeep(0);   // signal error 

That's it, for the SSCCE.

My questions are following :

  1. How can I properly position tooltip in my main window? How should I manipulate with caret coordinates?

  2. Is there a way for tooltip handle and toolinfo structure to not be global?

Thank you for your time.

Best regards.

EDIT #1:

I have managed to achieve quite an improvement by deleting ScreenToClient call in the subclass procedure. The x-coordinate is good, y-coordinate could be slightly lower. I still would like to remove global variables somehow...

EDIT #2:

I was able to adjust y-coordinate by using EM_GETRECT message and setting y-coordinate to the bottom of the formatting rectangle:

RECT rcClientRect;
Edit_GetRect( hwnd, &rcClientRect );
pt.y = rcClient.bottom;

Now the end-result is much better. All that is left is to remove global variables...

EDIT #3:

It seems that I have cracked it! The solution is in EM_SHOWBALLOONTIP and EM_HIDEBALLOONTIP messages! Tooltip is placed at the caret position, ballon shape is the same as the one on the picture, and it auto-dismisses itself properly. And the best thing is that I do not need global variables!

Here is my subclass procedure snippet:

case WM_CHAR:
{
    // whatever... This condition is for testing purpose only
    if( ! IsCharAlpha( wParam ) && IsCharAlphaNumeric( wParam ) )
    {
        SendMessage(hwnd, EM_HIDEBALLOONTIP, 0, 0);
        return ::DefSubclassProc( hwnd, message, wParam, lParam );
    }
    else
    {
        EDITBALLOONTIP ebt;

        ebt.cbStruct = sizeof( EDITBALLOONTIP );
        ebt.pszText = L" Tooltip text! ";
        ebt.pszTitle = L" Tooltip title!!! ";
        ebt.ttiIcon = TTI_ERROR_LARGE;    // tooltip icon

        SendMessage(hwnd, EM_SHOWBALLOONTIP, 0, (LPARAM)&ebt);

        return FALSE;
    }
 }
 break;
3

There are 3 answers

7
Edward Clements On BEST ANSWER

I'm giving the comment as an answer (I should have done that earlier) so that it's clear that the question has been answered:

MSDN Docs for TTM_TRACKPOSITION says that the x/y values are "in screen coordinates".

I'm not totally sure, but the y-coordinate probably corresponds to the top of the caret, you could add half of the edit box height if you want to position your tooltip in the middle of the edit box.

EDIT re Global variables, you could bundle all your global variables into a structure, allocate memory for the structure and pass the pointer of the structure using the SetWindowLongPtr API call for the edit window using the GWLP_USERDATA, the window proc can then retrieve the values using GetWindowLongPtr...

5
enhzflep On

As a follow-up to comments regarding the use of the SetProp function to remove the need to hold onto a pair of globals for the tool-tip data, I present the following solution.

Note: By error-checking on calls to GetProp, I've designed a WndProc for the subclassed edit control that would function regardless of whether or not it was desired to make use of tool-tips. If the property isn't found, I simply omit any tool-tip handling code.

Note 2: One downside to all of the available approaches to making the tooltip info non-global is that it introduces coupling between the subclassed WndProc and the parent window's wndProc.

  • By using dwRefData, one must check that it holds a non-NULL pointer.
  • By using SetWindowLongPtr, one must remember an index into the user-data.
  • By using SetProp, one must remember a textual property name. I find this easier.

Removing the call to SetProp removes the tool-tip functionality. I.e you could use the same subclassed wndProc for edit controls whether they took advantage of tooltips or not.

Anyhoo, on with the (Code::Blocks) code.

#define _WIN32_IE 0x0500
#define _WIN32_WINNT 0x0501

#if defined(UNICODE) && !defined(_UNICODE)
    #define _UNICODE
#elif defined(_UNICODE) && !defined(UNICODE)
    #define UNICODE
#endif

#include <tchar.h>
#include <windows.h>
#include <windowsx.h>
#include <commctrl.h>
#include <ctype.h>
#include <cstdio>

/*  Declare Windows procedure  */
LRESULT CALLBACK WindowProcedure (HWND, UINT, WPARAM, LPARAM);

/*  Make the class name into a global variable  */
TCHAR szClassName[ ] = _T("CodeBlocksWindowsApp");



HWND g_hwndTT;
TOOLINFO g_ti;
typedef struct mToolTipInfo
{
    HWND hwnd;
    TOOLINFO tInfo;
} * p_mToolTipInfo;


LRESULT CALLBACK EditSubProc ( HWND hwnd, UINT message,
    WPARAM wParam, LPARAM lParam,
    UINT_PTR uIdSubclass, DWORD_PTR dwRefData )
{
    p_mToolTipInfo tmp = (p_mToolTipInfo)GetProp(hwnd, _T("tipData"));

    switch (message)
    {
    case WM_CHAR:
        {
            POINT pt;

            if( ! isdigit( wParam ) )  // if not a number pop a tooltip!
            {
                if (GetCaretPos(&pt))  // here comes the problem
                {
                    // coordinates are not good, so tooltip is misplaced
                    ClientToScreen( hwnd, &pt );

                    RECT lastCharRect;
                    lastCharRect.left = lastCharRect.top = 0;
                    lastCharRect.right = lastCharRect.bottom = 32;

                    HDC editHdc;
                    char lastChar;
                    int charHeight, charWidth;

                    lastChar = (char)wParam;
                    editHdc = GetDC(hwnd);
                    charHeight = DrawText(editHdc, &lastChar, 1, &lastCharRect, DT_CALCRECT);
                    charWidth = lastCharRect.right;
                    ReleaseDC(hwnd, editHdc);

                    //pt.x += xOfs + charWidth; // invalid char isn't drawn, so no need to advance xPos to reflect width of last char
                    pt.y += charHeight;

                    if (tmp)
                    {
                        SendMessage(tmp->hwnd, TTM_TRACKACTIVATE, TRUE, (LPARAM)&tmp->tInfo);
                        SendMessage(tmp->hwnd, TTM_TRACKPOSITION, 0, MAKELPARAM(pt.x, pt.y));
                    }
                }
                return FALSE;
            }
            else
            {
                if (tmp)
                    SendMessage(tmp->hwnd, TTM_TRACKACTIVATE,
                    FALSE, (LPARAM)&tmp->tInfo  );
                return ::DefSubclassProc( hwnd, message, wParam, lParam );
            }
        }
        break;

    case WM_DESTROY:
        {
            p_mToolTipInfo tmp = (p_mToolTipInfo)GetProp(hwnd, _T("tipData"));
            if (tmp)
            {
                delete(tmp);
                RemoveProp(hwnd, _T("tipData"));
            }
        }
        return 0;

    case WM_NCDESTROY:
        ::RemoveWindowSubclass( hwnd, EditSubProc, 0 );
        return DefSubclassProc( hwnd, message, wParam, lParam);
        break;
    }
    return DefSubclassProc( hwnd, message, wParam, lParam);
}






HINSTANCE hInst;

int WINAPI WinMain (HINSTANCE hThisInstance,
                     HINSTANCE hPrevInstance,
                     LPSTR lpszArgument,
                     int nCmdShow)
{
    HWND hwnd;               /* This is the handle for our window */
    MSG messages;            /* Here messages to the application are saved */
    WNDCLASSEX wincl;        /* Data structure for the windowclass */

    /* The Window structure */
    wincl.hInstance = hThisInstance;
    wincl.lpszClassName = szClassName;
    wincl.lpfnWndProc = WindowProcedure;      /* This function is called by windows */
    wincl.style = CS_DBLCLKS;                 /* Catch double-clicks */
    wincl.cbSize = sizeof (WNDCLASSEX);

    /* Use default icon and mouse-pointer */
    wincl.hIcon = LoadIcon (NULL, IDI_APPLICATION);
    wincl.hIconSm = LoadIcon (NULL, IDI_APPLICATION);
    wincl.hCursor = LoadCursor (NULL, IDC_ARROW);
    wincl.lpszMenuName = NULL;                 /* No menu */
    wincl.cbClsExtra = 0;                      /* No extra bytes after the window class */
    wincl.cbWndExtra = 0;                      /* structure or the window instance */
    /* Use Windows's default colour as the background of the window */
    wincl.hbrBackground = (HBRUSH) COLOR_BACKGROUND;

    /* Register the window class, and if it fails quit the program */
    if (!RegisterClassEx (&wincl))
        return 0;

    /* The class is registered, let's create the program*/
    hwnd = CreateWindowEx (
           0,                   /* Extended possibilites for variation */
           szClassName,         /* Classname */
           _T("Code::Blocks Template Windows App"),       /* Title Text */
           WS_OVERLAPPEDWINDOW, /* default window */
           CW_USEDEFAULT,       /* Windows decides the position */
           CW_USEDEFAULT,       /* where the window ends up on the screen */
           544,                 /* The programs width */
           375,                 /* and height in pixels */
           HWND_DESKTOP,        /* The window is a child-window to desktop */
           NULL,                /* No menu */
           hThisInstance,       /* Program Instance handler */
           NULL                 /* No Window Creation data */
           );

    /* Make the window visible on the screen */
    ShowWindow (hwnd, nCmdShow);

    /* Run the message loop. It will run until GetMessage() returns 0 */
    while (GetMessage (&messages, NULL, 0, 0))
    {
        /* Translate virtual-key messages into character messages */
        TranslateMessage(&messages);
        /* Send message to WindowProcedure */
        DispatchMessage(&messages);
    }

    /* The program return-value is 0 - The value that PostQuitMessage() gave */
    return messages.wParam;
}


/*  This function is called by the Windows function DispatchMessage()  */
LRESULT CALLBACK WindowProcedure (HWND hWnd, UINT message, WPARAM wParam, LPARAM lParam)
{
    switch (message)                  /* handle the messages */
    {
        case WM_CREATE:
        {
            HWND hEdit = CreateWindowEx( 0, _T("EDIT"), _T("edit"), WS_CHILD | WS_VISIBLE |
                WS_BORDER | ES_CENTER, 150, 150, 100, 30, hWnd, (HMENU)1000, hInst, 0 );

            p_mToolTipInfo tmp = new mToolTipInfo;
            SetProp(hEdit, _T("tipData"), tmp);

            // try with tooltip
            //g_hwndTT = CreateWindow(TOOLTIPS_CLASS, NULL,
            tmp->hwnd = CreateWindow(TOOLTIPS_CLASS, NULL,
                WS_POPUP | TTS_ALWAYSTIP | TTS_BALLOON,
                0, 0, 0, 0, hWnd, NULL, hInst, NULL);

            //if( !g_hwndTT )
            if( !tmp->hwnd )
                MessageBeep(0);  // just to signal error somehow

//            g_ti.cbSize = sizeof(TOOLINFO);
//            g_ti.uFlags = TTF_TRACK | TTF_ABSOLUTE;
//            g_ti.hwnd = hWnd;
//            g_ti.hinst = hInst;
//            g_ti.lpszText = _T("Hi there");
            tmp->tInfo.cbSize = sizeof(TOOLINFO);
            tmp->tInfo.uFlags = TTF_TRACK | TTF_ABSOLUTE;
            tmp->tInfo.hwnd = hWnd;
            tmp->tInfo.hinst = hInst;
            tmp->tInfo.lpszText = _T("Hi there");

//            if( ! SendMessage(g_hwndTT, TTM_ADDTOOL, 0, (LPARAM)&g_ti) )
            if( ! SendMessage(tmp->hwnd, TTM_ADDTOOL, 0, (LPARAM)&tmp->tInfo) )
                MessageBeep(0);  // just to have some error signal

            // subclass edit control
            SetWindowSubclass( hEdit, EditSubProc, 0, 0 );
        }
        return 0L;

        case WM_DESTROY:
            PostQuitMessage (0);       /* send a WM_QUIT to the message queue */
            break;
        default:                      /* for messages that we don't deal with */
            return DefWindowProc (hWnd, message, wParam, lParam);
    }

    return 0;
}
0
AlwaysLearningNewStuff On

After further testing, I have decided to put this as an answer so others can clearly spot it.

The solution is in using EM_SHOWBALLOONTIP and EM_HIDEBALLOONTIP messages. You do not need to create tooltip and associate it to an edit control! Therefore, all I need to do now is simply subclass edit control and everything works :

LRESULT CALLBACK EditSubProc ( HWND hwnd, UINT message, 
WPARAM wParam, LPARAM lParam, 
UINT_PTR uIdSubclass, DWORD_PTR dwRefData )
{
    switch (message)
    {
    case WM_CHAR:
        {
            if( ! isdigit( wParam ) )  // if not a number pop a tooltip!
            {
                EDITBALLOONTIP ebt;

                ebt.cbStruct = sizeof( EDITBALLOONTIP );
                ebt.pszText = L" Tooltip text! ";
                ebt.pszTitle = L" Tooltip title!!! ";
                ebt.ttiIcon = TTI_ERROR_LARGE;    // tooltip icon

                SendMessage(hwnd, EM_SHOWBALLOONTIP, 0, (LPARAM)&ebt);
                return FALSE;
            }
            else
            {
                SendMessage(hwnd, EM_HIDEBALLOONTIP, 0, 0);
                return ::DefSubclassProc( hwnd, message, wParam, lParam );
            }
        }
        break;
    case WM_NCDESTROY:
        ::RemoveWindowSubclass( hwnd, EditSubProc, 0 );
        return DefSubclassProc( hwnd, message, wParam, lParam);
        break;
    }
    return DefSubclassProc( hwnd, message, wParam, lParam);
} 

That's it!

Hopefully this answer will help someone too!