Skip to content

[NET 11 API Proposal] Application.SystemTextSize - runtime awareness of the Accessibility text-scale setting #14583

@KlausLoeffelmann

Description

@KlausLoeffelmann

Background and motivation

This proposal completes an existing partial implementation. WinForms
already reads the Windows Accessibility text-size setting today — but only
once, only for the default font, and without surfacing the value
or any change notification to developers.

ScaleHelper.ScaleToSystemTextSize(Font?) (source) reads
HKEY_CURRENT_USER\Software\Microsoft\AccessibilityTextScaleFactor
(REG_DWORD, clamped 100–225) and returns the font scaled by
TextScaleFactor / 100, or null if the value is 100, the font
IsSystemFont, or the OS is < Windows 10 1507. It is documented in source
as the Settings → Display → Make Text Bigger setting.

The three gaps this proposal fills:

  1. The text-scale factor is never surfaced to application code.
  2. It is read once, at default-font construction, and the app never
    reacts
    when the user changes the setting at runtime.
  3. There is no notification mechanism — neither process-wide nor per
    Form.

Mandatory disambiguation: three different sizing "knobs"

Windows surfaces three independent sizing mechanisms that reviewers will
conflate (the Settings UI even shows two of them on one page: System
→ Display
has a Custom scaling 100–500% box and a
separate Text size link).

# Knob Where Mechanism Already handled?
1 Display / Custom scaling (100–500%) System → Display → Scale DPI; WM_DPICHANGED, Control.DpiChanged* Yes — via HighDpiMode and the DPI events. NOT this proposal.
2 Accessibility → Text size (100–225%) Accessibility → Text size Registry HKCU\Software\Microsoft\Accessibility\TextScaleFactor; WinRT UISettings.TextScaleFactor / TextScaleFactorChanged No runtime awareness today. THIS is the target. Independent of DPI.
3 Legacy pre-Win10 per-element text sizing (title bars/menus, etc.) (removed in Windows 10 1703) (removed; replaced by the Accessibility slider) N/A — mentioned only to close the loop.

