Recipe for context help windows--not easy, but do-able

Started by NoCforMe, December 11, 2011, 09:59:25 PM

Previous topic - Next topic

NoCforMe

The latest in my series of "recipes" for stuff I've figured out how to do with Windoze. Please realize I'm not proposing this as the only, or even the best way to do this. If you have a better way, please let us all know!

Note: I attached a new .zip which has clearer code (previous worked but was in confusing order).

This is for creating context-help windows. You know, those little "tips" you get from a dialog box that has the "?" button in the upper-right corner you can use to click over a control to get a description of what it does.

Demo .zip file is attached.

Here's the recipe: this one is a little complicated, so please bear with me.

======================================================

Recipe for Tasty Context Help Tip Windows:

1. Create a list of control-to-text mapping structures ($contextHelp here), each with a control ID and a corresponding string for the help window text:


$contextHelp STRUCT
  ID DD ?
  textPtr DD ?
$contextHelp ENDS


2. Create the dialog with the WS_EX_CONTEXTHELP "extended" style, which puts the "?" button in the upper-right corner of the dialog frame.

3. In the dialog proc, create a handler for the WM_HELP message. On receipt of this message, create the help tip window. (I put this code in a subroutine, DisplayCtrlHelp(), to make things a little cleaner, but the code could just as easily go into the dialog proc itself.)

4. To create the help tip window, first match the ID of the control generating the WM_HELP message to a control ID in the table you created in step 1 above. The control ID is in the iCntrlId member of the HELPINFO structure which is passed to the window proc in the lParam parameter. You can also use the iContextType member to determine whether the user clicked on a control window (HELPINFO_WINDOW) or a menu item (HELPINFO_MENU), although this seems unnecessary, as the IDs of everything within the dialog (controls and menus) should be unique.

5. We need to get the position (on the screen) of the control window. This is a little tricky. Here's how I did it:


  • Use GetWindow() to get a handle to the dialog that contains the control. This is done by using the GW_ENABLEDPOPUP value to find the currently enabled popup of the main (parent) window, which is the dialog box. This seems to work reliably.

  • Using this handle, use GetDlgItem() to get a handle to the control, using the control ID.

    Here's the code for this part:


    INVOKE GetWindow, MainWinHandle, GW_ENABLEDPOPUP
    MOV dialogHandle, EAX
    INVOKE GetDlgItem, dialogHandle, [EBX + HELPINFO.iCtrlId]
    MOV controlHandle, EAX

