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
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.