using System; using System.Collections; using System.Collections.Generic; using System.Linq; 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. /// /// , if marked, otherwise. /// Item index. bool IsMarked (int item); /// /// Flags the item as marked. /// /// Item index. /// If set to value. void SetMark (int item, bool value); /// /// Return the source as IList. /// /// IList ToList (); } /// /// Implement to provide custom rendering for a that /// supports searching for items. /// public interface IListDataSourceSearchable : IListDataSource { /// /// Finds the first item that starts with the specified search string. Used by the default implementation /// to support typing the first characters of an item to find it and move the selection to i. /// /// Text to search for. /// The index of the first item that starts with . /// Returns if was not found. int StartsWith (string search); } /// /// 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 /// or 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. /// /// /// By default or if is set to an object that implements /// , 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 { 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; navigator = null; 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 || !(Source is ListWrapper))) 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 || !(Source is ListWrapper))) 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. /// /// Set to to allow marking elements of the list. /// /// If set to , will render items marked items with "[x]", and unmarked items with "[ ]" /// spaces. SPACE key will toggle marking. The default is . /// public bool AllowsMarking { get => allowsMarking; set { allowsMarking = value; if (allowsMarking) { AddKeyBinding (Key.Space, Command.ToggleChecked); } else { ClearKeybinding (Key.Space); } SetNeedsDisplay (); } } /// /// 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 != 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); } } } SetNeedsDisplay (); } } /// /// 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 leftmost column that is currently visible (when scrolling horizontally). /// /// 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 in the list. /// 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; // Things this view knows how to do AddCommand (Command.LineUp, () => MoveUp ()); AddCommand (Command.LineDown, () => MoveDown ()); AddCommand (Command.ScrollUp, () => ScrollUp (1)); AddCommand (Command.ScrollDown, () => ScrollDown (1)); AddCommand (Command.PageUp, () => MovePageUp ()); AddCommand (Command.PageDown, () => MovePageDown ()); AddCommand (Command.TopHome, () => MoveHome ()); AddCommand (Command.BottomEnd, () => MoveEnd ()); AddCommand (Command.OpenSelectedItem, () => OnOpenSelectedItem ()); AddCommand (Command.ToggleChecked, () => MarkUnmarkRow ()); // Default keybindings for all ListViews AddKeyBinding (Key.CursorUp, Command.LineUp); AddKeyBinding (Key.P | Key.CtrlMask, Command.LineUp); AddKeyBinding (Key.CursorDown, Command.LineDown); AddKeyBinding (Key.N | Key.CtrlMask, Command.LineDown); AddKeyBinding (Key.PageUp, Command.PageUp); AddKeyBinding (Key.PageDown, Command.PageDown); AddKeyBinding (Key.V | Key.CtrlMask, Command.PageDown); AddKeyBinding (Key.Home, Command.TopHome); AddKeyBinding (Key.End, Command.BottomEnd); AddKeyBinding (Key.Enter, Command.OpenSelectedItem); } /// 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 : GetNormalColor ()) : (isSelected ? ColorScheme.HotNormal : GetNormalColor ()); 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 { var rowEventArgs = new ListViewRowEventArgs (item); OnRowRender (rowEventArgs); if (rowEventArgs.RowAttribute != null && current != rowEventArgs.RowAttribute) { current = (Attribute)rowEventArgs.RowAttribute; Driver.SetAttribute (current); } 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; /// /// This event is invoked when this is being drawn before rendering. /// public event Action RowRender; private SearchCollectionNavigator navigator; /// public override bool ProcessKey (KeyEvent kb) { if (source == null) { return base.ProcessKey (kb); } var result = InvokeKeybindings (kb); if (result != null) { return (bool)result; } // Enable user to find & select an item by typing text if (SearchCollectionNavigator.IsCompatibleKey(kb)) { if (navigator == null) { navigator = new SearchCollectionNavigator (source.ToList ().Cast ()); } var newItem = navigator.CalculateNewIndex (SelectedItem, (char)kb.KeyValue); if (newItem != SelectedItem) { SelectedItem = newItem; EnsuresVisibilitySelectedItem (); SetNeedsDisplay (); return true; } } return false; } /// /// If and are both , /// unmarks all marked items other than the currently selected. /// /// if unmarking was successful. 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 the if it is not already marked. /// /// if the was marked. public virtual bool MarkUnmarkRow () { if (AllowsAll ()) { Source.SetMark (SelectedItem, !Source.IsMarked (SelectedItem)); SetNeedsDisplay (); return true; } return false; } /// /// Changes the to the item at the top of the visible list. /// /// 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; } /// /// Changes the to the item just below the bottom /// of the visible list, scrolling if needed. /// /// 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; } /// /// Changes the to the next item in the list, /// scrolling the list if needed. /// /// 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; } else if (selected < top) { top = selected; } OnSelectedChanged (); SetNeedsDisplay (); } else if (selected == 0) { OnSelectedChanged (); SetNeedsDisplay (); } else if (selected >= top + Frame.Height) { top = source.Count - Frame.Height; SetNeedsDisplay (); } return true; } /// /// Changes the to the previous item in the list, /// scrolling the list if needed. /// /// 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 (); } else if (selected < top) { top = selected; SetNeedsDisplay (); } return true; } /// /// Changes the to last item in the list, /// scrolling the list if needed. /// /// 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; } /// /// Changes the to the first item in the list, /// scrolling the list if needed. /// /// public virtual bool MoveHome () { if (selected != 0) { selected = 0; top = selected; OnSelectedChanged (); SetNeedsDisplay (); } return true; } /// /// Scrolls the view down by items. /// /// Number of items to scroll down. public virtual bool ScrollDown (int items) { top = Math.Max (Math.Min (top + items, source.Count - 1), 0); SetNeedsDisplay (); return true; } /// /// Scrolls the view up by items. /// /// Number of items to scroll up. public virtual bool ScrollUp (int items) { top = Math.Max (top - items, 0); SetNeedsDisplay (); return true; } /// /// Scrolls the view right. /// /// Number of columns to scroll right. public virtual bool ScrollRight (int cols) { left = Math.Max (Math.Min (left + cols, Maxlength - 1), 0); SetNeedsDisplay (); return true; } /// /// Scrolls the view left. /// /// Number of columns to scroll left. public virtual bool ScrollLeft (int cols) { left = Math.Max (left - cols, 0); SetNeedsDisplay (); return true; } int lastSelectedItem = -1; private bool allowsMultipleSelection = true; /// /// Invokes the 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 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; } /// /// Virtual method that will invoke the . /// /// public virtual void OnRowRender (ListViewRowEventArgs rowEventArgs) { RowRender?.Invoke (rowEventArgs); } /// public override bool OnEnter (View view) { Application.Driver.SetCursorVisibility (CursorVisibility.Invisible); if (lastSelectedItem == -1) { EnsuresVisibilitySelectedItem (); OnSelectedChanged (); } return base.OnEnter (view); } /// public override bool OnLeave (View view) { if (lastSelectedItem > -1) { lastSelectedItem = -1; } return base.OnLeave (view); } void EnsuresVisibilitySelectedItem () { SuperView?.LayoutSubviews (); if (selected < top) { top = selected; } else if (Frame.Height > 0 && selected >= top + Frame.Height) { top = Math.Max (selected - Frame.Height + 1, 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; } } /// public class ListWrapper : IListDataSourceSearchable { IList src; BitArray marks; int count, len; /// public ListWrapper (IList source) { if (source != null) { count = source.Count; marks = new BitArray (count); src = source; len = GetMaxLengthItem (); } } /// public int Count => src != null ? src.Count : 0; /// public int Length => len; int GetMaxLengthItem () { if (src == null || 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 (' '); } } /// 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); } } } /// public bool IsMarked (int item) { if (item >= 0 && item < count) return marks [item]; return false; } /// public void SetMark (int item, bool value) { if (item >= 0 && item < count) marks [item] = value; } /// public IList ToList () { return src; } /// public int StartsWith (string search) { if (src == null || src?.Count == 0) { return -1; } for (int i = 0; i < src.Count; i++) { var t = src [i]; if (t is ustring u) { if (u.ToUpper ().StartsWith (search.ToUpperInvariant ())) { return i; } } else if (t is string s) { if (s.ToUpperInvariant().StartsWith (search.ToUpperInvariant())) { return i; } } } return -1; } } /// /// 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; } } /// /// used by the event. /// public class ListViewRowEventArgs : EventArgs { /// /// The current row being rendered. /// public int Row { get; } /// /// The used by current row or /// null to maintain the current attribute. /// public Attribute? RowAttribute { get; set; } /// /// Initializes with the current row. /// /// public ListViewRowEventArgs (int row) { Row = row; } } }