6. Create the help window: make it a popup window (WS_POPUP), not a child window (you can't create a child of the desktop). I gave mine the WS_BORDER style. Do NOT use the WS_VISIBLE style. Use initial values for size and position; I used 0, 0 for the position and fixed values for the width and height. It doesn't really matter, since the window isn't yet visible, and it will be repositioned and sized shortly.

7. OK, so now we need to size and position the window. I wanted each little help-tip window to be automagically sized to the text block it contains, so I first get the size of this block, in the subroutine MeasureText(). This routine uses GetTextExtentPoint32() to measure each line of text.By "line" I mean a single line, not a text block containing carriage returns and line feeds, so you need to scan each block of text, break it into lines, measure each line, and determine the overall width (maximum line length) and height (sum of line heights).

Before you call GetTextExtentPoint32(), you need to set up the proper "environment" in the window so you get the correct measurements. This means:

  • Getting a device context for the window (GetDC() )
  • Selecting the font you'll use for the text into the DC
You also need to get the location on the screen of the control window, using GetWindowRect(). (Don't use GetClientRect()--we need the screen coordinates.)

7. Now we know how big the text block is, so we can resize and re-position the window. Here's how:

  • x-position = x0 + wc/2 - wt/2 - wp/2
         where
          x0 = x-position of control
          wc = width of control
          wt = width of text
          wp = width of text padding

  • y-position = y0 + hc/2
         where
          y0 = y-position of control
          hc = height of control

    Note: x0 & y0 come from the window rect from GetWindowRect().

  • width = wt + wp
  • height = ht + hp

    (you can play around with the padding values to make nice-looking windows)
8. Almost there. To display the text, we need 3 things:

  • a pointer to the text
  • the length of the text in bytes
  • the rectangle in which to draw the text
(The last item is set to half the padding values for the "left" and "top" members, and these values plus the width/height of the text for the "right" and "bottom" members.) This centers the text nicely within the window.
(I use DrawText() to display the text)

Set all of these items up as global variables. Set them in the help function (DisplayCtrlHelp() ) for later use by the painting code (WM_PAINT) in the help-window proc.

In the help-window proc, handle the WM_PAINT message. Using these global variables, draw the text in the window. Before drawing, you must:

  • Select the font (the same one you use to measure the text with)
  • Select the background brush
  • Select the pen, if needed
  • Set the background mode for drawing
After painting, restore the DC to its previous values for each of these selections.

9. Last thing: closing the window.
I found that playing the game "Capture The Mouse" was the best way to handle this. After displaying the window (with ShowWindow() ), I  called SetCapture() to capture the mouse by the help window. I then look for WM_LBUTTONDOWN messages in the help-window proc. Since the mouse is captured, this means that any mouse click anywhere on the desktop will send a message to our window proc. Upon a receipt of  this message, simply destroy the window. It's that easy. This seems to work reliably and identically to the context help implemented by MFC classes.

Things that could be done differently:

1. The way I'm passing stuff to the WM_PAINT handler is a bit of a hack (just stuffing values into global variables), but it seems to work OK. I suppose these values could be transferred to the window proc by sending messages (private?), but they'd still need to be stored somewhere, so this seems alright.

2. Creating the windows as popups on the desktop seemed to be the only way to get this to work correctly. They can't be children, as they'll be clipped by their parent.

Anyhow, let me know what you think, and if you have any brilliant ideas for improving this. These help tips seem to work pretty much exactly the same as the standard Wndows ones (for instance, try the Font dialog in Notepad), which I guess are some sort of MFC objects/methods/other thingies?

This recipe could be pretty neatly packaged up and made a part of anyone's program.


NoCforMe

#1
Something else about mine that's not as good as the Windows implementation: my code "eats" the mouse click used to close the help window.

Should I pass the WM_LBUTTONDOWN message on to DefWindowProc() after destroying the help window?
Hmm, tried that: doesn't help.

Oh, another thing: the standard Windows help windows have a cool semi-transparent "shadow" underneath them (at least under Windows 2000). Maybe I could do that with another window with a gray background under the help window ...

... yepper, just did that. And it was easy:


$medGray EQU 127
$medGrayColor EQU $medGray OR ($medGray SHL 8) OR ($medGray SHL 16)

$shadowOpacity EQU 100 ;<50%

. . .

INVOKE CreateSolidBrush, $medGrayColor
MOV ShadowBrush, EAX

. . .

; Create the shadow window first as a popup on the desktop:
INVOKE CreateWindowEx, WS_EX_LEFT OR WS_EX_LAYERED, ADDR HelpClassName, NULL,
$shadowWinAttrs, 0, 0, $initialHelpWinWidth, $initialHelpWinHeight,
0, NULL, InstanceHandle, NULL
MOV ShadowWinHandle, EAX
INVOKE SetLayeredWindowAttributes, EAX, $medGrayColor, $shadowOpacity, LWA_ALPHA

. . .

case WM_PAINT:

INVOKE BeginPaint, hWin, ADDR ps
MOV hDC, EAX
MOV EAX, hWin
CMP EAX, ShadowWinHandle
JE do_shadow

. . .

do_shadow:
INVOKE GetClientRect, hWin, ADDR gpRect
INVOKE FillRect, hDC, ADDR gpRect, ShadowBrush
INVOKE EndPaint, hWin, ADDR ps
XOR EAX, EAX
RET


Makes a nice translucent shadow.

New .zip attached with shadows.

jj2007

Quote from: NoCforMe on December 11, 2011, 09:59:25 PMThese help tips seem to work pretty much exactly the same as the standard Wndows ones

They look indeed like tooltips. Might be easier to implement.
Excellent work, NoC :U

P.S.: Have you noticed the weird behaviour of CreateWindowEx?
   invoke CreateWindowEx, WS_EX_CLIENTEDGE or WS_EX_CONTEXTHELP,
      addr ClassAppWin, addr txApp,
      WS_OVERLAPPED or WS_CAPTION or WS_SYSMENU or WS_THICKFRAME or WS_CLIPCHILDREN or \
      WS_VISIBLE or WS_MINIMIZEBOX,
      AppX, AppY, AppWidth, AppHeight,
      wmNull, wmNull, wc.hInstance, wmNull

This shows the question mark, but it does not react.
Take away WS_MINIMIZEBOX, and everything works fine but no box
Add WS_MAXIMIZEBOX, and the question mark disappears...
Oh Microsoft ::)

NoCforMe

No, I haven't noticed that, because I use DialogBoxParam() to create my dialogs, and who the hell knows what goes on in there with those style bits? (It must call CreateWindowEx() at some point, but probably after a massive amount of massaging.) I'm not surprised, though. (Isn't this what they refer to a, um, "undocumented behavior"?)

So do you have any ideas how I can avoid having the mouse click used to close the context-help window get "eaten"? If you try the standard Microsoft context help, whenyou click on something with the little window open, it closes it and activates whatever you click on. A little nicer than my implementation.

jj2007

Capture the right message...
WM_NCLBUTTONDOWN
WM_SYSCOMMAND
... whatever

;include \masm32\include\masm32rt.inc
include \masm32\MasmBasic\MasmBasic.inc
....

;====================================================================
; Dialog Window Proc
;====================================================================
.data?
msgCount dd ?
.code

DialogProc PROC hWin:DWORD, uMsg:DWORD, wParam:DWORD, lParam:DWORD
local buffer[100]:byte
inc msgCount
.if uMsg!=WM_NCMOUSEMOVE
deb 4, "DlgProc change for ...", chg:msgCount, uMsg ; this console deb will only be shown if chg:xxx has changed
.endif


You need console assembly & link to see the messages.

NoCforMe

I might jump into trying MasmBasic one of these days. For now, though, I'm trying to wrap my head around the conceptual problem here.

Problem is the mouse click used to close my help window is being consumed. Not a huge problem, but annoying.

Capture the right message? OK, what might that be?

I chose WM_LBUTTONDOWN because, well, it detects any mouse click anywhere in the realm of the desktop (remember that the mouse has been captured at this point).

So a click could be just about anywhere:

  • in the client area of a window
  • in the nonclient area of a window
  • in a control (button/checkbox/radio button/list box/etc., etc.)
  • in dead space
So which message(s) should I look at, if the object is to detect the event (a mouse click somewhere) and to let the message pass on to the intended recipient? I don't know how to do that.

jj2007

Quote from: NoCforMe on December 13, 2011, 07:00:29 PM
Capture the right message? OK, what might that be?

You may want one that is specifically triggered by a mouseclick into the question mark. I mentioned two above, but they are more candidates.
Attached the *.asc file as needed for seeing the messages. For example, a click into an "arrow up" triggers this:
DlgProc
chg:msgCount    1275
uMsg            33
# uMsg #        WM_VSCROLL

DlgProc
chg:msgCount    1277
uMsg            1024
# uMsg #        WM_MOUSEAC

DlgProc
chg:msgCount    1278
uMsg            78
# uMsg #        WM_USER

DlgProc
chg:msgCount    1279
uMsg            273
# uMsg #        WM_NOTIFY

DlgProc
chg:msgCount    1280
uMsg            273
# uMsg #        WM_COMMAND

DlgProc
chg:msgCount    1281
uMsg            277
# uMsg #        WM_COMMAND

DlgProc
chg:msgCount    1285
uMsg            277
# uMsg #        WM_VSCROLL


You can see from msgCount that many messages (WM_MOUSEMOVE...) have been filtered out already.

NoCforMe

Quote from: jj2007 on December 13, 2011, 07:39:03 PM
You may want one that is specifically triggered by a mouseclick into the question mark.

Why would I want to do that? Remember, I'm talking about the second mouseclick after the user clicks on the question mark--the mouseclick that closes the help window, not the one that opens it. That second click could be anywhere on the screen.

Does that make sense?