#nullable enable
using System.Collections;
using System.Collections.ObjectModel;
using System.Collections.Specialized;
namespace Terminal.Gui.Views;
///
/// Provides a scrollable list of data where each item can be activated to perform an
/// action.
///
///
///
/// The displays lists of data and allows the user to scroll through the data. Items in
/// the can be activated firing an event (with the ENTER key or a mouse double-click). If the
/// property is true, elements of the list can be marked by the user.
///
///
/// By default uses to render the items of any
/// object (e.g. arrays, , and other collections).
/// Alternatively, an
/// object that implements can be provided giving full control of what is rendered.
///
///
/// can display any object that implements the interface.
/// values are converted into values before rendering, and other values
/// are converted into by calling and then converting to
/// .
///
///
/// To change the contents of the ListView, set the property (when providing custom
/// rendering via ) or call an is being
/// used.
///
///
/// When is set to true the rendering will prefix the rendered items with [x] or [ ]
/// and bind the SPACE key to toggle the selection. To implement a different marking style set
/// to false and implement custom rendering.
///
///
/// Searching the ListView with the keyboard is supported. Users type the first characters of an item, and the
/// first item that starts with what the user types will be selected.
///
///
public class ListView : View, IDesignable
{
// TODO: ListView has been upgraded to use Viewport and ContentSize instead of the
// TODO: bespoke _top and _left. It was a quick & dirty port. There is now duplicate logic
// TODO: that could be removed.
//private int _top, _left;
///
/// Initializes a new instance of . Set the property to display
/// something.
///
public ListView ()
{
CanFocus = true;
// Things this view knows how to do
//
AddCommand (Command.Up, ctx => RaiseActivating (ctx) == true || MoveUp ());
AddCommand (Command.Down, ctx => RaiseActivating (ctx) == true || MoveDown ());
// TODO: add RaiseActivating to all of these
AddCommand (Command.ScrollUp, () => ScrollVertical (-1));
AddCommand (Command.ScrollDown, () => ScrollVertical (1));
AddCommand (Command.PageUp, () => MovePageUp ());
AddCommand (Command.PageDown, () => MovePageDown ());
AddCommand (Command.Start, () => MoveHome ());
AddCommand (Command.End, () => MoveEnd ());
AddCommand (Command.ScrollLeft, () => ScrollHorizontal (-1));
AddCommand (Command.ScrollRight, () => ScrollHorizontal (1));
// Accept (Enter key) - Raise Accept event - DO NOT advance state
AddCommand (
Command.Accept,
ctx =>
{
if (RaiseAccepting (ctx) == true)
{
return true;
}
return OnOpenSelectedItem ();
});
// Activate (Space key and single-click) - If markable, change mark and raise Activate event
AddCommand (
Command.Activate,
ctx =>
{
if (!_allowsMarking)
{
return false;
}
if (RaiseActivating (ctx) == true)
{
return true;
}
return MarkUnmarkSelectedItem ();
});
// Hotkey - If none set, activate and raise Activate event. SetFocus. - DO NOT raise Accept
AddCommand (
Command.HotKey,
ctx =>
{
if (SelectedItem is { })
{
return !SetFocus ();
}
SelectedItem = 0;
if (RaiseActivating (ctx) == true)
{
return true;
}
return !SetFocus ();
});
AddCommand (
Command.SelectAll,
ctx =>
{
if (ctx is not CommandContext keyCommandContext)
{
return false;
}
return keyCommandContext.Binding.Data is { } && MarkAll ((bool)keyCommandContext.Binding.Data);
});
// Default keybindings for all ListViews
KeyBindings.Add (Key.CursorUp, Command.Up);
KeyBindings.Add (Key.P.WithCtrl, Command.Up);
KeyBindings.Add (Key.CursorDown, Command.Down);
KeyBindings.Add (Key.N.WithCtrl, Command.Down);
KeyBindings.Add (Key.PageUp, Command.PageUp);
KeyBindings.Add (Key.PageDown, Command.PageDown);
KeyBindings.Add (Key.V.WithCtrl, Command.PageDown);
KeyBindings.Add (Key.Home, Command.Start);
KeyBindings.Add (Key.End, Command.End);
// Key.Space is already bound to Command.Activate; this gives us activate then move down
KeyBindings.Add (Key.Space.WithShift, Command.Activate, Command.Down);
// Use the form of Add that lets us pass context to the handler
KeyBindings.Add (Key.A.WithCtrl, new KeyBinding ([Command.SelectAll], true));
KeyBindings.Add (Key.U.WithCtrl, new KeyBinding ([Command.SelectAll], false));
}
private bool _allowsMarking;
private bool _allowsMultipleSelection;
private IListDataSource? _source;
///
public bool EnableForDesign ()
{
ListWrapper source = new (["List Item 1", "List Item two", "List Item Quattro", "Last List Item"]);
Source = source;
return true;
}
/// Gets or sets whether this allows items to be marked.
/// Set to to allow marking elements of the list.
///
/// If set to , will render items marked items with "[x]", and
/// unmarked items with "[ ]". SPACE key will toggle marking. The default is .
///
public bool AllowsMarking
{
get => _allowsMarking;
set
{
_allowsMarking = value;
SetNeedsDraw ();
}
}
///
/// If set to more than one item can be selected. If selecting an
/// item will cause all others to be un-selected. The default is .
///
public bool AllowsMultipleSelection
{
get => _allowsMultipleSelection;
set
{
_allowsMultipleSelection = value;
if (Source is { } && !_allowsMultipleSelection)
{
// Clear all selections except selected
for (var i = 0; i < Source.Count; i++)
{
if (Source.IsMarked (i) && SelectedItem.HasValue && i != SelectedItem.Value)
{
Source.SetMark (i, false);
}
}
}
SetNeedsDraw ();
}
}
///
/// Event to raise when an item is added, removed, or moved, or the entire list is refreshed.
///
public event NotifyCollectionChangedEventHandler? CollectionChanged;
/// Ensures the selected item is always visible on the screen.
public void EnsureSelectedItemVisible ()
{
if (SelectedItem is null)
{
return;
}
if (SelectedItem < Viewport.Y)
{
Viewport = Viewport with { Y = SelectedItem.Value };
}
else if (Viewport.Height > 0 && SelectedItem >= Viewport.Y + Viewport.Height)
{
Viewport = Viewport with { Y = SelectedItem.Value - Viewport.Height + 1 };
}
}
///
/// Gets the that searches the collection as the
/// user types.
///
public IListCollectionNavigator KeystrokeNavigator { get; } = new CollectionNavigator ();
/// Gets or sets the leftmost column that is currently visible (when scrolling horizontally).
/// The left position.
public int LeftItem
{
get => Viewport.X;
set
{
if (Source is null)
{
return;
}
if (value < 0 || (MaxLength > 0 && value >= MaxLength))
{
throw new ArgumentException ("value");
}
Viewport = Viewport with { X = value };
SetNeedsDraw ();
}
}
///
/// If and are both ,
/// marks all items.
///
/// marks all items; otherwise unmarks all items.
/// if marking was successful.
public bool MarkAll (bool mark)
{
if (!_allowsMarking)
{
return false;
}
if (AllowsMultipleSelection)
{
for (var i = 0; i < Source?.Count; i++)
{
Source.SetMark (i, mark);
}
return true;
}
return false;
}
/// Marks the if it is not already marked.
/// if the was marked.
public bool MarkUnmarkSelectedItem ()
{
if (Source is null || SelectedItem is null || !UnmarkAllButSelected ())
{
return false;
}
Source.SetMark (SelectedItem.Value, !Source.IsMarked (SelectedItem.Value));
SetNeedsDraw ();
return Source.IsMarked (SelectedItem.Value);
}
/// Gets the widest item in the list.
public int MaxLength => Source?.Length ?? 0;
/// Changes the to the next item in the list, scrolling the list if needed.
///
public virtual bool MoveDown ()
{
if (Source is null || Source.Count == 0)
{
return false; //Nothing for us to move to
}
if (SelectedItem is null || SelectedItem >= Source.Count)
{
// If SelectedItem is null or for some reason we are currently outside the
// valid values range, we should select the first or bottommost valid value.
// This can occur if the backing data source changes.
SelectedItem = SelectedItem is null ? 0 : Source.Count - 1;
}
else if (SelectedItem + 1 < Source.Count)
{
//can move by down by one.
SelectedItem++;
if (SelectedItem >= Viewport.Y + Viewport.Height)
{
Viewport = Viewport with { Y = Viewport.Y + 1 };
}
else if (SelectedItem < Viewport.Y)
{
Viewport = Viewport with { Y = SelectedItem.Value };
}
}
else if (SelectedItem >= Viewport.Y + Viewport.Height)
{
Viewport = Viewport with { Y = Source.Count - Viewport.Height };
}
return true;
}
/// Changes the to last item in the list, scrolling the list if needed.
///
public virtual bool MoveEnd ()
{
if (Source is { Count: > 0 } && SelectedItem != Source.Count - 1)
{
SelectedItem = Source.Count - 1;
if (Viewport.Y + SelectedItem > Viewport.Height - 1)
{
Viewport = Viewport with
{
Y = SelectedItem < Viewport.Height - 1
? Math.Max (Viewport.Height - SelectedItem.Value + 1, 0)
: Math.Max (SelectedItem.Value - Viewport.Height + 1, 0)
};
}
}
return true;
}
/// Changes the to the first item in the list, scrolling the list if needed.
///
public virtual bool MoveHome ()
{
if (SelectedItem != 0)
{
SelectedItem = 0;
Viewport = Viewport with { Y = SelectedItem.Value };
}
return true;
}
///
/// Changes the to the item just below the bottom of the visible list, scrolling if
/// needed.
///
///
public virtual bool MovePageDown ()
{
if (Source is null || Source.Count == 0)
{
return false;
}
int n = (SelectedItem ?? 0) + Viewport.Height;
if (n >= Source.Count)
{
n = Source.Count - 1;
}
if (n != SelectedItem)
{
SelectedItem = n;
if (Source.Count >= Viewport.Height)
{
Viewport = Viewport with { Y = SelectedItem.Value };
}
else
{
Viewport = Viewport with { Y = 0 };
}
}
return true;
}
/// Changes the to the item at the top of the visible list.
///
public virtual bool MovePageUp ()
{
if (Source is null || Source.Count == 0)
{
return false;
}
int n = (SelectedItem ?? 0) - Viewport.Height;
if (n < 0)
{
n = 0;
}
if (n != SelectedItem && n < Source?.Count)
{
SelectedItem = n;
Viewport = Viewport with { Y = SelectedItem.Value };
}
return true;
}
/// Changes the to the previous item in the list, scrolling the list if needed.
///
public virtual bool MoveUp ()
{
if (Source is null || Source.Count == 0)
{
return false; //Nothing for us to move to
}
if (SelectedItem is null || SelectedItem >= Source.Count)
{
// If SelectedItem is null or for some reason we are currently outside the
// valid values range, we should select the bottommost valid value.
// This can occur if the backing data source changes.
SelectedItem = Source.Count - 1;
}
else if (SelectedItem > 0)
{
SelectedItem--;
if (SelectedItem > Source.Count)
{
SelectedItem = Source.Count - 1;
}
if (SelectedItem < Viewport.Y)
{
Viewport = Viewport with { Y = SelectedItem.Value };
}
else if (SelectedItem > Viewport.Y + Viewport.Height)
{
Viewport = Viewport with { Y = SelectedItem.Value - Viewport.Height + 1 };
}
}
else if (SelectedItem < Viewport.Y)
{
Viewport = Viewport with { Y = SelectedItem.Value };
}
return true;
}
/// Invokes the event if it is defined.
/// if the event was fired.
public bool OnOpenSelectedItem ()
{
if (Source is null || SelectedItem is null || Source.Count <= SelectedItem || SelectedItem < 0 || OpenSelectedItem is null)
{
return false;
}
object? value = Source.ToList () [SelectedItem.Value];
OpenSelectedItem?.Invoke (this, new (SelectedItem.Value, value!));
// BUGBUG: this should not blindly return true.
return true;
}
/// Virtual method that will invoke the .
///
public virtual void OnRowRender (ListViewRowEventArgs rowEventArgs) { RowRender?.Invoke (this, rowEventArgs); }
/// This event is raised when the user Double-Clicks on an item or presses ENTER to open the selected item.
public event EventHandler? OpenSelectedItem;
///
/// Allow resume the event from being invoked,
///
public void ResumeSuspendCollectionChangedEvent ()
{
if (Source is { })
{
Source.SuspendCollectionChangedEvent = false;
}
}
/// This event is invoked when this is being drawn before rendering.
public event EventHandler? RowRender;
private int? _selectedItem = null;
private int? _lastSelectedItem = null;
/// Gets or sets the index of the currently selected item.
/// The selected item or null if no item is selected.
public int? SelectedItem
{
get => _selectedItem;
set
{
if (Source is null)
{
return;
}
if (value.HasValue && (value < 0 || value >= Source.Count))
{
throw new ArgumentException (@"SelectedItem must be greater than 0 or less than the number of items.");
}
_selectedItem = value;
OnSelectedChanged ();
SetNeedsDraw ();
}
}
// TODO: Use standard event model
/// Invokes the event if it is defined.
///
public virtual bool OnSelectedChanged ()
{
if (SelectedItem != _lastSelectedItem)
{
object? value = SelectedItem.HasValue && Source?.Count > 0 ? Source.ToList () [SelectedItem.Value] : null;
SelectedItemChanged?.Invoke (this, new (SelectedItem, value));
_lastSelectedItem = SelectedItem;
EnsureSelectedItemVisible ();
return true;
}
return false;
}
/// 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 IList interface.
///
/// Use the property to set a new source and use custom
/// rendering.
///
public void SetSource (ObservableCollection? source)
{
if (source is null && Source is not ListWrapper)
{
Source = null;
}
else
{
Source = new ListWrapper (source);
}
}
/// Sets the source to an value asynchronously.
/// An item implementing the IList interface.
///
/// Use the property to set a new source and use custom
/// rendering.
///
public Task SetSourceAsync (ObservableCollection? source)
{
return Task.Factory.StartNew (
() =>
{
if (source is null && Source is not ListWrapper)
{
Source = null;
}
else
{
Source = new ListWrapper (source);
}
return source;
},
CancellationToken.None,
TaskCreationOptions.DenyChildAttach,
TaskScheduler.Default
);
}
/// Gets or sets the backing this , enabling custom rendering.
/// The source.
/// Use to set a new source.
public IListDataSource? Source
{
get => _source;
set
{
if (_source == value)
{
return;
}
_source?.Dispose ();
_source = value;
if (_source is { })
{
_source.CollectionChanged += Source_CollectionChanged;
SetContentSize (new Size (_source?.Length ?? Viewport.Width, _source?.Count ?? Viewport.Width));
KeystrokeNavigator.Collection = _source?.ToList ();
}
SelectedItem = null;
_lastSelectedItem = null;
SetNeedsDraw ();
}
}
///
/// Allow suspending the event from being invoked,
///
public void SuspendCollectionChangedEvent ()
{
if (Source is { })
{
Source.SuspendCollectionChangedEvent = true;
}
}
/// Gets or sets the index of the item that will appear at the top of the .
///
/// This a helper property for accessing listView.Viewport.Y.
///
/// The top item.
public int TopItem
{
get => Viewport.Y;
set
{
if (Source is null)
{
return;
}
Viewport = Viewport with { Y = value };
}
}
///
/// If and are both ,
/// unmarks all marked items other than .
///
/// if unmarking was successful.
public bool UnmarkAllButSelected ()
{
if (!_allowsMarking)
{
return false;
}
if (!AllowsMultipleSelection)
{
for (var i = 0; i < Source?.Count; i++)
{
if (Source.IsMarked (i) && i != SelectedItem)
{
Source.SetMark (i, false);
return true;
}
}
}
return true;
}
///
protected override void Dispose (bool disposing)
{
Source?.Dispose ();
base.Dispose (disposing);
}
///
/// Call the event to raises the .
///
///
protected virtual void OnCollectionChanged (NotifyCollectionChangedEventArgs e) { CollectionChanged?.Invoke (this, e); }
///
protected override bool OnDrawingContent (DrawContext? context)
{
if (Source is null)
{
return base.OnDrawingContent (context);
}
var current = Attribute.Default;
Move (0, 0);
Rectangle f = Viewport;
int item = Viewport.Y;
bool focused = HasFocus;
int col = _allowsMarking ? 2 : 0;
int start = Viewport.X;
for (var row = 0; row < f.Height; row++, item++)
{
bool isSelected = item == SelectedItem;
Attribute newAttribute = focused ? isSelected ? GetAttributeForRole (VisualRole.Focus) : GetAttributeForRole (VisualRole.Normal) :
isSelected ? GetAttributeForRole (VisualRole.Active) : GetAttributeForRole (VisualRole.Normal);
if (newAttribute != current)
{
SetAttribute (newAttribute);
current = newAttribute;
}
Move (0, row);
if (Source is null || item >= Source.Count)
{
for (var c = 0; c < f.Width; c++)
{
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)
{
AddRune (
Source.IsMarked (item) ? AllowsMultipleSelection ? Glyphs.CheckStateChecked : Glyphs.Selected :
AllowsMultipleSelection ? Glyphs.CheckStateUnChecked : Glyphs.UnSelected
);
AddRune ((Rune)' ');
}
Source.Render (this, isSelected, item, col, row, f.Width - col, start);
}
}
return true;
}
///
protected override void OnFrameChanged (in Rectangle frame) { EnsureSelectedItemVisible (); }
///
protected override void OnHasFocusChanged (bool newHasFocus, View? currentFocused, View? newFocused)
{
if (newHasFocus && _lastSelectedItem != SelectedItem)
{
EnsureSelectedItemVisible ();
}
}
///
protected override bool OnKeyDown (Key key)
{
// If the key was bound to key command, let normal KeyDown processing happen. This enables overriding the default handling.
// See: https://github.com/gui-cs/Terminal.Gui/issues/3950#issuecomment-2807350939
if (KeyBindings.TryGet (key, out _))
{
return false;
}
// Enable user to find & select an item by typing text
if (KeystrokeNavigator.Matcher.IsCompatibleKey (key))
{
int? newItem = KeystrokeNavigator?.GetNextMatchingItem (SelectedItem ?? null, (char)key);
if (newItem is { } && newItem != -1)
{
SelectedItem = (int)newItem;
EnsureSelectedItemVisible ();
SetNeedsDraw ();
return true;
}
}
return false;
}
///
protected override bool OnMouseEvent (MouseEventArgs me)
{
if (!me.Flags.HasFlag (MouseFlags.Button1Clicked)
&& !me.Flags.HasFlag (MouseFlags.Button1DoubleClicked)
&& me.Flags != MouseFlags.WheeledDown
&& me.Flags != MouseFlags.WheeledUp
&& me.Flags != MouseFlags.WheeledRight
&& me.Flags != MouseFlags.WheeledLeft)
{
return false;
}
if (!HasFocus && CanFocus)
{
SetFocus ();
}
if (Source is null)
{
return false;
}
if (me.Flags == MouseFlags.WheeledDown)
{
if (Viewport.Y + Viewport.Height < GetContentSize ().Height)
{
ScrollVertical (1);
}
return true;
}
if (me.Flags == MouseFlags.WheeledUp)
{
ScrollVertical (-1);
return true;
}
if (me.Flags == MouseFlags.WheeledRight)
{
if (Viewport.X + Viewport.Width < GetContentSize ().Width)
{
ScrollHorizontal (1);
}
return true;
}
if (me.Flags == MouseFlags.WheeledLeft)
{
ScrollHorizontal (-1);
return true;
}
if (me.Position.Y + Viewport.Y >= Source.Count
|| me.Position.Y + Viewport.Y < 0
|| me.Position.Y + Viewport.Y > Viewport.Y + Viewport.Height)
{
return true;
}
SelectedItem = Viewport.Y + me.Position.Y;
if (MarkUnmarkSelectedItem ())
{
// return true;
}
SetNeedsDraw ();
if (me.Flags == MouseFlags.Button1DoubleClicked)
{
return InvokeCommand (Command.Accept) is true;
}
return true;
}
///
protected override void OnViewportChanged (DrawEventArgs e) { SetContentSize (new Size (MaxLength, Source?.Count ?? Viewport.Height)); }
private void Source_CollectionChanged (object? sender, NotifyCollectionChangedEventArgs e)
{
SetContentSize (new Size (Source?.Length ?? Viewport.Width, Source?.Count ?? Viewport.Width));
if (Source is { Count: > 0 } && SelectedItem.HasValue && SelectedItem > Source.Count - 1)
{
SelectedItem = Source.Count - 1;
}
SetNeedsDraw ();
OnCollectionChanged (e);
}
}