dnail
dnail

Reputation: 31

How to make combobox list item selection case sensitive

I have a combobox that contains 3 strings in its Items:

1 2 3

If I type "3" into the combobox's text field and then click the down arrow to open the dropdown list then the "3" is selected and highlighted.

However, if the combobox contains the following 3 strings:

zebra Zebra ZEBRA

If I type the all upper-case string "ZEBRA" into the combobox's text field and then click the down arrow to open the dropdown list then "zebra" is selected and highlighted and the text field changes to "zebra". I believe what is happening in this example is that the first item in the list is always selected. That is, if I change the order of the strings to:

Zebra zebra ZEBRA

then when I type the all upper-case "ZEBRA" into the text field and open the dropdown then the first item "Zebra" is selected and the text field changes to "Zebra".

As a test, I have tried overriding the combobox's DropDown event and setting the SelectedIndex to 2 but when the dropdown list is shown the selected string is always the first string in the list, i.e. the SelectedIndex reverts to 0.

What I want to happen is that the string type I type into the text field is selected in the list when the dropdown is opened.

    public partial class Form1 : Form
    {
        ComboBox comboBox1 = new ComboBox();

        public Form1()
        {
            InitializeComponent();
            comboBox1.Items.Add("zebra");
            comboBox1.Items.Add("Zebra");
            comboBox1.Items.Add("ZEBRA");
            comboBox1.DropDown += ComboBox1_DropDown;
            this.Controls.Add(comboBox1);
        }

        private void ComboBox1_DropDown(object? sender, EventArgs e)
        {
            // this has no effect when the list opens; uncomment to test
            //comboBox1.SelectedIndex = 2;
        }
    }

Upvotes: 1

Views: 239

Answers (4)

IV.
IV.

Reputation: 9438

As I mentioned in a comment, your answer is quite elegant; I like it a lot ▲. This supplementary answer shouldn't in any way diminish what you've accomplished; it's just to take a closer look at those unexplained events that occur in the native code after the DropDown event handler returns. If you didn't already know how to hook the ListBox messages (distinct from the ComboBox messages) this shows how.


Before posting my first answer, I had already tried the subclassing approach, and saw the same behavior on the DropDown event that you did, where "waiting for the method to return" by using BeginInvoke was insufficient because the Win32 message that causes the unwanted selection hasn't been added to the message queue yet, and that, sure, adding a "magic delay" to compensate does work. But I couldn't quite let it go without trying to ID a specific message and see what it would take to suppress that one msg.

What's tricky here is that this particular message cannot be observed in the WndProc of the ComboBox control or hooked in IMessageFilter because the ListBox portion has its own separate native control, with its own hWnd and WndProc. But since you're already making such good use of P/Invoke we can go ahead and use that to get the native handle for the list box.


Get ListBox Native HWND


private const uint CB_GETCOMBOBOXINFO = 0x0164;
[StructLayout(LayoutKind.Sequential)]
private struct COMBOBOXINFO
{
    public int cbSize;
    public RECT rcItem;
    public RECT rcButton;
    public int stateButton;
    public IntPtr hwndCombo;
    public IntPtr hwndEdit;
    public IntPtr hwndList;
}

[StructLayout(LayoutKind.Sequential)]
private struct RECT
{
    public int left;
    public int top;
    public int right;
    public int bottom;
}

public static void GetNativeListBoxHandle(ComboBox comboBox, out IntPtr hwndList)
{
    COMBOBOXINFO comboBoxInfo = new COMBOBOXINFO();
    comboBoxInfo.cbSize = Marshal.SizeOf(comboBoxInfo);
    IntPtr comboBoxHandle = comboBox.Handle;
    IntPtr result = SendMessage(comboBoxHandle, CB_GETCOMBOBOXINFO, IntPtr.Zero, ref comboBoxInfo);
    if (result == IntPtr.Zero)
    {
        throw new InvalidOperationException("Failed to retrieve ComboBox information.");
    }
    hwndList = comboBoxInfo.hwndList;
}

Monitor Listbox Win32 Messages

Using the retrieved handle, we can hook into its WndProc like this and start being able to see messages like LB_GETCURSEL and LB_SETCURSEL for example that we couldn't see otherwise. The message that I got traction with was LB_SETTOPINDEX. Although ostensibly it doesn't select anything on its own, by suppressing this event I could prevent any selection from occurring and fire an event to make something else happen instead.

private class ListBoxNativeWindow : NativeWindow
{
    public ListBoxNativeWindow(IntPtr handle) => AssignHandle(handle);
    protected override void WndProc(ref Message m)
    {
        var e = new CancelMessageEventArgs(m);
        switch ((WindowsMessages)m.Msg)
        {
            case WindowsMessages.LB_SETTOPINDEX:
                LB_SETTOPINDEX?.Invoke(this, e);
                break;
            default:
                break;
        }
        if (e.Cancel)
        {
            m.Result = 1;
        }
        else
        {
            base.WndProc(ref m);
        }
    }
    public event EventHandler<CancelMessageEventArgs>? LB_SETTOPINDEX;
}

Alternative Approach, Leveraging LB Messages

Obviously, this has the potential clear up most of the mystery that surrounds what's happening in the native LB. Here's one thing I tried, that seemed fairly effective in meeting your spec, at least as I understand it.

public class ComboBoxCS : ComboBox
{
    IntPtr _hwndListBox;
    ListBoxNativeWindow? _listBoxNativeWindow { get; set; }
    protected override void OnCreateControl()
    {
        base.OnCreateControl();
        GetNativeListBoxHandle(this, out _hwndListBox);
        _listBoxNativeWindow = new ListBoxNativeWindow(_hwndListBox);
        _listBoxNativeWindow.LB_SETTOPINDEX += (sender, e) =>
        {
            if(DroppedDown)
            {
                if (CaseSensitiveMatchIndex != -1)
                {
                    if (SelectedIndex != CaseSensitiveMatchIndex)
                    {
                        LockWindowUpdate(this.Handle);
                        e.Cancel = true;
                        BeginInvoke(() =>
                        {
                            SelectedIndex = CaseSensitiveMatchIndex;
                            LockWindowUpdate(IntPtr.Zero);
                        });
                    }
                }
            }
        };
    }
    Keys _key = Keys.None;
    private int _selectionStartB4;
    protected override void OnKeyDown(KeyEventArgs e)
    {
        base.OnKeyDown(e);
        _key = e.KeyData;
        if (_key == Keys.Return)
        {
            BeginInvoke(() => SelectAll());
        }
        else
        {
            // Capture, e.g. "pre-backspace"
            _selectionStartB4 = SelectionStart;
        }
    }
    protected override void OnSelectionChangeCommitted(EventArgs e)
    {
        base.OnSelectionChangeCommitted(e);
        CaseSensitiveMatchIndex = SelectedIndex;
    }
    protected override void OnTextChanged(EventArgs e)
    {
        base.OnTextChanged(e);
        var captureKey = _key;
        _key = Keys.None;
        if (captureKey == Keys.None)
        {
            // Text is changing programmatically.
            // Do not recalculate auto-complete here.

            // This next block fixes an artifact of drop closing without committing the selection.
            if (CaseSensitiveMatchIndex != -1 )
            {
                var sbText = Items[CaseSensitiveMatchIndex]?.ToString() ?? String.Empty;
                Debug.WriteLine($"{CaseSensitiveMatchIndex} is:{Text} sb:{sbText}");
                if(Text != sbText)
                {
                    BeginInvoke(() => Text = sbText);
                }
            }
        }
        else
        {
            BeginInvoke(() =>
            {
                if (captureKey == Keys.Back)
                {
                    SelectionStart = Math.Max(0, _selectionStartB4 - 1);
                    if(SelectionStart == 0) // Backspaced to the start
                    {
                        BeginInvoke(() =>
                        {
                            CaseSensitiveMatchIndex = -1;
                            Text = string.Empty;
                        });
                        return;
                    }
                }
                var substr = Text.Substring(0, SelectionStart);
                if (string.IsNullOrEmpty(substr))
                {
                    CaseSensitiveMatchIndex = -1;
                }
                else
                {
                    Debug.WriteLine(substr);
                    int i;
                    for (i = 0; i < Items.Count; i++)
                    {
                        if ((Items[i]?.ToString() ?? string.Empty).StartsWith(substr))
                        {
                            SelectIndexAndRestoreCursor(i);
                            break;
                        }
                    }
                    CaseSensitiveMatchIndex = i == Items.Count ? -1 : i;
                }
            });
        }
    }
    void SelectIndexAndRestoreCursor(int index)
    {
        BeginInvoke(() =>
        {
            var selStartB4 = SelectionStart;
            SelectedIndex = index;
            SelectionStart = selStartB4;
            SelectionLength = Text.Length - SelectionStart;
        });
    }

    public int CaseSensitiveMatchIndex { get; private set; } = -1;

    #region P I N V O K E
    ...
    #endregion P I N V O K E
}

class CancelMessageEventArgs : CancelEventArgs
{
    public CancelMessageEventArgs(Message message) => Message = message;

    public Message Message { get; }
}

Upvotes: 0

dnail
dnail

Reputation: 31

I'm a newbie here and I hope I'm not out of line if I post an answer to my own question. Here's what I came up with.

public partial class Form1 : Form
{
    public Form1()
    {
        InitializeComponent();
        this.DoubleBuffered = true;

        object[] olist = { "andy", "Andy", "bob", "Bob", "cathy", "Cathy", "$dollar", "zebra", "Zebra", "ZEBRA" };

        CaseSensitiveComboBox cscb = new CaseSensitiveComboBox();
        cscb.populate(olist);
        this.Controls.Add(cscb);
    }
}


/// <summary>
/// A case-sensitive ComboBox implementation with a couple of added features.
/// Mild editing may be required if anything other than strings are used to
/// populate the ComboBox's Items collection, i.e. calling the item's
/// "ToString()" method rather than using a cast as is done here.
/// </summary>
internal class CaseSensitiveComboBox : ComboBox
{
    [DllImport("user32")] public static extern bool LockWindowUpdate(IntPtr hWndLock);
    private bool _editing;

    internal CaseSensitiveComboBox()
    {
        this.DoubleBuffered = true;
        this.DropDown += ComboBox1_DropDown;
        this.DropDownClosed += ComboBox1_DropDownClosed;
        this.KeyDown += ComboBox1_KeyDown;
        this.TextChanged += ComboBox1_TextChanged;
    }

    internal void populate(object[] olist)
    {
        this.Items.AddRange(olist);
    }

    /// <summary>
    /// When a ComboBox's dropdown list is opened, the default implementation seems
    /// to select the first item in its Items collection that matches the text in its
    /// text field using a non-case-sensitive search. The following implementation
    /// selects the first item using a case-sensitive search. This implementation also
    /// keeps the text in the text field from flashing 2 possibly different text values
    /// (see method "selectIndex()" for an explanation).
    /// </summary>
    private void ComboBox1_DropDown(object? sender, EventArgs e)
    {
        // Do a case-sensitive search to find the first item in the ComboBox's Items
        // collection that starts with the string currently in the ComboBox's text field.
        int matchingIndex = -1;
        for (int i = 0; i < this.Items.Count; i++)
        {
            if (((string)this.Items[i]!).StartsWith(this.Text))
            {
                matchingIndex = i;
                break;
            }
        }

        // If a match was found use a Timer to select it.
        if (matchingIndex >= 0)
            selectIndex(matchingIndex);
    }

    /// <summary>
    /// If the user is using the up/down arrow keys on the keyboard to select an item
    /// when the ComboBox's drop down list is open and then clicks the ComboBox's down
    /// arrow icon or presses F4 to close it, this re-selects the user's currently
    /// selected item. Seems weird but this must be done.
    /// </summary>
    private void ComboBox1_DropDownClosed(object? sender, EventArgs e)
    {
        // Use a Timer to re-select the user's item.
        if (this.SelectedIndex >= 0)
            selectIndex(this.SelectedIndex);
    }

    /// <summary>
    /// This provides some elemental editing for the ComboBox's text field.
    /// </summary>
    private void ComboBox1_KeyDown(object? sender, KeyEventArgs e)
    {
        switch (e.KeyCode)
        {
            // Track when the user is editing the text in the ComboBox's text field.
            case Keys.Delete:
            case Keys.Back:
                _editing = true;
                break;

            // Unselect any selected text in the ComboBox's text field if the user
            // presses the right arrow key when the shift key is not down. Otherwise
            // if any chars are selected then the caret jumps to the end of the entire
            // string rather than unselecting any selected text and remaining in place.
            case Keys.Right:
                if (e.Shift == false && this.SelectionLength != 0)
                {
                    this.SelectionLength = 0;  // unselect any selected text
                    --this.SelectionStart;     // leave the caret where it was
                }
                break;

            // Reset the editing flag for any other key.
            default:
                _editing = false;
                break;
        }
    }

    /// <summary>
    /// This method implements auto-completion as the user types in the ComboBox's text field.
    /// It also auto-tracks what the user is typing in the drop down list. That is, if the
    /// ComboBox's drop down list is open then, if a matching item exists, it is selected as
    /// the user types.
    /// </summary>
    private void ComboBox1_TextChanged(object? sender, EventArgs e)
    {
        // Do nothing if the user is editing the current text in the ComboBox's text field.
        if (_editing == false)
        {
            _editing = true;

            // Cache how many chars in the ComboBox's text field that are not selected.
            int count = this.Text.Length - this.SelectionLength;

            // Do a case-sensitive search to find the first item in the ComboBox's Items
            // collection that starts with the string of chars that are not selected in the
            // ComboBox's text field.
            int matchingIndex = -1;
            for (int i = 0; i < this.Items.Count; i++)
            {
                if (((string)this.Items[i]!).StartsWith(this.Text.Substring(0, count)))
                {
                    matchingIndex = i;
                    break;
                }
            }

            // If we found a match...
            if (matchingIndex >= 0)
            {
                // Track what the user is typing by setting the ComboBox's selected index
                // to the matching index. !!! NOTE !!! The assignment in this line of code
                // apparently is processed and completes before the next line of code is
                // processed. See the summary comment for the "selectIndex" method below.
                this.SelectedIndex = matchingIndex;

                // Place the caret at the end of the chars that were typed by the user.
                this.SelectionStart = count;

                // Select the chars in the ComoboBox's text field that were not typed by
                // the user. That is, select the auto-filled text.
                this.SelectionLength = this.Text.Length - count;
            }
            else
            {
                // Do nothing so that the caret stays where the user stopped typing.
            }
        }

        _editing = false;
    }

