Building a Callout Control

Thiadmer's balloon-style Windows control is configurable for many purposes.


August 01, 2004
URL:http://www.drdobbs.com/tools/building-a-callout-control/184405768

August, 2004: Building a Callout Control

A comic-style balloon Callout control

Thiadmer develops multimedia and system software for his company, CompuPhase, based in the Netherlands. He can be contacted at http://www.compuphase.com/.


Optimizing the Text Box Shape


When you need to display a message that refers to another object, window, or user-interface element, a "callout" with an arrow to its target or a comic-style balloon are a better option than a message box. With this in mind, I designed a balloon-style Windows control that is sufficiently configurable to suit many purposes. I call this control a callout rather than a balloon to reduce the chance of confusion with balloon tooltips that recent versions of Microsoft Windows provide. Figure 1 shows a few of the layouts that the callout control supports.

A callout is not a tooltip—it is not attached to other controls (and it does not subclass other controls). You may have multiple callouts visible at a time, as well. As an added bonus, you can add other controls (buttons, check boxes) inside a callout. A callout is similar to a tooltip in the sense that it adapts its size and position to its contents. A callout is not a message box, either, because it does not enter a modal loop; therefore, it is better suited to display informational messages without disrupting the user's current activity. The tail of the callout, pointing to the object or icon/button that the message applies to, lets you write shorter messages in many circumstances.

I designed the callout control to be just like other standard controls—you create it with CreateWindow() and you configure the control by sending messages to it. The control supports standard messages such as WM_SETTEXT, WM_SETICON, and WM_SETFONT, plus a set of control-specific messages. For conformity, many style attributes, such as the border width and the radius of the balloon corners, are set with messages, too, rather than with style bits in the CreateWindow() call. There are only two basic styles for the callout tail: a triangular pointer or a sequence of three aligned ellipses. More variations are obtained by changing the dimensions of the tail or other attributes.

Using the Callout Control

The callout control reformats and repositions itself at any time that its contents or style changes. In typical usage, you set the text (and other contents) and the style of the callout while it is hidden, then show it.

The source code for the callout control can be compiled to a separate DLL. In that case, all that is required to create a callout is to load the DLL via LoadLibrary() and call CreateWindow() with the class name Callout. The DLL registers this (global) class name automatically when it is loaded. If, instead, you compile the source code for static linking to your application, the application must call CalloutInit() before it creates its first callout control. If so desired, the application can also call CalloutCleanup() at exit point to unregister the Callout class.

The window style used in CreateWindow() can be WS_CHILD if you want the callout to be snapped inside its parent window, but usually WS_POPUP is more appropriate. Style bits like those for a border and a caption are redundant, as the callout control removes them on creation. The window position and size parameters are also ignored; the callout control positions and sizes itself based on the text and the coordinate pair that the "tail" of the callout points to. This coordinate pair is called the "anchor" point, and you can set it by sending a message to the control. The callout chooses an appropriate position based on its attributes and the anchor point. Instead of sending messages, as in Example 1, you may also use wrapper macros as presented in Example 2.

The callout control recalculates its shape and position and repaints itself every time that its style or contents change, with two exceptions. First, if the control is "hidden," the routine skips a few steps in the calculation/repaint sequence. Second, nearly all custom messages that change the attributes of the control take a "repaint" parameter, and if this parameter is zero, the recalculation and repainting steps are suppressed (this parameter was inspired from the WM_SETFONT message). I think that it is best to create the callout control as hidden and set all attributes before showing it. If you have a sequence of attributes to set, you can avoid redundant calculations by setting the repaint parameter to True for the last attribute setting and to False for its predecessors.

Table 1 gives an overview of the standard and custom messages that you can send to the callout control to change its appearance or behavior. There are wrapper macros for each of these messages. Figures 2 and 3 may also be illustrative in describing many of the custom messages.

The control hides itself when users click in the callout with the left mouse button or after a timeout, which you must have set earlier. You can change this behavior by intercepting the WM_NOTIFY message that the control sends to its parent or owner window. The callout control sends a notification for the events NM_CLICK and NM_RCLICK, plus the custom event CN_POP, for when it is about to be automatically hidden. The default action of the callout control is to hide itself—it does not destroy itself.

To add additional controls in the callout, you must first reserve room in the callout for the controls. The callout assumes that you will place these controls below the text. That is, you reserve vertical space at the bottom of the text box area. The widest or right-most control also sets the minimum width that the text box should have. After setting all the other styles, your program can query the balloon text box rectangle and position its controls relative to that rectangle; see Example 3. The callout control already handles the coloring for the added controls (it intercepts the WM_CTLCOLORxxx messages), but it does not set the font for the controls. Typically, you will want to use a different font than the System font for the embedded controls; Example 3 copies the font for the balloon control itself to the added buttons.

Implementation Details

Figures 2 and 3 show the anatomy of a callout control, where the dimensions of various attributes are controlled by the messages in Table 1. The size of the text box is calculated from the text, with the minimum width and the extra height for embedded controls taken into account. The icon area (Figure 3) is only present when you set an icon. Some settings are "hard"; if you set the border thickness to be four pixels, the border will be four pixels thick—no more, no less. Other settings, such as the tail slant angle, are "soft": the callout control may change these attributes if it does not fit in the display, otherwise.

As you can see in Figure 2, the tail does not necessarily "touch" the anchor point: There is an optional vertical offset of the tail to the anchor. The offset is useful if you want to avoid that the tail overlaps part of the control that it points to. The default value for the tail offset is zero, though.

In the first step in calculating the size and position of the callout, the control calculates the size of the text box using the DrawText() function. It attempts to format the text so that the width of the text box is at most four times its height—to create aesthetically pleasing text boxes. The way that it does this is to first call DrawText() with the text string and only the flag DT_CALCRECT set. If the calculated rectangle is too wide, the callout control adjusts the rectangle's width based on the area (width times height) of the rectangle and makes a second call to DrawText(), now with the flag DT_WORDBREAK set in addition to DT_CALCRECT.

The DrawText() function only allows for minimal formatting (word wrapping and line breaks). If you wish to plug-in another text-formatting engine in the callout control (HTML formatting seems a popular request), you can set it by sending a CM_SETDRAWTEXTPROC message to the control. The only requirement is that the replacement is compatible with DrawText() at the call level, including the functioning of the DT_CALCRECT and DT_WORDBREAK flags.

Once you know the width and the height of the text portion, you add space for an optional icon and margins for the balloon. Then the callout control goes looking for a suitable location for the balloon part of the callout. When you position a callout, you set the coordinates of the anchor point, the point that the stem or tail of the callout points to. The control initially puts the balloon part at a fixed offset from the anchor point, depending on the shape and size of the tail. However, the balloon part is always snapped inside the bounds of the display, and the callout control also tries to avoid overlapping other callout controls.

When there are multiple monitors attached to a system, you can no longer assume that (0,0) points to the upper left corner of the display and use GetSystemMetrics() to get the working area of the display. Starting with Windows 98, Microsoft added multimonitor support functions to the Windows API. In a typical configuration, the display areas of the monitors are combined into a virtual screen (with a width that is the sum of the widths of the monitor areas). To make it easier to port existing software to become multimonitor aware, Microsoft also provides the file MULTIMON.H. Using MULTIMON.H has as an added bonus that it automatically provides substitute (stub) functions for those versions of Microsoft Windows that do not support multiple monitors.

To add multimonitor support, you need to include MULTIMON.H in your source files. In one of these files, you should declare the macro COMPILE_MULTIMON_STUBS before including the file so that the substitute functions get defined. From that point on, the multimonitor functions like MonitorFromPoint() and GetMonitorInfo() are available. As a side note, the function GetMonitorInfo() returns a "monitor rectangle" and a "work rectangle." The monitor rectangle describes the coordinates of the display area of the monitor in the larger virtual screen; the work rectangle is the monitor rectangle minus any task bar on that monitor.

To avoid overlapping sibling controls, the callout control uses the Windows function FindWindowEx() to walk through all windows of the Callout class. All example code for FindWindowEx() that I have come across uses FindWindowEx() to get a handle on child windows, but the function can also locate any nonchild (popup, overlapped) window. In experimenting with FindWindowEx(), I noticed that the order in which FindWindowEx() returns handles to the callout windows was consistently the inverse of their creation order, but the callout control does not rely on any order.

When the callout control finds that in its preferred situation it overlaps another callout control, it moves itself to the left or the right, depending on which direction gives the shortest displacement. If that move causes another conflict, the callout toggles the vertical alignment (from above to below the anchor point or vice versa) and tries again, perhaps after another horizontal movement. To keep the tail pointing at the anchor point after moving horizontally, the callout first changes the position where the tail joins the balloon. If that is not enough, the callout changes the slant of the tail. It is quite possible that all these attempts to find a good fit fail, especially because of the additional requirement that the callout control must fit inside the display area of a single monitor. So in some (crowded) situations, a callout may overlap another callout, but at least it tries to avoid that.

A final matter is whether the callout is a child window or a popup window. A child window is clipped inside the client area of its parent, a popup window can extend beyond the parent window's rectangle. For the positioning algorithm of the callout control, it therefore becomes important to check whether the control has a parent window—a popup window has an owner window but (usually) not a parent. Here, the Windows API becomes confusing. In spite of its name, the GetParent() function may return the owner window instead of the parent window. According to the oft-cited Knowledge Base article Q84190, there is not a good way to find the real parent of a window in all circumstances. But actually, that article is out of date because Windows 98 introduced the function GetAncestor(), which does not mix up owner and parent windows. I was not prepared to give up Windows 95 yet, so the callout control implements its own fix for GetParent(), called GetChildParent(), that checks for the "child window"-style flags before calling GetParent().

After the size and position of the balloon part of the callout are determined, the control can finally calculate the size and position of the complete window, taking the callout tail into consideration.

To create a nonrectangular window, I use window HASH(0x832da8). Creating such a region is fairly simple because Windows provides both the functions to create basic HASH(0x832da8) and the functions to combine HASH(0x832da8) (union, intersection, and others). When I started implementing the control, I thought that the most difficult part would be to draw the outline of the callout exactly along the edge of the region. Fortunately, it turns out that the Windows function FrameRgn() does exactly that in one simple call.

Painting windows with a window region is simplest if the window does not have any nonclient areas, such as a border or caption. When you create a callout window, the callout control checks for these flags and removes them if set. If you wish to create a callout window without a border, you must set the border width to zero by sending a message, rather than by adjusting the window style.

Conclusion

Callout.c (available electronically; see "Resource Center," page 5) is the implementation of the callout control. Custom messages, wrapper macros, and other constants are in the include file CALLOUT.H. There is a BUILD.BAT batch file in the source archive to compile it into a DLL. The batch file has command lines for Borland and Microsoft C compilers; you may have to edit it to uncomment the lines for your compiler before running it.

The file MULTIMON.H is part of the Win32 Platform SDK, but you can also get it (in a smaller download) from the archives of Microsoft Systems Journal. The June 1997 issue carries a detailed article on multimonitor support, which you can still read at http://www.microsoft.com/ msj/0697/monitor/monitor.aspx.

The two Haiku error messages at the bottom in Figure 1 are by David Carlson and David Dixon, who wrote them for the Haiku error messages contest for Salon .com (see http://archive.salon.com/21st/ chal/1998/02/10chal2.html).

Window HASH(0x832da8) are not the only method that allows for nonrectangular windows. Windows 2000 introduced "layered windows" with a transparent color key. Layered windows are easier to use than window HASH(0x832da8), but they require Windows 2000 or XP. Apart from constructing a region from primitive shapes, a tool like RegionCreator (found at http://www.flipcode .com/) makes a region from a bitmap with a specific color set as transparent.

Despite the degree of customization that the callout control already provides, I foresee that most future improvements will focus on adding more visual styles and increasing the flexibility of the control. I am confident that the current implementation is useful as it is, and that it will be easily extendible if it needs to.

DDJ

August, 2004: Building a Callout Control

#include "callout.h"

LoadLibrary("callout.dll");
HWND hwndCallout = CreateWindow("Callout",
                    "Guess what this icon does...",
                    WS_POPUP, 0, 0, 0, 0,
                    NULL, 0, hInstance, NULL);
SendMessage(hwndCallout, CM_SETANCHOR, 0, MAKELONG(100, 200));
ShowWindow(hwndCallout, SW_SHOW);

Example 1: Sending messages.

August, 2004: Building a Callout Control

#include "callout.h"

LoadLibrary("callout.dll");
HWND hwndCallout = CreateWindow(CALLOUT_CLASS,
                    "Guess what this icon does...",
                    WS_POPUP, 0, 0, 0, 0,
                    NULL, 0, hInstance, NULL);
Callout_SetAnchor(hwndCallout, 100, 200);
Callout_SetBorder(hwnd, 2, TRUE);
ShowWindow(hwndCallout, SW_SHOW);

Example 2: Using wrapper macros.

August, 2004: Building a Callout Control

#include "callout.h"

LoadLibrary("callout.dll");
HWND hwndCallout = CreateWindow(CALLOUT_CLASS,
                     "Everything is gone;\n"
                     "Your life's work has been destroyed.\n"
                     "Squeeze trigger?", /* David Carlson */
                     WS_POPUP, 0, 0, 0, 0,
                     NULL, 0, hInstance, NULL);
Callout_SetMinWidth(hwndCallout, 180, FALSE);
Callout_SetExtraHeight(hwndCallout, 44, FALSE);
LPRECT rc = Callout_GetRect(hwndCallout);

HWND buttons[3];
buttons[0] = CreateWindow("Button", "Do not ask me this again",
                      WS_CHILD | WS_VISIBLE | BS_AUTOCHECKBOX,
                      rc->left, rc->bottom - 16, 180, 16,
                      hwndCallout, 0, hInstance, NULL);
buttons[1] = CreateWindow("Button", "Yes",
                      WS_CHILD | WS_VISIBLE | BS_PUSHBUTTON,
                      rc->left, rc->bottom - 38, 70, 20,
                      hwndCallout, 0, hInstance, NULL);
buttons[2] = CreateWindow("Button", "No",
                      WS_CHILD | WS_VISIBLE | BS_PUSHBUTTON,
                      rc->left + 110, rc->bottom - 38, 70, 20,
                      hwndCallout, 0, hInstance, NULL);
hfont = (HFONT)SendMessage(hwnd, WM_GETFONT, 0, 0);
for (i = 0; i < 3; i++)
  SendMessage(buttons[i], WM_SETFONT, (WPARAM)hfont, TRUE);

Example 3: Positioning controls relative to a rectangle.

August, 2004: Building a Callout Control

Figure 1: Example layouts of the callout control.

August, 2004: Building a Callout Control

Figure 2: Anatomy of a callout, dimensions and attributes.

August, 2004: Building a Callout Control

Figure 3: The callout components.

August, 2004: Building a Callout Control

Optimizing the Text Box Shape

If you want to reformat a text box, for which we already have an initial width and height, so that it is at most four times as wide as it is high, you only need to combine the equation width×height= area where the area is kept constant, with the upper bound for the width: width£4×height. Simple algebra then gives you the criterion: width£--4×area.

Calculating the Text Box with DrawText()

I found the description of the DT_CALCRECT flag in the documentation for DrawText() confusing and so I did a few experiments to verify the behavior. Fortunately, the DrawText() function is quite robust and more versatile than the documentation suggests.

If the DT_CALCRECT is present in the flags field of the DrawText() function, the function changes the right and the bottom fields of the formatting rectangle, but it does not touch the left and top fields. The value of the right field becomes the left field plus the width of the calculated bounding box of the text after formatting; the bottom field becomes the top field plus the height of the bounding box. Hence, with DT_CALCRECT set, the rectangle parameter is half input and half output.

When you include DT_SINGLELINE in the flags field, the DrawText() function replaces all carriage return (CR) and line feed (LF) characters to a space. There is no special handling of pairs of CR and LF characters; each such character is replaced by a space character.

When neither the flags DT_SINGLELINE and DT_WORDBREAK are set, DrawText() adheres to line-breaking characters in the input string, but it does not otherwise add line breaks. Any occurrence of the CR or LF characters is a line break; a combination like CR-LF or LF-CR counts as a single line break, but CR-CR or LF-LF is a double line break. A line break does not add to the width of the bounding text box, but it does add to its height.

The DT_WORDBREAK flag makes DrawText() use the value of the right field of the formatting rectangle parameter as the right margin and it tries to format the text inside this margin. DrawText() only breaks lines between the words, there is no hyphenation algorithm hidden in the function. If the input string contains a word that is wider than the formatting rectangle, DrawText() extends the formatting rectangle to the width of that word. In a typical situation, after word breaking, the text fits a narrower rectangle than the input formatting rectangle. When DT_WORDBREAK is combined with DT_CALCRECT, DrawText() stores the updated right margin back in the formatting rectangle. So when you use DT_CALCRECT together with DT_WORDBREAK, the resulting formatting rectangle may both be wider (in the presence of a long, nonbreakable word) or narrower than the input rectangle.

I experienced DrawText() entering an infinite loop when the left field of the formatting rectangle was set to a value greater than the right field on input, and the DT_WORDBREAK flag was set. In this case, the input width of the formatting rectangle is negative, so this is arguably an input error and the behavior of DrawText() should be expected to be undefined. DrawText() does not have any difficulty when the left and right fields are set to the same value (input width is zero). I verified this DrawText() behavior on Windows 98/2000, by the way.

—T.R.

August, 2004: Building a Callout Control

Message Wrapper macro Description
WM_GETFONT Font handle to use for the text
WM_SETFONT
WM_GETICON Icon handle (optional)
WM_SETICON
CM_GETANCHOR Callout_GetAnchor(hwnd) Position of anchor in client coordinates
CM_SETANCHOR Callout_SetAnchor(hwnd, x, y)
CM_GETBACKCOLOR Callout_GetBackColor(hwnd) Color of interior of balloon
CM_SETBACKCOLOR Callout_SetBackColor(hwnd, Color, Repaint)
CM_GETBORDER Callout_GetBorder(hwnd) Border thickness
CM_SETBORDER Callout_SetBorder(hwnd, Width, Repaint)
CM_GETDRAWTEXTPROC Callout_GetDrawTextProc(hwnd) Text drawing function to replace DrawText()
CM_SETDRAWTEXTPROC Callout_SetDrawTextProc(hwnd, Proc, Repaint)
CM_GETEXTRAHEIGHT Callout_GetExtraHeight(hwnd) Extra height of balloon text box
CM_SETEXTRAHEIGHT Callout_SetExtraHeight(hwnd, Height, Repaint)
CM_GETMINWIDTH Callout_GetMinWidth(hwnd) Minimum width of balloon text box
CM_SETMINWIDTH Callout_SetMinWidth(hwnd, Width, Repaint)
CM_GETRADIUS Callout_GetRadius(hwnd) Radius of rectangle rounding
CM_SETRADIUS Callout_SetRadius(hwnd, Radius, Repaint)
CM_GETRECT Callout_GetRect(hwnd) Position and size of balloon text box
CM_GETTAILHEIGHT Callout_GetTailHeight(hwnd) Height of tail in pixels
CM_SETTAILHEIGHT Callout_SetTailHeight(hwnd, Height, Repaint)
CM_GETTAILJOIN Callout_GetTailJoin(hwnd) Horizontal position where tail joins balloon; a percentage of the balloon width
CM_SETTAILJOIN Callout_SetTailJoin(hwnd, Join, Repaint)
CM_GETTAILOFFSET Callout_GetTailOffset(hwnd) Vertical offset of tail to anchor point
CM_SETTAILOFFSET Callout_SetTailOffset(hwnd, Offset, Repaint)
CM_GETTAILSLANT Callout_GetTailSlant(hwnd) Tail slant (tangent of the angle); a negative value slants to the left
CM_SETTAILSLANT Callout_SetTailSlant(hwnd, Slant, Repaint)
CM_GETTAILSTYLE Callout_GetTailStyle(hwnd) Tail style: CS_SPEAK or CS_THINK
CM_SETTAILSTYLE Callout_SetTailStyle(hwnd, Style, Repaint)
CM_GETTAILWIDTH Callout_GetTailWidth(hwnd) Width of the tail where it joins the balloon
CM_SETTAILWIDTH Callout_SetTailWidth(hwnd, Width, Repaint)
CM_GETTEXTCOLOR Callout_GetTextColor(hwnd) Color of the text and border of the callout
CM_SETTEXTCOLOR Callout_SetTextColor(hwnd, Color, Repaint)
CM_GETTIMEOUT Callout_GetTimeout(hwnd) Timeout in milliseconds; 0 = no timeout
CM_SETTIMEOUT Callout_SetTimeout(hwnd, Timeout)
CM_GETVERTALIGN Callout_GetVertAlign(hwnd) Alignment of callout relative to the anchor point: CS_ALIGNABOVE or CS_ALIGNBELOW
CM_SETVERTALIGN Callout_SetVertAlign(hwnd, Alignment, Repaint)

Table 1: An overview of the standard and custom messages that you can send to the callout control.

Terms of Service | Privacy Statement | Copyright © 2024 UBM Tech, All rights reserved.