Click here to Skip to main content
65,938 articles
CodeProject is changing. Read more.
Articles
(untagged)

InteractiveToolTip - Tooltips you can click on!

0.00/5 (No votes)
17 Jan 2013 1  
A balloon-style tooltip whose contents are a WinForms Control

Introduction

InteractiveToolTip is an extension to the balloon-style WinForms ToolTip which supports the use of a caller-supplied Control to render its contents and provide user-interaction.


An example InteractiveToolTip. The icon, text and link-label are all separate WinForms Controls.

A few months ago I was working on a code-analysis tool for a project. The tool had the ability to detect a wide range of issues with the code under analysis, and as many of these problems could be corrected automatically I started looking at a suitable user-interface for presenting the information to the user and allowing them to choose whether to apply the automated fixes. After trying a few ideas out, I hit on the idea of using a balloon-style tooltip which could be displayed adjacent to the piece of code with a problem. This took care of the presentation, but of course you can't put controls into WinForms tooltips... I hit Google thinking that I couldn't possibly be the only person out there with this problem. I wasn't, but I also couldn't find any solutions, just a lot of people asking how to do it. Undeterred, I started to research how tooltips work.

My immediate requirement was to be able to display some text in the balloon (the problem description) and optionally one or more LinkLabels (for the user to apply fixes for the problem), but it seemed that the best solution would be to allow any arbitrary Control to be placed in the tooltip, as this would give me complete control over its appearance (one other limitation of the balloon-style tooltip is that it doesn't support OwnerDraw, so I would find it hard to format the text the way I wanted).

This article presents my solution and the techniques I used to achieve it.

Using the code

With the code added to your project (or the supplied DLL added as a reference), InteractiveToolTip should show up in the Forms Designer toolbox, from where it can be added to your Form in the same way as a regular ToolTip. It provides a Show() / Hide() API, but does not currently support SetToolTip().

To display an InteractiveToolTip for a Control call one of the Show() methods, passing the Control to be used as the tooltip's content and the UI window (typically a Control) with which the tooltip is to be associated:

public void Show(Control content, IWin32Window window);
public void Show(Control content, IWin32Window window, StemPosition stemPosition);
public void Show(Control content, IWin32Window window, Point location);
public void Show(Control content, IWin32Window window, int x, int y);
public void Show(Control content, IWin32Window window, Point location, StemPosition stemPosition, int duration);
public void Show(Control content, IWin32Window window, int x, int y, StemPosition stemPosition, int duration);
To remove the tooltip, call its Hide() method. An instance of InteractiveToolTip can only display one tooltip at a time; calling Show() whilst one is already open will close that one before opening the new one. The tooltip's location relative to the origin of window may be specified by passing either location or x and y; this controls where the tip of the ballon's stem will appear. The position of the stem on the balloon may be specified by passing one of the InteractiveToolTip.StemPosition values: TopLeft, TopCentre, TopRight, BottomLeft, BottomCentre, BottomRight. Note that if the specified location or position would result in the balloon appearing off-screen then they may be adjusted when the balloon is shown.


Balloon-stem positions.

If the duration parameter is non-zero then the balloon will be removed automatically after the specfied time (in milliseconds) has elapsed; otherwise it will remain on-screen until explicitly closed by a call to Show() or Hide(), or the InteractiveToolTip is disposed.

Two events are provided: ToolTipShown and ToolTipHidden. Each uses an InteractiveToolTipEventArgs as its event-data; this contains a reference to the window of the current tooltip. Finally, InteractiveToolTip provides the UseAnimation and UseFading properties, which are analgous to those on ToolTip.

ToolTip Content

The content of an InteractiveToolTip can be any subclass of System.Windows.Forms.Control. After passing it to Show(), the caller may not modify or dispose of the Control until it has been released again by either calling Show() with a different content Control, or by calling Hide(). When Show() is called, InteractiveToolTip will add the supplied Control to a private container Control, so it may not already have a Parent.

The caller is free to handle any events coming from the Control; InteractiveToolTip takes no interest in these. It is recommended that the Control's BackColor be set to System.Drawing.Color.Transparent in order for the tooltip's background to show through.

How it works

There were a number of problems to solve: tooltips aren't themselves Controls, so you can't just add your own to them; they do their own sizing and layout, so you can't specify dimensions; balloon-style tooltips don't allow you to specify where you want the stem to appear; and of course some of the more interesting and useful APIs aren't exposed in the .NET libraries yet.

The Win32 APIs are used to manage the tooltip window. First, create its Handle:

CreateParams createParams = new CreateParams();
createParams.ClassName = Win32.TOOLTIPS_CLASS;
createParams.Style = Win32.TTS_ALWAYSTIP | Win32.TTS_BALLOON;
CreateHandle(createParams);
Now we work out its dimensions. We have a Control that we want to display, so we need to set the tooltip window to be large enough to accomodate both the Control and the balloon's own borders and stem. This is a little awkward, since the tooltip does not allow us to set its size in pixels. We can, however, fake it by setting the tooltip's content to be a string of whitespace which when rendered will have approximately the same size as the Control, and we generate such a string like this:
private string GetSizingText(Control content)
{
    StringBuilder sb  = new StringBuilder();
    Graphics graphics = Graphics.FromHwnd(Handle);
    Font font         = Font.FromHfont((IntPtr)Win32.SendMessage(Handle, Win32.WM_GETFONT, 0, 0));

    // use a small font to improve precision
    font = new Font(font.FontFamily, 1.0f);
    Win32.SendMessage(Handle, Win32.WM_SETFONT, (int)font.ToHfont(), 1);

    Size size = TextRenderer.MeasureText(" ", font);
    int rows  = (content.Height + size.Height - 1) / size.Height;

    for (int n = 0; n < rows; n++)
    {
        sb.Append("\r\n");
    }

    size = TextRenderer.MeasureText(sb.ToString(), font);

    // pad the width out to match the spacing on the height so the border around the content is of roughly constant size
    int width = content.Width + size.Height - content.Height;

    // we can't do a simple 'how many columns' calculation here, as the text-renderer will apply kerning
    while (size.Width < width)
    {
        sb.Append(" ");
        size = TextRenderer.MeasureText(sb.ToString(), font);
    }

    return sb.ToString();
}
This string can then be passed to the tooltip when we create it a little later. The next thing we want to do is determine where the balloon's stem is going:
// first, work out the actual stem-position: the supplied value is a hint, but may have to be changed if there isn't enough space to accomodate it
string contentSpacing = GetSizingText(content);

// this is where the caller would like us to be
Rectangle toolTipBounds = CalculateToolTipLocation(contentSpacing, Window, x, y, stemPosition);
Screen currentScreen    = Screen.FromHandle(Window.Handle);
Rectangle screenBounds  = currentScreen.WorkingArea;

stemPosition = AdjustStemPosition(stemPosition, ref toolTipBounds, ref screenBounds);

// and this is where we'll actually end up
toolTipBounds = CalculateToolTipLocation(contentSpacing, Window, x, y, stemPosition);
toolTipBounds.X = Math.Max(0, toolTipBounds.X);
toolTipBounds.Y = Math.Max(0, toolTipBounds.Y);
AdjustStemPosition() takes the value passed by the caller, stemPosition and checks that this is actually possible - if it would result in the tooltip being positioned partly or entirely off the visible screen area, the value may be changed and the tooltip repositioned. CalculateToolTipLocation() works out the coordinates of the top-left corner of the balloon ready for display, based on the caller-supplied position (which is relative to the associated Window) and the final stem-position. We can now create the tooltip window and start setting it up:
Win32.TOOLINFO ti = new Win32.TOOLINFO();

ti.cbSize   = Marshal.SizeOf(ti);
ti.uFlags   = Win32.TTF_IDISHWND | Win32.TTF_TRACK | Win32.TTF_TRANSPARENT;
ti.uId      = window.Handle;
ti.hwnd     = window.Handle;
ti.lpszText = contentSpacing;

if (StemPosition.BottomCentre == stemPosition || StemPosition.TopCentre == stemPosition)
    ti.uFlags |= Win32.TTF_CENTERTIP;

if (0 == Win32.SendMessage(Handle, Win32.TTM_ADDTOOL, 0, ref ti))
    throw new Exception();

// enable multi-line text-layout
Win32.SendMessage(Handle, Win32.TTM_SETMAXTIPWIDTH, 0, SystemInformation.MaxWindowTrackSize.Width);
Now, this isn't quite enough to get the result we're after: the tooltip API only allows us to specify that the stem should be centered or not-centered. It doesn't let us specify left, right, top or bottom. So how do we do it? We cheat! Handily, Windows also does some repositioning and reformatting of the tooltip to prevent it from going off the visible screen, and even more handily it only does these calculations when the tooltip is first shown - if you reposition the tooltip whilst it is visible, its layout remains unchanged.

The default behaviour is for the stem to appear at the top-left corner of the balloon:

Setting TTF_CENTERTIP moves it to top-centre:

But if you place the tooltip too close to the bottom edge of the screen then Windows helpfully moves the stem to the bottom edge of the balloon:

Similarly if you place it too close to the right edge of the screen then the stem moves to the right edge of the balloon:

So by setting a suitable initial position for the tooltip we can force Windows into putting the stem where we want it:

// initial position to force the stem into the correct orientation
int initialX = screenBounds.X;
int initialY = screenBounds.Y;

if (StemPosition.TopLeft == stemPosition || StemPosition.BottomLeft == stemPosition)
    initialX += StemInset;
else if (StemPosition.TopCentre == stemPosition || StemPosition.BottomCentre == stemPosition)
    initialX += screenBounds.Width / 2;
else
    initialX += screenBounds.Width - StemInset;

if (StemPosition.BottomLeft == stemPosition || StemPosition.BottomCentre == stemPosition || StemPosition.BottomRight == stemPosition)
    initialY += screenBounds.Height;

Win32.SendMessage(Handle, Win32.TTM_TRACKPOSITION, 0, (initialY << 16) | initialX);
Finally, we show the tooltip...
Win32.SendMessage(Handle, Win32.TTM_TRACKACTIVATE, 1, ref _toolInfo);
...and move it to where the caller wants it:
Win32.SetWindowPos(Handle, Win32.HWND_TOPMOST, toolTipBounds.X, toolTipBounds.Y, 0, 0, Win32.SWP_NOACTIVATE | Win32.SWP_NOSIZE | Win32.SWP_NOOWNERZORDER);
So far so good. We now have a balloon tooltip with position, layout and dimensions of our choosing. But we still don't have any content. The simplest way to attach our Control is this:
Win32.SetParent(Control.Handle, Handle);
Handle is the HWND of the tooltip window. This does work, but it's not really satisfactory - for one thing, the transparent BackColor of the Control doesn't work and the Control is grey:

This happens because BackColor is an ambient property and the Control doesn't have a 'real' Parent yet. So we need to insert another Control between the content Control and the tooltip Window. InteractiveToolTip provides this in the form of a private class:

private class ContentPanel : UserControl
{
    private IntPtr _toolTipHwnd;

    public ContentPanel(IntPtr toolTipHWnd)
    {
        _toolTipHwnd = toolTipHWnd;
        Win32.SetParent(Handle, toolTipHWnd);
    }
}
It attaches itself by calling Win32.SetParent() as before, and the content Control is added to it in the usual way. Of course, this just means that the content Control will pick up the BackColor from the ContentPanel...which is still grey, so we're no better off yet. There's also another problem: by adding the Control to the tooltip Window, we're trashing the tooltip's own background:

So ContentPanel implements a paint method:

protected override void OnPaintBackground(PaintEventArgs e)
{
    // paint the balloon
    Win32.SendMessage(_toolTipHwnd, Win32.WM_PRINTCLIENT, (int)e.Graphics.GetHdc(), 0);
}
This simply calls the tooltip window and has it paint itself into the ContentPanel. Voila - we have a fully-rendered balloon tooltip background - and because it's being rendered in the context of a Control, ambient properties work with it and the content Control gets its transparent background:

And finally...

So there we have it. It was an interesting problem to solve, and hopefully I've managed to do it without breaking any rules or relying on undocumented behaviour. It's only been tested on Windows 7 to date, although I can't see any reason why it shouldn't work on at least Windows 8, Vista or XP. The DLL requires .NET 2.0 or better.

If you find it useful, please let me know!

History

  • 1.0 2013-01-17 Initial release

License

This article has no explicit license attached to it but may contain usage terms in the article text or the download files themselves. If in doubt please contact the author via the discussion board below.

A list of licenses authors might use can be found here