    /// <summary>
    /// This is a hack. Its purpose is to provide a method of selecting an item in the
    /// ComboBox's list of items while the drop down list is being opened or closed.
    /// I "think" this is needed because I "think" the following is what happens:
    /// 1) When the ComboBox's DropDown event handler is executing and
    /// 2) it sets a new/different SelectedIndex then 
    /// 3) the SelectedIndex is immediately processed and finishes then
    /// 4) execution returns to the DropDown handler which
    /// 5) resets the SelectedIndex back to the value that it was when the DropDown handler began.
    /// So what needs to happen is that when the new SelectedIndex is assigned from
    /// within the DropDown event handler then the assignment needs to wait until the
    /// DropDown event handler has returned. Thus the use of the Timer in this method.
    /// There's probably a better and more accepted process for doing this but, at the
    /// moment, I don't know what that would be. Hopefully someone will have a better
    /// solution and provide it for all to see.
    private void selectIndex(int index)
    {
        // Disable this window's drawing so that the user doesn't see the
        // result of the ComboBox selecting an item. Without this the user
        // will see the text field flash the default ComboBox's non-case-sensitive
        // selected item and then the desired item at the given index.
        LockWindowUpdate(this.Handle);

        // Use a timer to set the ComboBox's selected index to the given index sometime
        // in the very near future. 10ms is a guess as to much time the current event
        // (DropDown, DropDownClosed, etc.) needs to complete its work.
        System.Windows.Forms.Timer timer = new System.Windows.Forms.Timer();
        timer.Interval = 10;
        timer.Tick += (sender, args) =>
        {
            timer.Stop();
            timer.Dispose();

            // Set the ComboBox's selected index to the given index.
            this.SelectedIndex = index;

            // Set the text in the ComboBox's text field.
            this.Text = (string)this.Items[index]!;

            // Select all of the text in the ComboBox's text field.
            this.SelectAll();

            // Re-enable window drawing.
            LockWindowUpdate((IntPtr)0);
        };
        timer.Start();
    }
}

Upvotes: 2

IV.
IV.

Reputation: 9438

At first I tried subclassing a ComboBox to try and bend it to have the case sensitive qualities you describe, but similar to the reporting in the comments I found this particular behavior to be especially challenging even though I do this kind of thing a lot. In cases like this it's fairly straightforward to create a custom "combo box" from scratch, i.e. without subclassing ComboBox or its TextBox and ListBox components.

case-sensitive tracking


The Essentials

The general idea is that you just need the basic elements of text entry, a toggle for the visibility of a top-level container (i.e. a borderless Form, configured to display list items), and a dynamic means to track any movement of the custom control (or more likely, the parent form of the custom) so that the "list box" stays stuck to the main control.

  • Using a single-line RichTextBox is a good way to get text entry without the "focus underline" artifact of TextBox.

  • Using something like a TableLayoutPanel to contain the text entry control and the drop down icon leaves open the possibility for extra functional icons. You could, for example, implement a dynamic add behavior, represented by a '+' symbol.

  • The "drop down triangle" can be a label, with a simple unicode ▼ symbol as text, that toggles the visibility of a borderless, top-level form.

  • The location of the form, which can be used flexibly to display a list of items, is bound (in some respects) to the size of the main control, and to movements of the main control's containing form.

  • Using something like a FlowLayoutPanel docked (fill) to the form provides a flexible container for a variety of data templates. You could, for example, have items with check boxes, functional color schemes, or a dynamic delete behavior.


You can browse a full example repo and you can play around with it as a starting point for your project. See also: https://stackoverflow.com/a/78998658/5438626 which demonstrates a version with dynamic features.

The TL;DR is that we're going to track text changes on the RichTextBox to search the view templates in the _dropDownContainer_ and visually select the first case-sensitive match. Clicking on a line item:

  • Sets the SelectedIndex property
  • Copies the line item text to richTexBox
  • Closes the drop list.
public class CaseSensitiveComboBox : Panel
{
    [DesignerSerializationVisibility(DesignerSerializationVisibility.Content)]
    [Bindable(true)]
    public BindingList<object> Items { get; } = new BindingList<object>();

    public CaseSensitiveComboBox()
    {
        var tableLayoutPanel = new TableLayoutPanel
        {
            Dock = DockStyle.Fill,
            ColumnCount = 2,
        };
        tableLayoutPanel.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, Width = 80));
        tableLayoutPanel.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, Width = 20));
        Controls.Add(tableLayoutPanel);
        _richTextBox.TextChanged += (sender, e) =>
        {
            Text = _richTextBox.Text;
            if (_richTextBox.IsPlaceholderText)
            {
                _dropDownContainer.SelectedIndex = -1;
            }
            else
            {
                var aspirant =
                    _dropDownContainer.Selectables
                    .OfType<object>()
                    .FirstOrDefault(_ =>
                        (_?.ToString() ?? string.Empty)
                        .IndexOf(Text) == 0, StringComparison.Ordinal)
                    as ISelectable;
                Debug.Write($"{aspirant}");
                if (aspirant is null || string.IsNullOrWhiteSpace(Text))
                {
                    _dropDownContainer.SelectedIndex = -1;
                }
                else
                {
                    _dropDownContainer.SelectedIndex = _dropDownContainer.Selectables.IndexOf(aspirant);
                    _caseSensitiveText = aspirant.ToString();
                    Debug.WriteLine($" {_dropDownContainer.SelectedIndex}");
                }
            }
        };
        tableLayoutPanel.Controls.Add(_richTextBox, 0, 0);
        tableLayoutPanel.Controls.Add(_dropDownIcon, 1, 0);
        _dropDownIcon.MouseDown += (sender, e) =>
            BeginInvoke(() => DroppedDown = !DroppedDown);
        Items.ListChanged += OnItemsChanged;
        _dropDownContainer.ItemClicked += OnItemClicked;
    }
    ...

Displaying the List Items

In this case, we made it so that basic types like strings like "zebra" or values like 1, 2, 3 will be wrapped in a selectable data template while providing a hook for more full-featured ISelectable implementations.

public interface ISelectable
{
    bool IsSelected { get; set; }
    public string Text { get; set; }
}

class DefaultView : Label, ISelectable
{
    public override string ToString() => Text;
    public bool IsSelected
    {
        get => _isSelected;
        set
        {
            if (!Equals(_isSelected, value))
            {
                _isSelected = value;
                BackColor = value ? Color.CornflowerBlue : Color.White;
                ForeColor = value ? Color.White : Color.Black;
            }
        }
    }
    bool _isSelected = default;
}

Upvotes: 0

hassaan mustafa
hassaan mustafa

Reputation: 530

You can handle the DropDown event and explicitly set the SelectedIndex based on the exact match of the typed text in the ComboBox's Text property.

Here's the updated code:

using System;
using System.Windows.Forms;

public partial class Form1 : Form
{
    ComboBox comboBox1 = new ComboBox();

    public Form1()
    {
        InitializeComponent();

        // Add items to the ComboBox
        comboBox1.Items.Add("zebra");
        comboBox1.Items.Add("Zebra");
        comboBox1.Items.Add("ZEBRA");

        // Subscribe to the DropDown event
        comboBox1.DropDown += ComboBox1_DropDown;

        // Add the ComboBox to the form
        this.Controls.Add(comboBox1);
    }

    private void ComboBox1_DropDown(object? sender, EventArgs e)
    {
        // Get the text in the ComboBox
        string typedText = comboBox1.Text;

        // Find the exact match in the list
        int matchingIndex = -1;

        for (int i = 0; i < comboBox1.Items.Count; i++)
        {
            if (comboBox1.Items[i].ToString() == typedText)
            {
                matchingIndex = i;
                break;
            }
        }

        // Set the SelectedIndex if a match is found
        if (matchingIndex >= 0)
        {
            comboBox1.SelectedIndex = matchingIndex;
        }
    }
}

The DropDown event handler retrieves the text in the ComboBox's text field (comboBox1.Text) and iterates through the items in the ComboBox to find an exact match. The comparison is case-sensitive using the == operator.

If an exact match is found, the SelectedIndex property is set to the index of the matching item. This ensures the correct item is highlighted when the dropdown is opened.

Upvotes: 1

Related Questions