[NET11 API Proposal] `TreeView.NodeLeading` - opt-in honest, text-scale-aware node-row height
- Dominant language
- C#
- Stars
- 4.9k
- Forks
- 1.1k
- Avg merge
- 20h 23m
- Merged PRs (30d)
- 103
Description
## 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 takes the chance to turn 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`][14583]. 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.
[14583]: https://github.com/dotnet/winforms/issues/14583
### 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 ([`TreeView.cs`][treeview-src]):
[treeview-src]: https://github.com/dotnet/winforms/blob/main/src/System.Windows.Forms/System/Windows/Forms/Controls/TreeView/TreeView.cs
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.
4. **WinForms already sets `TVS_NONEVENHEIGHT`** — but only *reactively*, inside `ItemHeight.set` when the user assigns an odd explicit value: `_setOddHeight = true` → `RecreateHandle()` → `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.
5. **`OnHandleCreated` pushes `_itemHeight` only when explicitly set** (line 1916-1919). With no explicit value, comctl picks the default and the even-floor applies.
6. 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.
7. Precedent for a back-compat switch in this exact area exists:
`AppContextSwitches.MoveTreeViewTextLocationOnePixel` (line 2816).
A high-DPI repro: System-DPI-aware app at 175%, TreeView font 11pt, node descenders visibly clipped.
## API Proposal
```csharp
namespace System.Windows.Forms;
public partial class TreeView
{
///
/// Gets or sets the per-node vertical leading factor applied on top of
/// the live height. Acts as a sentinel
/// switch as well as a scaling factor — see remarks.
///
public float NodeLeading { get; set; }
///
/// Occurs when the value of changes.
///
public event EventHandler? NodeLeadingChanged;
///
/// Raises the event.
///
protected virtual void OnNodeLeadingChanged(EventArgs e);
}
```
## API Usage
```csharp
// Default behavior (0.0f) — 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) — uniform for all nodes,
// DPI-independent by construction.
tv.NodeLeading = 1.25f;
// React to the change (e.g., from designer/property window) — 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 `≠ 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. |
| `< 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 × 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 **uniform** — `TVM_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 `` (see [XML doc requirements](#xml-documentation-requirements)).
> Pronunciation/etymology note for international readers (to be embedded in
> ``): *"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 risk** — `NodeLeading` could be read as
*per-node*. Mitigated by `` and by placement on `TreeView` not
`TreeNode`. Review may rename.
- **Interaction with explicit `ItemHeight`** — precedence rules must
be defined (see [Open questions](#open-questions-for-review)).
- **Custom-drawn `TreeView`s** — 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 (#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
- **``** — "Gets or sets the per-node vertical leading
factor applied on top of the live `Font.Height`. Default `0.0f` selects
legacy behavior."
- **``** — 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 semantics** — `0.0f` = legacy
(`FontHeight + 3` managed getter, no `TVS_NONEVENHEIGHT`); `1.0f` =
honest, font-derived row height with `TVS_NONEVENHEIGHT` set;
`> 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."*
- **``** — "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."
- **``** — 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
(`≠ 0.0f`); otherwise `ItemHeight` behavior is unchanged.**
Rationale: once the system text-size can change at runtime (#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` (#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 × 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](#naming-note--review-negotiable).
## Companion proposal
- **#14583 — `Application.SystemTextSize`** — the foundation
proposal this one depends on. `NodeLeading` is the per-control concrete
consumer that motivates the framework-level text-scale awareness; it is
the *evidence* that the foundation matters.
## 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
- [x] API proposal has `api-suggestion` label
- [x] 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`
Contributor guide
Assessment
This issue has not been assessed yet.