Back in the day, if you wanted to draw in a form’s title bar for some reason (to, for example, add one or more extra buttons), you would handle the WM_NCPAINT message and draw to a handle got from the GetDCEx function. While you can still do this with recent versions of Windows, it will cause the window frame to lose its ‘glass’ styling and translucent effects, something that is generally not what you want.
How, then, should you go about customising the title bar in the ‘Aero’ world of Vista and Windows 7? In this post and its sequel, I will document my attempts at finding out. One thing I should emphasise at the off is that this has been a matter of trial and error on my part, of playing around really. Moreover, one might well think that custom window frames are a crappy idea in the first place — even I can’t see the point of Google Chrome putting its page tabs inside its title bar rather than below it, for example. Nonetheless, they can have their use, and anyway, solving a Windows API puzzle can be interesting in itself…
The basics
Realistically, the minimum Delphi version you should be using here is 2007, which makes sense given this version was released not long after Vista debuted. While you could struggle along with an older version — hell, Delphi 2 might be considered ‘good enough’ to the extent of having a 32 bit compiler capable of calling the Desktop Window Manager (DWM) API — Delphi 2007 both does some of the work of setting up the glass frame for you, and more importantly, amends aspects of the VCL’s implementation to be more (though by no means totally) glass friendly.
Now if you check out MSDN, you’ll come across an article with the very promising title ‘Custom Window Frame Using DWM’. This is basically a mild edit of an earlier blog post on the Windows shell team’s (now discontinued) blog, so you might think it would be the be all and end all of the matter. Unfortunately it is not though, the elephant in the room being its advice to completely remove your window’s (i.e., your form’s) non-client area (see the section entitled ‘Removing the Standard Frame’). The problem with this is that doing such a thing messes up the position of every child control. While this is OK if you can guarantee your application will only run when the DWM is active, making such a guarantee would be foolish given it only takes a few clicks for the user to disable the thing and return to a non-glassy world.
Nonetheless, the basic principle outlined in the article still holds: namely, that to draw on a glass title bar, you have to (a) extend the client area onto it and (b) extend the glass area by the same amount that you just added to the client area. (In practice, given the way Windows works, you actually do (b) before (a), but I’ll come to that in a moment.) Painting must then be done on a bitmap in a specific format, which then gets blitted to the DWM’s own buffer. Given most GDI functions (and thus, most native Delphi graphics) are not alpha-channel aware, you also have to be a bit careful about what you draw onto the bitmap. Nonetheless, the VCL does a fair bit of the legwork overall, even if there’s still a lot of things to do manually, as we shall now see.
Setting things up
To get going, create a new VCL application, and head for the code editor. There, create a uses clause in the implementation section, and add DwmApi to it. Then, to the form class, add a private integer field called FWndFrameSize, and the following code as a handler for the form’s OnCreate event:
procedure TForm1.FormCreate(Sender: TObject);
var
R: TRect;
begin
if DwmCompositionEnabled then
begin
SetRectEmpty(R);
AdjustWindowRectEx(R, GetWindowLong(Handle, GWL_STYLE), False,
GetWindowLong(Handle, GWL_EXSTYLE));
FWndFrameSize := R.Right;
GlassFrame.Top := -R.Top;
GlassFrame.Enabled := True;
SetWindowPos(Handle, 0, Left, Top, Width, Height, SWP_FRAMECHANGED);
DoubleBuffered := True;
end;
end;
This code first checks to see whether glass is enabled, before extending the glass frame by the size of the title bar. The SetWindowPos call then causes the non-client area to be recalculated, and setting DoubleBuffered to True gets the VCL to do the special buffering I mentioned earlier (in Delphi 2009 or later, it also causes child controls to be double buffered — more on that in a later post however).
Run the application, and you’ll find it produces a form with a fat title bar, since while we’ve increased the glass frame size, we haven’t taken out the non-client area for the standard title bar. To do that, we need to now override the default handling of the WM_NCCALCSIZE message. For this, add the following to the form’s definition:
protected
procedure WMNCCalcSize(var Message: TWMNCCalcSize); message WM_NCCALCSIZE;
And then this code to its implementation:
procedure TForm1.WMNCCalcSize(var Message: TWMNCCalcSize);
begin
if not GlassFrame.Enabled then
inherited
else
with Message.CalcSize_Params.rgrc[0] do
begin
Inc(Left, FWndFrameSize);
Dec(Right, FWndFrameSize);
Dec(Bottom, FWndFrameSize);
end;
end;
Basically, the input rectangle here is the new window bounds. By leaving Top alone, we are in effect extending the client area onto where the non-client area would normally be. (An alternative implementation would be to call ‘inherited’ and change Top to be back to what it was before calling the default implementation.)
So, run the application again; this time, the title bar should be restored to its proper size. However, you may now note the following issues:
- Both the form’s icon and its caption have been lost.
- The title bar itself does not do anything when you double-click or right-click it, or indeed attempt to drag it.
- The form’s top border does not allow us to resize the form any more.
- While the minimise/maximise/close buttons still show, they don’t do anything (you’ve have to press Alt+F4 to exit the application cleanly).
- Under 64 bit versions of Windows, ‘ghost’ buttons can emerge underneath the real ones — and unlike the real ones, the zombies do actually work! To see this, hover the mouse cursor just below the close button, directly underneath or just right of the X; after a short while, the ‘Close’ tooltip will show. If you then press and hold down the left mouse button, the ghostly appearance of an old-style close button will emerge; let go, and the form will close.
All in all, it’s time to do a bit of fixing.
Fix, fix and fix again
With respect to the real buttons not working, the fix is to call a particular DWM API function when handling the WM_NCHITTEST message. Since the name of this function is the rather generic-sounding DwmDefWindowProc, I would do this by overriding the form’s WndProc method as thus:
procedure TForm1.WndProc(var Message: TMessage);
begin
if GlassFrame.Enabled and HandleAllocated and DwmDefWindowProc(Handle,
Message.Msg, Message.WParam, Message.LParam, Message.Result) then Exit;
inherited;
end;
While that was nice and easy, fixing the other issues is a bit more involved. In the first instance, we need to be able to know where the icon should be, so define a private method called GetSysIconRect as thus:
function TForm1.GetSysIconRect: TRect;
begin
if not (biSystemMenu in BorderIcons) or not (BorderStyle in [bsSingle, bsSizeable]) then
SetRectEmpty(Result)
else
begin
Result.Left := 0;
Result.Right := GetSystemMetrics(SM_CXSMICON);
Result.Bottom := GetSystemMetrics(SM_CYSMICON);
if WindowState = wsMaximized then
Result.Top := GlassFrame.Top - Result.Bottom - 2
else
Result.Top := 6; //is the 'right' value for both normal and large fonts on my machine
Inc(Result.Bottom, Result.Top);
end;
end;
With this, we can now amend the handling of the WM_NCHITEST message to detect the system icon area as normal. A custom WM_NCHITEST handler is also where we slay the ghost buttons, the method being to correct any claim of the default handler to have found where the minimise/maximise/close buttons should be (this works because the real buttons are all handled by a separate DWM process rather than the form and its underlying window).
procedure TForm1.WMNCHitTest(var Message: TWMNCHitTest);
var
ClientPos: TPoint;
IconRect: TRect;
begin
inherited;
if not GlassFrame.Enabled then Exit;
case Message.Result of
HTCLIENT: {to be dealt with below};
HTMINBUTTON, HTMAXBUTTON, HTCLOSE:
begin
Message.Result := HTCAPTION; //slay ghost btns when running on Win64
Exit;
end;
else Exit;
end;
ClientPos := ScreenToClient(Point(Message.XPos, Message.YPos));
if ClientPos.Y > GlassFrame.Top then Exit;
if ControlAtPos(ClientPos, True) <> nil then Exit;
IconRect := GetSysIconRect;
if (ClientPos.X < IconRect.Right) and ((WindowState = wsMaximized) or
((ClientPos.Y >= IconRect.Top) and (ClientPos.Y < IconRect.Bottom))) then
Message.Result := HTSYSMENU
else if ClientPos.Y < FWndFrameSize then
Message.Result := HTTOP
else
Message.Result := HTCAPTION;
end;
This should all be pretty self-explanatory. Perhaps the only line that isn’t is the one with the ControlAtPos call — all that’s for, though, is to make sure we don’t prevent any TGraphicControl descendant placed on the custom title bar from receiving mouse messages.
Anyhow, run the application once more, and you should find the following:
- Double clicking the form’s title bar now correctly toggles its maximised state.
- The top border of the form once more allows us to resize it.
- While it isn’t visible, left-clicking where the icon belongs brings up the system menu. This exhibits the behaviour of the title bar in Windows Explorer under both Vista and Windows 7.
Alas, but two issues remain, along with a third that is now explicit given the maximise button works:
- We still need to actually draw the icon. There’s also the caption to be drawn too.
- Right-clicking the title bar still doesn’t do what it should, which is to bring up the system menu.
- Maximise the form, then minimise it, then un-minimize it. The client area goes all black!
The third issue here is actually the first time the VCL’s default handling of things has got in the way (all the other issues have been standard DWM irritations you would have to face when using any framework). Basically, when trapping the WM_WINDOWPOSCHANGING message, TCustomForm can set a flag that says the glass frame needs to be ‘refreshed’, which in practice means painting the form black in PaintWindow, a virtual protected method inherited from TWinControl (Ord(clBlack) = 0 = completely transparent if an alpha channel is assumed). Presumably because the code doesn’t imagine an extended glass frame will be used only to make up for an extended client area, this flag then ends up getting set when we don’t want it to.
Nonetheless, after a bit of playing around, I found a fix that’s pretty simple — basically, you just need to clip out the client area in an override for PaintWindow:
procedure TForm1.PaintWindow(DC: HDC);
begin
with GetClientRect do
ExcludeClipRect(DC, 0, GlassFrame.Top, Right, Bottom);
inherited;
end;
Note that if you want to draw outside of the custom title bar area in either an override for the Paint method or a handler for the OnPaint event, you’ll have to now reset the clipping region first.
While we’re about it, we should also ensure the form gets invalidated appropriately when being resized. For this, define both a private method called InvalidateTitleBar and a custom handler for the WM_WINDOWPOSCHANGING message, implementing them like this:
procedure TForm1.InvalidateTitleBar;
var
R: TRect;
begin
if not HandleAllocated then Exit;
R.Left := 0;
R.Top := 0;
R.Right := Width;
R.Bottom := GlassFrame.Top;
InvalidateRect(Handle, @R, False);
end;
procedure TForm1.WMWindowPosChanging(var Message: TWMWindowPosChanging);
const
SWP_STATECHANGED = $8000; //see TCustomForm.WMWindowPosChanging in Forms.pas
begin
if GlassFrame.Enabled then
if (Message.WindowPos.flags and SWP_STATECHANGED) = SWP_STATECHANGED then
Invalidate
else
InvalidateTitleBar;
inherited;
end;
Black painting solved, the next thing to be fixed is the missing system menu. For this, first add some custom handling for the WM_NCRBUTTONUP message:
procedure TForm1.WMNCRButtonUp(var Message: TWMNCRButtonUp);
begin
if not GlassFrame.Enabled or not (biSystemMenu in BorderIcons) then
inherited
else
case Message.HitTest of
HTCAPTION, HTSYSMENU: ShowSystemMenu(Self, Message);
else inherited;
end;
end;
We obviously now need a ShowSystemMenu routine. This turns out to be a bit long-winded given the need to initialise the menu ourselves. Moreover, the actual showing and making effective of the user’s selection requires a bit of hocus pocus, though the following implementation works well for me:
procedure ShowSystemMenu(Form: TForm; const Message: TWMNCRButtonUp);
var
Cmd: WPARAM;
Menu: HMENU;
procedure UpdateItem(ID: UINT; Enable: Boolean; MakeDefaultIfEnabled: Boolean = False);
const
Flags: array[Boolean] of UINT = (MF_GRAYED, MF_ENABLED);
begin
EnableMenuItem(Menu, ID, MF_BYCOMMAND or Flags[Enable]);
if MakeDefaultIfEnabled and Enable then
SetMenuDefaultItem(Menu, ID, MF_BYCOMMAND);
end;
begin
Menu := GetSystemMenu(Form.Handle, False);
if Form.BorderStyle in [bsSingle, bsSizeable, bsToolWindow, bsSizeToolWin] then
begin
SetMenuDefaultItem(Menu, UINT(-1), 0);
UpdateItem(SC_RESTORE, Form.WindowState <> wsNormal, True);
UpdateItem(SC_MOVE, Form.WindowState <> wsMaximized);
UpdateItem(SC_SIZE, (Form.WindowState <> wsMaximized) and
(Form.BorderStyle in [bsSizeable, bsSizeToolWin]));
UpdateItem(SC_MINIMIZE, (biMinimize in Form.BorderIcons) and
(Form.BorderStyle in [bsSingle, bsSizeable]));
UpdateItem(SC_MAXIMIZE, (biMaximize in Form.BorderIcons) and
(Form.BorderStyle in [bsSingle, bsSizeable]) and
(Form.WindowState <> wsMaximized), True);
end;
if Message.HitTest = HTSYSMENU then
SetMenuDefaultItem(Menu, SC_CLOSE, MF_BYCOMMAND);
Cmd := WPARAM(TrackPopupMenu(Menu, TPM_RETURNCMD or
GetSystemMetrics(SM_MENUDROPALIGNMENT), Message.XCursor,
Message.YCursor, 0, Form.Handle, nil));
PostMessage(Form.Handle, WM_SYSCOMMAND, Cmd, 0)
end;
Restoring the icon and caption
In a standard VCL application, a form’s icon as displayed will come either from its Icon property, or if that is empty (as it will be by default), Application.Icon. Given this and the fact we have already calculated where it should be drawn, one might think of handling the form’s OnPaint event as thus:
procedure TForm1.FormPaint(Sender: TObject);
var
IconHandle: HICON;
begin
IconHandle := Icon.Handle;
if IconHandle = 0 then IconHandle := Application.Icon.Handle;
with GetSysIconRect do
DrawIconEx(Canvas.Handle, Left, Top, IconHandle,
Right - Left, Bottom - Top, 0, 0, DI_NORMAL);
end;
Unless you’ve explicitly set a 32 bit icon, however, this won’t work properly due to the alpha channel issue I mentioned in passing near the start. One easy-ish way to get around this, though, is to use a 32 bit image list as an intermediary.
So, drop a TImageList component onto the form; if you are using Delphi 2009 or later, go and set its ColorDepth property to cd32bit. Then, add CommCtrl to a uses clause, and handle the form’s OnPaint event like this:
procedure TForm1.FormPaint(Sender: TObject);
var
IconHandle: HICON;
R: TRect;
begin
if ImageList1.Count = 0 then
begin
ImageList1.Width := GetSystemMetrics(SM_CXSMICON);
ImageList1.Height := GetSystemMetrics(SM_CYSMICON);
{$IF NOT DECLARED(TColorDepth)}
ImageList1.Handle := ImageList_Create(ImageList1.Width,
ImageList1.Height, ILC_COLOR32 or ILC_MASK, 1, 1);
{$IFEND}
IconHandle := Icon.Handle;
if IconHandle = 0 then IconHandle := Application.Icon.Handle;
ImageList_AddIcon(ImageList1.Handle, IconHandle);
end;
R := GetSysIconRect;
ImageList1.Draw(Canvas, R.Left, R.Top, 0);
end;
If you’re wondering, the $IF is to support Delphi 2007, and the call to ImageList_AddIcon directly is to avoid an entirely unnecessary exception liable to be raised by TImageList.AddIcon.
With the icon done, the last piece in the jigsaw is to reinstate the form’s caption. Now the advice on MSDN is to use the DrawThemeTextEx API function with a ‘glow size’ of 15, so we’ll follow that. Nonetheless, you need to watch out for the fact that if the form is maximised and running on Vista, it (a) shouldn’t have any glow effect and (b) should be drawn white.
Moreover, I’ve found that the correct font to use is not that found from any theming API, but the old-style GetSysColor and SystemParametersInfo functions — basically, even if you manage to figure out valid parameter values for the theming API equivalents, you’ll only be getting back default values that the user may have overridden in the Control Panel.
Putting this all together, I have come up with the following utility function. It’s a bit lengthy partly because it is intended to be properly generic, though much of it is needed even for the simplest case:
{$IF not Declared(UnicodeString)}
type
UnicodeString = WideString;
{$IFEND}
procedure DrawGlassCaption(Form: TForm; const Text: UnicodeString;
Color: TColor; var R: TRect; HorzAlignment: TAlignment = taLeftJustify;
VertAlignment: TTextLayout = tlCenter; ShowAccel: Boolean = False); overload;
const
BasicFormat = DT_SINGLELINE or DT_END_ELLIPSIS;
HorzFormat: array[TAlignment] of UINT = (DT_LEFT, DT_RIGHT, DT_CENTER);
VertFormat: array[TTextLayout] of UINT = (DT_TOP, DT_VCENTER, DT_BOTTOM);
AccelFormat: array[Boolean] of UINT = (DT_NOPREFIX, 0);
var
DTTOpts: TDTTOpts; { This routine doesn't use GetThemeSysFont and }
Element: TThemedWindow; { GetThemeSysColor because they just return theme }
IsVistaAndMaximized: Boolean; { defaults that will be overridden by the 'traditional' }
NCM: TNonClientMetrics; { settings as and when the latter are set by the user. }
ThemeData: HTHEME;
procedure DoTextOut;
begin
with ThemeServices.GetElementDetails(Element) do
DrawThemeTextEx(ThemeData, Form.Canvas.Handle, Part, State, PWideChar(Text),
Length(Text), BasicFormat or AccelFormat[ShowAccel] or
HorzFormat[HorzAlignment] or VertFormat[VertAlignment], @R, DTTOpts);
end;
begin
if Color = clNone then Exit;
IsVistaAndMaximized := (Form.WindowState = wsMaximized) and
(Win32MajorVersion = 6) and (Win32MinorVersion = 0);
ThemeData := OpenThemeData(0, 'CompositedWindow::Window');
Assert(ThemeData <> 0, SysErrorMessage(GetLastError));
try
NCM.cbSize := SizeOf(NCM);
if SystemParametersInfo(SPI_GETNONCLIENTMETRICS, 0, @NCM, 0) then
if Form.BorderStyle in [bsToolWindow, bsSizeToolWin] then
Form.Canvas.Font.Handle := CreateFontIndirect(NCM.lfSmCaptionFont)
else
Form.Canvas.Font.Handle := CreateFontIndirect(NCM.lfCaptionFont);
ZeroMemory(@DTTOpts, SizeOf(DTTOpts));
DTTOpts.dwSize := SizeOf(DTTOpts);
DTTOpts.dwFlags := DTT_COMPOSITED or DTT_TEXTCOLOR;
if Color <> clDefault then
DTTOpts.crText := ColorToRGB(Color)
else if IsVistaAndMaximized then
DTTOpts.dwFlags := DTTOpts.dwFlags and not DTT_TEXTCOLOR
else if Form.Active then
DTTOpts.crText := GetSysColor(COLOR_CAPTIONTEXT)
else
DTTOpts.crText := GetSysColor(COLOR_INACTIVECAPTIONTEXT);
if not IsVistaAndMaximized then
begin
DTTOpts.dwFlags := DTTOpts.dwFlags or DTT_GLOWSIZE;
DTTOpts.iGlowSize := 15;
end;
if Form.WindowState = wsMaximized then
if Form.Active then
Element := twMaxCaptionActive
else
Element := twMaxCaptionInactive
else if Form.BorderStyle in [bsToolWindow, bsSizeToolWin] then
if Form.Active then
Element := twSmallCaptionActive
else
Element := twSmallCaptionInactive
else
if Form.Active then
Element := twCaptionActive
else
Element := twCaptionInactive;
DoTextOut;
if IsVistaAndMaximized then DoTextOut;
finally
CloseThemeData(ThemeData);
end;
end;
procedure DrawGlassCaption(Form: TForm; var R: TRect;
HorzAlignment: TAlignment = taLeftJustify; VertAlignment: TTextLayout = tlCenter;
ShowAccel: Boolean = False); overload;
begin
DrawGlassCaption(Form, Form.Caption, clDefault, R,
HorzAlignment, VertAlignment, ShowAccel);
end;
To get this to compile, you’ll need to add both UxTheme and Themes to a uses clause, together with StdCtrls (the latter for the TTextLayout enumerated type). Just to make things a touch easier, define the following utility function too:
function GetDwmBorderIconsRect(Form: TForm): TRect;
begin
if DwmGetWindowAttribute(Form.Handle, DWMWA_CAPTION_BUTTON_BOUNDS, @Result,
SizeOf(Result)) <> S_OK then SetRectEmpty(Result);
end;
This all done, go back to the OnPaint handler we added earlier, and append to it the following code:
R.Left := R.Right + FWndFrameSize - 3;
if WindowState = wsMaximized then
R.Top := FWndFrameSize
else
R.Top := 0;
R.Right := GetDwmBorderIconsRect(Self).Left - FWndFrameSize - 1;
R.Bottom := GlassFrame.Top;
DrawGlassCaption(Self, R);
end;
Lastly, invalidate the title bar when the form is either activated or deactivated — we need to do this since the font colour used in non-active title bars is typically different (if only slightly) from that used in active ones:
procedure TForm1.WMActivate(var Message: TWMActivate);
begin
inherited;
InvalidateTitleBar;
end;
If you want to be a perfectionist, you can also handle CM_TEXTCHANGED to make sure the custom title bar gets updated when the form’s Caption property is changed:
procedure TForm1.CMTextChanged(var Message: TMessage);
begin
inherited;
InvalidateTitleBar;
end;
Run the application again, and you should find this has all produced a reasonable enough result. Admittedly, and despite the advice on MSDN to use it, DrawThemeTextEx doesn’t paint the caption exactly like the standard frame does — if you look closely, you’ll see the latter uses a solid rectangular background ‘glow’ rather than a character-outlining one. Nonetheless, the DrawThemeTextEx approach seems to be what the custom frames of Microsoft applications use — check out (say) Word 2007 or Windows Live Movie Maker to see what I mean.
That said, the whole point of this exercise was not simply to replicate the standard frame as best we could; rather, it was to add things to it. So, back in the form designer, drop a TSpeedButton onto the form, and place it near the top of the client area:
Add a handler for the button’s OnClick handler if you wish, then run the application once more; if things are all well, it should look like this:
Result!
[Note: I amended this article on 30/1/11 to fix a ghost buttons issue when running on Windows 7 64 bit.]