//
// ComboBox.cs: ComboBox control
//
// Authors:
// Ross Ferguson (ross.c.ferguson@btinternet.com)
//
using System.Collections.ObjectModel;
using System.ComponentModel;
namespace Terminal.Gui;
/// Provides a drop-down list of items the user can select from.
public class ComboBox : View, IDesignable
{
private readonly ComboListView _listview;
private readonly int _minimumHeight = 2;
private readonly TextField _search;
private readonly ObservableCollection _searchSet = [];
private bool _autoHide = true;
private bool _hideDropdownListOnClick;
private int _lastSelectedItem = -1;
private int _selectedItem = -1;
private IListDataSource _source;
private string _text = "";
/// Public constructor
public ComboBox ()
{
CanFocus = true;
_search = new TextField () { CanFocus = true, TabStop = TabBehavior.NoStop };
_listview = new ComboListView (this, HideDropdownListOnClick) { CanFocus = true, TabStop = TabBehavior.NoStop };
_search.TextChanged += Search_Changed;
_listview.Y = Pos.Bottom (_search);
_listview.OpenSelectedItem += (sender, a) => SelectText ();
_listview.Accepting += (sender, args) =>
{
// This prevents Accepted from bubbling up to the combobox
args.Cancel = true;
// But OpenSelectedItem won't be fired because of that. So do it here.
SelectText ();
};
_listview.SelectedItemChanged += (sender, e) =>
{
if (!HideDropdownListOnClick && _searchSet.Count > 0)
{
SetValue (_searchSet [_listview.SelectedItem]);
}
};
Add (_search, _listview);
// BUGBUG: This should not be needed; LayoutComplete will handle
Initialized += (s, e) => ProcessLayout ();
// On resize
SubviewsLaidOut += (sender, a) => ProcessLayout ();
Added += (s, e) =>
{
// Determine if this view is hosted inside a dialog and is the only control
for (View view = SuperView; view != null; view = view.SuperView)
{
if (view is Dialog && SuperView is { } && SuperView.Subviews.Count == 1 && SuperView.Subviews [0] == this)
{
_autoHide = false;
break;
}
}
SetNeedsLayout ();
SetNeedsDraw ();
ShowHideList (Text);
};
// Things this view knows how to do
AddCommand (Command.Accept, (ctx) =>
{
if (ctx.Data == _search)
{
return null;
}
return ActivateSelected (ctx);
});
AddCommand (Command.Toggle, () => ExpandCollapse ());
AddCommand (Command.Expand, () => Expand ());
AddCommand (Command.Collapse, () => Collapse ());
AddCommand (Command.Down, () => MoveDown ());
AddCommand (Command.Up, () => MoveUp ());
AddCommand (Command.PageDown, () => PageDown ());
AddCommand (Command.PageUp, () => PageUp ());
AddCommand (Command.Start, () => MoveHome ());
AddCommand (Command.End, () => MoveEnd ());
AddCommand (Command.Cancel, () => CancelSelected ());
AddCommand (Command.UnixEmulation, () => UnixEmulation ());
// Default keybindings for this view
KeyBindings.Add (Key.F4, Command.Toggle);
KeyBindings.Add (Key.CursorDown, Command.Down);
KeyBindings.Add (Key.CursorUp, Command.Up);
KeyBindings.Add (Key.PageDown, Command.PageDown);
KeyBindings.Add (Key.PageUp, Command.PageUp);
KeyBindings.Add (Key.Home, Command.Start);
KeyBindings.Add (Key.End, Command.End);
KeyBindings.Add (Key.Esc, Command.Cancel);
KeyBindings.Add (Key.U.WithCtrl, Command.UnixEmulation);
}
///
public new ColorScheme ColorScheme
{
get => base.ColorScheme;
set
{
_listview.ColorScheme = value;
base.ColorScheme = value;
SetNeedsDraw ();
}
}
/// Gets or sets if the drop-down list can be hide with a button click event.
public bool HideDropdownListOnClick
{
get => _hideDropdownListOnClick;
set => _hideDropdownListOnClick = _listview.HideDropdownListOnClick = value;
}
/// Gets the drop-down list state, expanded or collapsed.
public bool IsShow { get; private set; }
/// If set to true, no changes to the text will be allowed.
public bool ReadOnly
{
get => _search.ReadOnly;
set
{
_search.ReadOnly = value;
if (_search.ReadOnly)
{
if (_search.ColorScheme is { })
{
_search.ColorScheme = new ColorScheme (_search.ColorScheme) { Normal = _search.ColorScheme.Focus };
}
}
}
}
/// Current search text
public string SearchText
{
get => _search.Text;
set => SetSearchText (value);
}
/// Gets the index of the currently selected item in the
/// The selected item or -1 none selected.
public int SelectedItem
{
get => _selectedItem;
set
{
if (_selectedItem != value
&& (value == -1
|| (_source is { } && value > -1 && value < _source.Count)))
{
_selectedItem = _lastSelectedItem = value;
if (_selectedItem != -1)
{
SetValue (_source.ToList () [_selectedItem].ToString (), true);
}
else
{
SetValue ("", true);
}
OnSelectedChanged ();
}
}
}
/// Gets or sets the backing this , enabling custom rendering.
/// The source.
/// Use to set a new source.
public IListDataSource Source
{
get => _source;
set
{
_source = value;
// Only need to refresh list if its been added to a container view
if (SuperView is { } && SuperView.Subviews.Contains (this))
{
Text = string.Empty;
SetNeedsDraw ();
}
}
}
/// The text of the currently selected list item
public new string Text
{
get => _text;
set => SetSearchText (value);
}
///
/// Collapses the drop-down list. Returns true if the state changed or false if it was already collapsed and no
/// action was taken
///
public virtual bool Collapse ()
{
if (!IsShow)
{
return false;
}
IsShow = false;
HideList ();
return true;
}
/// This event is raised when the drop-down list is collapsed.
public event EventHandler Collapsed;
///
/// Expands the drop-down list. Returns true if the state changed or false if it was already expanded and no
/// action was taken
///
public virtual bool Expand ()
{
if (IsShow)
{
return false;
}
SetSearchSet ();
IsShow = true;
ShowList ();
FocusSelectedItem ();
return true;
}
/// This event is raised when the drop-down list is expanded.
public event EventHandler Expanded;
///
protected override bool OnMouseEvent (MouseEventArgs me)
{
if (me.Position.X == Viewport.Right - 1
&& me.Position.Y == Viewport.Top
&& me.Flags == MouseFlags.Button1Pressed
&& _autoHide)
{
if (IsShow)
{
IsShow = false;
HideList ();
}
else
{
SetSearchSet ();
IsShow = true;
ShowList ();
FocusSelectedItem ();
}
return me.Handled = true;
}
if (me.Flags == MouseFlags.Button1Pressed)
{
if (!_search.HasFocus)
{
_search.SetFocus ();
}
return me.Handled = true;
}
return false;
}
/// Virtual method which invokes the event.
public virtual void OnCollapsed () { Collapsed?.Invoke (this, EventArgs.Empty); }
///
protected override bool OnDrawingContent (Rectangle viewport)
{
if (!_autoHide)
{
return true;
}
if (ColorScheme != null)
{
SetAttribute (ColorScheme.Focus);
}
Move (Viewport.Right - 1, 0);
Driver?.AddRune (Glyphs.DownArrow);
return true;
}
/// Virtual method which invokes the event.
public virtual void OnExpanded () { Expanded?.Invoke (this, EventArgs.Empty); }
///
protected override void OnHasFocusChanged (bool newHasFocus, View previousFocusedView, View view)
{
if (newHasFocus)
{
if (!_search.HasFocus && !_listview.HasFocus)
{
_search.SetFocus ();
}
_search.CursorPosition = _search.Text.GetRuneCount ();
}
else
{
if (_source?.Count > 0
&& _selectedItem > -1
&& _selectedItem < _source.Count - 1
&& _text != _source.ToList () [_selectedItem].ToString ())
{
SetValue (_source.ToList () [_selectedItem].ToString ());
}
if (_autoHide && IsShow && view != this && view != _search && view != _listview)
{
IsShow = false;
HideList ();
}
else if (_listview.TabStop?.HasFlag (TabBehavior.TabStop) ?? false)
{
_listview.TabStop = TabBehavior.NoStop;
}
}
}
/// Invokes the OnOpenSelectedItem event if it is defined.
///
public virtual bool OnOpenSelectedItem ()
{
string value = _search.Text;
_lastSelectedItem = SelectedItem;
OpenSelectedItem?.Invoke (this, new ListViewItemEventArgs (SelectedItem, value));
return true;
}
/// Invokes the SelectedChanged event if it is defined.
///
public virtual bool OnSelectedChanged ()
{
// Note: Cannot rely on "listview.SelectedItem != lastSelectedItem" because the list is dynamic.
// So we cannot optimize. Ie: Don't call if not changed
SelectedItemChanged?.Invoke (this, new ListViewItemEventArgs (SelectedItem, _search.Text));
return true;
}
/// This event is raised when the user Double Clicks on an item or presses ENTER to open the selected item.
public event EventHandler OpenSelectedItem;
/// This event is raised when the selected item in the has changed.
public event EventHandler SelectedItemChanged;
/// Sets the source of the to an .
/// An object implementing the INotifyCollectionChanged and INotifyPropertyChanged interface.
///
/// Use the property to set a new source and use custom
/// rendering.
///
public void SetSource (ObservableCollection source)
{
if (source is null)
{
Source = null;
}
else
{
_listview.SetSource (source);
Source = _listview.Source;
}
}
private bool ActivateSelected (CommandContext ctx)
{
if (HasItems ())
{
if (SelectText ())
{
return false;
}
return RaiseAccepting (ctx) == true;
}
return false;
}
/// Internal height of dynamic search list
///
private int CalculateHeight ()
{
if (!IsInitialized || Viewport.Height == 0)
{
return 0;
}
return Math.Min (
Math.Max (Viewport.Height - 1, _minimumHeight - 1),
_searchSet?.Count > 0 ? _searchSet.Count :
IsShow ? Math.Max (Viewport.Height - 1, _minimumHeight - 1) : 0
);
}
private bool CancelSelected ()
{
if (HasFocus)
{
_search.SetFocus ();
}
if (ReadOnly || HideDropdownListOnClick)
{
SelectedItem = _lastSelectedItem;
if (SelectedItem > -1 && _listview.Source?.Count > 0)
{
Text = _listview.Source.ToList () [SelectedItem]?.ToString ();
}
}
else if (!ReadOnly)
{
Text = string.Empty;
_selectedItem = _lastSelectedItem;
OnSelectedChanged ();
}
return Collapse ();
}
/// Toggles the expand/collapse state of the sublist in the combo box
///
private bool ExpandCollapse ()
{
if (_search.HasFocus || _listview.HasFocus)
{
if (!IsShow)
{
return Expand ();
}
return Collapse ();
}
return false;
}
private void FocusSelectedItem ()
{
_listview.SelectedItem = SelectedItem > -1 ? SelectedItem : 0;
_listview.TabStop = TabBehavior.TabStop;
_listview.SetFocus ();
OnExpanded ();
}
private int GetSelectedItemFromSource (string searchText)
{
if (_source is null)
{
return -1;
}
for (var i = 0; i < _searchSet.Count; i++)
{
if (_searchSet [i].ToString () == searchText)
{
return i;
}
}
return -1;
}
private bool HasItems () { return Source?.Count > 0; }
/// Hide the search list
/// Consider making public
private void HideList ()
{
if (_lastSelectedItem != _selectedItem)
{
OnOpenSelectedItem ();
}
Reset (true);
_listview.ClearViewport ();
_listview.TabStop = TabBehavior.NoStop;
SuperView?.MoveSubviewToStart (this);
// BUGBUG: SetNeedsDraw takes Viewport relative coordinates, not Screen
Rectangle rect = _listview.ViewportToScreen (_listview.IsInitialized ? _listview.Viewport : Rectangle.Empty);
SuperView?.SetNeedsDraw (rect);
OnCollapsed ();
}
private bool? MoveDown ()
{
if (_search.HasFocus)
{
// jump to list
if (_searchSet?.Count > 0)
{
_listview.TabStop = TabBehavior.TabStop;
_listview.SetFocus ();
if (_listview.SelectedItem > -1)
{
SetValue (_searchSet [_listview.SelectedItem]);
}
else
{
_listview.SelectedItem = 0;
}
}
else
{
return false;
}
return true;
}
return null;
}
private bool? MoveEnd ()
{
if (!IsShow && _search.HasFocus)
{
return null;
}
if (HasItems ())
{
_listview.MoveEnd ();
}
return true;
}
private bool? MoveHome ()
{
if (!IsShow && _search.HasFocus)
{
return null;
}
if (HasItems ())
{
_listview.MoveHome ();
}
return true;
}
private bool? MoveUp ()
{
if (HasItems ())
{
return _listview.MoveUp ();
}
return false;
}
private bool? MoveUpList ()
{
if (_listview.HasFocus && _listview.SelectedItem == 0 && _searchSet?.Count > 0) // jump back to search
{
_search.CursorPosition = _search.Text.GetRuneCount ();
_search.SetFocus ();
}
else
{
MoveUp ();
}
return true;
}
private bool PageDown ()
{
if (HasItems ())
{
_listview.MovePageDown ();
}
return true;
}
private bool PageUp ()
{
if (HasItems ())
{
_listview.MovePageUp ();
}
return true;
}
// TODO: Upgrade Combobox to use Dim.Auto instead of all this stuff.
private void ProcessLayout ()
{
if (Viewport.Height < _minimumHeight && (Height is null || Height is DimAbsolute))
{
Height = _minimumHeight;
}
// BUGBUG: This uses Viewport. Should use ContentSize
if ((!_autoHide && Viewport.Width > 0 && _search.Frame.Width != Viewport.Width)
|| (_autoHide && Viewport.Width > 0 && _search.Frame.Width != Viewport.Width - 1))
{
_search.Width = _listview.Width = _autoHide ? Viewport.Width - 1 : Viewport.Width;
_listview.Height = CalculateHeight ();
_search.SetRelativeLayout (GetContentSize ());
_listview.SetRelativeLayout (GetContentSize ());
}
}
/// Reset to full original list
private void Reset (bool keepSearchText = false)
{
if (!keepSearchText)
{
SetSearchText (string.Empty);
}
ResetSearchSet ();
_listview.SetSource (_searchSet);
_listview.Height = CalculateHeight ();
if (Subviews.Count > 0 && HasFocus)
{
_search.SetFocus ();
}
}
private void ResetSearchSet (bool noCopy = false)
{
_listview.SuspendCollectionChangedEvent ();
_searchSet.Clear ();
_listview.ResumeSuspendCollectionChangedEvent ();
if (_autoHide || noCopy)
{
return;
}
SetSearchSet ();
}
private void Search_Changed (object sender, EventArgs e)
{
if (_source is null)
{
// Object initialization
return;
}
ShowHideList (Text);
}
private void ShowHideList (string oldText)
{
if (string.IsNullOrEmpty (_search.Text) && string.IsNullOrEmpty (oldText))
{
ResetSearchSet ();
}
else if (_search.Text != oldText)
{
if (_search.Text.Length < oldText.Length)
{
_selectedItem = -1;
}
IsShow = true;
ResetSearchSet (true);
if (!string.IsNullOrEmpty (_search.Text))
{
_listview.SuspendCollectionChangedEvent ();
foreach (object item in _source.ToList ())
{
// Iterate to preserver object type and force deep copy
if (item.ToString ()
.StartsWith (
_search.Text,
StringComparison.CurrentCultureIgnoreCase
))
{
_searchSet.Add (item);
}
}
_listview.ResumeSuspendCollectionChangedEvent ();
}
}
if (HasFocus)
{
ShowList ();
}
else if (_autoHide)
{
IsShow = false;
HideList ();
}
}
private bool SelectText ()
{
IsShow = false;
_listview.TabStop = TabBehavior.NoStop;
if (_listview.Source.Count == 0 || (_searchSet?.Count ?? 0) == 0)
{
_text = "";
HideList ();
IsShow = false;
return false;
}
SetValue (_listview.SelectedItem > -1 ? _searchSet [_listview.SelectedItem] : _text);
_search.CursorPosition = _search.Text.GetColumns ();
ShowHideList (Text);
OnOpenSelectedItem ();
Reset (true);
HideList ();
IsShow = false;
return true;
}
private void SetSearchSet ()
{
if (Source is null)
{
return;
}
// PERF: At the request of @dodexahedron in the comment https://github.com/gui-cs/Terminal.Gui/pull/3552#discussion_r1648112410.
_listview.SuspendCollectionChangedEvent ();
// force deep copy
foreach (object item in Source.ToList ())
{
_searchSet.Add (item);
}
_listview.ResumeSuspendCollectionChangedEvent ();
}
// Sets the search text field Text as well as our own Text property
private void SetSearchText (string value)
{
_search.Text = value;
_text = value;
}
private void SetValue (object text, bool isFromSelectedItem = false)
{
// TOOD: The fact we have to suspend events to change the text makes this feel very hacky.
_search.TextChanged -= Search_Changed;
// Note we set _text, to avoid set_Text from setting _search.Text again
_text = _search.Text = text.ToString ();
_search.CursorPosition = 0;
_search.TextChanged += Search_Changed;
if (!isFromSelectedItem)
{
_selectedItem = GetSelectedItemFromSource (_text);
OnSelectedChanged ();
}
}
/// Show the search list
/// Consider making public
private void ShowList ()
{
_listview.SuspendCollectionChangedEvent ();
_listview.SetSource (_searchSet);
_listview.ResumeSuspendCollectionChangedEvent ();
_listview.ClearViewport ();
_listview.Height = CalculateHeight ();
SuperView?.MoveSubviewToStart (this);
}
private bool UnixEmulation ()
{
// Unix emulation
Reset ();
return true;
}
private class ComboListView : ListView
{
private ComboBox _container;
private bool _hideDropdownListOnClick;
private int _highlighted = -1;
private bool _isFocusing;
public ComboListView (ComboBox container, bool hideDropdownListOnClick) { SetInitialProperties (container, hideDropdownListOnClick); }
public ComboListView (ComboBox container, ObservableCollection source, bool hideDropdownListOnClick)
{
Source = new ListWrapper (source);
SetInitialProperties (container, hideDropdownListOnClick);
}
public bool HideDropdownListOnClick
{
get => _hideDropdownListOnClick;
set => _hideDropdownListOnClick = WantContinuousButtonPressed = value;
}
protected override bool OnMouseEvent (MouseEventArgs me)
{
bool isMousePositionValid = IsMousePositionValid (me);
var res = false;
if (isMousePositionValid)
{
// We're derived from ListView and it overrides OnMouseEvent, so we need to call it
res = base.OnMouseEvent (me);
}
if (HideDropdownListOnClick && me.Flags == MouseFlags.Button1Clicked)
{
if (!isMousePositionValid && !_isFocusing)
{
_container.IsShow = false;
_container.HideList ();
}
else if (isMousePositionValid)
{
OnOpenSelectedItem ();
}
else
{
_isFocusing = false;
}
return true;
}
if (me.Flags == MouseFlags.ReportMousePosition && HideDropdownListOnClick)
{
if (isMousePositionValid)
{
_highlighted = Math.Min (TopItem + me.Position.Y, Source.Count);
SetNeedsDraw ();
}
_isFocusing = false;
return true;
}
return res;
}
protected override bool OnDrawingContent (Rectangle viewport)
{
Attribute current = ColorScheme?.Focus ?? Attribute.Default;
SetAttribute (current);
Move (0, 0);
Rectangle f = Frame;
int item = TopItem;
bool focused = HasFocus;
int col = AllowsMarking ? 2 : 0;
int start = LeftItem;
for (var row = 0; row < f.Height; row++, item++)
{
bool isSelected = item == _container.SelectedItem;
bool isHighlighted = _hideDropdownListOnClick && item == _highlighted;
Attribute newcolor;
if (isHighlighted || (isSelected && !_hideDropdownListOnClick))
{
newcolor = focused ? ColorScheme.Focus : ColorScheme.HotNormal;
}
else if (isSelected && _hideDropdownListOnClick)
{
newcolor = focused ? ColorScheme.HotFocus : ColorScheme.HotNormal;
}
else
{
newcolor = focused ? GetNormalColor () : GetNormalColor ();
}
if (newcolor != current)
{
SetAttribute (newcolor);
current = newcolor;
}
Move (0, row);
if (Source is null || item >= Source.Count)
{
for (var c = 0; c < f.Width; c++)
{
Driver?.AddRune ((Rune)' ');
}
}
else
{
var rowEventArgs = new ListViewRowEventArgs (item);
OnRowRender (rowEventArgs);
if (rowEventArgs.RowAttribute is { } && current != rowEventArgs.RowAttribute)
{
current = (Attribute)rowEventArgs.RowAttribute;
SetAttribute (current);
}
if (AllowsMarking)
{
Driver?.AddRune (
Source.IsMarked (item) ? AllowsMultipleSelection ? Glyphs.CheckStateChecked : Glyphs.Selected :
AllowsMultipleSelection ? Glyphs.CheckStateUnChecked : Glyphs.UnSelected
);
Driver?.AddRune ((Rune)' ');
}
Source.Render (this, Driver, isSelected, item, col, row, f.Width - col, start);
}
}
return true;
}
protected override void OnHasFocusChanged (bool newHasFocus, [CanBeNull] View previousFocusedView, [CanBeNull] View focusedView)
{
if (newHasFocus)
{
if (_hideDropdownListOnClick)
{
_isFocusing = true;
_highlighted = _container.SelectedItem;
Application.GrabMouse (this);
}
}
else
{
if (_hideDropdownListOnClick)
{
_isFocusing = false;
_highlighted = _container.SelectedItem;
Application.UngrabMouse ();
}
}
}
public override bool OnSelectedChanged ()
{
bool res = base.OnSelectedChanged ();
_highlighted = SelectedItem;
return res;
}
private bool IsMousePositionValid (MouseEventArgs me)
{
if (me.Position.X >= 0 && me.Position.X < Frame.Width && me.Position.Y >= 0 && me.Position.Y < Frame.Height)
{
return true;
}
return false;
}
private void SetInitialProperties (ComboBox container, bool hideDropdownListOnClick)
{
_container = container
?? throw new ArgumentNullException (
nameof (container),
"ComboBox container cannot be null."
);
HideDropdownListOnClick = hideDropdownListOnClick;
AddCommand (Command.Up, () => _container.MoveUpList ());
}
}
///
public bool EnableForDesign ()
{
var source = new ObservableCollection (["Combo Item 1", "Combo Item two", "Combo Item Quattro", "Last Combo Item"]);
SetSource (source);
Height = Dim.Auto (DimAutoStyle.Content, minimumContentDim: source.Count + 1);
return true;
}
}