|
| 1 | +# Work-Order: Centralize ComboBox Modern Chrome State into an Idempotent Chokepoint |
| 2 | + |
| 3 | +**Area:** WinForms Runtime — `VisualStylesMode` / .NET 11 visual modernization |
| 4 | +**Component:** `System.Windows.Forms.ComboBox` (modern `FlatStyle` rendering) |
| 5 | +**Branch baseline:** `KlausLoeffelmann/winforms` @ `d6ca49640903fa4f15be20b427f253bb940855d4` (`Net11/VisualStylesMode-Fixes`, rebased on `Net11/Integration-3`, upstream draft PR dotnet/winforms#14768) |
| 6 | +**Type:** Rearchitecture (bug-class elimination), supersedes point-fix follow-ups to the "1-pixel internal padding" work-order. |
| 7 | + |
| 8 | +--- |
| 9 | + |
| 10 | +## 1. Problem Statement |
| 11 | + |
| 12 | +The modern ComboBox chrome (`UsesModernComboAdapter == true`) mutates **three independent pieces of native state**: |
| 13 | + |
| 14 | +1. `CB_SETITEMHEIGHT(-1)` — the native selection-field height, |
| 15 | +2. `EM_SETMARGINS` on the child edit HWND, |
| 16 | +3. `SetWindowPos` on the child edit (and, for `ComboBoxStyle.Simple`, the child list) HWND. |
| 17 | + |
| 18 | +Each mutation has its own capture/restore bookkeeping (`_modernEditBaseBounds`, `_modernEditBaseClientSize`, `_modernSimpleListBaseBounds`, `_modernFieldHeightApplied`, `_modernHandleInitialized`), and the mutations are applied from **many triggers** (`OnVisualStylesModeChanged`, `OnHandleCreated`, `OnFontChanged`, `OnPaddingChanged`, `OnSystemVisualSettingsChanged`, `RescaleConstantsForDpi`, `WM_SETFONT` on the child, `WM_WINDOWPOSCHANGED` for Simple, `UpdateItemHeight`, the `FlatStyle` setter) in trigger-specific orderings that depend on what `base.*` does in between. |
| 19 | + |
| 20 | +This produces an entire **class** of state-desynchronization bugs. Four confirmed instances against the pinned baseline: |
| 21 | + |
| 22 | +### Defect A — Vertical/horizontal inset asymmetry (edit overshoots the rounded border) |
| 23 | +`UpdateModernEditMargins` applies `Fixed3DBorderPadding (2) + InternalChromeInset (2) + ComboBoxStyleInset (1)` = 5 logical px **horizontally** via `EM_SETMARGINS`, but `UpdateModernEditBounds` insets the edit **vertically** by only `ComboBoxStyleInset (1) + Padding.Top/Bottom`. The rounded border ring consumes ~3 px vertically (Fixed3D zone + AA stroke), so with `Padding.Empty` the edit client overlaps the border curve. `Padding = 2` masks it, which confirms the diagnosis. |
| 24 | + |
| 25 | +### Defect B — `EM_SETMARGINS` never reset on modern → classic |
| 26 | +In `ComboBox.OnVisualStylesModeChanged`, `RestoreAndResetModernEditBounds()` empties `_modernEditBaseBounds` **before** the trailing `UpdateModernEditMargins()` call; that method's guard `(!UsesModernComboAdapter && _modernEditBaseBounds.IsEmpty)` then early-returns, so the classic edit child keeps the 5 px modern margins. |
| 27 | + |
| 28 | +### Defect C — Restore targets `COMBOBOXINFO.rcItem`, not the edit's native bounds |
| 29 | +The DropDown path seeds `_modernEditBaseBounds` from `rcItem` (the combo's selection-field rect) but restores the **edit HWND** to that rect. The edit natively sits *inside* `rcItem` with a theme inset. The Simple path already does this correctly via `GetChildBounds(comboBoxInfo.hwndItem)`; the DropDown path does not. |
| 30 | + |
| 31 | +### Defect D — Delta-based `CB_SETITEMHEIGHT` is order-fragile and non-idempotent |
| 32 | +`ApplyPreferredFieldHeight` computes `heightDelta = preferredHeight - Height` and applies it **relative** to the current item height. Before this runs, `Control.OnVisualStylesModeChanged` (`Metrics` impact) has already executed `RequestLayout`, `UpdateStyles`, and `RefreshVisualStylesModeNonClientArea` (SetWindowPos w/ `SWP_FRAMECHANGED`). Whenever any of these reconciles `Height` first — anchored controls, `TableLayoutPanel` cells, designer behavior service, or **deserialization**, where the mode transition occurs before handle creation and `Height` is already set to the target — the delta collapses to `0` and the native selection-field height silently keeps the previous mode's value. Because the math is relative, each round-trip can accumulate error. This is the primary suspect for the classic missing drop-down button and the misplaced button/frame remnants after classic → modern round-trips, and it fully explains why every deserialization pass re-triggers the corruption. |
| 33 | + |
| 34 | +--- |
| 35 | + |
| 36 | +## 2. Target Architecture |
| 37 | + |
| 38 | +Replace the trigger-specific mutation choreography with **one idempotent chokepoint**, mirroring the `EffectiveVisualStylesMode` chokepoint pattern already established for the .NET 11 work: many inputs, one authoritative computation, one applier. |
| 39 | + |
| 40 | +### 2.1 Core principle |
| 41 | + |
| 42 | +> The chokepoint computes the complete **target native state absolutely** from current inputs and applies it. It never reads back its own previous output to decide the next one, and calling it N times in any order after any trigger yields the same native state as calling it once. |
| 43 | +
|
| 44 | +There is no separate "restore" path: applying the chokepoint while `UsesModernComboAdapter == false` *is* the restore, because the target state for classic mode is the captured native baseline. |
| 45 | + |
| 46 | +### 2.2 New shape (in `ComboBox.Modern.cs`) |
| 47 | + |
| 48 | +```csharp |
| 49 | +/// <summary> |
| 50 | +/// Captures the native, unmodified child layout of the ComboBox once per handle |
| 51 | +/// lifetime, before any modern chrome mutation is applied. |
| 52 | +/// </summary> |
| 53 | +private readonly struct NativeComboBaseline |
| 54 | +{ |
| 55 | + public int SelectionFieldItemHeight { get; init; } |
| 56 | + public Rectangle EditBounds { get; init; } |
| 57 | + public Rectangle SimpleListBounds { get; init; } |
| 58 | + public Size ClientSize { get; init; } |
| 59 | + |
| 60 | + public bool IsCaptured |
| 61 | + => !EditBounds.IsEmpty || SelectionFieldItemHeight > 0; |
| 62 | +} |
| 63 | + |
| 64 | +/// <summary> |
| 65 | +/// The complete target state for the ComboBox's native children under the |
| 66 | +/// current effective visual styles mode. |
| 67 | +/// </summary> |
| 68 | +private readonly struct ModernComboTargetState |
| 69 | +{ |
| 70 | + public int SelectionFieldItemHeight { get; init; } |
| 71 | + public Padding EditMargins { get; init; } |
| 72 | + public Rectangle EditBounds { get; init; } |
| 73 | + public Rectangle SimpleListBounds { get; init; } |
| 74 | +} |
| 75 | + |
| 76 | +/// <summary> |
| 77 | +/// Single chokepoint. Computes the absolute target state for item height, |
| 78 | +/// edit margins, and child bounds from the current inputs and applies it. |
| 79 | +/// Idempotent; safe to call from any trigger in any order. |
| 80 | +/// </summary> |
| 81 | +private void ApplyModernComboLayout(); |
| 82 | +``` |
| 83 | + |
| 84 | +### 2.3 Rules |
| 85 | + |
| 86 | +1. **Baseline capture happens exactly once per handle lifetime**, in `OnHandleCreated`, *before* the first mutation: native default `CB_GETITEMHEIGHT(-1)`, `GetChildBounds(hwndItem)` (fixes Defect C — use child bounds, never `rcItem`), `GetChildBounds(hwndList)` for Simple, and the `ClientSize` at capture time. Cleared in `OnHandleDestroyed`. |
| 87 | +2. **Item height is computed absolutely** (fixes Defect D): |
| 88 | + - Modern: derive the target selection-field height from `ModernPreferredHeight` minus the native frame, measured against the **HWND** (`GetWindowRect`), never against `Control.Height` bookkeeping. |
| 89 | + - Classic: target = `NativeComboBaseline.SelectionFieldItemHeight`. |
| 90 | + - Apply only when the current native value differs (read-compare-write, so repaint/relayout storms are avoided while idempotency is preserved). |
| 91 | +3. **Edit margins are computed absolutely**: modern = the existing `Fixed3DBorderPadding + InternalChromeInset + ComboBoxStyleInset` formula plus `Padding.Left/Right`; classic = `0` (fixes Defect B — restore is just "apply classic target"). |
| 92 | +4. **Vertical edit insets use the same chrome formula as the horizontal margins** (fixes Defect A): `topInset`/`bottomInset` = scaled `Fixed3DBorderPadding + ComboBoxStyleInset` (+ `Padding.Top/Bottom`), sourced from a single `GetModernChromeInsets()` helper so the border geometry (`CreateFieldPath`, `ClearNativeFrame`) and the child layout can never diverge again. If the "≥ 1 px internal padding" requirement from the previous work-order needs adjusting, it is adjusted **here, in one place**. |
| 93 | +5. **All triggers call only the chokepoint.** `OnVisualStylesModeChanged`, `OnHandleCreated`, `OnFontChanged`, `OnPaddingChanged`, `OnSystemVisualSettingsChanged`, `RescaleConstantsForDpi`, child `WM_SETFONT`, `WM_WINDOWPOSCHANGED` (Simple), `UpdateItemHeight`, and the `FlatStyle` setter each reduce to: invalidate caches as needed → `ApplyModernComboLayout()`. Ordering relative to `base.*` calls no longer matters, because the chokepoint reads only current inputs. |
| 94 | +6. **Reentrancy guard lives inside the chokepoint** (`_adjustingModernChildBounds` moves in; callers never manage it). |
| 95 | +7. **Delete** `RestoreModernEditBounds`, `RestoreAndResetModernEditBounds`, `ResetModernEditBounds`, `ResizeModernSimpleBaseBounds`, `UpdateModernEditMargins`, `UpdateModernEditBounds`, `UpdateModernSimpleEditBounds`, `ApplyPreferredFieldHeight`'s delta logic, and the fields `_modernEditAppliedBounds`, `_modernEditBaseBounds`, `_modernEditBaseClientSize`, `_modernFieldHeightApplied`, `_modernSimpleListBaseBounds`. Replaced by `NativeComboBaseline` + the chokepoint. |
| 96 | +8. `WinFormsApplicationBuilder`/host concerns are out of scope; this is runtime-internal. Public API surface is unchanged (`FlatStyle`, `Padding`, `PreferredHeight` semantics stay as shipped in Integration-3). |
| 97 | + |
| 98 | +### 2.4 Explicit non-goals |
| 99 | + |
| 100 | +- No changes to `ModernComboAdapter` painting itself except consuming `GetModernChromeInsets()` for its geometry constants. |
| 101 | +- No changes to the `EffectiveVisualStylesMode` propagation in `Control` — the chokepoint must work with the existing `Metrics`/`Repaint` impact model as-is. |
| 102 | +- No behavioral change for `FlatStyle.System` (remains fully native) or for classic `Flat`/`Popup` (existing `FlatComboAdapter`). |
| 103 | + |
| 104 | +--- |
| 105 | + |
| 106 | +## 3. Tasks |
| 107 | + |
| 108 | +| # | Task | Notes | |
| 109 | +|---|------|-------| |
| 110 | +| 1 | Introduce `NativeComboBaseline` capture in `OnHandleCreated`; clear in `OnHandleDestroyed`. | Capture **before** dark-mode theming and before any `CB_SETITEMHEIGHT`. | |
| 111 | +| 2 | Introduce `GetModernChromeInsets()` shared by adapter geometry and child layout. | Single source of truth for Defect A. | |
| 112 | +| 3 | Implement `ComputeModernComboTargetState()` (pure, absolute). | Inputs: `EffectiveVisualStylesMode`, `FlatStyle`, `DropDownStyle`, `Font`/`FontHeight`, `Padding`, `DeviceDpiInternal`, `SystemVisualSettings`, `RightToLeft`, baseline. | |
| 113 | +| 4 | Implement `ApplyModernComboLayout()` applier with read-compare-write per state item and internal reentrancy guard. | Item height via HWND-measured frame, not `Control.Height`. | |
| 114 | +| 5 | Reroute all triggers listed in Rule 5 through the chokepoint; delete legacy methods/fields per Rule 7. | `OnVisualStylesModeChanged` body shrinks to cache invalidation + `base` + chokepoint + `RefreshModernDropDownCornerPreference`. | |
| 115 | +| 6 | Verify `DrawMode.OwnerDrawFixed/Variable` interplay: `UpdateItemHeight`'s explicit `CB_SETITEMHEIGHT` calls must not fight the chokepoint. | Owner-draw item heights own indices ≥ 0; chokepoint owns index −1 in Normal mode; define precedence for owner-draw −1. | |
| 116 | +| 7 | Add a debug assertion helper that dumps `CB_GETITEMHEIGHT(-1)`, `COMBOBOXINFO.rcItem/rcButton`, and edit HWND bounds — used for the acceptance runs below. | Also resolves the open question whether the missing classic arrow is a Defect D consequence (degenerate `rcButton`) or an overlap. | |
| 117 | +| 8 | Consider a `WFCC` `ComponentChange` entry if any observable classic-mode behavior shifts (e.g., corrected edit inset after round-trip). | Only if acceptance runs surface an observable delta vs. .NET 10 classic. | |
| 118 | + |
| 119 | +--- |
| 120 | + |
| 121 | +## 4. Acceptance Criteria |
| 122 | + |
| 123 | +1. **Round-trip invariance:** `Classic → Net11 → Classic` repeated 10× (runtime *and* designer-hosted) yields native state byte-identical to a freshly created classic ComboBox: item height, edit HWND bounds, `EM_GETMARGINS`, `rcButton` non-degenerate, drop-down arrow visible. |
| 124 | +2. **Order independence:** Deserialization sequences that set `FlatStyle`/`Padding`/`Font`/mode in any order, before or after handle creation, converge to the same state. Specifically: mode transition before handle creation must not zero out the item-height adjustment (Defect D repro). |
| 125 | +3. **Layout-container safety:** Combo inside `TableLayoutPanel` and with `Anchor = Top|Bottom` survives mode round-trips without frame remnants or misplaced button (Screenshot 4/5 repro). |
| 126 | +4. **No overshoot at `Padding.Empty`:** the edit client rect is fully contained within the rounded field path at 100 %, 150 %, 200 % DPI (Screenshot 1 repro). `Padding` values add on top and never subtract. |
| 127 | +5. **Simple style:** resize + mode round-trips keep edit/list stacked correctly; no drift of `_modernSimpleListBaseBounds`-equivalent state. |
| 128 | +6. **RTL:** all of the above with `RightToLeft.Yes`. |
| 129 | +7. **Idempotency probe:** calling `ApplyModernComboLayout()` 5× consecutively produces zero additional native messages after the first call (read-compare-write verified via message trace). |
| 130 | +8. Existing modern paint tests (adapter geometry, chevron, `DropDownList` text) pass unchanged. |
| 131 | + |
| 132 | +--- |
| 133 | + |
| 134 | +## 5. Risks / Open Questions |
| 135 | + |
| 136 | +- **Native height authority:** for non-Simple styles the native control owns window height derived from item height; the chokepoint must treat `CB_SETITEMHEIGHT(-1)` as the *only* height lever and let `Control.Height` bookkeeping follow via `WM_WINDOWSPOSCHANGED`, never the reverse. Any remaining `Height = preferredHeight` assignments should be audited for redundancy. |
| 137 | +- **Owner-draw precedence** (Task 6) needs an explicit decision; proposal: in `DrawMode != Normal`, the chokepoint does not touch index −1 and modern metrics derive the field height from the owner-draw value instead. |
| 138 | +- **Classic missing-arrow root cause** is expected to be Defect D but must be confirmed via the Task 7 dump before closing; if it turns out to be an overlap issue, Task 1's child-bounds baseline already covers it. |
| 139 | + |
| 140 | +--- |
| 141 | + |
| 142 | +## 6. References (pinned) |
| 143 | + |
| 144 | +- `ComboBox.cs`, `ComboBox.Modern.cs`, `ComboBox.ModernComboAdapter.cs`, `ComboBox.FlatComboAdapter.cs`, `Rendering/ModernControlVisualStyles.cs` — all at |
| 145 | + `https://github.com/KlausLoeffelmann/winforms/blob/d6ca49640903fa4f15be20b427f253bb940855d4/src/System.Windows.Forms/System/Windows/Forms/...` |
| 146 | +- `Control.OnVisualStylesModeChanged` / `VisualStylesModeChangeImpact.Metrics` handling — `Control.cs`, same SHA (~lines 7060–7110). |
| 147 | +- Upstream draft PR: dotnet/winforms#14768 (`Net11/Integration-3`). |
0 commit comments