Skip to content

[NET11 API Proposal] TreeView.NodeLeading - opt-in honest, text-scale-aware node-row height #14584

@KlausLoeffelmann

Description

@KlausLoeffelmann

Rationale, Background and Motivation

TreeView carries a long-standing peculiarity in how the upper/lower margin
between node text and the row edges is calculated — a quirk that
traces back to .NET Framework 2.0 and was ported verbatim into Core. This
proposal aus der Not eine Tugend macht ("turns the necessity into a
virtue"): rather than silently "fix a bug," it productizes the fix as a
first-class, opt-in, designer-visible property — TreeView.NodeLeading
— that

  • surfaces an existing native TreeView capability
    (TVS_NONEVENHEIGHT / TVM_SETITEMHEIGHT) the managed wrapper has never
    exposed,
  • controls backwards compatibility via a sentinel default
    (0.0f → legacy path, byte-for-byte unchanged), and
  • controls the parameter of that capability (the amount of leading
    applied to the live Font.Height).

Why this matters now — companion to #14583

This proposal depends on and references the foundation proposal
#14583 — Application.SystemTextSize. Once the system
text-size setting (Settings → Accessibility → Text size; registry
HKCU\Software\Microsoft\Accessibility\TextScaleFactor) can change at
runtime
, the effective Font.Height can change at runtime — and a
single fixed/cached row-height scalar (today: ItemHeight) is inherently
fragile. NodeLeading is TreeView's specific mechanism for an honest,
text-scale-aware node height.

Why TreeView specifically — the worst-positioned control

Control Per-item measure hatch? Native item-height API wrapped?
ListBox / ComboBox Yes (MeasureItem + OwnerDrawVariable) ItemHeight exists
ListView (Details) No Row height is comctl-computed from SmallImageList + control font
TreeView No (MeasureItem absent) Only a single uniform native item height (TVM_SETITEMHEIGHT), with a height-rounding gotcha

TreeView is the only common text-measuring control with neither a
per-item measure hatch nor a clean managed-side knob for the row height
the native control actually uses.
Hence the dedicated property.

The legacy calculation — verified against main

Verified against src/System.Windows.Forms/System/Windows/Forms/Controls/TreeView/TreeView.cs
(permalink):

  1. Managed ItemHeight getter without an explicit value returns
    FontHeight + 3 (or Math.Max(16, FontHeight + 3) for
    CheckBoxes + OwnerDrawAll), only when the handle has not yet been
    created
    (lines 783–788). Once the handle exists, the getter
    delegates to TVM_GETITEMHEIGHT (line 779). So +3/16 are
    pre-handle/managed-side estimates, not what the native control
    ultimately uses — but they leak into design-time layout and into
    any code that queries ItemHeight before realization.
  2. TVM_SETITEMHEIGHT rounds an odd value down to the nearest even
    value
    unless TVS_NONEVENHEIGHT is set — silently eating a
    descender pixel for fonts whose natural row height is odd.
  3. WinForms already sets TVS_NONEVENHEIGHT — but only
    reactively, inside ItemHeight.set when the user assigns an odd
    explicit value: _setOddHeight = trueRecreateHandle()
    TVS_NONEVENHEIGHT flows through CreateParams (lines 51, 369,
    801–811). The auto/default, DPI-scaled, non-explicit path
    never sets TVS_NONEVENHEIGHT — which is exactly the path
    that clips at high DPI.
  4. OnHandleCreated pushes _itemHeight only when explicitly set
    (line 1916–1919). With no explicit value, comctl picks the
    default and the even-floor applies.
  5. The CustomDraw / CDDS_ITEMPREPAINT path carries a long-standing
    comment acknowledging that comctl clips node fonts larger than the
    control's own font ("better in comctl 5 and above", line 2787–2788).
    OnHandleCreated conditionally forces CCM_SETVERSION 5 if the current
    version is < 5 (lines 1856–1860), which mitigates but does not
    fully eliminate the clipping at high DPI.
  6. Precedent for a back-compat switch in this exact area exists:
    AppContextSwitches.MoveTreeViewTextLocationOnePixel (line 2816).

API Proposal

namespace System.Windows.Forms;

public partial class TreeView
{
    /// <summary>
    ///  Gets or sets the per-node vertical leading factor applied on top of
    ///  the live <see cref="Control.Font"/> height. Acts as a sentinel
    ///  switch as well as a scaling factor &#x2014; see <c>remarks</c>.
    /// </summary>
    public float NodeLeading { get; set; }

    /// <summary>
    ///  Occurs when the value of <see cref="NodeLeading"/> changes.
    /// </summary>
    public event EventHandler? NodeLeadingChanged;

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

API Usage

// Default behavior (0.0f) &mdash; legacy path, no change for existing apps.
var tv = new TreeView();
Debug.Assert(tv.NodeLeading == 0.0f);

// Opt in to the honest, font-derived row-height path.
tv.NodeLeading = 1.0f;

// Slightly airier rows (25% extra leading) &mdash; uniform for all nodes,
// DPI-independent by construction.
tv.NodeLeading = 1.25f;

// React to the change (e.g., from designer/property window) &mdash; standard
// PropertyChanged pattern.
tv.NodeLeadingChanged += (s, e) =>
    Trace.WriteLine($"NodeLeading is now {((TreeView)s!).NodeLeading:0.00}");

Sentinel + DPI semantics — the crux

Value Behavior
0.0f (default) Sentinel for legacy. Keep the existing FontHeight + 3 managed getter, keep the existing even-rounding behavior, do not set TVS_NONEVENHEIGHT. Existing apps are byte-for-byte unchanged. A zero-height row is mathematically meaningless, which is precisely why 0 is safe as the legacy sentinel.
1.0f Honest calculation. Derive the base row height from the live Font.Height plus deliberate leading; set TVS_NONEVENHEIGHT so the even-floor cannot eat a descender. Recompute on font change and DPI change.
> 0 and &ne; 1 (e.g. 1.25f) Scale the leading on top of the honest base (denser/airier rows). Uniform, control-wide multiplier — all that comctl supports.
&lt; 0 ArgumentOutOfRangeException.

DPI behavior — state this explicitly

NodeLeading is a dimensionless factor applied to the live Font.Height,
which is already in device units (it carries the current DPI from the
layout pass). The property is therefore DPI-independent by construction:
1.25 means "+25% leading" identically at 100% and 175%; the pixels
scale with DPI, the factor does not. This is precisely what the legacy
+3 constant got wrong — a fixed pixel addend in the wrong unit,
which is why it clips at high DPI.

Implementation constraint — the honest path MUST compute
the row height as (factor &times; Font.Height)-derived, NOT as
Font.Height + N pixels. A pixel addend reintroduces the DPI bug.

Explicit non-goal — per-node / variable row heights

Native TreeView item height is uniformTVM_SETITEMHEIGHT
takes one value for all rows. The property lives on TreeView, not on
TreeNode, by design — that placement is itself the signal that it
governs all nodes uniformly. A fractional value does not mean "subdivide
a row" or enable owner-draw "N small rows faked as one tall node." True
per-node heights would need a MeasureItem-style model and effectively a
from-scratch TreeView — out of scope for this proposal.

Naming note — review-negotiable

NodeLeading follows WinForms vocabulary (TreeView has Nodes as
ListView has Items), and placement on TreeView conveys the
control-wide scope. Risk: a reader could misread "Node…" as
per-node. Recorded so review can decide. Alternatives considered:

  • ItemLeading — consistent with ItemHeight, but TreeView
    uses the term Node elsewhere (Nodes, SelectedNode, TopNode).
  • Leading — shortest, but ambiguous as a control property name.

Recommendation: NodeLeading, with the per-node misread risk
documented in <remarks> (see XML doc requirements).

Pronunciation/etymology note for international readers (to be embedded in
<remarks>): "Leading" (pronounced "ledding") is a typesetting term from
the strips of lead metal once placed between lines of type to add
vertical spacing; it is unrelated to "leading/guiding."

Alternative designs

  • Expose TVS_NONEVENHEIGHT directly as a bool (e.g.
    AllowOddItemHeight).
    Rejected: solves only the rounding gotcha;
    does not address the DPI-fragile addend, does not give callers a knob
    for denser/airier rows, and adds a second property the user must
    understand alongside ItemHeight.
  • Auto-enable TVS_NONEVENHEIGHT unconditionally in OnHandleCreated.
    Rejected: silent behavior change for every existing TreeView — a
    rounding shift of 1 pixel will break pixel-perfect screenshot tests and
    some custom-drawn UIs. The sentinel-default NodeLeading is opt-in by
    construction.
  • Re-use ItemHeight with a "magic" interpretation
    (e.g. 0/-1/float.NaN).
    Rejected: ItemHeight is an int with a
    clear "number of pixels" contract; overloading it with a sentinel is a
    worse contract than introducing a small, single-purpose property.
  • Add MeasureItem to TreeView. Rejected: comctl TreeView has no
    variable-row support; faking it would mean re-implementing the control.
    See "Explicit non-goal" above.

Risks

  • Naming-misread riskNodeLeading could be read as
    per-node. Mitigated by <remarks> and by placement on TreeView not
    TreeNode. Review may rename.
  • Interaction with explicit ItemHeight — precedence rules must
    be defined (see Open questions).
  • Custom-drawn TreeViews — apps that draw at exact pixel
    offsets calibrated against the legacy FontHeight + 3 will see different
    bounds once they opt in. Default 0.0f keeps them on the legacy path.
  • Back-compat — the sentinel-0 default is the principal
    mitigation. No existing app changes behavior unless it sets
    NodeLeading.
  • TVS_NONEVENHEIGHT requires a handle recreate when toggled (today's
    _setOddHeight path proves this). The honest-path codegen must follow
    the same RecreateHandle() pattern, with the standard cost.

Will this feature affect UI controls?

Yes — this is a new property on TreeView.

  • Designer support: appears in the Properties window. Standard
    [SRCategory(nameof(SR.CatBehavior))] (or CatAppearance — to be
    decided; see existing TreeView property categories for precedent),
    [SRDescription(...)], [DefaultValue(0.0f)]. CodeDOM serialization
    via the ShouldSerializeNodeLeading / ResetNodeLeading pattern from
    the new-control-api skill (so the default 0.0f is not emitted into
    generated InitializeComponent code).
  • Accessibility impact: positive — this is the row-height
    half of the runtime text-scale story ([NET 11 API Proposal] Application.SystemTextSize - runtime awareness of the Accessibility text-scale setting #14583). At high DPI and high
    text scale, the honest-path (NodeLeading >= 1.0f) eliminates the
    descender-clipping that today renders some glyphs incomplete for
    accessibility-text-size users.
  • Localization: N/A for the value. The category/description strings
    added to SR.resx follow the existing Cat* / TreeViewOn*Descr
    naming conventions and will be .xlf-translated on next build.

XML documentation requirements

  • <summary> — "Gets or sets the per-node vertical leading
    factor applied on top of the live Font.Height. Default 0.0f selects
    legacy behavior."
  • <remarks> — must state explicitly:
    1. Uniform, not per-node. NodeLeading applies uniformly to all
      nodes; it is not a per-node knob. Per-node row heights are out of
      scope and would require a MeasureItem-style model not supported by
      the native TreeView.
    2. Sentinel semantics0.0f = legacy
      (FontHeight + 3 managed getter, no TVS_NONEVENHEIGHT); 1.0f =
      honest, font-derived row height with TVS_NONEVENHEIGHT set;
      &gt; 1.0f = additional leading on top of the honest base.
    3. DPI behavior — the factor is dimensionless; the resulting
      pixels scale with Font.Height, which already carries DPI.
    4. Etymology"Leading" (pronounced "ledding") is a
      typesetting term from the strips of lead metal once placed between
      lines of type to add vertical spacing; it is unrelated to
      "leading/guiding."
  • <value> — "A non-negative float. 0.0f (default) selects
    legacy behavior. 1.0f opts in to the honest row-height calculation.
    Values greater than 1.0f add extra leading."
  • <exception cref="ArgumentOutOfRangeException"> — thrown when
    set to a negative value or to a non-finite value (NaN, ±Infinity).
  • Serialization: [DefaultValue(0.0f)] plus
    private bool ShouldSerializeNodeLeading() /
    private void ResetNodeLeading() so the designer suppresses default
    emission.

Open questions for review

  1. Precedence between NodeLeading and an explicit ItemHeight.
    Recommended: NodeLeading wins when set to any non-default value
    (&ne; 0.0f); otherwise ItemHeight behavior is unchanged.

    Rationale: once the system text-size can change at runtime ([NET 11 API Proposal] Application.SystemTextSize - runtime awareness of the Accessibility text-scale setting #14583), a
    fixed integer ItemHeight is inherently fragile (it does not track
    a text-scale change). NodeLeading — a factor on the live
    Font.Height — is the more robust primary mechanism;
    ItemHeight becomes the legacy/explicit override that the developer
    re-asserts only if they want to pin a pixel value. Alternative: layer
    NodeLeading on top of ItemHeight. Flagged for review.
  2. AppContextSwitches gate. Whether the honest-base +
    TVS_NONEVENHEIGHT path should be additionally gated behind an
    AppContext switch (precedent: MoveTreeViewTextLocationOnePixel)
    for ultra-conservative back-compat. Recommended: no — the
    sentinel-0 default already provides full opt-in and makes a second
    switch redundant noise.
  3. Re-evaluate on SystemTextSizeChanged ([NET 11 API Proposal] Application.SystemTextSize - runtime awareness of the Accessibility text-scale setting #14583). Whether a
    NodeLeading != 0.0f TreeView should also recompute its
    row-height on the Application / Form SystemTextSizeChanged
    notification — so the row height re-flows when the user changes
    text size at runtime, not just on font/DPI change.
    Recommended: yes, conditional on Application.SystemTextSizeAwareness == Notify. The recompute is exactly the same Font.Height &times; NodeLeading
    path; subscribing internally in TreeView.OnHandleCreated (and
    unsubscribing in Dispose) is straightforward.
  4. Type: float vs. double. Recommended: float — matches
    Font.Size, matches WinForms' general "no double in control APIs"
    posture, and the precision is more than sufficient for a leading
    factor.
  5. Name confirmation. NodeLeading vs. ItemLeading vs. Leading
    — see Naming note.

Companion proposal

Source references (verified against main)

All line numbers below verified against
src/System.Windows.Forms/System/Windows/Forms/Controls/TreeView/TreeView.cs
on main at the time of filing; they may drift — re-verify at
implementation time.

Symbol / behavior Line(s)
_setOddHeight field 51
TVS_NONEVENHEIGHT in CreateParams (gated on _setOddHeight) 369–372
ItemHeight getter default FontHeight + 3 / Math.Max(16, FontHeight + 3) 783–788
ItemHeight setter — reactive _setOddHeight + RecreateHandle() + TVM_SETITEMHEIGHT 801–815
CCM_SETVERSION 5 in OnHandleCreated (only if version < 5) 1856–1860
TVM_SETITEMHEIGHT in OnHandleCreated (only when _itemHeight != -1) 1916–1919
CDDS_ITEMPREPAINT comctl-clipping comment 2787–2789
AppContextSwitches.MoveTreeViewTextLocationOnePixel precedent 2816

Status Checklist

  • API proposal has api-suggestion label
  • Background, API Proposal, API Usage, and Risks sections are complete
  • API shape has been discussed with the team
  • Review the issue for compatibility with what the API review board expects
  • Change label to api-ready-for-review
  • If late in the release cycle, also add the blocking label to expedite the review appointment
  • API review completed — label changed to api-approved

Metadata

Metadata

Labels

api-suggestion(1) Early API idea and discussion, it is NOT ready for implementationneeds-area-label

Type

No type
No fields configured for issues without a type.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions