System Font Handling in Delphi Forms

Delphi forms designed with a hardcoded font look wrong the moment they run on a machine with different DPI or a different default UI font, and the usual fixes people reach for only patch the symptom. This post digs deeper into the system-font problem than my earlier posts on the topic, covering where the font actually comes from, how to apply it correctly at runtime, and what breaks when you get it wrong.

The problem

I’ve written about this before in pieces — one post on reading the system font, another on getting it right specifically for input dialogs — but the topic deserves a single, complete treatment, because the failure mode is so common and so avoidable. You design a form in the IDE at your own machine’s DPI and font settings, ship it, and on a client’s machine it renders with clipped labels, mismatched control heights, or a font that looks noticeably different from every native Windows dialog around it. The form isn’t broken; it’s just not asking Windows what font to use, so it’s stuck with whatever the designer saved.

Where the “system font” actually comes from

Windows exposes the current UI font through SystemParametersInfo with the SPI_GETNONCLIENTMETRICS flag, which fills a NONCLIENTMETRICS structure. The field you want is lfMessageFont — the font Windows itself uses for dialog text and message boxes. This is the correct source, not GetStockObject(DEFAULT_GUI_FONT), which returns a stable but outdated fallback font on modern Windows and won’t reflect the user’s actual display settings or DPI.

function GetSystemMessageFont: TFont;
var
  Metrics: TNonClientMetrics;
begin
  Metrics.cbSize := SizeOf(Metrics);
  if not SystemParametersInfo(SPI_GETNONCLIENTMETRICS, SizeOf(Metrics),
    @Metrics, 0) then
    RaiseLastOSError;

  Result := TFont.Create;
  Result.Handle := CreateFontIndirect(Metrics.lfMessageFont);
end;

Note that Result.Handle := CreateFontIndirect(...) hands GDI ownership to the TFont; you don’t need to separately call DeleteObject on the handle, TFont manages it. What you do need to manage is the returned TFont object itself — this function returns an owned instance the caller must free.

Applying it to a form correctly

The naive approach sets Form.Font once in FormCreate and calls it done:

// Works for the form itself, but does nothing for child controls
// that have their own explicit Font settings
procedure TMyForm.FormCreate(Sender: TObject);
var
  SysFont: TFont;
begin
  SysFont := GetSystemMessageFont;
  try
    Font := SysFont;
  finally
    SysFont.Free;
  end;
end;

This works for controls that inherit their parent’s font by default, which is most of them — but any control where the form designer explicitly set a Font property (which the IDE does more often than people expect, especially after copy-pasting controls between forms) won’t pick up the change, because an explicit font assignment breaks the inheritance chain. To catch those, walk the control tree and reset any child whose font doesn’t match a “default” marker, or more reliably, avoid setting explicit fonts on individual controls in the designer in the first place and rely on ParentFont := True, which is the property actually meant for this.

// Recursively force ParentFont so no child control has an orphaned
// explicit font setting left over from the designer
procedure ForceParentFont(Ctrl: TWinControl);
var
  I: Integer;
  Child: TControl;
begin
  for I := 0 to Ctrl.ControlCount - 1 do
  begin
    Child := Ctrl.Controls[I];
    if Child is TControl then
      TControl(Child).Perform(CM_PARENTFONTCHANGED, 1, 0);
    if Child is TWinControl then
      ForceParentFont(TWinControl(Child));
  end;
end;

Sending CM_PARENTFONTCHANGED rather than directly touching a ParentFont property is deliberate — not every TControl descendant exposes ParentFont at the same level, but the message is handled uniformly by the VCL’s font-inheritance machinery, so it’s the more robust way to force the reset across a mixed control tree.

DPI scaling makes this worse, not optional

Everything above solves the “wrong font” problem. It doesn’t solve the “wrong size” problem, which shows up on high-DPI displays even with the correct system font applied. NONCLIENTMETRICS.lfMessageFont.lfHeight already comes back DPI-adjusted for the monitor the API call happens on, which helps, but if your form was designed at 96 DPI and the IDE recorded fixed pixel positions and sizes for controls, those positions won’t scale to match the new font metrics on their own.

The practical fix, if you’re not already on a Delphi version with proper per-monitor DPI awareness in the VCL, is to set the form’s Scaled property and let TForm.ChangeScale do the arithmetic, rather than hand-rolling scale factors:

// Call after applying the system font, so ChangeScale has
// the correct new metrics to scale against
procedure TMyForm.ApplySystemFontAndScale;
var
  SysFont: TFont;
  OldHeight, NewHeight: Integer;
begin
  OldHeight := Font.Height;
  SysFont := GetSystemMessageFont;
  try
    Font := SysFont;
  finally
    SysFont.Free;
  end;
  NewHeight := Font.Height;

  if NewHeight <> OldHeight then
    ScaleBy(NewHeight, OldHeight);
end;

ScaleBy is an older API than the DPI-aware scaling introduced in later Delphi versions, and it’s blunt — it scales every control’s bounds by the same ratio, which occasionally overshoots for controls with fixed minimum sizes like buttons. It’s still better than doing nothing, and for VCL applications that need to support older Delphi versions without full manifest-based DPI awareness, it’s the most reliable option I’ve found.

Input dialogs specifically

The input-dialog case deserves its own mention because InputQuery and similar VCL helper functions construct their dialog forms internally, which means you can’t reach in and set the font on the form instance the way you can for your own forms. The workaround I use is to hook Application.OnMessage or, more directly, subclass the dialog via TCustomForm creation notification, catching the form as it’s created and applying the font before it’s shown:

// Apply the system font to every TForm as it's created, catching
// VCL-internal dialogs like InputQuery along with our own forms
procedure TMyForm.ApplicationCreateForm(Sender: TObject; var Form: TForm);
var
  SysFont: TFont;
begin
  SysFont := GetSystemMessageFont;
  try
    Form.Font := SysFont;
  finally
    SysFont.Free;
  end;
end;

This needs to be wired up before any forms are created — typically in the project’s .dpr file, right after Application.Initialize, using Application.OnCreateForm. It’s a broader hook than most people want for a single input box, but it’s the only reliable way to touch VCL-internal dialog forms without reimplementing them.

Conclusion

Getting fonts right on Delphi forms is less about any single API call and more about consistency — pull the font from SystemParametersInfo, let ParentFont do the propagation instead of fighting the designer’s explicit assignments, and account for DPI scaling as part of the same step rather than as an afterthought.

Leave a Reply

Your email address will not be published. Required fields are marked *