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

A Windows.Forms.ComboBox with Item 'Super' ToolTips for 32 bit Windows XP (SP2)

0.00/5 (No votes)
6 Jun 2007 1  
A ComboBox that can render owner-drawn tooltips for individual list items.
Screenshot - tooltip.jpg

Introduction

I recently posted an article here that showed an approach to extending the Windows.Forms.ComboBox control to render a tooltip over an item in the dropdown listbox if its text was obscured; similar to how the TreeView control works.

After a little feedback, I decided to extend the control to support owner-drawn 'superish' tooltips. It's all pretty simple stuff, but there was enough refactoring and additions to the original code to warrant a new article as opposed to an edit.

Background

The changes between this and the previous article are relatively minor in terms of the ComboBox and Tooltip code base. I've introduced a new interface ITooltipExtender which is recognised by the ComboBox and Tooltip classes, and done a little reworking of the original code so that items implementing this interface are treated as delegates for some tooltip operations.

Using the code

The first class I'll introduce is a static class called Win32. It's new to the project, and is used purely to logically separate interop code into its own namespace.

Imports System.Runtime.InteropServices

Public NotInheritable Class Win32

  Private Sub New()
    MyBase.New()
  End Sub

#Region "Constants"

  Public Const WM_CTLCOLORLISTBOX As UInt32 = &H134

  Public Const WS_EX_NOACTIVATE As Integer = &H8000000
  Public Const WS_EX_TOOLWINDOW As Integer = &H80
  Public Const WS_EX_TOPMOST As Integer = &H8

#End Region

#Region "Declarations"

  Public Declare Function GetScrollPos Lib "user32" _
        (ByVal hWnd As IntPtr, ByVal bar As Integer) As Integer
  Public Declare Function GetWindowRect Lib "user32" _
        (ByVal hWnd As IntPtr, ByRef rct As Rect) As Integer
  Public Declare Function SendMessage Lib "user32" _
        (ByVal hWnd As IntPtr, ByVal msg As Integer, ByVal wParam As IntPtr, _
         ByRef lParam As IntPtr) As IntPtr
  Public Declare Function SetParent Lib "user32" _
        (ByVal hWndChild As IntPtr, ByVal hWndNewParent As IntPtr) As Integer
  Public Declare Function ShowWindow Lib "user32" _
        (ByVal hWnd As IntPtr, ByVal cmdShow As Integer) As Integer

#End Region

#Region "Structures"

  <StructLayout(LayoutKind.Explicit)> _
  Public Structure Rect
    <FieldOffset(0)> Public Left As Integer
    <FieldOffset(4)> Public Top As Integer
    <FieldOffset(8)> Public Right As Integer
    <FieldOffset(12)> Public Bottom As Integer
  End Structure

#End Region

End Class

There is nothing particularly interesting to see here, so we'll take just a quick moment to comment. These Win32 functions are used sparingly in the ComboBox and Tooltip classes mainly to determine which ListBox item the mouse is over and obtain the hWnd for the ListBox class when the ComboBox enters drop-down mode.

Now, a quick look at the ComboBox class.

Imports System.Drawing
Imports System.Windows.Forms

