Skip to content

TStyledToolbar: ButtonWidth/ButtonHeight scaled twice on a Per-Monitor DPI change (window moved between monitors with different scaling) #110

Description

@lancerasmussen

Version: 4.2.2 (also present in current master), Delphi 13, VCL application with Per-Monitor v2 DPI awareness.

Symptom

A TStyledToolbar (Align = alTop, ButtonWidth = 91, ButtonHeight = 25, ShowCaptions = True) with a few TStyledToolButtons. Run the form on a 175% (or 150%) monitor and drag it to a 100% monitor and back - or change the display scale in Windows Settings while it runs.

  • After the first DPI change the buttons still look right, but ButtonWidth / ButtonHeight now read 51 x 14 instead of the expected 91 x 25 (the ratio applied twice: 159 x 0.571^2).
  • After the second change (back to 175%) they read 487 x 135 and the buttons are drawn 279 x 77 inside a 44 px bar. Going the other way the captions are clipped to a single letter on a squashed row.
  • Any later ResizeButtons (a SetBounds, an AutoSize toggle, the next DPI change) applies the wrong values to every button.

Measured with a log of ButtonWidth/ButtonHeight and each button's bounds around OnAfterMonitorDpiChanged:

step ButtonWidth x ButtonHeight actual buttons
175% steady state 159 x 44 159 x 44 (correct)
after 175% -> 100% 51 x 14 91 x 25 (looks right, poisoned)
after 100% -> 175% 487 x 135 279 x 77

Cause

FRescaling is raised only inside TStyledToolbar.ChangeScale. On a Per-Monitor DPI change (isDpiChange = True) the VCL scales the children first: TWinControl.ScaleForPPI runs ScaleControlsForDpi (each TStyledToolButton.ScaleForPPI -> ChangeScale -> SetBounds) and only then calls inherited ScaleForPPI, which reaches the toolbar's own ChangeScale.

So every button scales while FRescaling is still False; TStyledToolButton.SetBounds therefore stores its already-scaled width/height into FToolBar.FButtonWidth / FButtonHeight (and calls ResizeButtons), and afterwards TStyledToolbar.ChangeScale(M, D, isDpiChange) does FButtonWidth := MulDiv(FButtonWidth, M, D) on that value a second time.

The guard works on the legacy ChangeScale(M, D) / ScaleBy path because there TWinControl.ChangeScale scales the children inside the parent's ChangeScale (if not isDpiChange then ScaleControls(M, D)), i.e. while the guard is up. The Per-Monitor path reverses the order.

Suggested fix

Raise the guard around the whole DPI pass by overriding ScaleForPPI in TStyledToolbar:

{$IFDEF D10_1+}
procedure TStyledToolbar.ScaleForPPI(NewPPI: Integer);
begin
  // Per-Monitor DPI: the VCL scales the child buttons BEFORE calling our
  // ChangeScale, so FRescaling must already be up while they scale, or
  // TStyledToolButton.SetBounds writes their scaled size back into
  // FButtonWidth/FButtonHeight and ChangeScale then scales it again.
  FRescaling := True;
  try
    inherited;
  finally
    FRescaling := False;
  end;
end;
{$ENDIF}

(override next to the existing ChangeScale overrides; both ChangeScale bodies can stay as they are - with the guard up, FButtonWidth/FButtonHeight get their single MulDiv and nothing else touches them.)

We are working around it from the outside with an interposer that, after every ScaleForPPI, resets ButtonWidth/ButtonHeight to the design values x the control's ScaleFactor and calls ResizeButtons; with that in place the buttons are 159 x 44 at 175% and 91 x 25 at 100% on every crossing, repeatedly. CurrentPPI is not usable as a "before" reference inside the override, by the way - the form has already stamped the new PPI onto its children when the toolbar's ScaleForPPI runs.

Thanks for the library - happy to test a fix.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions