//
// ListView.cs: ListView control
//
// Authors:
// Miguel de Icaza (miguel@gnome.org)
//
//
// TODO:
// - Should we support multiple columns, if so, how should that be done?
// - Show mark for items that have been marked.
// - Mouse support
// - Scrollbars?
//
// Column considerations:
// - Would need a way to specify widths
// - Should it automatically extract data out of structs/classes based on public fields/properties?
// - It seems that this would be useful just for the "simple" API, not the IListDAtaSource, as that one has full support for it.
// - Should a function be specified that retrieves the individual elements?
//
using System;
using System.Collections;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using NStack;
namespace Terminal.Gui {
///
/// Implement to provide custom rendering for a .
///
public interface IListDataSource {
///
/// Returns the number of elements to display
///
int Count { get; }
///
/// Returns the maximum length of elements to display
///
int Length { get; }
///
/// This method is invoked to render a specified item, the method should cover the entire provided width.
///
/// The render.
/// The list view to render.
/// The console driver to render.
/// Describes whether the item being rendered is currently selected by the user.
/// The index of the item to render, zero for the first item and so on.
/// The column where the rendering will start
/// The line where the rendering will be done.
/// The width that must be filled out.
/// The index of the string to be displayed.
///
/// The default color will be set before this method is invoked, and will be based on whether the item is selected or not.
///
void Render (ListView container, ConsoleDriver driver, bool selected, int item, int col, int line, int width, int start = 0);
///
/// Should return whether the specified item is currently marked.
///
/// true, if marked, false otherwise.
/// Item index.
bool IsMarked (int item);
///
/// Flags the item as marked.
///
/// Item index.
/// If set to true value.
void SetMark (int item, bool value);
///
/// Return the source as IList.
///
///
IList ToList ();
}
///
/// ListView renders 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 the
/// interface 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.
///
///
public class ListView : View {
int top, left;
int selected;
IListDataSource source;
///
/// 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;
top = 0;
selected = 0;
lastSelectedItem = -1;
SetNeedsDisplay ();
}
}
///
/// Sets the source of the to an .
///
/// An object implementing the IList interface.
///
/// Use the property to set a new source and use custome rendering.
///
public void SetSource (IList source)
{
if (source == null)
Source = null;
else {
Source = MakeWrapper (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 (IList source)
{
return Task.Factory.StartNew (() => {
if (source == null)
Source = null;
else
Source = MakeWrapper (source);
return source;
}, CancellationToken.None, TaskCreationOptions.DenyChildAttach, TaskScheduler.Default);
}
bool allowsMarking;
///
/// Gets or sets whether this allows items to be marked.
///
/// true if allows marking elements of the list; otherwise, false.
///
///
/// If set to true, will render items marked items with "[x]", and unmarked items with "[ ]"
/// spaces. SPACE key will toggle marking.
///
public bool AllowsMarking {
get => allowsMarking;
set {
allowsMarking = value;
SetNeedsDisplay ();
}
}
///
/// If set to true allows more than one item to be selected. If false only allow one item selected.
///
public bool AllowsMultipleSelection {
get => allowsMultipleSelection;
set {
allowsMultipleSelection = value;
if (Source != null && !allowsMultipleSelection) {
// Clear all selections except selected
for (int i = 0; i < Source.Count; i++) {
if (Source.IsMarked (i) && i != selected) {
Source.SetMark (i, false);
}
}
}
}
}
///
/// Gets or sets the item that is displayed at the top of the .
///
/// The top item.
public int TopItem {
get => top;
set {
if (source == null)
return;
if (value < 0 || (source.Count > 0 && value >= source.Count))
throw new ArgumentException ("value");
top = value;
SetNeedsDisplay ();
}
}
///
/// Gets or sets the left column where the item start to be displayed at on the .
///
/// The left position.
public int LeftItem {
get => left;
set {
if (source == null)
return;
if (value < 0 || (Maxlength > 0 && value >= Maxlength))
throw new ArgumentException ("value");
left = value;
SetNeedsDisplay ();
}
}
///
/// Gets the widest item.
///
public int Maxlength => (source?.Length) ?? 0;
///
/// Gets or sets the index of the currently selected item.
///
/// The selected item.
public int SelectedItem {
get => selected;
set {
if (source == null || source.Count == 0) {
return;
}
if (value < 0 || value >= source.Count) {
throw new ArgumentException ("value");
}
selected = value;
OnSelectedChanged ();
}
}
static IListDataSource MakeWrapper (IList source)
{
return new ListWrapper (source);
}
///
/// Initializes a new instance of that will display the contents of the object implementing the interface,
/// with relative positioning.
///
/// An data source, if the elements are strings or ustrings, the string is rendered, otherwise the ToString() method is invoked on the result.
public ListView (IList source) : this (MakeWrapper (source))
{
}
///
/// Initializes a new instance of that will display the provided data source, using relative positioning.
///
/// object that provides a mechanism to render the data.
/// The number of elements on the collection should not change, if you must change, set
/// the "Source" property to reset the internal settings of the ListView.
public ListView (IListDataSource source) : base ()
{
this.source = source;
Initialize ();
}
///
/// Initializes a new instance of . Set the property to display something.
///
public ListView () : base ()
{
Initialize ();
}
///
/// Initializes a new instance of that will display the contents of the object implementing the interface with an absolute position.
///
/// Frame for the listview.
/// An IList data source, if the elements of the IList are strings or ustrings, the string is rendered, otherwise the ToString() method is invoked on the result.
public ListView (Rect rect, IList source) : this (rect, MakeWrapper (source))
{
Initialize ();
}
///
/// Initializes a new instance of with the provided data source and an absolute position
///
/// Frame for the listview.
/// IListDataSource object that provides a mechanism to render the data. The number of elements on the collection should not change, if you must change, set the "Source" property to reset the internal settings of the ListView.
public ListView (Rect rect, IListDataSource source) : base (rect)
{
this.source = source;
Initialize ();
}
void Initialize ()
{
Source = source;
CanFocus = true;
}
///
public override void Redraw (Rect bounds)
{
var current = ColorScheme.Focus;
Driver.SetAttribute (current);
Move (0, 0);
var f = Frame;
var item = top;
bool focused = HasFocus;
int col = allowsMarking ? 2 : 0;
int start = left;
for (int row = 0; row < f.Height; row++, item++) {
bool isSelected = item == selected;
var newcolor = focused ? (isSelected ? ColorScheme.Focus : ColorScheme.Normal) : (isSelected ? ColorScheme.HotNormal : ColorScheme.Normal);
if (newcolor != current) {
Driver.SetAttribute (newcolor);
current = newcolor;
}
Move (0, row);
if (source == null || item >= source.Count) {
for (int c = 0; c < f.Width; c++)
Driver.AddRune (' ');
} else {
if (allowsMarking) {
Driver.AddRune (source.IsMarked (item) ? (AllowsMultipleSelection ? Driver.Checked : Driver.Selected) : (AllowsMultipleSelection ? Driver.UnChecked : Driver.UnSelected));
Driver.AddRune (' ');
}
Source.Render (this, Driver, isSelected, item, col, row, f.Width - col, start);
}
}
}
///
/// This event is raised when the selected item in the has changed.
///
public event Action SelectedItemChanged;
///
/// This event is raised when the user Double Clicks on an item or presses ENTER to open the selected item.
///
public event Action OpenSelectedItem;
///
public override bool ProcessKey (KeyEvent kb)
{
if (source == null)
return base.ProcessKey (kb);
switch (kb.Key) {
case Key.CursorUp:
case Key.P | Key.CtrlMask:
return MoveUp ();
case Key.CursorDown:
case Key.N | Key.CtrlMask:
return MoveDown ();
case Key.V | Key.CtrlMask:
case Key.PageDown:
return MovePageDown ();
case Key.PageUp:
return MovePageUp ();
case Key.Space:
if (MarkUnmarkRow ())
return true;
else
break;
case Key.Enter:
return OnOpenSelectedItem ();
case Key.End:
return MoveEnd ();
case Key.Home:
return MoveHome ();
default:
return false;
}
return true;
}
///
/// Prevents marking if it's not allowed mark and if it's not allows multiple selection.
///
///
public virtual bool AllowsAll ()
{
if (!allowsMarking)
return false;
if (!AllowsMultipleSelection) {
for (int i = 0; i < Source.Count; i++) {
if (Source.IsMarked (i) && i != selected) {
Source.SetMark (i, false);
return true;
}
}
}
return true;
}
///
/// Marks an unmarked row.
///
///
public virtual bool MarkUnmarkRow ()
{
if (AllowsAll ()) {
Source.SetMark (SelectedItem, !Source.IsMarked (SelectedItem));
SetNeedsDisplay ();
return true;
}
return false;
}
///
/// Moves the selected item index to the next page.
///
///
public virtual bool MovePageUp ()
{
int n = (selected - Frame.Height);
if (n < 0)
n = 0;
if (n != selected) {
selected = n;
top = selected;
OnSelectedChanged ();
SetNeedsDisplay ();
}
return true;
}
///
/// Moves the selected item index to the previous page.
///
///
public virtual bool MovePageDown ()
{
var n = (selected + Frame.Height);
if (n >= source.Count)
n = source.Count - 1;
if (n != selected) {
selected = n;
if (source.Count >= Frame.Height)
top = selected;
else
top = 0;
OnSelectedChanged ();
SetNeedsDisplay ();
}
return true;
}
///
/// Moves the selected item index to the next row.
///
///
public virtual bool MoveDown ()
{
if (source.Count == 0) {
// Do we set lastSelectedItem to -1 here?
return false; //Nothing for us to move to
}
if (selected >= source.Count) {
// If for some reason we are currently outside of the
// valid values range, we should select the bottommost valid value.
// This can occur if the backing data source changes.
selected = source.Count - 1;
OnSelectedChanged ();
SetNeedsDisplay ();
} else if (selected + 1 < source.Count) { //can move by down by one.
selected++;
if (selected >= top + Frame.Height) {
top++;
} else if (selected < top) {
top = selected;
}
OnSelectedChanged ();
SetNeedsDisplay ();
} else if (selected == 0) {
OnSelectedChanged ();
SetNeedsDisplay ();
}
return true;
}
///
/// Moves the selected item index to the previous row.
///
///
public virtual bool MoveUp ()
{
if (source.Count == 0) {
// Do we set lastSelectedItem to -1 here?
return false; //Nothing for us to move to
}
if (selected >= source.Count) {
// If for some reason we are currently outside of the
// valid values range, we should select the bottommost valid value.
// This can occur if the backing data source changes.
selected = source.Count - 1;
OnSelectedChanged ();
SetNeedsDisplay ();
} else if (selected > 0) {
selected--;
if (selected > Source.Count) {
selected = Source.Count - 1;
}
if (selected < top) {
top = selected;
} else if (selected > top + Frame.Height) {
top = Math.Max (selected - Frame.Height + 1, 0);
}
OnSelectedChanged ();
SetNeedsDisplay ();
}
return true;
}
///
/// Moves the selected item index to the last row.
///
///
public virtual bool MoveEnd ()
{
if (source.Count > 0 && selected != source.Count - 1) {
selected = source.Count - 1;
if (top + selected > Frame.Height - 1) {
top = selected;
}
OnSelectedChanged ();
SetNeedsDisplay ();
}
return true;
}
///
/// Moves the selected item index to the first row.
///
///
public virtual bool MoveHome ()
{
if (selected != 0) {
selected = 0;
top = selected;
OnSelectedChanged ();
SetNeedsDisplay ();
}
return true;
}
///
/// Scrolls the view down.
///
/// Number of lines to scroll down.
public virtual void ScrollDown (int lines)
{
top = Math.Max (Math.Min (top + lines, source.Count - 1), 0);
SetNeedsDisplay ();
}
///
/// Scrolls the view up.
///
/// Number of lines to scroll up.
public virtual void ScrollUp (int lines)
{
top = Math.Max (top - lines, 0);
SetNeedsDisplay ();
}
///
/// Scrolls the view right.
///
/// Number of columns to scroll right.
public virtual void ScrollRight (int cols)
{
left = Math.Max (Math.Min (left + cols, Maxlength - 1), 0);
SetNeedsDisplay ();
}
///
/// Scrolls the view left.
///
/// Number of columns to scroll left.
public virtual void ScrollLeft (int cols)
{
left = Math.Max (left - cols, 0);
SetNeedsDisplay ();
}
int lastSelectedItem = -1;
private bool allowsMultipleSelection = true;
///
/// Invokes the SelectedChanged event if it is defined.
///
///
public virtual bool OnSelectedChanged ()
{
if (selected != lastSelectedItem) {
var value = source?.Count > 0 ? source.ToList () [selected] : null;
SelectedItemChanged?.Invoke (new ListViewItemEventArgs (selected, value));
if (HasFocus) {
lastSelectedItem = selected;
}
return true;
}
return false;
}
///
/// Invokes the OnOpenSelectedItem event if it is defined.
///
///
public virtual bool OnOpenSelectedItem ()
{
if (source.Count <= selected || selected < 0 || OpenSelectedItem == null) {
return false;
}
var value = source.ToList () [selected];
OpenSelectedItem?.Invoke (new ListViewItemEventArgs (selected, value));
return true;
}
///
public override bool OnEnter (View view)
{
Application.Driver.SetCursorVisibility (CursorVisibility.Invisible);
if (lastSelectedItem == -1) {
EnsuresVisibilitySelectedItem ();
OnSelectedChanged ();
return true;
}
return base.OnEnter (view);
}
///
public override bool OnLeave (View view)
{
if (lastSelectedItem > -1) {
lastSelectedItem = -1;
return true;
}
return false;
}
void EnsuresVisibilitySelectedItem ()
{
SuperView?.LayoutSubviews ();
if (selected < top) {
top = selected;
} else if (Frame.Height > 0 && selected >= top + Frame.Height) {
top = Math.Max (selected - Frame.Height + 2, 0);
}
}
///
public override void PositionCursor ()
{
if (allowsMarking)
Move (0, selected - top);
else
Move (Bounds.Width - 1, selected - top);
}
///
public override bool MouseEvent (MouseEvent 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 == null) {
return false;
}
if (me.Flags == MouseFlags.WheeledDown) {
ScrollDown (1);
return true;
} else if (me.Flags == MouseFlags.WheeledUp) {
ScrollUp (1);
return true;
} else if (me.Flags == MouseFlags.WheeledRight) {
ScrollRight (1);
return true;
} else if (me.Flags == MouseFlags.WheeledLeft) {
ScrollLeft (1);
return true;
}
if (me.Y + top >= source.Count) {
return true;
}
selected = top + me.Y;
if (AllowsAll ()) {
Source.SetMark (SelectedItem, !Source.IsMarked (SelectedItem));
SetNeedsDisplay ();
return true;
}
OnSelectedChanged ();
SetNeedsDisplay ();
if (me.Flags == MouseFlags.Button1DoubleClicked) {
OnOpenSelectedItem ();
}
return true;
}
}
///
/// Implements an that renders arbitrary instances for .
///
/// Implements support for rendering marked items.
public class ListWrapper : IListDataSource {
IList src;
BitArray marks;
int count, len;
///
/// Initializes a new instance of given an
///
///
public ListWrapper (IList source)
{
if (source != null) {
count = source.Count;
marks = new BitArray (count);
src = source;
len = GetMaxLengthItem ();
}
}
///
/// Gets the number of items in the .
///
public int Count => src != null ? src.Count : 0;
///
/// Gets the maximum item length in the .
///
public int Length => len;
int GetMaxLengthItem ()
{
if (src?.Count == 0) {
return 0;
}
int maxLength = 0;
for (int i = 0; i < src.Count; i++) {
var t = src [i];
int l;
if (t is ustring u) {
l = u.RuneCount;
} else if (t is string s) {
l = s.Length;
} else {
l = t.ToString ().Length;
}
if (l > maxLength) {
maxLength = l;
}
}
return maxLength;
}
void RenderUstr (ConsoleDriver driver, ustring ustr, int col, int line, int width, int start = 0)
{
int byteLen = ustr.Length;
int used = 0;
for (int i = start; i < byteLen;) {
(var rune, var size) = Utf8.DecodeRune (ustr, i, i - byteLen);
var count = Rune.ColumnWidth (rune);
if (used + count > width)
break;
driver.AddRune (rune);
used += count;
i += size;
}
for (; used < width; used++) {
driver.AddRune (' ');
}
}
///
/// Renders a item to the appropriate type.
///
/// The ListView.
/// The driver used by the caller.
/// Informs if it's marked or not.
/// The item.
/// The col where to move.
/// The line where to move.
/// The item width.
/// The index of the string to be displayed.
public void Render (ListView container, ConsoleDriver driver, bool marked, int item, int col, int line, int width, int start = 0)
{
container.Move (col, line);
var t = src [item];
if (t == null) {
RenderUstr (driver, ustring.Make (""), col, line, width);
} else {
if (t is ustring u) {
RenderUstr (driver, u, col, line, width, start);
} else if (t is string s) {
RenderUstr (driver, s, col, line, width, start);
} else {
RenderUstr (driver, t.ToString (), col, line, width, start);
}
}
}
///
/// Returns true if the item is marked, false otherwise.
///
/// The item.
/// trueIf is marked.falseotherwise.
public bool IsMarked (int item)
{
if (item >= 0 && item < count)
return marks [item];
return false;
}
///
/// Sets the item as marked or unmarked based on the value is true or false, respectively.
///
/// The item
/// Marks the item.Unmarked the item.The value.
public void SetMark (int item, bool value)
{
if (item >= 0 && item < count)
marks [item] = value;
}
///
/// Returns the source as IList.
///
///
public IList ToList ()
{
return src;
}
}
///
/// for events.
///
public class ListViewItemEventArgs : EventArgs {
///
/// The index of the item.
///
public int Item { get; }
///
/// The item.
///
public object Value { get; }
///
/// Initializes a new instance of
///
/// The index of the item.
/// The item
public ListViewItemEventArgs (int item, object value)
{
Item = item;
Value = value;
}
}
}