Jump to content
PiedSoftware

canvas.TextWidth not working in Win 11

Recommended Posts

Posted (edited)

Hi

 

I want to be able to expand a small modal dialog that offers the user just a combo box to the minimum size able to show all values in the dropdown list of a TComboBox. I wrote the following code to calculate the size required by the combobox:

function MaxTextWidth(canvas: TCanvas; list: TStrings): integer;
var s: string;
begin
  result := 0;
  for s in list do
    result := Max(result, canvas.TextWidth(s));
end;


procedure SetComboWidth(Combo: TComboBox);
var
  width: integer;
begin
  width :=  MaxTextWidth(Combo.Canvas, Combo.Items) + 26; // Include arrow button
  if Combo.Style = csDropDown then
    inc(width, 8);
  Combo.Width := width;
end{ SetComboWidth};

It had the desired effect on my machine, a NUC box still on good ole' Windows 10. The users however on Windows 11 find that the calculated with is not enough. The longest values were clipped. I don't know if the OS is the problem. My machine doesn't let me update.

Does this problem look familiar to anyone?

 

-- Mark
 

Edited by PiedSoftware

Share this post


Link to post

It is always a good idea to not hard code any widths related to the visual apsects of controls. They might change when the OS changes version. 


You have to get the width of the non-client area of a ComboBox so that you can add it your calculated MaxTextWidth to get a proper control width. But... ComboBox does not play nicely and getting its ClientWidth is not possible with functions like GetClientRect thus ComboBox does not have a properly calculated ClientWidth for it so you will have to make another plan to get it.

 

There is a Win32 function called GetComboBoxInfo. It returns a record with details about the combobox like size of the control, the size and position of the button and size of position of the edit control in it.

 

You will use the width of the edit control and deduct that from the width of the ComboBox and there you have the non-client area width. Just add that to the MaxTextWidth and you have the size the ComboBox should be.

 

Remember to do these calculations and setting of the ComboBox size again when the Windows theme or settings change or the font changes.

 

 

  • Like 2

Share this post


Link to post

Another possible issue is that when you use a control's canvas outside the paint event, the font of that canvas may not be set properly.  It's good practice to set it explicitly with something like:

 

Combo.Canvas.Font := Combo.Font;

 

before calling TextWidth.  Also note that you may have to scale any hard coded pixel values based on dpi.

Share this post


Link to post
On 8/20/2025 at 8:24 PM, MarkShark said:

Another possible issue is that when you use a control's canvas outside the paint event, the font of that canvas may not be set properly.  It's good practice to set it explicitly with something like:

 

Combo.Canvas.Font := Combo.Font;

 

before calling TextWidth.  Also note that you may have to scale any hard coded pixel values based on dpi.


I tried this, which is similar to what you said, and it seemed to make no difference:

 

procedure SetComboWidth(Combo: TComboBox);
var
  width: integer;
begin
  Combo.Canvas.Font := Combo.Font;  //<<<<<
  width :=  MaxTextWidth(Combo.Canvas, Combo.Items) + 26; // Include arrow button
  if Combo.Style = csDropDown then
    inc(width, 8);
  Combo.Width := width;
end{ SetComboWidth};

 

Share this post


Link to post

You didn't account for any font scaling. So if the user has a high dpi screen and a font scaling larger than 100% the your 26 and 8 won't be right. 

 

Then again, because you didn't provide full code and details we are all guessing a bit. 

  • Like 1

Share this post


Link to post

For scaling pixel values, I use this helper function:

{$IFNDEF CPUX86 } {$IFNDEF CPUX64 } {$DEFINE PUREPASCAL } {$ENDIF } {$ENDIF }

CONST DefDPI = WinAPI.Windows.USER_DEFAULT_SCREEN_DPI;