Public Class ComboBox

  Inherits System.Windows.Forms.ComboBox

  Private mToolTip As Tooltip

  Private Function IsPointOfInterest(ByVal pt As Drawing.Point, _
                   ByVal lbhWnd As IntPtr) As Boolean

    Dim dropDownRect As Win32.Rect

    If Not CType(Win32.GetWindowRect(lbhWnd, dropDownRect), Boolean) Then
        Return False

    If dropDownRect.Left <= pt.X AndAlso dropDownRect.Right >= pt.X Then
      If dropDownRect.Top <= pt.Y AndAlso dropDownRect.Bottom >= pt.Y Then
        Return True
      End If
    End If

    Me.mToolTip.Hide()

    Return False

  End Function

  Private Function IndexFromPoint(ByVal Y As Integer, ByVal lbhWnd As IntPtr,_
          ByRef offset As Integer) As Integer

    offset = Win32.GetScrollPos(lbhWnd, 1)

    Dim n As Integer = offset - 1
    Dim ih As Integer = Me.ItemHeight

    If Math.Sign(Y) = -1 Then
      n += CType(Math.Ceiling((Me.DropDownHeight + Y) / ih), Integer)
    Else : n += CType(Math.Ceiling((Y - Me.ClientSize.Height) / ih), Integer)
    End If

    If n >= 0 AndAlso n < Me.Items.Count Then Return n Else Return -1

  End Function

  Private Sub HideToolTip(ByVal sender As Object, ByVal e As System.EventArgs) _
          Handles Me.DropDownClosed
    If Me.mToolTip IsNot Nothing Then Me.mToolTip.Hide()
  End Sub

  Protected Overridable Sub RefreshToolTip(ByVal lbhWnd As IntPtr)

    If Me.mToolTip Is Nothing Then Me.mToolTip = New Tooltip

    Dim pt As Point = Control.MousePosition

    If Not Me.IsPointOfInterest(pt, lbhWnd) Then Return

    pt = Me.PointToClient(pt)

    Dim offset As Integer = -1
    Dim n As Integer = Me.IndexFromPoint(pt.Y, lbhWnd, offset)

    If n = -1 Then Return


    Dim item As Object = Me.Items(n)
    Dim s As String = item.ToString.Replace(Environment.NewLine, "X"c)
    Dim size As Size = Me.ClientSize
    Dim showTooltip As Boolean = False
    Dim isExtender As Boolean = (TypeOf item Is ITooltipExtender)

    Using g As Graphics = Me.CreateGraphics
      showTooltip = (g.MeasureString(s, Me.Font).Width > size.Width)
    End Using

    If isExtender Then showTooltip = DirectCast(item, _
       ITooltipExtender).ShouldTooltipBeShown(showTooltip)

    If showTooltip Then

      Dim ih As Integer = Me.ItemHeight

      If Math.Sign(pt.Y) = 1 Then
        pt = Me.PointToScreen(New Point(1, 1 + size.Height + _
                             (ih * (n - offset))))
      Else : pt = Me.PointToScreen(New Point(1, 1 - Me.DropDownHeight + _
                                            (ih * (n - offset))))
      End If

      If isExtender Then
          pt.Offset(DirectCast(item, ITooltipExtender).GetTooltipOffset(pt))

      With Me.mToolTip
        .TooltipSource = Me.Items(n)
        .Location = pt
        .Show()
      End With

    Else : mToolTip.Hide()
    End If

  End Sub

  Protected Overrides Sub WndProc(ByRef m As System.Windows.Forms.Message)

    MyBase.WndProc(m)

    If m.Msg = Win32.WM_CTLCOLORLISTBOX Then Me.RefreshToolTip(m.LParam)

  End Sub

  Protected Overrides Sub Dispose(ByVal disposing As Boolean)

    If disposing AndAlso Me.mToolTip IsNot Nothing Then
      Me.mToolTip.Dispose()
      Me.mToolTip = Nothing
    End If

    MyBase.Dispose(disposing)

  End Sub

End Class

The only thing changed in this version is the RefreshToolTip method. Once the method locates the item under the cursor, it determines if it implements the ITooltipExtender interface we defined. If it doesn't, the control works exactly as our previous incarnation; but if it does, all logic used to determine whether the tooltip should be shown, its popup location, and its size is delegated to the implementor ... giving them total control.

Let's take a quick look now at the changes to the Tooltip class.

Imports System.Drawing
Imports System.Windows.Forms