Application.SystemTextSize reflects knob #2 only, and is orthogonal
to DPI
(knob #1). Knob #3 is mentioned for completeness only.

Why this matters across controls (evidence)

Multiple text-measuring controls derive item/row/tile extents from a Font
that today reacts to the text-size setting once at startup (via
ScaleToSystemTextSize on the default font) and never again. Any single
cached height scalar is wrong the moment the user changes text size at
runtime — which is the concrete argument for runtime awareness.

Control Per-item measure hatch? Native height API wrapped? Gap
ListBox / ComboBox Yes (MeasureItem + OwnerDrawVariable) ItemHeight exists Legacy default-base calculation plus the missing runtime text-scale reaction. Addressable from user code given runtime awareness.
TreeView No (MeasureItem absent) Only a single uniform native item height Worst-positioned for a managed fix. Addressed by a companion TreeView.NodeLeading proposal that depends on this one.
ListView (Details) No (MeasureItem absent), no native row-height message Row height is comctl-computed from SmallImageList + control font Per-item/subitem fonts are honored via NM_CUSTOMDRAW (CDRF_NEWFONT). All userland workarounds (phantom SmallImageList; LVS_OWNERDRAWFIXED + one-shot reflected WM_MEASUREITEM; "inflate control font / shrink item fonts") have holes (header leak, set-once, exhaustive per-item font setting, or owner-draw-all). No clean complete userland solution exists.

The matrix above is the case for putting text-size reaction in the framework
rather than asking every app to roll its own. This proposal is the
foundation; the per-control follow-ups (starting with
TreeView.NodeLeading) build on it.

API Proposal

namespace System.Windows.Forms;

public sealed partial class Application
{
    /// <summary>
    ///  Gets the current Windows Accessibility text-scale factor
    ///  (Settings &#x2192; Accessibility &#x2192; Text size), as a multiplier
    ///  in the range 1.0&#x2013;2.25.
    /// </summary>
    /// <remarks>
    ///  Live getter &#x2014; re-reads the underlying system value on every
    ///  access; does not cache. This property is orthogonal to display DPI;
    ///  see <see cref="HighDpiMode"/> for the DPI-scaling story.
    /// </remarks>
    public static double GetSystemTextSize();

    /// <summary>
    ///  Gets or sets the application&#x2019;s mode for reacting to changes in
    ///  the Windows Accessibility text-scale setting.
    /// </summary>
    public static SystemTextSizeAwareness SystemTextSizeAwareness { get; }

    public static SetSystemTextSizeAwareness(SystemTextSizeAwareness awareness):

    /// <summary>
    ///  Occurs once per process when the Windows Accessibility text-scale
    ///  setting changes, while <see cref="SystemTextSizeAwareness"/> is
    ///  <see cref="SystemTextSizeAwareness.Notify"/>.
    /// </summary>
    public static event EventHandler? SystemTextSizeChanged;
}

/// <summary>
///  Specifies how the application reacts to changes in the Windows
///  Accessibility text-scale setting.
/// </summary>
public enum SystemTextSizeAwareness
{
    /// <summary>
    ///  Default. The application does not raise any text-scale-change
    ///  notification. Fully back-compatible behavior.
    /// </summary>
    Unaware = 0,

    /// <summary>
    ///  The application raises <see cref="Application.SystemTextSizeChanged"/>
    ///  and the per-<see cref="Form"/>
    ///  <see cref="Form.SystemTextSizeChanged"/> event when the setting
    ///  changes. The application decides how to respond.
    /// </summary>
    Notify = 1,
}

// Note: a future `Automatic` value is intentionally reserved (so a later
// addition is not a breaking enum-shape change) but is NOT part of this
// proposal &#x2014; see "Notify-only stance" and "Alternative designs" below.

public partial class Form
{
    /// <summary>
    ///  Occurs on this top-level <see cref="Form"/> when the Windows
    ///  Accessibility text-scale setting changes, while
    ///  <see cref="Application.SystemTextSizeAwareness"/> is
    ///  <see cref="SystemTextSizeAwareness.Notify"/>.
    /// </summary>
    public event EventHandler? SystemTextSizeChanged;

    /// <summary>
    ///  Raises the <see cref="SystemTextSizeChanged"/> event.
    /// </summary>
    protected virtual void OnSystemTextSizeChanged(EventArgs e);
}

API Usage

[STAThread]
static void Main()
{
    ApplicationConfiguration.Initialize();

    // Opt in to text-scale notifications.
    Application.SetSystemTextSizeAwareness(SystemTextSizeAwareness.Notify);

    // Process-wide: e.g., refresh app-level caches.
    Application.SystemTextSizeChanged += (s, e) =>
    {
        Trace.WriteLine($"Text scale changed to {Application.SystemTextSize:0.00}x");
    };

    Application.Run(new MainForm());
}

public class MainForm : Form
{
    protected override void OnSystemTextSizeChanged(EventArgs e)
    {
        base.OnSystemTextSizeChanged(e);

        // Per-Form reaction: rebuild item-height caches, re-measure custom
        // owner-drawn content, force a layout pass, etc.
        myListBox.Invalidate();
        Invalidate(true);
    }
}

Trigger architecture (the leak-critical part — get this exactly right)

A naive design — a static Application.SystemTextSizeChanged that
Forms and Controls subscribe to — leaks: a static event
strongly roots every subscriber, so no Form that subscribes is ever
collected. We avoid this by mirroring the DPI architecture, where
Control/Form learn of DPI changes from their own WndProc
(WM_DPICHANGEDOnDpiChanged → instance event via
EventHandlerList), not from a static subscription.

Verified facts that constrain the design (each checked against current
main):

  1. WM_SETTINGCHANGE is broadcast (HWND_BROADCAST) and delivered
    directly to top-level windows' WndProcs — it bypasses the
    thread message queue, so IMessageFilter does NOT see it. Do not use
    a message filter.
  2. The WinForms parking window is message-only
    (CreateParams.Parent = HWND_MESSAGE, see
    Application.ParkingWindow.cs). Message-only windows
    are excluded from broadcasts, so the parking window cannot
    receive WM_SETTINGCHANGE. Do not use it.
  3. Application has no MainForm. The main form lives on
    ApplicationContext.MainForm (per-run, per-UI-thread), can be null
    (tray/loop-only apps), and is mutable (splash → main handoff). It is
    not a reliable receiver. Do not anchor the app-level event to it.
  4. SystemEvents already owns a hidden top-level broadcast-receiving
    window
    (its .NET-BroadcastEventWindow) and exposes
    UserPreferenceChanged with a UserPreferenceCategory. The relevant
    value is Accessibility, which is coarse — it covers any
    accessibility change, so the handler must re-read TextScaleFactor and
    diff against a cached value to confirm the change was actually a
    text-scale change.

Resulting two-path design:

  • App-level (process-static): Application makes one internal,
    process-lifetime
    subscription to SystemEvents.UserPreferenceChanged,
    filters Category == Accessibility, re-reads TextScaleFactor, diffs
    against the cached value, and — if changed and
    SetSystemTextSizeAwareness(Notify) — raises the static
    Application.SystemTextSizeChanged. This is a single
    framework-static → framework-static link — it does not
    root any user object, so it is not the leak hazard. Reuses the
    existing hidden broadcast window; no new HWND is created.
  • Form-level (instance): each top-level Form handles
    WM_SETTINGCHANGE in its own WndProc (it is a broadcast —
    every top-level window receives it), re-reads + diffs, and raises its
    instance SystemTextSizeChanged via OnSystemTextSizeChanged.
    Forms do not subscribe to Application — lifetime = the
    window, no rooting.

Caveat that must be documented: there is no dedicated
WM_TEXTSCALECHANGED
message. Both paths must recognize a relevant
change by re-reading TextScaleFactor and comparing — not by the
message arrival alone.

Notify-only stance — no auto re-layout aids

This proposal deliberately ships notify-only. No automatic re-layout or
font-rescaling helpers. Reasons:

  • Text scale interacts with AutoScaleMode, anchored/docked layout, and
    explicitly-set fonts in app-specific ways. A generic "scale all fonts by
    the factor" helper breaks more than it fixes.
  • The reserved Automatic enum value is exactly where such behavior would
    live in a future proposal.
  • Notify gives the developer the factor and the event; they decide how to
    respond. This is consistent with the companion TreeView.NodeLeading
    proposal's conservative-default philosophy.

Alternative designs

  • bool vs. enum for the awareness switch. A bool (e.g.
    Application.SystemTextSizeAware = true) would be smaller surface today
    but would force a breaking boolenum change the moment we want
    to add Automatic. HighDpiMode already learned this lesson; reserve the
    enum slot now.
  • Application-only, no Form instance event. Possible — users
    could override WndProc themselves. Rejected for parity with the DPI
    event model (Form.DpiChanged), where the framework provides the
    instance event so app code never has to touch WndProc.
  • IMessageFilter for the app-level path. Rejected: see constraint Modernize FolderBrowserDialog #1
    above — WM_SETTINGCHANGE is a broadcast, bypasses the thread
    message queue, and IMessageFilter does not see it.
  • Raw int percent (100–225) instead of double (1.0–2.25).
    Listed as an open question; double is recommended because it composes
    directly as a multiplier and matches the WinRT UISettings.TextScaleFactor
    shape conceptually.
  • Caching SystemTextSize. Rejected: the underlying value is a system
    setting that changes at runtime
    . A cached property would create a window
    where a freshly-changed scale is not yet visible to callers querying
    during the change handler. The live getter is a single registry read
    — not a hot path.

Risks

  • Coarse UserPreferenceCategory.Accessibility. Fires for any
    accessibility change, not just text-scale. Mitigation: re-read +
    diff — only raise SystemTextSizeChanged when the cached value
    actually changed. (Same pattern as e.g. Screen.OnUserPreferenceChanged
    in this repo.)
  • WM_SETTINGCHANGE is broadcast to every top-level Form. Each Form
    does one registry read + diff per broadcast. This is cheap, but it is
    per-Form. Mitigation: opt-in via SystemTextSizeAwareness; when
    Unaware, the Form-level path is a no-op (or not wired at all).
  • Back-compat. Default is Unaware. No existing app changes behavior
    unless it opts in.
  • OS < Windows 10 1507. ScaleToSystemTextSize no-ops on these
    versions. The proposal must define SystemTextSize's value there
    (recommendation: returns 1.0 and no events fire). Listed as an open
    question.

Will this feature affect UI controls?

Yes — see the cross-control evidence matrix in the Background and
motivation
section. Direct framework impact in this proposal is limited
to Form (the instance event + OnSystemTextSizeChanged). The per-control
work (TreeView.NodeLeading, ListView row-height, ListBox/ComboBox cached
ItemHeight) is intentionally not included here; those are separate
follow-up proposals that depend on this one shipping.

Designer impact: the new Form.SystemTextSizeChanged event surfaces in
the Properties window in the standard way. No designer-serialization
concerns — events are not serialized; the enum value on Application
is process-static, not designer-set.

Accessibility impact: this is the accessibility feature — it
is the mechanism by which apps will be able to react to the Windows
Accessibility text-size setting at runtime.

Localization: N/A (no user-visible strings introduced).

XML documentation requirements

  • Document that SystemTextSize is the Accessibility text-size factor
    (Settings → Accessibility → Text size), not DPI / display
    scaling, and that it is process-global and live (re-reads on every
    access).
  • Document the Unaware / Notify semantics and explicitly state that
    Automatic is reserved for future use (so a future addition is not a
    surprise).
  • Document the no-rooting design note on the static event (<remarks> on
    Application.SystemTextSizeChanged) so consumers understand the
    difference between the static event and the Form instance event, and do
    not invent leaking patterns.
  • Document the "re-read + diff" caveat on
    OnSystemTextSizeChanged — there is no dedicated
    WM_TEXTSCALECHANGED; the event fires only when the underlying value
    actually changed.

Open questions for review

  1. Type of SystemTextSizedouble (1.0–2.25,
    multiplier-shaped, recommended) or raw int percent (100–225,
    exactly what the registry holds)?
  2. Behavior on OS < Windows 10 1507 (where ScaleToSystemTextSize
    no-ops): should SystemTextSize return 1.0 and SystemTextSizeChanged
    never fire?
  3. Surface scope — ship both the Application static API and
    the Form instance event as proposed (recommended, for parity with the
    DPI event model), or expose only the Application API and leave
    Form-level consumers to override WndProc themselves?

Companion proposal

A companion TreeView.NodeLeading proposal depends on this one and will
be filed separately once this foundation proposal is shaped. TreeView has
neither MeasureItem nor a wrapped native item-height API; it is the
worst-positioned control for a managed runtime-text-scale fix, and is the
motivating example for surfacing SystemTextSize at the framework level.

Metadata

Metadata

Labels

api-suggestion(1) Early API idea and discussion, it is NOT ready for implementationneeds-area-labeltenet-accessibilityMAS violation, UIA issue; problems with accessibility standards

Type

No type
No fields configured for issues without a type.

Projects

No projects

Milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions