You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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\Accessibility → TextScaleFactor
(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:
The text-scale factor is never surfaced to application code.
It is read once, at default-font construction, and the app never
reacts when the user changes the setting at runtime.
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.
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
namespaceSystem.Windows.Forms;publicsealedpartialclassApplication{/// <summary>/// Gets the current Windows Accessibility text-scale factor/// (Settings → Accessibility → Text size), as a multiplier/// in the range 1.0–2.25./// </summary>/// <remarks>/// Live getter — 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>publicstaticdoubleGetSystemTextSize();/// <summary>/// Gets or sets the application’s mode for reacting to changes in/// the Windows Accessibility text-scale setting./// </summary>publicstaticSystemTextSizeAwarenessSystemTextSizeAwareness{get;}publicstaticSetSystemTextSizeAwareness(SystemTextSizeAwarenessawareness):/// <summary>/// Occurs once per process when the Windows Accessibility text-scale/// setting changes, while <see cref="SystemTextSizeAwareness"/> is/// <see cref="SystemTextSizeAwareness.Notify"/>./// </summary>
public staticeventEventHandler?SystemTextSizeChanged;}/// <summary>/// Specifies how the application reacts to changes in the Windows/// Accessibility text-scale setting./// </summary>publicenumSystemTextSizeAwareness{/// <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 — see "Notify-only stance" and "Alternative designs" below.publicpartialclassForm{/// <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>publiceventEventHandler?SystemTextSizeChanged;/// <summary>/// Raises the <see cref="SystemTextSizeChanged"/> event./// </summary>protectedvirtualvoidOnSystemTextSizeChanged(EventArgse);}
API Usage
[STAThread]staticvoidMain(){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(newMainForm());}publicclassMainForm:Form{protectedoverridevoidOnSystemTextSizeChanged(EventArgse){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_DPICHANGED → OnDpiChanged → instance event via EventHandlerList), not from a static subscription.
Verified facts that constrain the design (each checked against current main):
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.
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.
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.
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 instanceSystemTextSizeChanged 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 bool → enum 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
Type of SystemTextSize — double (1.0–2.25,
multiplier-shaped, recommended) or raw int percent (100–225,
exactly what the registry holds)?
Behavior on OS < Windows 10 1507 (where ScaleToSystemTextSize
no-ops): should SystemTextSize return 1.0 and SystemTextSizeChanged
never fire?
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.
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) readsHKEY_CURRENT_USER\Software\Microsoft\Accessibility→TextScaleFactor(REG_DWORD, clamped 100–225) and returns the font scaled by
TextScaleFactor / 100, ornullif the value is 100, the fontIsSystemFont, or the OS is < Windows 10 1507. It is documented in sourceas the Settings → Display → Make Text Bigger setting.
The three gaps this proposal fills:
reacts when the user changes the setting at runtime.
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).
WM_DPICHANGED,Control.DpiChanged*HighDpiModeand the DPI events. NOT this proposal.HKCU\Software\Microsoft\Accessibility\TextScaleFactor; WinRTUISettings.TextScaleFactor/TextScaleFactorChangedApplication.SystemTextSizereflects knob #2 only, and is orthogonalto 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
Fontthat today reacts to the text-size setting once at startup (via
ScaleToSystemTextSizeon the default font) and never again. Any singlecached height scalar is wrong the moment the user changes text size at
runtime — which is the concrete argument for runtime awareness.
ListBox/ComboBoxMeasureItem+OwnerDrawVariable)ItemHeightexistsTreeViewMeasureItemabsent)TreeView.NodeLeadingproposal that depends on this one.ListView(Details)MeasureItemabsent), no native row-height messageSmallImageList+ control fontNM_CUSTOMDRAW(CDRF_NEWFONT). All userland workarounds (phantomSmallImageList;LVS_OWNERDRAWFIXED+ one-shot reflectedWM_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
API Usage
Trigger architecture (the leak-critical part — get this exactly right)
A naive design — a static
Application.SystemTextSizeChangedthatForms andControls subscribe to — leaks: a static eventstrongly roots every subscriber, so no
Formthat subscribes is evercollected. We avoid this by mirroring the DPI architecture, where
Control/Formlearn of DPI changes from their ownWndProc(
WM_DPICHANGED→OnDpiChanged→ instance event viaEventHandlerList), not from a static subscription.Verified facts that constrain the design (each checked against current
main):WM_SETTINGCHANGEis broadcast (HWND_BROADCAST) and delivereddirectly to top-level windows'
WndProcs — it bypasses thethread message queue, so
IMessageFilterdoes NOT see it. Do not usea message filter.
(
CreateParams.Parent = HWND_MESSAGE, seeApplication.ParkingWindow.cs). Message-only windowsare excluded from broadcasts, so the parking window cannot
receive
WM_SETTINGCHANGE. Do not use it.Applicationhas noMainForm. The main form lives onApplicationContext.MainForm(per-run, per-UI-thread), can benull(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.
SystemEventsalready owns a hidden top-level broadcast-receivingwindow (its
.NET-BroadcastEventWindow) and exposesUserPreferenceChangedwith aUserPreferenceCategory. The relevantvalue is
Accessibility, which is coarse — it covers anyaccessibility change, so the handler must re-read
TextScaleFactoranddiff against a cached value to confirm the change was actually a
text-scale change.
Resulting two-path design:
Applicationmakes one internal,process-lifetime subscription to
SystemEvents.UserPreferenceChanged,filters
Category == Accessibility, re-readsTextScaleFactor, diffsagainst the cached value, and — if changed and
SetSystemTextSizeAwareness(Notify)— raises the staticApplication.SystemTextSizeChanged. This is a singleframework-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.
FormhandlesWM_SETTINGCHANGEin its ownWndProc(it is a broadcast —every top-level window receives it), re-reads + diffs, and raises its
instance
SystemTextSizeChangedviaOnSystemTextSizeChanged.Forms do not subscribe toApplication— lifetime = thewindow, no rooting.
Caveat that must be documented: there is no dedicated
WM_TEXTSCALECHANGEDmessage. Both paths must recognize a relevantchange by re-reading
TextScaleFactorand comparing — not by themessage 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:
AutoScaleMode, anchored/docked layout, andexplicitly-set fonts in app-specific ways. A generic "scale all fonts by
the factor" helper breaks more than it fixes.
Automaticenum value is exactly where such behavior wouldlive in a future proposal.
Notifygives the developer the factor and the event; they decide how torespond. This is consistent with the companion
TreeView.NodeLeadingproposal's conservative-default philosophy.
Alternative designs
boolvs.enumfor the awareness switch. Abool(e.g.Application.SystemTextSizeAware = true) would be smaller surface todaybut would force a breaking
bool→enumchange the moment we wantto add
Automatic.HighDpiModealready learned this lesson; reserve theenum slot now.
Application-only, noForminstance event. Possible — userscould override
WndProcthemselves. Rejected for parity with the DPIevent model (
Form.DpiChanged), where the framework provides theinstance event so app code never has to touch
WndProc.IMessageFilterfor the app-level path. Rejected: see constraint Modernize FolderBrowserDialog #1above —
WM_SETTINGCHANGEis a broadcast, bypasses the threadmessage queue, and
IMessageFilterdoes not see it.double(1.0–2.25).Listed as an open question;
doubleis recommended because it composesdirectly as a multiplier and matches the WinRT
UISettings.TextScaleFactorshape conceptually.
SystemTextSize. Rejected: the underlying value is a systemsetting 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
UserPreferenceCategory.Accessibility. Fires for anyaccessibility change, not just text-scale. Mitigation: re-read +
diff — only raise
SystemTextSizeChangedwhen the cached valueactually changed. (Same pattern as e.g.
Screen.OnUserPreferenceChangedin this repo.)
WM_SETTINGCHANGEis broadcast to every top-level Form. Each Formdoes one registry read + diff per broadcast. This is cheap, but it is
per-Form. Mitigation: opt-in via
SystemTextSizeAwareness; whenUnaware, the Form-level path is a no-op (or not wired at all).Unaware. No existing app changes behaviorunless it opts in.
ScaleToSystemTextSizeno-ops on theseversions. The proposal must define
SystemTextSize's value there(recommendation: returns
1.0and no events fire). Listed as an openquestion.
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-controlwork (
TreeView.NodeLeading, ListView row-height, ListBox/ComboBox cachedItemHeight) is intentionally not included here; those are separatefollow-up proposals that depend on this one shipping.
Designer impact: the new
Form.SystemTextSizeChangedevent surfaces inthe Properties window in the standard way. No designer-serialization
concerns — events are not serialized; the enum value on
Applicationis 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
SystemTextSizeis 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).
Unaware/Notifysemantics and explicitly state thatAutomaticis reserved for future use (so a future addition is not asurprise).
<remarks>onApplication.SystemTextSizeChanged) so consumers understand thedifference between the static event and the
Forminstance event, and donot invent leaking patterns.
OnSystemTextSizeChanged— there is no dedicatedWM_TEXTSCALECHANGED; the event fires only when the underlying valueactually changed.
Open questions for review
SystemTextSize—double(1.0–2.25,multiplier-shaped, recommended) or raw
intpercent (100–225,exactly what the registry holds)?
ScaleToSystemTextSizeno-ops): should
SystemTextSizereturn1.0andSystemTextSizeChangednever fire?
Applicationstatic API andthe
Forminstance event as proposed (recommended, for parity with theDPI event model), or expose only the
ApplicationAPI and leaveForm-level consumers to overrideWndProcthemselves?Companion proposal
A companion
TreeView.NodeLeadingproposal depends on this one and willbe filed separately once this foundation proposal is shaped.
TreeViewhasneither
MeasureItemnor a wrapped native item-height API; it is theworst-positioned control for a managed runtime-text-scale fix, and is the
motivating example for surfacing
SystemTextSizeat the framework level.