Public Class Tooltip

  Inherits Control

  Private mTooltipSource As Object

  Public Sub New()
    MyBase.New()
    Me.BackColor = SystemColors.Info
  End Sub

  Public Property TooltipSource() As Object
    Get
      Return Me.mTooltipSource
    End Get
    Set(ByVal value As Object)
      Me.mTooltipSource = value
      Me.CalculateSize()
    End Set
  End Property

  Private Sub CalculateSize()

    If Me.mTooltipSource Is Nothing Then
      Me.Size = Drawing.Size.Empty
    ElseIf TypeOf Me.mTooltipSource Is ITooltipExtender Then
      Me.Size = DirectCast(Me.mTooltipSource, ITooltipExtender).CalculateSize(Me)
    ElseIf String.IsNullOrEmpty(Me.mTooltipSource.ToString) Then
      Me.Size = Drawing.Size.Empty
    Else
      Using g As Graphics = Me.CreateGraphics
        Me.Size = g.MeasureString(Me.mTooltipSource.ToString, Me.Font).ToSize
        Me.Width += 1
      End Using
    End If

  End Sub

  Public Overloads Sub Show()

    Dim h As IntPtr = Me.Handle

    If (h.Equals(IntPtr.Zero)) Then MyBase.CreateControl()

    If Not Me.Visible Then Win32.SetParent(h, IntPtr.Zero)

    Win32.ShowWindow(h, 1)

    Me.Invalidate()

  End Sub

  Protected Overrides ReadOnly Property CreateParams() As _
                      System.Windows.Forms.CreateParams
    Get

      Dim p As CreateParams = MyBase.CreateParams

      p.ExStyle = p.ExStyle Or (Win32.WS_EX_NOACTIVATE Or _
                  Win32.WS_EX_TOOLWINDOW Or Win32.WS_EX_TOPMOST)
      p.Parent = IntPtr.Zero

      Return p

    End Get
  End Property

  Protected Overrides Sub OnPaint(ByVal e As PaintEventArgs)

    MyBase.OnPaint(e)

    Dim rect As Rectangle = Me.ClientRectangle

    e.Graphics.DrawRectangle(SystemPens.ControlDarkDark, 0, 0, _
                             rect.Width - 1, rect.Height - 1)

    Dim s As String = Me.Text

    If Me.mTooltipSource IsNot Nothing Then
      If TypeOf Me.mTooltipSource Is ITooltipExtender Then
        DirectCast(Me.mTooltipSource, ITooltipExtender).OnPaint(e)
        Return
      ElseIf String.IsNullOrEmpty(s) Then
        s = Me.mTooltipSource.ToString
      End If
    End If

    If String.IsNullOrEmpty(s) Then Return

    Using brush As Brush = New SolidBrush(Me.ForeColor)
      e.Graphics.DrawString(s, Me.Font, brush, rect)
    End Using

  End Sub

  Protected Overrides Sub OnPaintBackground(ByVal pevent As _
            System.Windows.Forms.PaintEventArgs)

    If Me.mTooltipSource IsNot Nothing AndAlso TypeOf _
              Me.mTooltipSource Is ITooltipExtender Then
      DirectCast(Me.mTooltipSource, ITooltipExtender).OnPaintBackground(pevent)
      Return
    Else : MyBase.OnPaintBackground(pevent)
    End If

  End Sub

End Class

The Tooltip class now has a TooltipSource property (of type Object) and a CalculateSize method. Originally, the ComboBox calculated the size of the Tooltip using the text to be displayed, but now that we need to support owner-drawn tooltips, we've had to delegate the calculation to the Tooltip class itself. If the TooltipSource is determined to implement the ITooltipExtender interface, the Tooltip class will once again delegate the calculations to the implementor.

The TooltipSource is set by the ComboBox to the item under the cursor when the Tooltip is refreshed. You don't need to worry about it when working with the control, which we'll see a little later on.

The only other new thing is the appearance of an overridden version of the OnPaintBackground method. As you can see, all this does is gives the ITooltipExtender total control over the paint operation .. they now also paint the background.

The only missing piece in the puzzle now is an implementation of the ITooltipExtender interface. First off, let's take a look at its definition.

