File: System\Windows\Forms\Controls\ComboBox\ComboBox.Modern.cs
Web Access
Project: src\winforms\src\System.Windows.Forms\System.Windows.Forms.csproj (System.Windows.Forms)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System.Drawing;
using System.Windows.Forms.Layout;
using Windows.Win32.Graphics.Dwm;
 
namespace System.Windows.Forms;
 
public partial class ComboBox
{
    private NativeComboBaseline _nativeComboBaseline;
    private bool _applyingModernComboLayout;
    private bool _nativeComboHandleInitialized;
    private bool _normalizingNativeComboBaseline;
    private int _modernComboLayoutWriteCount;
 
    internal bool UsesModernComboAdapter
        => EffectiveVisualStylesMode >= VisualStylesMode.Net11
            && FlatStyle != FlatStyle.System;
 
    private bool UsesComboAdapter
        => UsesModernComboAdapter
            || FlatStyle is FlatStyle.Flat or FlatStyle.Popup;
 
    private int ModernPreferredHeight
    {
        get
        {
            if (DrawMode != DrawMode.Normal)
            {
                return ItemHeight
                    + (2 * SystemInformation.FixedFrameBorderSize.Height);
            }
 
            return ModernControlVisualStyles.GetPreferredFieldHeight(
                fontHeight: FontHeight,
                fieldPadding: GetModernFieldPadding(),
                deviceDpi: DeviceDpiInternal);
        }
    }
 
    private void ResetComboAdapter()
        => Properties.RemoveValue(s_propFlatComboAdapter);
 
    private unsafe void CaptureNativeComboBaseline(
        COMBOBOXINFO comboBoxInfo)
    {
        if (_nativeComboBaseline.IsCaptured)
        {
            return;
        }
 
        int selectionFieldItemHeight = (int)PInvokeCore.SendMessage(
            this,
            PInvoke.CB_GETITEMHEIGHT,
            (WPARAM)(-1));
 
        if (selectionFieldItemHeight <= 0)
        {
            return;
        }
 
        PInvokeCore.GetWindowRect(this, out RECT windowBounds);
 
        Rectangle editBounds = DropDownStyle == ComboBoxStyle.DropDownList
            || comboBoxInfo.hwndItem.IsNull
                ? Rectangle.Empty
                : GetChildBounds(comboBoxInfo.hwndItem);
 
        Rectangle simpleListBounds = DropDownStyle == ComboBoxStyle.Simple
            && !comboBoxInfo.hwndList.IsNull
                ? GetChildBounds(comboBoxInfo.hwndList)
                : Rectangle.Empty;
 
        _nativeComboBaseline = new NativeComboBaseline
        {
            IsCaptured = true,
            DeviceDpi = DeviceDpiInternal,
            SelectionFieldItemHeight = selectionFieldItemHeight,
            SelectionFieldFrameHeight = Math.Max(
                0,
                windowBounds.Height - selectionFieldItemHeight),
            EditBounds = editBounds,
            SimpleListBounds = simpleListBounds,
            ClientSize = ClientSize
        };
    }
 
    private unsafe void EnsureNativeComboBaseline()
    {
        if (_nativeComboBaseline.IsCaptured
            || !_nativeComboHandleInitialized
            || !IsHandleCreated)
        {
            return;
        }
 
        COMBOBOXINFO comboBoxInfo = default;
        comboBoxInfo.cbSize = (uint)sizeof(COMBOBOXINFO);
 
        if (PInvoke.GetComboBoxInfo(
            HWND,
            ref comboBoxInfo))
        {
            CaptureNativeComboBaseline(comboBoxInfo);
        }
    }
 
    private void InitializeNativeComboBaseline()
    {
        if (_nativeComboBaseline.IsCaptured
            || _normalizingNativeComboBaseline
            || !_nativeComboHandleInitialized
            || !IsHandleCreated)
        {
            return;
        }
 
        _normalizingNativeComboBaseline = true;
 
        try
        {
            // Force the native ComboBox to settle rcItem, rcButton, and child bounds before
            // capturing them. A handle created with a requested height has not done this yet.
            UpdateStylesCore();
        }
        finally
        {
            _normalizingNativeComboBaseline = false;
        }
 
        EnsureNativeComboBaseline();
    }
 
