diff --git a/Examples/UICatalog/Scenarios/GenericListView.cs b/Examples/UICatalog/Scenarios/GenericListView.cs new file mode 100644 index 0000000000..4a394e8c4d --- /dev/null +++ b/Examples/UICatalog/Scenarios/GenericListView.cs @@ -0,0 +1,196 @@ +#nullable enable +using System.Collections.ObjectModel; + +namespace UICatalog.Scenarios; + +[ScenarioMetadata ("Generic ListView", "Demonstrates ListView with typed Value, SelectedItem, Index, and RowRender for custom row coloring")] +[ScenarioCategory ("Controls")] +[ScenarioCategory ("ListView")] +public class GenericListView : Scenario +{ + private ListView? _listView; + private ObservableCollection _eventList = []; + private ListView? _eventListView; + private Label? _nameLabel; + private Label? _capitalLabel; + private Label? _populationLabel; + private Label? _indexLabel; + private CheckBox? _cancelNextCb; + private bool _cancelNext; + + /// + public override void Main () + { + ConfigurationManager.Enable (ConfigLocations.All); + using IApplication app = Application.Create (); + app.Init (); + + using Window appWindow = new (); + appWindow.Title = GetQuitKeyAndName (); + + ObservableCollection countries = + [ + new ("Australia", "Canberra", 26_000_000), + new ("Brazil", "Brasília", 215_000_000), + new ("Canada", "Ottawa", 38_000_000), + new ("Denmark", "Copenhagen", 5_900_000), + new ("Egypt", "Cairo", 104_000_000), + new ("France", "Paris", 68_000_000), + new ("Germany", "Berlin", 84_000_000), + new ("Hungary", "Budapest", 9_700_000), + new ("India", "New Delhi", 1_428_000_000), + new ("Japan", "Tokyo", 124_000_000) + ]; + + _cancelNextCb = new CheckBox + { + X = 0, + Y = 0, + Text = "C_ancel next selection change" + }; + _cancelNextCb.ValueChanging += (_, args) => _cancelNext = args.NewValue == CheckState.Checked; + appWindow.Add (_cancelNextCb); + + _listView = new ListView + { + Title = "_Countries", + X = 0, + Y = Pos.Bottom (_cancelNextCb) + 1, + Width = 22, + Height = Dim.Fill (), + BorderStyle = LineStyle.Single + }; + _listView.SetSource (countries); + appWindow.Add (_listView); + + // Build highlight scheme by inheriting the resolved scheme and only + // overriding foreground colors so the background stays theme-consistent. + Scheme baseScheme = _listView.GetScheme (); + Scheme highlightScheme = new () + { + Normal = baseScheme.Normal with { Foreground = Color.BrightRed }, + Focus = baseScheme.Focus with { Foreground = Color.BrightRed, Style = TextStyle.Bold } + }; + + // Use RowRender to color rows with population > 100M (demonstrates + // how to achieve per-row coloring). + _listView.RowRender += (_, args) => + { + if (args.Row < countries.Count && countries [args.Row].Population > 100_000_000) + { + bool isSelected = args.Row == _listView.Index; + args.RowAttribute = isSelected ? highlightScheme.Focus : highlightScheme.Normal; + } + }; + + FrameView detailPanel = new () + { + Title = "_Selected", + X = Pos.Right (_listView) + 1, + Y = Pos.Top (_listView), + Width = Dim.Fill (), + Height = 9 + }; + appWindow.Add (detailPanel); + + Label nameTitleLbl = new () { X = 1, Y = 1, Text = "Name: " }; + detailPanel.Add (nameTitleLbl); + _nameLabel = new () { X = Pos.Right (nameTitleLbl), Y = 1, Width = Dim.Fill (1), Text = "(none)" }; + detailPanel.Add (_nameLabel); + + Label capitalTitleLbl = new () { X = 1, Y = 2, Text = "Capital: " }; + detailPanel.Add (capitalTitleLbl); + _capitalLabel = new () { X = Pos.Right (capitalTitleLbl), Y = 2, Width = Dim.Fill (1), Text = "" }; + detailPanel.Add (_capitalLabel); + + Label populationTitleLbl = new () { X = 1, Y = 3, Text = "Population:" }; + detailPanel.Add (populationTitleLbl); + _populationLabel = new () { X = Pos.Right (populationTitleLbl), Y = 3, Width = Dim.Fill (1), Text = "" }; + detailPanel.Add (_populationLabel); + + Label indexTitleLbl = new () { X = 1, Y = 5, Text = "Index: " }; + detailPanel.Add (indexTitleLbl); + _indexLabel = new () { X = Pos.Right (indexTitleLbl), Y = 5, Width = Dim.Fill (1), Text = "" }; + detailPanel.Add (_indexLabel); + + _eventList = []; + _eventListView = new ListView + { + Title = "_Events", + X = Pos.Right (_listView) + 1, + Y = Pos.Bottom (detailPanel) + 1, + Width = Dim.Fill (), + Height = Dim.Fill (), + Source = new ListWrapper (_eventList), + BorderStyle = LineStyle.Single + }; + appWindow.Add (_eventListView); + + _listView.ValueChanging += OnValueChanging; + _listView.ValueChanged += OnValueChanged; + + app.Run (appWindow); + } + + private void OnValueChanging (object? sender, ValueChangingEventArgs args) + { + if (_cancelNext) + { + args.Handled = true; + _cancelNext = false; + + _cancelNextCb?.Value = CheckState.UnChecked; + + LogEvent ($"ValueChanging CANCELLED: {FormatCountry (args.CurrentValue)} -> {FormatCountry (args.NewValue)}"); + + return; + } + + LogEvent ($"ValueChanging: {FormatCountry (args.CurrentValue)} -> {FormatCountry (args.NewValue)}"); + } + + private void OnValueChanged (object? sender, ValueChangedEventArgs args) + { + UpdateDetail (args.NewValue); + LogEvent ($"ValueChanged: {FormatCountry (args.OldValue)} -> {FormatCountry (args.NewValue)}"); + } + + private void UpdateDetail (Country? country) + { + if (_nameLabel is null) + { + return; + } + + if (country is null) + { + _nameLabel.Text = "(none)"; + _capitalLabel!.Text = ""; + _populationLabel!.Text = ""; + _indexLabel!.Text = ""; + + return; + } + + _nameLabel.Text = country.Name; + _capitalLabel!.Text = country.Capital; + _populationLabel!.Text = $"{country.Population:N0}"; + _indexLabel!.Text = _listView?.Index?.ToString () ?? ""; + } + + private void LogEvent (string message) + { + _eventList.Add (message); + + _eventListView?.MoveEnd (); + } + + private static string FormatCountry (Country? c) { return c is null ? "null" : c.Name; } +} + +/// A simple record used to demonstrate . +internal record Country (string Name, string Capital, int Population) +{ + // Overriding ToString() so ListView only displays the country name. + public override string ToString () { return Name; } +} diff --git a/Terminal.Gui/Views/ListView/ListViewT.cs b/Terminal.Gui/Views/ListView/ListViewT.cs new file mode 100644 index 0000000000..c9ddb252b4 --- /dev/null +++ b/Terminal.Gui/Views/ListView/ListViewT.cs @@ -0,0 +1,228 @@ +using System.Collections.ObjectModel; + +namespace Terminal.Gui.Views; + +/// +/// Provides a scrollable list of data where each item can be activated to perform an action, +/// with a strongly-typed property that returns the selected object of type +/// from the underlying . +/// +/// The type of items in the collection. +/// +/// +/// extends by implementing +/// . The property returns the currently selected +/// object of type rather than the selected index. +/// +/// +/// All functionality (rendering, marking, keyboard navigation, +/// key and mouse bindings) is inherited unchanged. Use +/// to provide the typed source collection. +/// +/// +/// The base (index-based, with +/// T = int?) remains accessible by casting to or +/// IValue<int?>. +/// +/// +public class ListView : ListView, IValue +{ + private ObservableCollection? _typedSource; + + /// + /// Initializes a new instance of . + /// + public ListView () + { + base.ValueChanging += TranslateValueChanging; + base.ValueChanged += TranslateValueChanged; + } + + /// + /// Sets the source collection and updates the display. + /// + /// + /// The to display, + /// or to clear the list. + /// + public void SetSource (ObservableCollection? source) + { + _typedSource = source; + base.SetSource (source); + } + + #region IValue Implementation + + /// + /// Gets or sets the currently selected item as a object. + /// + /// + /// The selected item, or if no item is selected or the source is not set. + /// + /// + /// + /// The getter retrieves the object at the selected index from the typed source collection. + /// + /// + /// The setter locates the object in the collection and updates + /// to the corresponding index. + /// + /// + /// If is , the selection is cleared. + /// + /// + /// If the source collection has not been set, or if is not found + /// in the collection, the setter is a no-op and the selection remains unchanged. + /// This differs from the base setter, which throws + /// for an out-of-range index. Here, a value not present in + /// the collection is not considered an error — the caller may hold a stale reference or the + /// collection may have changed since the reference was obtained. + /// + /// + public new T? Value + { + get => GetObjectAt (base.SelectedItem); + set + { + if (value is null) + { + base.SelectedItem = null; + + return; + } + + if (_typedSource is null) + { + return; + } + + int index = _typedSource.IndexOf (value); + + if (index < 0) + { + return; + } + + base.SelectedItem = index; + } + } + + /// + /// Gets the currently selected item as a boxed object. + /// + /// + /// The selected item of type , boxed as , + /// or if no item is selected. + /// + /// + /// This explicit implementation overrides the base 's behavior, + /// which returns the selected index as . + /// Here, the returned value is the selected object from the typed source collection, + /// consistent with . + /// + object? IValue.GetValue () => Value; + + /// + /// Gets or sets the currently selected object. + /// This is a convenience property that is an alias for . + /// + /// + /// The selected object of type , + /// or if no item is selected. + /// + public new T? SelectedItem { get => Value; set => Value = value; } + + /// + /// Gets or sets the zero-based index of the currently selected item. + /// + /// + /// The index of the selected item, or if no item is selected. + /// + /// + /// Use this property to get or set the selection by index directly. + /// To get or set the selection by object, use or . + /// + public int? Index { get => base.Value; set => base.Value = value; } + + /// + /// Called when is about to change. + /// + /// The event arguments containing the current and proposed typed values. + /// to cancel the change; otherwise . + protected virtual bool OnValueChanging (ValueChangingEventArgs args) => false; + + /// + /// Raised when is about to change. + /// Set to to cancel the change. + /// + public new event EventHandler>? ValueChanging; + + /// + /// Called when has changed. + /// + /// The event arguments containing the old and new typed values. + protected virtual void OnValueChanged (ValueChangedEventArgs args) { } + + /// + /// Raised when has changed. + /// + public new event EventHandler>? ValueChanged; + + /// + public new event EventHandler>? ValueChangedUntyped; + + #endregion IValue Implementation + + /// + protected override void Dispose (bool disposing) + { + if (disposing) + { + base.ValueChanging -= TranslateValueChanging; + base.ValueChanged -= TranslateValueChanged; + } + + base.Dispose (disposing); + } + + private T? GetObjectAt (int? index) + { + if (index is null || _typedSource is null || index < 0 || index >= _typedSource.Count) + { + return default (T?); + } + + return _typedSource [index.Value]; + } + + private void TranslateValueChanging (object? sender, ValueChangingEventArgs intArgs) + { + T? oldObj = GetObjectAt (intArgs.CurrentValue); + T? newObj = GetObjectAt (intArgs.NewValue); + ValueChangingEventArgs tArgs = new (oldObj, newObj); + + if (OnValueChanging (tArgs) || tArgs.Handled) + { + intArgs.Handled = true; + + return; + } + + ValueChanging?.Invoke (this, tArgs); + + if (tArgs.Handled) + { + intArgs.Handled = true; + } + } + + private void TranslateValueChanged (object? sender, ValueChangedEventArgs intArgs) + { + T? oldObj = GetObjectAt (intArgs.OldValue); + T? newObj = GetObjectAt (intArgs.NewValue); + ValueChangedEventArgs tArgs = new (oldObj, newObj); + OnValueChanged (tArgs); + ValueChanged?.Invoke (this, tArgs); + ValueChangedUntyped?.Invoke (this, new ValueChangedEventArgs (oldObj, newObj)); + } +} diff --git a/Terminal.Gui/Views/ListView/ListWrapper.cs b/Terminal.Gui/Views/ListView/ListWrapper.cs index 5f86d1fb4f..e2fc837f4d 100644 --- a/Terminal.Gui/Views/ListView/ListWrapper.cs +++ b/Terminal.Gui/Views/ListView/ListWrapper.cs @@ -198,7 +198,7 @@ private int GetMaxLengthItem () continue; } - int l = t is string u ? u.GetColumns () : t.ToString ()!.Length; + int l = t is string s ? s.GetColumns () : t.ToString ()!.GetColumns (); if (l > maxLength) { diff --git a/Tests/UnitTestsParallelizable/Views/ListViewTTests.cs b/Tests/UnitTestsParallelizable/Views/ListViewTTests.cs new file mode 100644 index 0000000000..7d31eae0e4 --- /dev/null +++ b/Tests/UnitTestsParallelizable/Views/ListViewTTests.cs @@ -0,0 +1,286 @@ +using System.Collections.ObjectModel; + +// Copilot + +namespace ViewsTests; + +public class ListViewTTests +{ + [Fact] + public void Value_ReturnsSelectedObject () + { + ObservableCollection source = ["one", "two", "three"]; + ListView listView = new (); + listView.SetSource (source); + listView.Index = 1; + + Assert.Equal ("two", listView.Value); + } + + [Fact] + public void Value_IsNull_WhenNoSelection () + { + ObservableCollection source = ["one", "two"]; + ListView listView = new (); + listView.SetSource (source); + + Assert.Null (listView.Value); + } + + [Fact] + public void Value_IsNull_WhenSourceIsNull () + { + ListView listView = new (); + + Assert.Null (listView.Value); + } + + [Fact] + public void Value_Setter_SelectsCorrectIndex () + { + ObservableCollection source = ["alpha", "beta", "gamma"]; + ListView listView = new (); + listView.SetSource (source); + listView.Index = 0; + + listView.Value = "beta"; + + Assert.Equal (1, listView.Index); + Assert.Equal ("beta", listView.Value); + } + + [Fact] + public void Value_Setter_Null_ClearsSelection () + { + ObservableCollection source = ["alpha", "beta"]; + ListView listView = new (); + listView.SetSource (source); + listView.Index = 0; + + listView.Value = null; + + Assert.Null (listView.Index); + Assert.Null (listView.Value); + } + + [Fact] + public void Value_Setter_DoesNothing_WhenObjectNotInCollection () + { + ObservableCollection source = ["one", "two"]; + ListView listView = new (); + listView.SetSource (source); + listView.Index = 0; + + listView.Value = "three"; + + Assert.Equal (0, listView.Index); + Assert.Equal ("one", listView.Value); + } + + [Fact] + public void Value_Setter_DoesNothing_WhenSourceIsNull () + { + ListView listView = new (); + + // Should not throw + listView.Value = "anything"; + + Assert.Null (listView.Index); + } + + [Fact] + public void ValueChanged_FiresWithTypedObject () + { + ObservableCollection source = ["a", "b", "c"]; + ListView listView = new (); + listView.SetSource (source); + listView.Index = 0; + + ValueChangedEventArgs? receivedArgs = null; + listView.ValueChanged += (_, args) => receivedArgs = args; + + listView.Index = 2; + + Assert.NotNull (receivedArgs); + Assert.Equal ("a", receivedArgs!.OldValue); + Assert.Equal ("c", receivedArgs.NewValue); + } + + [Fact] + public void ValueChanging_FiresWithTypedObject () + { + ObservableCollection source = ["x", "y", "z"]; + ListView listView = new (); + listView.SetSource (source); + listView.Index = 0; + + ValueChangingEventArgs? receivedArgs = null; + listView.ValueChanging += (_, args) => receivedArgs = args; + + listView.Index = 1; + + Assert.NotNull (receivedArgs); + Assert.Equal ("x", receivedArgs!.CurrentValue); + Assert.Equal ("y", receivedArgs.NewValue); + } + + [Fact] + public void ValueChanging_CanCancel () + { + ObservableCollection source = ["p", "q"]; + ListView listView = new (); + listView.SetSource (source); + listView.Index = 0; + + listView.ValueChanging += (_, args) => args.Handled = true; + + listView.Index = 1; + + Assert.Equal (0, listView.Index); + Assert.Equal ("p", listView.Value); + } + + [Fact] + public void ValueChangedUntyped_FiresWithObjectNotIndex () + { + ObservableCollection source = ["first", "second"]; + ListView listView = new (); + listView.SetSource (source); + listView.Index = 0; + + ValueChangedEventArgs? receivedArgs = null; + listView.ValueChangedUntyped += (_, args) => receivedArgs = args; + + listView.Index = 1; + + Assert.NotNull (receivedArgs); + Assert.Equal ("first", receivedArgs!.OldValue); + Assert.Equal ("second", receivedArgs.NewValue); + } + + [Fact] + public void GetValue_ReturnsTypedObject () + { + ObservableCollection source = ["item0", "item1"]; + ListView listView = new (); + listView.SetSource (source); + listView.Index = 1; + + object? result = ((IValue)listView).GetValue (); + + Assert.Equal ("item1", result); + } + + [Fact] + public void SetSource_Null_DoesNotThrow () + { + ListView listView = new (); + listView.SetSource (["a", "b"]); + + // Should not throw + listView.SetSource (null); + } + + [Fact] + public void SetSource_Null_ValueIsNull () + { + ListView listView = new (); + listView.SetSource (["a", "b"]); + listView.SetSource (null); + + Assert.Null (listView.Value); + } + + [Fact] + public void Index_ReturnsSelectedItemIndex () + { + ObservableCollection source = ["one", "two", "three"]; + ListView listView = new (); + listView.SetSource (source); + listView.Index = 2; + + Assert.Equal (2, listView.Index); + } + + [Fact] + public void Index_IsNull_WhenNoSelection () + { + ObservableCollection source = ["one", "two"]; + ListView listView = new (); + listView.SetSource (source); + + Assert.Null (listView.Index); + } + + [Fact] + public void Index_UpdatesWhenValueSetterChangesSelection () + { + ObservableCollection source = ["a", "b", "c"]; + ListView listView = new (); + listView.SetSource (source); + + listView.Value = "c"; + + Assert.Equal (2, listView.Index); + } + + [Fact] + public void Index_Setter_SelectsCorrectItem () + { + ObservableCollection source = ["one", "two", "three"]; + ListView listView = new (); + listView.SetSource (source); + + listView.Index = 2; + + Assert.Equal (2, listView.Index); + Assert.Equal ("three", listView.Value); + } + + [Fact] + public void Value_UsesObjectEquality_ForValueSetter () + { + ObservableCollection source = ["cat", "dog", "bird"]; + ListView listView = new (); + listView.SetSource (source); + listView.Index = 0; + + listView.Value = "bird"; + + Assert.Equal (2, listView.Index); + } + + [Fact] + public void SelectedItem_ReturnsSelectedObject () + { + ObservableCollection source = ["one", "two", "three"]; + ListView listView = new (); + listView.SetSource (source); + listView.Index = 1; + + Assert.Equal ("two", listView.SelectedItem); + } + + [Fact] + public void SelectedItem_IsNull_WhenNoSelection () + { + ObservableCollection source = ["one", "two"]; + ListView listView = new (); + listView.SetSource (source); + + Assert.Null (listView.SelectedItem); + } + + [Fact] + public void SelectedItem_Setter_SelectsCorrectItem () + { + ObservableCollection source = ["alpha", "beta", "gamma"]; + ListView listView = new (); + listView.SetSource (source); + + listView.SelectedItem = "beta"; + + Assert.Equal (1, listView.Index); + Assert.Equal ("beta", listView.Value); + } +}