#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); } }