    private ModernComboTargetState ComputeModernComboTargetState()
    {
        bool usesModernMetrics = UsesModernComboAdapter;
        int selectionFieldItemHeight = 0;
 
        if (DropDownStyle != ComboBoxStyle.Simple
            && DrawMode == DrawMode.Normal)
        {
            int desiredHeight = usesModernMetrics
                ? ModernPreferredHeight
                : GetClassicPreferredHeight();
 
            selectionFieldItemHeight = Math.Max(
                1,
                desiredHeight
                    - ScaleNativeBaselineValue(_nativeComboBaseline.SelectionFieldFrameHeight));
        }
 
        Padding chromeInsets = usesModernMetrics
            ? GetModernChromeInsets()
            : Padding.Empty;
 
        GetAdjustedNativeChildBounds(
            out Rectangle editBounds,
            out Rectangle simpleListBounds);
 
        Padding editMargins = Padding.Empty;
 
        if (usesModernMetrics && !editBounds.IsEmpty)
        {
            int topInset = chromeInsets.Top + Padding.Top;
            int bottomInset = chromeInsets.Bottom + Padding.Bottom;
            editBounds.Y += topInset;
 
            editBounds.Height = Math.Max(
                1,
                editBounds.Height - topInset - bottomInset);
 
            editBounds.Height = Math.Max(
                1,
                Math.Min(
                    editBounds.Height,
                    ClientRectangle.Bottom
                        - bottomInset
                        - editBounds.Top));
 
            // Inset the edit window horizontally so its rectangular corners clear the rounded
            // field arcs, and reserve the (now wider) drop-down button on the button side.
            int leftInset = chromeInsets.Left + Padding.Left;
            int rightInset = chromeInsets.Right + Padding.Right;
            int extraButtonWidth = DropDownStyle == ComboBoxStyle.Simple
                ? 0
                : ScaleHelper.ScaleToDpi(
                    ModernControlVisualStyles.ComboBoxButtonExtraWidth,
                    DeviceDpiInternal);
 
            if (RightToLeft == RightToLeft.Yes)
            {
                // In RTL the drop-down button sits on the left, so its reservation goes there.
                editBounds.X += rightInset + extraButtonWidth;
            }
            else
            {
                editBounds.X += leftInset;
            }
 
            editBounds.Width = Math.Max(
                1,
                editBounds.Width - leftInset - rightInset - extraButtonWidth);
 
            if (!simpleListBounds.IsEmpty)
            {
                int simpleListBottom = GetCurrentSimpleListBottom(
                    simpleListBounds.Bottom);
 
                simpleListBounds.Y = editBounds.Bottom + bottomInset;
 
                simpleListBounds.Height = Math.Max(
                    1,
                    simpleListBottom - simpleListBounds.Y);
            }
        }
 
        return new ModernComboTargetState
        {
            SelectionFieldItemHeight = selectionFieldItemHeight,
            EditMargins = editMargins,
            EditBounds = editBounds,
            SimpleListBounds = simpleListBounds
        };
    }
 
    private void GetAdjustedNativeChildBounds(
        out Rectangle editBounds,
        out Rectangle simpleListBounds)
    {
        editBounds = ScaleNativeBaselineRectangle(
            _nativeComboBaseline.EditBounds);
 
        simpleListBounds = ScaleNativeBaselineRectangle(
            _nativeComboBaseline.SimpleListBounds);
 
        Size baselineClientSize = ScaleNativeBaselineSize(
            _nativeComboBaseline.ClientSize);
 
        int widthDelta = ClientSize.Width - baselineClientSize.Width;
        int heightDelta = ClientSize.Height - baselineClientSize.Height;
 
        if (!editBounds.IsEmpty)
        {
            editBounds.Width = Math.Max(
                1,
                editBounds.Width + widthDelta);
 
            if (DropDownStyle == ComboBoxStyle.Simple)
            {
                int selectionFieldItemHeight = (int)PInvokeCore.SendMessage(
                    this,
                    PInvoke.CB_GETITEMHEIGHT,
                    (WPARAM)(-1));
 
                editBounds.Height = Math.Max(
                    1,
                    editBounds.Height
                        + selectionFieldItemHeight
                        - ScaleNativeBaselineValue(
                            _nativeComboBaseline.SelectionFieldItemHeight));
            }
            else
            {
                editBounds.Height = Math.Max(
                    1,
                    editBounds.Height + heightDelta);
            }
        }
 
        if (!simpleListBounds.IsEmpty)
        {
            simpleListBounds.Width = Math.Max(
                1,
                simpleListBounds.Width + widthDelta);
 
            simpleListBounds.Height = Math.Max(
                1,
                simpleListBounds.Height + heightDelta);
        }
    }
 
