Painting the borders of a custom control using WM_NCPAINT
Over the years I've created a number of controls that require
borders. Sometimes, I'll draw the borders manually as part of
the normal user paint sequence. Other times I'll apply the
WS_EX_CLIENTEDGE
or WS_BORDER
styles and let Windows handle
it for me.
The advantage of the latter approach is that that means there is nothing I need to do; the borders will automatically paint, and as they are excluded from the normal client region of the control, I don't need to account for them when performing my own painting or positioning of child controls such as scroll bars.
The disadvantage is that these borders will be painted in the classic Windows 95 style without any theming.
While working on a control recently, I went with applying window
styles for ease, but then decided I wanted to draw themed
borders. This time I decided to try something new, and have
Windows still manage the borders, but I would override its
default painting with my own, using the
WM_NCPAINT
message.
Sidequest: Applying window styles
If your custom controls use UserControl
as a base, this
already has a BorderStyle
property which creates a non-client
frame. Most of the controls I create don't need the extra
functionality of UserControl
and so I mostly inherit from
Control
. By default this does not create a frame; the code
below adds a BorderStyle
property and sets the appropriate
style when the window handle is created.
const int WS_BORDER = 0x00800000;
const int WS_EX_CLIENTEDGE = 0x00000200;
private BorderStyle _borderStyle;
[Category("Appearance")]
[DefaultValue(typeof(BorderStyle), "Fixed3D")]
public BorderStyle BorderStyle
{
get => _borderStyle;
set
{
if (_borderStyle != value)
{
_borderStyle = value;
this.UpdateStyles();
}
}
}
protected override CreateParams CreateParams
{
get
{
CreateParams createParams;
createParams = base.CreateParams;
createParams.ExStyle &= ~WS_EX_CLIENTEDGE;
createParams.Style &= ~WS_BORDER;
switch (_borderStyle)
{
case BorderStyle.Fixed3D:
createParams.ExStyle |= WS_EX_CLIENTEDGE;
break;
case BorderStyle.FixedSingle:
createParams.Style |= WS_BORDER;
break;
}
return createParams;
}
}
Here we have a fairly basic property definition, the only aspect
you might not normally see is the call to UpdateStyles
- this
will cause the window styles to be reapplied via our overridden
CreateParams
method.
The CreateParams
property is also something you might not see
or need to use very often, and is used to set the underlying
Win32 attributes of the window (remember that as far as Windows
is concerned, your forms, controls, etc are all "windows"). I'm
using it here to set the border styles, but you could also use
it to specify the name of an existing class such as EDIT
,
although that doesn't come up as often - a topic for another
day, perhaps.
In our override we first remove any existing border styles. There shouldn't be any set, but better to be sure. We then apply either a basic or an extended style depending on our property value. And with that done, Windows will create an appropriate frame and paint it for us, allowing me to move on with the rest of this article.
Introducing WM_NCPAINT
The WM_NCPAINT
message is sent to a window when its frame must
be painted. We can intercept this message via the WndProc
method of our control and then perform the desired painting.
const int WM_NCPAINT = 0x0085;
protected override void WndProc(ref Message m)
{
if (m.Msg == WM_NCPAINT)
{
this.WmNcPaint(ref m);
}
else
{
base.WndProc(ref m);
}
}
private void WmNcPaint(ref Message m)
{
base.WndProc(ref m); // Just going back to Windows, for now
}
Getting the device context
Regardless of if we're going to using managed or unmanaged
painting we need to start by getting the device context (DC).
According to the documentation for WM_NCPAINT
, I should have
been able to use GetDCEx
to do this, but in
practice I found it always returned a null handle 1.
Normally, I might use GetDC
but this returns a DC for the
client, and we need it for the non-client area. For this
technique, I will instead call GetWindowDC
-
the DC returned by this API will allow painting in both the
client and non-client areas. Once we have finished with a DC, it
needs to be released via ReleaseDC
.
[DllImport("user32.dll")]
static extern IntPtr GetWindowDC(IntPtr hWnd);
[DllImport("user32.dll")]
static extern bool ReleaseDC(IntPtr hWnd, IntPtr hDc);
private void WmNcPaint(ref Message m)
{
IntPtr hdc;
hdc = GetWindowDC(this.Handle);
// TODO: Paint something
ReleaseDC(this.Handle, hdc);
}
Painting in a mostly-managed way
Once we've got our DC, we can begin to paint. The easiest way is
use use the managed API, e.g. the Graphics
object. We can
create an instance of a Graphics
instance from a Win32 DC via
the static FromHdc
method.
using (Graphics g = Graphics.FromHdc(hdc))
{
// TODO: Paint
}
If we immediately start to paint here however, we'll run into
two issues - firstly, the DC is for the entire window, so we
could accidentally paint over the client area. This shouldn't
matter too much as client painting will follow but if that
itself is only partial, artefacts may be left behind (plus in my
testing there was obvious flicker). The second issue is that
properties such as Control.Size
may not have been update at
the point this message is received and so could return
inaccurate values.
To resolve these issues we need to do a little more work to both determine the correct client region, and also to exclude this region from painting.
First, we use GetClientRect
to get a
RECT
describing the client. Next, we will get the
window rectangle via GetWindowRect
. Note that
the former always has a location of zero, whilst the latter
includes the position of the window. Also note that unlike the
.NET Rectangle
structure, the Win32 rect is comprised of
left, top, right (left + width) and bottom (top +
height) values, not the more convenient width and height
you may be used to from working solely with .NET.
With these values in hand, I calculate the position of the
client area with the assumption that the horizontal and vertical
margins are equidistant. Save storing the actual values
retrieved or calculated via WM_NCCALCSIZE
(which I will
briefly cover later), I don't actually know how you'd get them
another way2.
The new painting implementation is a little longer, but more robust.
[DllImport("user32.dll", SetLastError = true)]
static extern bool GetClientRect(IntPtr hWnd, out RECT lpRect);
[DllImport("user32.dll", SetLastError = true)]
static extern bool GetWindowRect(IntPtr hWnd, out RECT lpRect);
[StructLayout(LayoutKind.Sequential)]
struct RECT
{
public int left;
public int top;
public int right;
public int bottom;
}
private void WmNcPaint(ref Message m)
{
int w;
int h;
Rectangle clip;
IntPtr hdc;
GetClientRect(this.Handle, out RECT clientRect);
GetWindowRect(this.Handle, out RECT windowRect);
w = windowRect.right - windowRect.left;
h = windowRect.bottom - windowRect.top;
clip = new Rectangle((w - clientRect.right) / 2, (h - clientRect.bottom) / 2, clientRect.right, clientRect.bottom);
hdc = GetWindowDC(this.Handle);
using (Graphics g = Graphics.FromHdc(hdc))
{
g.SetClip(clip, CombineMode.Exclude);
g.FillRectangle(Brushes.SeaGreen, 0, 0, w, h);
}
ReleaseDC(this.Handle, hdc);
}
And with this in place, we now have a control that has a green border.
Sidequest - Regions
In the above code, I calculated the clip region manually.
However, Windows does provide a paint region as part of the
message. The wParam parameter is a handle to an update region.
This doesn't always seem to be the case - the first call always
seems to be 1
which isn't a valid handle. When it isn't 1
,
you can use Region.FromHrgn
to convert this handle into a
managed object, leaving you with code something similar to the
below.
While trying to work find out what the seemly undocumented 1
meant, I came across this Stack Overflow answer
and in turn this MSDN post which first made me
realise why the calls to GetDCEx
were failing but also made me
realise I should probably ignore that parameter and continue
doing it the way I was. As a bonus it also pointed me in the
direction of the MapWindowPoints
call which may be the missing
piece I needed for offsetting the client rectangle, something to
investigate another day now though. (It also made me question if
I really should be using WM_NCPAINT
or if I should just do
it all manually, but I'm halfway through the article now so I
may as well finish it!).
// this works but needs more investigation and probably shouldn't be used
using (Graphics g = Graphics.FromHdc(hdc))
using (Region region = m.WParam != new IntPtr(1)
? Region.FromHrgn(m.WParam)
: new Region(clip))
{
g.SetClip(region, CombineMode.Exclude);
g.FillRectangle(Brushes.SeaGreen, 0, 0, w, h);
}
Painting using the themes API
Although I could probably just use the built in Visual Styles, using the native theme API is a nice way of demonstrating the pure unmanaged approach.
It starts of similar to the previous code, except I manipulate
the results of GetWindowRect
to be client based, otherwise the
painting would done at the wrong location. In this example, I'm
using the themes for the EDIT
control, e.g. a TextBox
.
As I don't have a Graphics
object to call SetClip
on, I call
ExcludeClipRect
instead.
For the actual painting, first I get a handle to the theme via
OpenThemeData
. Next I check to see if any the
theme is partially transparent via
IsThemeBackgroundPartiallyTransparent
and if so I draw the background via
DrawThemeParentBackground
. In
this case it isn't transparent, but I suppose it is good
practice to do these checks. After which I draw the theme
background via DrawThemeBackground
which will give me my nice themed borders. And to wrap it up, I
close the handle I opened earlier using
CloseThemeData
.
const int EP_EDITTEXT = 1;
const int ETS_NORMAL = 1;
const int ETS_DISABLED = 4;
[DllImport("uxtheme.dll")]
static extern int CloseThemeData(IntPtr hTheme);
[DllImport("uxtheme.dll")]
static extern int DrawThemeBackground(IntPtr hTheme, IntPtr hdc, int iPartId, int iStateId, ref RECT pRect, IntPtr pClipRect);
[DllImport("uxtheme.dll")]
static extern int DrawThemeParentBackground(IntPtr hWnd, IntPtr hdc, ref RECT pRect);
[DllImport("gdi32.dll")]
static extern int ExcludeClipRect(IntPtr hdc, int nLeftRect, int nTopRect, int nRightRect, int nBottomRect);
[DllImport("uxtheme.dll")]
static extern int IsThemeBackgroundPartiallyTransparent(IntPtr hTheme, int iPartId, int iStateId);
[DllImport("uxtheme.dll", CharSet = CharSet.Unicode)]
static extern IntPtr OpenThemeData(IntPtr hWnd, string classList);
private void WmNcPaint(ref Message m)
{
int w;
int h;
IntPtr hdc;
IntPtr hTheme;
int partId;
int stateId;
GetClientRect(this.Handle, out RECT clientRect);
GetWindowRect(this.Handle, out RECT windowRect);
w = windowRect.right - windowRect.left;
h = windowRect.bottom - windowRect.top;
windowRect.right = w;
windowRect.bottom = h;
windowRect.left = 0;
windowRect.top = 0;
hdc = GetWindowDC(this.Handle);
hTheme = OpenThemeData(this.Handle, "EDIT");
partId = EP_EDITTEXT;
stateId = this.Enabled
? ETS_NORMAL
: ETS_DISABLED;
ExcludeClipRect(hdc, (w - clientRect.right) / 2, (h - clientRect.bottom) / 2, clientRect.right, clientRect.bottom);
if (IsThemeBackgroundPartiallyTransparent(hTheme, partId, stateId) != 0)
{
DrawThemeParentBackground(this.Handle, hdc, ref windowRect);
}
DrawThemeBackground(hTheme, hdc, partId, stateId, ref windowRect, IntPtr.Zero);
CloseThemeData(hTheme);
ReleaseDC(this.Handle, hdc);
}
Although it is probably much more concise to use the built-in
VisualStyleRenderer
with a Graphics
instance than the above,
this serves the purpose and will give us a control with a nice
themed border.
Themes shouldn't be used blindly, they might be disabled by the operating system or by the current process. You should always check to see if themes are enabled (for example via
Application.RenderWithVisualStyles
) before attempting to render them, and fall back to another paint mode if not.
Bonus Chatter - the WM_NCCALCSIZE message
When I started this article, I intended to end it with the preceding section. However, given that themes don't have to follow expected sizing conventions I thought I ought not do a half job and should include some details on how you can define the size of the non-client area yourself.
The WM_NCCALCSIZE
message is sent when the
size and position of a window's client area must be calculated.
If you are just relying on painting over standard borders
without using themes then you may not need to use this message,
but if you are using themes them it is probably better to handle
it manually.
This message is slightly peculiar in my experience as it
contains different data at different times. If wParam is
TRUE, then lParam points to a
NCCALCSIZE_PARAMS
structure, otherwise it
points to a RECT
instead. This makes the code slightly more
complicated, but not excessively.
In my testing, it seemed a
RECT
value only happened once when first created, thereafter aNCCALCSIZE_PARAMS
value was always provided.
const int WM_NCCALCSIZE = 0x0083;
[StructLayout(LayoutKind.Sequential)]
struct NCCALCSIZE_PARAMS
{
[MarshalAs(UnmanagedType.ByValArray, SizeConst = 3)]
public RECT[] rgrc;
public WINDOWPOS lppos;
}
[StructLayout(LayoutKind.Sequential)]
struct WINDOWPOS
{
public IntPtr hwnd;
public IntPtr hwndInsertAfter;
public int x;
public int y;
public int cx;
public int cy;
public uint flags;
}
protected override void WndProc(ref Message m)
{
if (m.Msg == WM_NCPAINT)
{
this.WmNcPaint(ref m);
}
else if (m.Msg == WM_NCCALCSIZE)
{
this.WmNcCalcSize(ref m);
}
else
{
base.WndProc(ref m);
}
}
private void WmNcCalcSize(ref Message m)
{
if (m.WParam == IntPtr.Zero)
{
RECT clientRect;
clientRect = (RECT)Marshal.PtrToStructure(m.LParam, typeof(RECT));
clientRect.left += leftOffset;
clientRect.top += topOffset;
clientRect.right -= rightOffset;
clientRect.bottom -= bottomOffset;
Marshal.StructureToPtr(clientRect, m.LParam, false);
_clientRect = new Rectangle(leftOffset, topOffset, clientRect.right - clientRect.left, clientRect.bottom - clientRect.top);
}
else
{
NCCALCSIZE_PARAMS parameters;
RECT clientRect;
parameters = (NCCALCSIZE_PARAMS)Marshal.PtrToStructure(m.LParam, typeof(NCCALCSIZE_PARAMS));
clientRect = parameters.rgrc[0];
clientRect.left += leftOffset;
clientRect.top += topOffset;
clientRect.right -= rightOffset;
clientRect.bottom -= bottomOffset;
parameters.rgrc[0] = clientRect;
Marshal.StructureToPtr(parameters, m.LParam, false);
_clientRect = new Rectangle(leftOffset, topOffset, clientRect.right - clientRect.left, clientRect.bottom - clientRect.top);
}
}
The first thing I do is check if wParam is IntPtr.Zero
, and
if so I extract the RECT
structure from lParam using
Marshal.PtrToStructure
. I then adjust this rectangle
accordingly by increasing left and top, and reducing right
and bottom to account for how large I want the client area to
be. I then store the modified rectangle back into lParam using
Marshal.StructureToPtr
. Finally, I also store the new
rectangle for future use in painting.
If wParam is non-zero, I instead extract a NCCALCSIZE_PARAMS
structure from lParam. This has two members, a WINDOWPOS
describing the window position and an array containing 3 RECT
values. The first rectangle in this array is the rectangle is
the one we want to modify as per the previous paragraph. Once
we've replaced the first value in the array with our modified
version, we store the entire structure back into lParam, again
using Marshal.StructureToPtr
.
When painting in response to WM_NCPAINT
, I use the
_clientRectangle
value captured earlier to define the clipping
region.
Processing this message in order to handle visual styles is
reasonably straight forward - as with painting, we need to open
a DC, open a theme, and then use
GetThemeBackgroundContentRect
to get the client rectangle instead of calculating it ourselves.
[DllImport("uxtheme.dll")]
extern static int GetThemeBackgroundContentRect(IntPtr hTheme, IntPtr hdc, int iPartId, int iStateId, ref RECT pBoundingRect, out RECT pContentRect);
private void WmNcCalcSize(ref Message m)
{
IntPtr hdc;
IntPtr hTheme;
int partId;
int stateId;
hdc = GetWindowDC(this.Handle);
hTheme = OpenThemeData(this.Handle, "EDIT");
partId = EP_EDITTEXT;
stateId = this.Enabled
? ETS_NORMAL
: ETS_DISABLED;
if (m.WParam == IntPtr.Zero)
{
RECT clientRect;
clientRect = (RECT)Marshal.PtrToStructure(m.LParam, typeof(RECT));
GetThemeBackgroundContentRect(hTheme, hdc, partId, stateId, ref clientRect, out RECT adjustedClientRect);
Marshal.StructureToPtr(adjustedClientRect, m.LParam, false);
_clientRect = new Rectangle(adjustedClientRect.left - clientRect.left, adjustedClientRect.top - clientRect.top, (adjustedClientRect.right - adjustedClientRect.left) - (adjustedClientRect.left - clientRect.left), (adjustedClientRect.bottom - adjustedClientRect.top) - (adjustedClientRect.top - clientRect.top));
}
else
{
NCCALCSIZE_PARAMS parameters;
RECT clientRect;
parameters = (NCCALCSIZE_PARAMS)Marshal.PtrToStructure(m.LParam, typeof(NCCALCSIZE_PARAMS));
clientRect = parameters.rgrc[0];
GetThemeBackgroundContentRect(hTheme, hdc, partId, stateId, ref clientRect, out RECT adjustedClientRect);
parameters.rgrc[0] = adjustedClientRect;
Marshal.StructureToPtr(parameters, m.LParam, false);
_clientRect = new Rectangle(adjustedClientRect.left - clientRect.left, adjustedClientRect.top - clientRect.top, (adjustedClientRect.right - adjustedClientRect.left) - (adjustedClientRect.left - clientRect.left), (adjustedClientRect.bottom - adjustedClientRect.top) - (adjustedClientRect.top - clientRect.top));
}
CloseThemeData(hTheme);
ReleaseDC(this.Handle, hdc);
}
Bonus Chatter - Forcing a redraw of the non-client area
If you use this approach, you will find the WM_NCPAINT
message
isn't called all that often - you'll have a lot more WM_PAINT
messages for painting the actual client area. But what happens
if you need to refresh the non-client area? For example, you
might have design time properties that control the appearance,
or perhaps at runtime you want to change the border when the
control has focus.
Built in methods like Control.Invalidate
or Control.Refresh
won't have any impact as they only invalidate the client area.
The solution is to use the RedrawWindow
API,
which allows us to control which aspects of the window are
refreshed. By using the RDW_FRAME
flag, we tell Windows to
send a WM_NCPAINT
message as appropriate, and the
RDW_INVALIDATE
flag to repaint the window. By not including a
RECT
or HRGN
describing the area to repaint, it will paint
the full window.
It would probably be better to try to define a region that includes the non-client area and excludes the client area, but this is an area I haven't touched for some time (if ever) so I'm fuzzy on the details - I may post a follow up if I find it becomes necessary.
const int RDW_FRAME = 0x400;
const int RDW_INVALIDATE = 0x1;
[DllImport("user32.dll")]
static extern bool RedrawWindow(IntPtr hWnd, IntPtr lprcUpdate, IntPtr hrgnUpdate, int flags);
private void InvalidateAll()
{
RedrawWindow(this.Handle, IntPtr.Zero, IntPtr.Zero, RDW_FRAME | RDW_INVALIDATE);
}
protected override void OnEnter(EventArgs e)
{
base.OnEnter(e);
this.InvalidateAll();
}
protected override void OnLeave(EventArgs e)
{
base.OnLeave(e);
this.InvalidateAll();
}
Closing thoughts
This article turned out a little longer than I was expecting. As is often the case, I wrote the article in tandem with creating the demonstration and jumped back and forth whilst exploring different ideas or encountering issues. As a result of this it's possible that there are errors in the code embedded within the article or with the article content itself. If you spot any, please let me know!
A sample project can be downloaded from our GitHub page.
Would you follow this approach or would you do something different?
-
The reason this call was failing appears to be two-fold. Firstly, I was passing in an invalid HRGN whenever wParam was
1
. In addition, when calling in response toWM_NCPAINT
apparently you need to use an undefinedDCX_USESTYLE
(0x00010000
) flag too.↩ -
Potentially we can use
MapWindowPoints
for this, but at the time of writing this article I have not investigated this further.↩
Leave a Comment
While we appreciate comments from our users, please follow our posting guidelines. Have you tried the Cyotek Forums for support from Cyotek and the community?