Public Interface ITooltipExtender

  Function CalculateSize(ByVal sender As Tooltip) As Size
  Function GetTooltipOffset(ByVal pt As Point) As Point
  Function ShouldTooltipBeShown(ByVal itemTextIsObscured As Boolean) As Boolean

  Sub OnPaint(ByVal e As PaintEventArgs)
  Sub OnPaintBackground(ByVal e As PaintEventArgs)

End Interface 

We've already discussed the CalculateSize method, and will see an example implementation shortly. Suffice to say, you return the tooltip window size that you need.

The GetTooltipOffset method can be used to offset the location of the tooltip window. By default, the ComboBox will locate it at the same position as the item it represents. If you want a more natural tooltip window position, see the enclosed example which moves it to the cursor location. The incoming parameter is the point where the tooltip window will be shown if you return Point.Empty from the function. It should be noted that the Point is in screen co-ordinates.

The ShouldToolTipBeShown method is a simple 'predicate' style check which defers the visible decision to the interface implementor. By default, the ComboBox will only show the tooltip if the item text is obscured in the listbox (the incoming flag advises if this is the case). The implementor should return True to show the item tooltip, else it should return False.

The other methods are simple delegates for the tooltip's painting operations. This is where the implementor should render the appropriate tooltip.

I did wonder whether owner-drawn was the right way to go as some developers may be unfamiliar with the GDI+ framework, but decided that it doesn't have a particularly steep learning curve and opened up a lot more possibilities for tooltip styling.

So now, we come to look at the implementor of the ITooltipExtender interface. There's a good chunk of code here because I've tried to provide a detailed example that renders image, text, headings, and a background. Most of the code is of little interest as it simply calculates the tooltip layout, but hopefully, there is enough there to get you going.

Imports System.Drawing
Imports System.Windows.Forms 

Public Class ComboBoxItem

    Implements ITooltipExtender

    Private ReadOnly mHeading As String
    Private ReadOnly mText As String
    Private ReadOnly mImage As Image

    Private mSize As Size
    Private mImageRect As Rectangle
    Private mHeadingRect As Rectangle
    Private mTextRect As Rectangle

    Public Sub New(ByVal heading As String, ByVal text As String, _
                   ByVal img As Image)

      MyBase.New()

      Me.mHeading = heading
      Me.mText = text
      Me.mImage = img

    End Sub

    Public Overrides Function ToString() As String
      Return Me.mText
    End Function

    Public Function CalculateSize(ByVal sender As Tooltip) As Drawing.Size _
           Implements ITooltipExtender.CalculateSize

      Const HBORDER As Integer = 7
      Const VBORDER As Integer = 5

      If Not Me.mSize.IsEmpty Then Return Me.mSize

      Dim hBorderNum As Integer = 0, vBorderNum As Integer = 0

      If Me.mImage IsNot Nothing Then
        Me.mImageRect = New Rectangle(HBORDER, VBORDER, _
                        Me.mImage.Width, Me.mImage.Height)
        Me.mSize = Me.mImageRect.Size : hBorderNum += 2 : vBorderNum += 2
      End If

      Using g As Graphics = sender.CreateGraphics
        If Not String.IsNullOrEmpty(Me.mHeading) Then
          Using font As New Font("Arial", 8, FontStyle.Bold)
            With g.MeasureString(Me.mHeading, font).ToSize
              Me.mSize.Height += (.Height + 1) : vBorderNum += 1
              If Me.mSize.Width < (.Width + 1) Then
                  Me.mSize.Width = (.Width + 1)
              Me.mHeadingRect = New Rectangle(HBORDER, VBORDER, _
                                        (.Width + 1), (.Height + 1))
              Me.mImageRect.Y = (Me.mHeadingRect.Bottom + VBORDER)
            End With
          End Using
        End If

        If Not String.IsNullOrEmpty(Me.mText) Then
          Using font As New Font("Arial", 8)
            With g.MeasureString(Me.mText, font).ToSize
              If Me.mSize.Width < (.Width + 1 + Me.mImageRect.Width) Then
                  Me.mSize.Width = (.Width + 1 + Me.mImageRect.Width) : _
                                    hBorderNum += 1
              If Me.mSize.Height < (.Height + 1) Then
                  Me.mSize.Height = (.Height + 1)
              Me.mTextRect = New Rectangle((Me.mImageRect.Right + VBORDER), _
                             Me.mImageRect.Top, (.Width + 1), (.Height + 1))
            End With
          End Using
        End If
      End Using

      If hBorderNum > 0 Then Me.mSize.Width += (hBorderNum * HBORDER)
      If vBorderNum > 0 Then Me.mSize.Height += (vBorderNum * VBORDER)

      Return Me.mSize

    End Function

    Public Sub OnPaint(ByVal e As System.Windows.Forms.PaintEventArgs) _
               Implements ITooltipExtender.OnPaint

      If Me.mSize.IsEmpty Then Return

      If Me.mImage IsNot Nothing Then
          e.Graphics.DrawImage(Me.mImage, Me.mImageRect)

      If Not String.IsNullOrEmpty(Me.mHeading) Then
        Using font As New Font("Arial", 8, FontStyle.Bold)
          e.Graphics.DrawString(Me.mHeading, font, _
                                Brushes.Black, Me.mHeadingRect)
        End Using
      End If

      If Not String.IsNullOrEmpty(Me.mText) Then
        Using font As New Font("Arial", 8)
          e.Graphics.DrawString(Me.mText, font, Brushes.Black, Me.mTextRect)
        End Using
      End If

    End Sub

    Public Sub OnPaintBackground(ByVal e As _
           System.Windows.Forms.PaintEventArgs) Implements _
           ITooltipExtender.OnPaintBackground

      If Me.mSize.IsEmpty Then Return

      Dim rect As RectangleF = _
          New RectangleF(0, 0, Me.mSize.Width, Me.mSize.Height)
      Dim clrF As Color = Color.FromArgb(255, 255, 255, 255)
      Dim clrT As Color = Color.FromArgb(255, 201, 217, 239)

      Using brush As New Drawing.Drawing2D.LinearGradientBrush(rect, _
            clrF, clrT, Drawing2D.LinearGradientMode.Vertical)
        e.Graphics.FillRectangle(brush, rect)
      End Using

    End Sub

    Public Function GetTooltipOffset(ByVal pt As Point) As _
           System.Drawing.Point Implements ITooltipExtender.GetTooltipOffset
      'Return Point.Empty


      Dim cp As Point = Control.MousePosition

      Return New Point(cp.X - pt.X, cp.Y - pt.Y)

    End Function

    Public Function ShouldTooltipBeShown(ByVal itemTextIsObscured As Boolean) _
           As Boolean Implements ITooltipExtender.ShouldTooltipBeShown
      Return True
    End Function
  End Class

As I said previously, there is no great surprise in any of this code. The CalculateSize method considers the length of the heading, the content text, and also any image attached. The Paint methods render a nice gradient background and the image/text in the appropriate places. To keep calculations down to a minimum, I cache the size and layout once it has been calculated.

Points of interest

I chose to support the ITooltipExtender solely for combo items that implemented it themselves. I think this approach keeps things very simple for the consumer of the control and has no impact on the majority of applications. I'd envisage the developer coding a wrapper class for their combo items.

Of course, of equal merit is an approach where the tooltip delegation is handled by a single instance for all items. Hopefully, the ComboBox and Tooltip controls can be easily adapted (maybe you have thousands of items in the list) to use a single instance of the implementor with a few interface tweaks (e.g., passes list item index as a parameter to methods).

Well, this is by no means a complete solution, but there is a good chunk for you to work with out-of-the-box. Interactive tooltips may be the next step in this project, but I've seen little need for such a monster and so haven't invested the time developing it yet.

As with the previous version, there is no timed delay before showing a tooltip and no auto-hide feature. These are all minor things to add if you need them.

I hope you find the article and code useful some day. Please leave any feedback on your thoughts, use of, and potential improvements to the API.

History

  • Initial release - 6th June 2007.

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