    private unsafe int GetCurrentSimpleListBottom(
        int fallback)
    {
        COMBOBOXINFO comboBoxInfo = default;
        comboBoxInfo.cbSize = (uint)sizeof(COMBOBOXINFO);
 
        return !PInvoke.GetComboBoxInfo(
            hwndCombo: HWND,
            pcbi: ref comboBoxInfo)
            || comboBoxInfo.hwndList.IsNull
                ? fallback
                : GetChildBounds(comboBoxInfo.hwndList).Bottom;
    }
 
    private int ScaleNativeBaselineValue(int value)
    {
        if (_nativeComboBaseline.DeviceDpi == DeviceDpiInternal)
        {
            return value;
        }
 
        return (int)Math.Round(
            value * DeviceDpiInternal / (double)_nativeComboBaseline.DeviceDpi);
    }
 
    private Rectangle ScaleNativeBaselineRectangle(Rectangle bounds)
        => bounds.IsEmpty
            ? Rectangle.Empty
            : new(
                x: ScaleNativeBaselineValue(bounds.X),
                y: ScaleNativeBaselineValue(bounds.Y),
                width: ScaleNativeBaselineValue(bounds.Width),
                height: ScaleNativeBaselineValue(bounds.Height));
 
    private Size ScaleNativeBaselineSize(Size size)
        => new(
            width: ScaleNativeBaselineValue(size.Width),
            height: ScaleNativeBaselineValue(size.Height));
 
    /// <summary>
    ///  Applies the complete native ComboBox target for the current managed state.
    /// </summary>
    private unsafe void ApplyModernComboLayout()
    {
        if (!IsHandleCreated)
        {
            ApplyManagedPreferredHeightBeforeHandle();
            return;
        }
 
        if (!_nativeComboBaseline.IsCaptured
            && !UsesModernComboAdapter)
        {
            return;
        }
 
        InitializeNativeComboBaseline();
 
        if (_applyingModernComboLayout
            || _normalizingNativeComboBaseline
            || !_nativeComboBaseline.IsCaptured)
        {
            return;
        }
 
        _applyingModernComboLayout = true;
 
        try
        {
            ModernComboTargetState target = ComputeModernComboTargetState();
            ApplySelectionFieldItemHeight(target.SelectionFieldItemHeight);
 
            // Changing the selection-field height synchronously changes the native client geometry.
            target = ComputeModernComboTargetState();
 
            COMBOBOXINFO comboBoxInfo = default;
            comboBoxInfo.cbSize = (uint)sizeof(COMBOBOXINFO);
 
            if (!PInvoke.GetComboBoxInfo(
                HWND,
                ref comboBoxInfo))
            {
                return;
            }
 
            if (!target.EditBounds.IsEmpty)
            {
                ApplyEditMargins(
                    comboBoxInfo.hwndItem,
                    target.EditMargins);
            }
 
            ApplyChildBounds(
                comboBoxInfo.hwndItem,
                target.EditBounds);
 
            ApplyChildBounds(
                comboBoxInfo.hwndList,
                target.SimpleListBounds);
        }
        finally
        {
            _applyingModernComboLayout = false;
        }
    }
 
    private void ApplyManagedPreferredHeightBeforeHandle()
    {
        if (DropDownStyle == ComboBoxStyle.Simple)
        {
            return;
        }
 
        int targetHeight = UsesModernComboAdapter
            ? ModernPreferredHeight
            : GetClassicPreferredHeight();
 
        if (Height != targetHeight)
        {
            Height = targetHeight;
        }
    }
 
    private void ApplySelectionFieldItemHeight(int targetHeight)
    {
        if (targetHeight <= 0)
        {
            return;
        }
 
        int currentHeight = (int)PInvokeCore.SendMessage(
            this,
            PInvoke.CB_GETITEMHEIGHT,
            (WPARAM)(-1));
 
        if (currentHeight == targetHeight)
        {
            return;
        }
 
        PInvokeCore.SendMessage(
            this,
            PInvoke.CB_SETITEMHEIGHT,
            (WPARAM)(-1),
            (LPARAM)targetHeight);
 
        _modernComboLayoutWriteCount++;
    }
 
    private void ApplyEditMargins(
        HWND editHandle,
        Padding targetMargins)
    {
        if (editHandle.IsNull)
        {
            return;
        }
 
        nint currentMargins = PInvokeCore.SendMessage(
            editHandle,
            PInvokeCore.EM_GETMARGINS);
 
        int currentLeft = PARAM.LOWORD(currentMargins);
        int currentRight = PARAM.HIWORD(currentMargins);
 
        if (currentLeft == targetMargins.Left
            && currentRight == targetMargins.Right)
        {
            return;
        }
 
        PInvokeCore.SendMessage(
            editHandle,
            PInvokeCore.EM_SETMARGINS,
            (WPARAM)(PInvoke.EC_LEFTMARGIN | PInvoke.EC_RIGHTMARGIN),
            LPARAM.MAKELPARAM(
                targetMargins.Left,
                targetMargins.Right));
        _modernComboLayoutWriteCount++;
    }
 
    private void ApplyChildBounds(HWND childHandle, Rectangle targetBounds)
    {
        if (childHandle.IsNull
            || targetBounds.IsEmpty
            || GetChildBounds(childHandle) == targetBounds)
        {
            return;
        }
 
        PInvoke.SetWindowPos(
            childHandle,
            HWND.Null,
            targetBounds.Left,
            targetBounds.Top,
            targetBounds.Width,
            targetBounds.Height,
            SET_WINDOW_POS_FLAGS.SWP_NOACTIVATE
                | SET_WINDOW_POS_FLAGS.SWP_NOZORDER);
 
        _modernComboLayoutWriteCount++;
    }
 
    private Rectangle GetChildBounds(HWND child)
    {
        PInvokeCore.GetWindowRect(child, out RECT bounds);
 
        Point topLeft = PointToClient(
            new Point(bounds.left, bounds.top));
 
        return new Rectangle(
            topLeft,
            new Size(bounds.Width, bounds.Height));
    }
 
    private Padding GetModernChromeInsets()
    {
        // Minimal modern field inset plus a small arc-clearance so the flat native edit child's
        // rectangular corners are not painted into the rounded-corner arcs. This is chrome padding
        // only; the caller still adds the user's Padding on top (see ComputeModernComboTargetState
        // and GetModernFieldPadding). It deliberately excludes the classic 3D-border metrics
        // (Fixed3DBorderPadding / InternalChromeInset) that the previous implementation inherited
        // from the classic field model; the modern field owns a single rounded border across the
        // full control, so those insets only produced a spurious inner margin.
        int inset = ScaleHelper.ScaleToDpi(
            ModernControlVisualStyles.BorderThickness
                + ModernControlVisualStyles.ComboBoxStyleInset
                + ModernControlVisualStyles.ComboBoxFieldArcClearance,
            DeviceDpiInternal);
 
        return new Padding(inset);
    }
 
    private Rectangle GetNativeComboBaselineEditBounds()
        => _nativeComboBaseline.EditBounds;
 
    private Rectangle GetNativeComboBaselineSimpleListBounds()
        => _nativeComboBaseline.SimpleListBounds;
 
    private int GetNativeComboBaselineSelectionFieldItemHeight()
        => _nativeComboBaseline.SelectionFieldItemHeight;
 
    private int GetModernComboLayoutWriteCount()
        => _modernComboLayoutWriteCount;
 
    private Padding GetModernFieldPadding()
    {
        SystemVisualSettings settings = Application.SystemVisualSettings;
 
        int styleInset = ScaleHelper.ScaleToDpi(
            ModernControlVisualStyles.ComboBoxStyleInset,
            DeviceDpiInternal);
 
        // Vertical clearance is kept on the classic-derived field model so the modern preferred
        // height stays aligned with TextBox (GetPreferredFieldHeight only consumes the vertical
        // component). Horizontal clearance uses the minimal modern field inset so the field text
        // and drop-down-list caption are not over-inset by classic 3D metrics.
        Padding verticalSource = ModernControlVisualStyles.GetFieldPadding(
            BorderStyle.Fixed3D,
            Padding + new Padding(styleInset),
            settings.FocusBorderMetrics,
            settings.TextScaleFactor,
            DeviceDpiInternal);
 
        Padding horizontalSource = GetModernChromeInsets();
 
        return new Padding(
            left: horizontalSource.Left + Padding.Left,
            top: verticalSource.Top,
            right: horizontalSource.Right + Padding.Right,
            bottom: verticalSource.Bottom);
    }
 
    /// <summary>
    ///  Hit-tests a left-button click against the modern drop-down button.
    /// </summary>
    /// <param name="clientPoint">The click location in client coordinates.</param>
    /// <returns>
    ///  <see langword="true"/> when the click lands on the drop-down button (or anywhere in a
    ///  drop-down-list combo, which acts as a single button); otherwise <see langword="false"/>.
    /// </returns>
    /// <remarks>
    ///  <para>
    ///   Modern VisualStyles mode expands the client area over the themed drop-down button via
    ///   the <c>WM_NCCALCSIZE</c> handling in <see cref="WndProc"/>, so the button is no longer
    ///   part of the native non-client area. As a result comctl32 stops toggling the list when
    ///   the button is clicked, and <see cref="WndProc"/> drives <see cref="DroppedDown"/> from
    ///   this hit-test instead.
    ///  </para>
    /// </remarks>
    private bool IsModernDropDownButtonClick(Point clientPoint)
    {
        if (!UsesModernComboAdapter || DropDownStyle == ComboBoxStyle.Simple)
        {
            return false;
        }
 
        // A drop-down-list combo behaves as a single button, so any click toggles the list.
        // An editable combo toggles only when the click lands on the drop-down button itself,
        // leaving text-area clicks to the hosted edit control.
        return DropDownStyle == ComboBoxStyle.DropDownList
            || FlatComboBoxAdapter._dropDownRect.Contains(clientPoint);
    }
 
    private unsafe void ApplyModernDropDownCornerPreference(
        HWND dropDownHandle)
    {
        if (DropDownStyle == ComboBoxStyle.Simple
            || dropDownHandle.IsNull
            || !OsVersion.IsWindows11_OrGreater())
        {
            return;
        }
 
        DWM_WINDOW_CORNER_PREFERENCE preference = UsesModernComboAdapter
            ? DWM_WINDOW_CORNER_PREFERENCE.DWMWCP_ROUNDSMALL
            : DWM_WINDOW_CORNER_PREFERENCE.DWMWCP_DEFAULT;
 
        _ = PInvoke.DwmSetWindowAttribute(
            dropDownHandle,
            DWMWINDOWATTRIBUTE.DWMWA_WINDOW_CORNER_PREFERENCE,
            &preference,
            sizeof(DWM_WINDOW_CORNER_PREFERENCE));
    }
 
    private unsafe void RefreshModernDropDownCornerPreference()
    {
        if (!IsHandleCreated
            || DropDownStyle == ComboBoxStyle.Simple
            || !OsVersion.IsWindows11_OrGreater())
        {
            return;
        }
 
        COMBOBOXINFO comboBoxInfo = default;
        comboBoxInfo.cbSize = (uint)sizeof(COMBOBOXINFO);
 
        if (PInvoke.GetComboBoxInfo(
            HWND,
            ref comboBoxInfo))
        {
            ApplyModernDropDownCornerPreference(
                comboBoxInfo.hwndList);
        }
 
        ApplyModernDropDownCornerPreference(
            _dropDownHandle);
    }
 
    internal static bool SupportsModernDropDownCorners(Version windowsVersion)
        => windowsVersion >= new Version(10, 0, 22000);
 
    internal override Size GetPreferredSizeCore(
        Size proposedConstraints)
    {
        Size preferredSize = base.GetPreferredSizeCore(
            proposedConstraints);
 
        if (UsesModernComboAdapter
            && DropDownStyle != ComboBoxStyle.Simple)
        {
            preferredSize.Height = ModernPreferredHeight;
        }
 
        return preferredSize;
    }
 
    /// <inheritdoc/>
    protected override void OnVisualStylesModeChanged(EventArgs e)
    {
        ResetComboAdapter();
        ResetHeightCache();
 
        // Crossing the modern/classic boundary is reported as VisualStylesModeChangeImpact.Recreate
        // (see GetVisualStylesModeChangeImpact), so the base recreates the handle here. That is the
        // only clean way to unwind the modern native-window state (the WM_NCCALCSIZE client
        // expansion and the per-handle modern baseline); a fresh classic handle then behaves exactly
        // as before, and a fresh modern handle captures a clean baseline.
        base.OnVisualStylesModeChanged(e);
        ApplyModernComboLayout();
        RefreshModernDropDownCornerPreference();
    }
 
    /// <inheritdoc/>
    protected override void OnSystemVisualSettingsChanged(
        SystemVisualSettingsChangedEventArgs e)
    {
        base.OnSystemVisualSettingsChanged(e);
 
        if (!UsesModernComboAdapter)
        {
            return;
        }
 
        if ((e.Changed
            & (SystemVisualSettingsCategories.TextScale
                | SystemVisualSettingsCategories.FocusMetrics)) != 0)
        {
            ResetComboAdapter();
            ResetHeightCache();
            CommonProperties.xClearPreferredSizeCache(this);
            ApplyModernComboLayout();
 
            LayoutTransaction.DoLayout(
                this,
                this,
                PropertyNames.SystemVisualSettings);
 
            if (ParentInternal is { } parent)
            {
                LayoutTransaction.DoLayout(
                    parent,
                    this,
                    PropertyNames.SystemVisualSettings);
            }
        }
 
        if ((e.Changed
            & (SystemVisualSettingsCategories.TextScale
                | SystemVisualSettingsCategories.FocusMetrics
                | SystemVisualSettingsCategories.AccentColor)) != 0)
        {
            Invalidate();
        }
    }
 
    /// <inheritdoc/>
    protected override void OnPaddingChanged(EventArgs e)
    {
        ResetComboAdapter();
        ResetHeightCache();
        base.OnPaddingChanged(e);
 
        if (!UsesModernComboAdapter)
        {
            return;
        }
 
        CommonProperties.xClearPreferredSizeCache(this);
        ApplyModernComboLayout();
        Invalidate();
    }
 
    /// <inheritdoc/>
    protected override void OnLayout(LayoutEventArgs levent)
    {
        base.OnLayout(levent);
        ApplyModernComboLayout();
    }
 
    /// <inheritdoc/>
    /// <remarks>
    ///  <para>
    ///   Net11 and later share ComboBox field metrics. Crossing the modern/classic boundary bakes or
    ///   removes native-window state (the WM_NCCALCSIZE client expansion and the per-handle modern
    ///   baseline), which cannot be unwound in place, so the handle is recreated. Modern-to-modern
    ///   transitions only repaint. <see cref="FlatStyle.System"/> remains native.
    ///  </para>
    /// </remarks>
    protected override VisualStylesModeChangeImpact GetVisualStylesModeChangeImpact(
        VisualStylesMode oldMode,
        VisualStylesMode newMode)
    {
        if (FlatStyle == FlatStyle.System)
        {
            return VisualStylesModeChangeImpact.None;
        }
 
        bool oldUsesModernMetrics = oldMode >= VisualStylesMode.Net11;
        bool newUsesModernMetrics = newMode >= VisualStylesMode.Net11;
 
        return oldUsesModernMetrics != newUsesModernMetrics
            ? VisualStylesModeChangeImpact.Recreate
            : VisualStylesModeChangeImpact.Repaint;
    }
 
    /// <inheritdoc/>
    protected override void RescaleConstantsForDpi(
        int deviceDpiOld,
        int deviceDpiNew)
    {
        ResetComboAdapter();
        ResetHeightCache();
        base.RescaleConstantsForDpi(deviceDpiOld, deviceDpiNew);
        ApplyModernComboLayout();
    }
}