{$IFDEF PUREPASCAL }
FUNCTION Scale(Pixels,OldDPI,NewDPI : NativeInt) : NativeInt;
  BEGIN
    IF OldDPI<>NewDPI THEN BEGIN
      IF OldDPI<>DefDPI THEN Pixels:=MulDiv(Pixels,DefDPI,OldDPI);
      IF NewDPI<>DefDPI THEN Pixels:=MulDiv(Pixels,NewDPI,DefDPI)
    END;
    Result:=Pixels
  END;
{$ELSEIF DEFINED(CPUX64) }
FUNCTION Scale(Pixels{RCX},OldDPI{RDX},NewDPI{R8} : NativeInt) : NativeInt; ASSEMBLER;
  ASM
                MOV     RAX,Pixels
                CMP     OldDPI,NewDPI
                JE      @OUT
                MOV     ECX,DefDPI              // Also clears upper 32 bits of RCX
                MOV     R9,OldDPI
                CMP     OldDPI,RCX
                JE      @SKIP1
                IMUL    RAX,RCX
                IDIV    R9
        @SKIP1: CMP     NewDPI,DefDPI
                JE      @OUT
                IMUL    NewDPI
                IDIV    RCX
        @OUT:
  END;
{$ELSEIF DEFINED(CPUX86) }
FUNCTION Scale(Pixels{EAX},OldDPI{EDX},NewDPI{ECX} : NativeInt) : NativeInt; ASSEMBLER;
  ASM
                CMP     OldDPI,NewDPI
                JE      @OUT
                CMP     OldDPI,DefDPI
                JE      @SKIP1
                PUSH    NewDPI
                MOV     ECX,OldDPI
                IMUL    EAX,DefDPI
                IDIV    ECX
                POP     NewDPI
        @SKIP1: CMP     ECX,DefDPI
                JE      @OUT
                IMUL    NewDPI
                MOV     ECX,DefDPI
                IDIV    ECX
        @OUT:
  END;
{$ENDIF }

And these CLASS HELPERs:

CLASS FUNCTION TMonitorHelper.Scale(Pixels,OldDPI,NewDPI : NativeInt) : NativeInt;
  BEGIN
    Result:=HeartWare.VCL.Funcs.Scale(Pixels,OldDPI,NewDPI) // The base function from previous code block //
  END;

FUNCTION TMonitorHelper.Scale(Value : Integer) : Integer;
  BEGIN
    Result:=Scale(Value,Screen.DefaultPixelsPerInch,PixelsPerInch)
  END;

FUNCTION TFormHelper.Scale(Pixels,OldDPI,NewDPI : NativeInt) : NativeInt;
  BEGIN
    Result:=Monitor.Scale(Pixels,OldDPI,NewDPI)
  END;

FUNCTION TFormHelper.Scale(Value : Integer) : Integer;
  BEGIN
    Result:=Monitor.Scale(Value)
  END;

Then I just use Form1.Scale(8) to get a 96dpi 8-pixel scaled value at whatever scale factor the form (or rather, the monitor upon which the form is located) currently is at.
 

Share this post


Link to post

 

2 hours ago, HeartWare said:

I use this helper function:

Without having timed this my guess is that your assembler version would be faster if you simply got rid of all the "optimizations" and just did the IMUL+IDIV.

 

Also, newer versions of Delphi already have all this stuff in TControl.

Share this post


Link to post
4 hours ago, HeartWare said:

Then I just use Form1.Scale(8) to get a 96dpi 8-pixel scaled value at whatever scale factor the form (or rather, the monitor upon which the form is located) currently is at.

You definitely don't want to be doing this when the VCL provides exactly this functionality.

Share this post


Link to post
19 hours ago, David Heffernan said:

You definitely don't want to be doing this when the VCL provides exactly this functionality.

Okay - then I'll just do it the way you described... Oh, Wait! You didn't!

 

Guess that means I'll continue doing it the way I've always done it...

Share this post


Link to post
21 hours ago, Anders Melander said:

Also, newer versions of Delphi already have all this stuff in TControl.

Didn't know of the "ScaleValue" but it doesn't say from the description that it always scales the value as given from 96dpi to the current scale. It seems like it has the usual weakness of being an accumulated (floating point) scaling factor, which means that it'll slowly go out of sync if you move the form back and forth between various DPI settings. I prefer my solution (or David's if he ever gives it and it is different from this (IMO faulty) implementation).
 

From what I can deduce from the source, it also scales from the control's original scaling factor being 1, so if the .DFM file is saved in 150% it scales out from that size (144dpi) and not from 100% (96dpi). I may be wrong on this, but that's what it seems to me from the source code.

Edited by HeartWare

Share this post


Link to post
21 hours ago, Anders Melander said:

 

Without having timed this my guess is that your assembler version would be faster if you simply got rid of all the "optimizations" and just did the IMUL+IDIV.

 

What "optimizations" are you referring to? My test for unnecessary DIV/MUL when calculating to/from 96dpi?

 

I always go via 96dpi (in the generalized function) as this will ensure that the scaled value is predictable and always the same, regardless of what the current DPI is (I get a more precise and consistent value if I go 150->96->200 than if I go from 150->200 directly - assuming that the 150 was a calculated value from 96 originally. Maybe not with 150/200 exactly, but other combinations can lead to rounding errors)

Share this post


Link to post

I'm not disagreeing (don't have the knowledge to do that), but do you really think that a MUL/DIV sequence takes less time than a JMP? I know that the newer processors have all this read-ahead and stuff, but (and I honestly don't know) does it really slow it down that much to flush the read-ahead cache and the pipeline?

 

I'm not contradicting you in any way - just surprised (but my assembly knowledge is primarily back from the Pentium age, so I'm not fluent in all the new optimizations done on-chip in modern CPUs).

Share this post


Link to post
10 hours ago, HeartWare said:

Didn't know of the "ScaleValue" but it doesn't say from the description that it always scales the value as given from 96dpi to the current scale.

Yes, ScaleValue is it. 

Share this post


Link to post
14 hours ago, HeartWare said:

but do you really think that a MUL/DIV sequence takes less time than a JMP?

As I said, I haven't timed it - and it depends. MUL is dirt cheap. DIV is a little more expensive but not nearly as bad as it once was. Branches are almost always bad but if OldDPI=NewDPI most of the time then the jump out is obviously worth it.

 

I honestly wouldn't bother doing this in asm in the first place. Unless you are using it to scale graphics in real-time, or something like that, then it's a completely wasted effort. Also remember that pascal code can be inlined (avoiding the call overhead), while asm functions can't. I would replace the MulDiv with a simple expression; You likely don't need the 64-bit and overflow handling baked into MulDiv (which is a Windows API function, btw - not cheap).

Share this post


Link to post
On 9/8/2025 at 10:13 PM, Anders Melander said:

Also, newer versions of Delphi already have all this stuff in TControl.

Which version introduced that? It sounds useful. I am on 10.4, but I don't think we are likely to upgrade. The product is going to be replaced by a web app in C#

Edited by PiedSoftware

Share this post


Link to post
10 hours ago, Anders Melander said:

Unless you are using it to scale graphics in real-time, or something like that, then it's a completely wasted effort. Also remember that pascal code can be inlined (avoiding the call overhead), while asm functions can't. I would replace the MulDiv with a simple expression; You likely don't need the 64-bit and overflow handling baked into MulDiv (which is a Windows API function, btw - not cheap).

There's absolutely no scenario where the performance of these scaling functions could be important. It's just blind asm for the sake of it, premature optimisation 101

Share this post


Link to post

Create an account or sign in to comment

You need to be a member in order to leave a comment

Create an account

Sign up for a new account in our community. It's easy!

Register a new account

Sign in

Already have an account? Sign in here.

Sign In Now

×