In putting some finishing polish on the GUI console of my Smoketest framework (see, I am working on it!) I ran into something that I remember I once knew and – seemingly – had forgotten, about mixing API level access to a GDI device context with the high-level TCanvas access that is conveniently provided for us.
For a lot of basic drawing work, TCanvas is plenty good enough, but there are certain things that you cannot do with it, at least not directly.
One of these is – or rather, used to be :
- Calculating the width of some text when rendered into some
rectangle constrained to a maximum width, using ellipsis to fit if necessary.
And other more complex text measurements other than just “how wide” or “how high” is this line of text.
I say “used to be“, because you can now achieve this using TCanvas, as of at least Delphi 2010 (possibly much earlier – I haven’t checked), courtesy of additional overloads of the TextRect() method. But previously to perform this calculation required that a call be made to the Windows GDI DrawTextEx() function. Since Smoketest aims to remain compatible – at the core framework implementation level – with Delphi 7, this rules out the use of the new overload and demands the use of the Windows API.
In any case, the point of this post is about the general problem of mixing certain GDI and TCanvas operations, not about solving that particular, specific problem. I only use it as an example.
So, first of all, let’s see the symptoms of the problem.
The issue arose in my case with a simple calculation of the width of a label which was then used to determine the left edge position of a following piece of text. This is most clearly seen in the “Inspections” output of the self-test suite:
The problem is the inconsistent spacing between the labels and the following value. This spacing should be consistent and indeed, in the code, it is a simple absolute pixel adjustment applied to the TRect subsequently to be used for text value:
aCanvas.Font.Style := [fsBold];
DrawTextEx(dc, PChar(LabelText), Length(LabelText), LabelRect,
DT_CALCRECT or DT_WORD_ELLIPSIS or DT_NOPREFIX, NIL);
TextRect.Left := LabelRect.Right + 4;
Do you see the problem ? Don’t worry, it isn’t immediately obvious.
The dc parameter passed to DrawTextEx() is the HDC (device context handle) of the canvas we are performing the calculations for. Since there are a large number of calculations being performed in this canvas, this handle is obtained and stored in a local variable for re-use, to avoid having to repeatedly use the aCanvas.Handle accessor.
var
dc: HDC;
begin
:
dc := aCanvas.Handle;
:
end;
This however is the key to the problem. Or at least a partner in the crime. The accomplice is the fact that the label text we are calculating the width of is rendered in bold, requiring a change in the font properties.
If we look at the implementation of TCanvas we find that this installed it’s own listener (handler if you prefer) on the OnChanged event of it’s Font object. Changing the properties of the Font results in the following:
procedure TCanvas.FontChanged(AFont: TObject);
begin
if csFontValid in State then
begin
Exclude(State, csFontValid);
SelectObject(FHandle, StockFont);
end;
end;
Because the Font has changed, the canvas removes the csFontValid flag from it’s internal state. But it also selects the StockFont into the device context. i.e. having changed the properties of the Font, the device context is now using a completely different Font (unless the new font properties happen by coincidence to reflect those of StockFont itself of course)!
If we were only using TCanvas methods, this would not be a problem.
When we next access the TCanvas.Handle property, the canvas checks the internal state to make sure that everything is good to go. If not, it takes whatever steps are necessary to rectify that.
In the case of the Font properties having changed, the lack of csFontValid in the state will cause the canvas to create the required GDI font object and select it into the device context for us.
But because I am specifically avoiding using that accessor, the device context is left with StockFont selected, which is absolutely not the font I expected, and in this case has quite different characteristics. Which mean that when I then start measuring text, I get results that are not correct for the font I will subsequently use to actually render the text (the actual drawing code is able to use TCanvas, without having to mix-in GDI calls, so the problem only arises in calculations for the layout which that drawing code then relies on).
The solution therefore is simply to ensure that after changing the Font properties, that I “touch” the TCanvas Handle property in order to ensure that the internal state of the device context is brought up-to-update:
aCanvas.Font.Style := [fsBold];
dc := aCanvas.Handle; // Cause the changed font to be selected into the canvas
:
// Proceed to use dc ...
With all of the relevant areas of my layout code addressed, I now get the result I was aiming for. Nice, consistent spacing:
It’s worth noting that the problem isn’t that our cached HDC is no longer “valid”. The value of Handle (dc) itself doesn’t actually change since the device context doesn’t need to be re-created, we simply need the side-effects that come from simply accessing the handle.
Also worth noting is that this behaviour is also seen with the other GDI objects exposed through wrapper objects on the canvas – the Pen and the Brush.
The final thing to mention is that the tempting looking TCanvas.Refresh() method is a bit of a red-herring, since it does the exact opposite of what you might expect – it DE-selects all the current Pen, Brush and Font objects and selects the GDI StockPen, StockBrush and StockFont, marking all these objects as invalid so that they ALL get reselected when Handle is accessed.
Refresh() should perhaps really be called RefreshWhenINextAccessHandle().
Footnote
There is a mechanism in TCanvas to enable you to explicitly request that a particular graphic object be selected into the device context – RequiredState(). This is used all over the place in TCanvas itself to ensure that the objects required for any particular operation are up-to-date in the device context.
Unfortunately this mechanism is a protected implementation detail, so if a well-behaved consumer of the class needs to trigger this mechanism, all they can do is rely on side-effects. (When I say well-behaved, I mean if you don’t want to have to fiddle around with “cracker” classes or helpers etc to gain access to the protected members through a side-door)
-
Removing the local variable and always using Canvas.Handle is the way to go. The rule you need to follow when working with Canvas is never cache the DC.
-
Comments are now closed.




DelphiFeeds
8 comments